'use strict';

const { PermissionFlagsBits } = require('discord-api-types/v10');
const Base = require('./Base');
const VoiceState = require('./VoiceState');
const TextBasedChannel = require('./interfaces/TextBasedChannel');
const { DiscordjsError, ErrorCodes } = require('../errors');
const GuildMemberRoleManager = require('../managers/GuildMemberRoleManager');
const { GuildMemberFlagsBitField } = require('../util/GuildMemberFlagsBitField');
const PermissionsBitField = require('../util/PermissionsBitField');

/**
 * Represents a member of a guild on Discord.
 * @implements {TextBasedChannel}
 * @extends {Base}
 */
class GuildMember extends Base {
  constructor(client, data, guild) {
    super(client);

    /**
     * The guild that this member is part of
     * @type {Guild}
     */
    this.guild = guild;

    /**
     * The timestamp the member joined the guild at
     * @type {?number}
     */
    this.joinedTimestamp = null;

    /**
     * The last timestamp this member started boosting the guild
     * @type {?number}
     */
    this.premiumSinceTimestamp = null;

    /**
     * The nickname of this member, if they have one
     * @type {?string}
     */
    this.nickname = null;

    /**
     * Whether this member has yet to pass the guild's membership gate
     * @type {?boolean}
     */
    this.pending = null;

    /**
     * The timestamp this member's timeout will be removed
     * @type {?number}
     */
    this.communicationDisabledUntilTimestamp = null;

    /**
     * The role ids of the member
     * @name GuildMember#_roles
     * @type {Snowflake[]}
     * @private
     */
    Object.defineProperty(this, '_roles', { value: [], writable: true });

    if (data) this._patch(data);
  }

  _patch(data) {
    if ('user' in data) {
      /**
       * The user that this guild member instance represents
       * @type {?User}
       */
      this.user = this.client.users._add(data.user, true);
    }

    if ('nick' in data) this.nickname = data.nick;
    if ('avatar' in data) {
      /**
       * The guild member's avatar hash
       * @type {?string}
       */
      this.avatar = data.avatar;
    } else if (typeof this.avatar !== 'string') {
      this.avatar = null;
    }

    if ('banner' in data) {
      /**
       * The guild member's banner hash.
       * @type {?string}
       */
      this.banner = data.banner;
    } else {
      this.banner ??= null;
    }

    if ('joined_at' in data) this.joinedTimestamp = Date.parse(data.joined_at);
    if ('premium_since' in data) {
      this.premiumSinceTimestamp = data.premium_since ? Date.parse(data.premium_since) : null;
    }
    if ('roles' in data) this._roles = data.roles;

    if ('pending' in data) {
      this.pending = data.pending;
    } else if (!this.partial) {
      // See https://github.com/discordjs/discord.js/issues/6546 for more info.
      this.pending ??= false;
    }

    if ('communication_disabled_until' in data) {
      this.communicationDisabledUntilTimestamp =
        data.communication_disabled_until && Date.parse(data.communication_disabled_until);
    }

    if ('flags' in data) {
      /**
       * The flags of this member
       * @type {Readonly<GuildMemberFlagsBitField>}
       */
      this.flags = new GuildMemberFlagsBitField(data.flags).freeze();
    } else {
      this.flags ??= new GuildMemberFlagsBitField().freeze();
    }
  }

  _clone() {
    const clone = super._clone();
    clone._roles = this._roles.slice();
    return clone;
  }

  /**
   * Whether this GuildMember is a partial
   * @type {boolean}
   * @readonly
   */
  get partial() {
    return this.joinedTimestamp === null;
  }

  /**
   * A manager for the roles belonging to this member
   * @type {GuildMemberRoleManager}
   * @readonly
   */
  get roles() {
    return new GuildMemberRoleManager(this);
  }

  /**
   * The voice state of this member
   * @type {VoiceState}
   * @readonly
   */
  get voice() {
    return this.guild.voiceStates.cache.get(this.id) ?? new VoiceState(this.guild, { user_id: this.id });
  }

  /**
   * A link to the member's guild avatar.
   * @param {ImageURLOptions} [options={}] Options for the image URL
   * @returns {?string}
   */
  avatarURL(options = {}) {
    return this.avatar && this.client.rest.cdn.guildMemberAvatar(this.guild.id, this.id, this.avatar, options);
  }

  /**
   * A link to the member's banner.
   * @param {ImageURLOptions} [options={}] Options for the banner URL
   * @returns {?string}
   */
  bannerURL(options = {}) {
    return this.banner && this.client.rest.cdn.guildMemberBanner(this.guild.id, this.id, this.banner, options);
  }

  /**
   * A link to the member's guild avatar if they have one.
   * Otherwise, a link to their {@link User#displayAvatarURL} will be returned.
   * @param {ImageURLOptions} [options={}] Options for the image URL
   * @returns {string}
   */
  displayAvatarURL(options) {
    return this.avatarURL(options) ?? this.user.displayAvatarURL(options);
  }

  /**
   * A link to the member's guild banner if they have one.
   * Otherwise, a link to their {@link User#bannerURL} will be returned.
   * @param {ImageURLOptions} [options={}] Options for the image URL
   * @returns {?string}
   */
  displayBannerURL(options) {
    return this.bannerURL(options) ?? this.user.bannerURL(options);
  }

  /**
   * The time this member joined the guild
   * @type {?Date}
   * @readonly
   */
  get joinedAt() {
    return this.joinedTimestamp && new Date(this.joinedTimestamp);
  }

  /**
   * The time this member's timeout will be removed
   * @type {?Date}
   * @readonly
   */
  get communicationDisabledUntil() {
    return this.communicationDisabledUntilTimestamp && new Date(this.communicationDisabledUntilTimestamp);
  }

  /**
   * The last time this member started boosting the guild
   * @type {?Date}
   * @readonly
   */
  get premiumSince() {
    return this.premiumSinceTimestamp && new Date(this.premiumSinceTimestamp);
  }

  /**
   * The presence of this guild member
   * @type {?Presence}
   * @readonly
   */
  get presence() {
    return this.guild.presences.cache.get(this.id) ?? null;
  }

  /**
   * The displayed role color of this member in base 10
   * @type {number}
   * @readonly
   */
  get displayColor() {
    return this.roles.color?.color ?? 0;
  }

  /**
   * The displayed role color of this member in hexadecimal
   * @type {string}
   * @readonly
   */
  get displayHexColor() {
    return this.roles.color?.hexColor ?? '#000000';
  }

  /**
   * The member's id
   * @type {Snowflake}
   * @readonly
   */
  get id() {
    return this.user.id;
  }

  /**
   * The DM between the client's user and this member
   * @type {?DMChannel}
   * @readonly
   */
  get dmChannel() {
    return this.client.users.dmChannel(this.id);
  }

  /**
   * The nickname of this member, or their user display name if they don't have one
   * @type {?string}
   * @readonly
   */
  get displayName() {
    return this.nickname ?? this.user.displayName;
  }

  /**
   * The overall set of permissions for this member, taking only roles and owner status into account
   * @type {Readonly<PermissionsBitField>}
   * @readonly
   */
  get permissions() {
    if (this.user.id === this.guild.ownerId) return new PermissionsBitField(PermissionsBitField.All).freeze();
    return new PermissionsBitField(this.roles.cache.map(role => role.permissions)).freeze();
  }

  /**
   * Whether the client user is above this user in the hierarchy, according to role position and guild ownership.
   * This is a prerequisite for many moderative actions.
   * @type {boolean}
   * @readonly
   */
  get manageable() {
    if (this.user.id === this.guild.ownerId) return false;
    if (this.user.id === this.client.user.id) return false;
    if (this.client.user.id === this.guild.ownerId) return true;
    if (!this.guild.members.me) throw new DiscordjsError(ErrorCodes.GuildUncachedMe);
    return this.guild.members.me.roles.highest.comparePositionTo(this.roles.highest) > 0;
  }

  /**
   * Whether this member is kickable by the client user
   * @type {boolean}
   * @readonly
   */
  get kickable() {
    if (!this.guild.members.me) throw new DiscordjsError(ErrorCodes.GuildUncachedMe);
    return this.manageable && this.guild.members.me.permissions.has(PermissionFlagsBits.KickMembers);
  }

  /**
   * Whether this member is bannable by the client user
   * @type {boolean}
   * @readonly
   */
  get bannable() {
    if (!this.guild.members.me) throw new DiscordjsError(ErrorCodes.GuildUncachedMe);
    return this.manageable && this.guild.members.me.permissions.has(PermissionFlagsBits.BanMembers);
  }

  /**
   * Whether this member is moderatable by the client user
   * @type {boolean}
   * @readonly
   */
  get moderatable() {
    return (
      !this.permissions.has(PermissionFlagsBits.Administrator) &&
      this.manageable &&
      (this.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers) ?? false)
    );
  }

  /**
   * Whether this member is currently timed out
   * @returns {boolean}
   */
  isCommunicationDisabled() {
    return this.communicationDisabledUntilTimestamp > Date.now();
  }

  /**
   * Returns `channel.permissionsFor(guildMember)`. Returns permissions for a member in a guild channel,
   * taking into account roles and permission overwrites.
   * @param {GuildChannelResolvable} channel The guild channel to use as context
   * @returns {Readonly<PermissionsBitField>}
   */
  permissionsIn(channel) {
    channel = this.guild.channels.resolve(channel);
    if (!channel) throw new DiscordjsError(ErrorCodes.GuildChannelResolve);
    return channel.permissionsFor(this);
  }

  /**
   * Edits this member.
   * @param {GuildMemberEditOptions} options The options to provide
   * @returns {Promise<GuildMember>}
   */
  edit(options) {
    return this.guild.members.edit(this, options);
  }

  /**
   * Sets the flags for this member.
   * @param {GuildMemberFlagsResolvable} flags The flags to set
   * @param {string} [reason] Reason for setting the flags
   * @returns {Promise<GuildMember>}
   */
  setFlags(flags, reason) {
    return this.edit({ flags, reason });
  }

  /**
   * Sets the nickname for this member.
   * @param {?string} nick The nickname for the guild member, or `null` if you want to reset their nickname
   * @param {string} [reason] Reason for setting the nickname
   * @returns {Promise<GuildMember>}
   * @example
   * // Set a nickname for a guild member
   * guildMember.setNickname('cool nickname', 'Needed a new nickname')
   *   .then(member => console.log(`Set nickname of ${member.user.username}`))
   *   .catch(console.error);
   * @example
   * // Remove a nickname for a guild member
   * guildMember.setNickname(null, 'No nicknames allowed!')
   *   .then(member => console.log(`Removed nickname for ${member.user.username}`))
   *   .catch(console.error);
   */
  setNickname(nick, reason) {
    return this.edit({ nick, reason });
  }

  /**
   * Creates a DM channel between the client and this member.
   * @param {boolean} [force=false] Whether to skip the cache check and request the API
   * @returns {Promise<DMChannel>}
   */
  createDM(force = false) {
    return this.user.createDM(force);
  }

  /**
   * Deletes any DMs with this member.
   * @returns {Promise<DMChannel>}
   */
  deleteDM() {
    return this.user.deleteDM();
  }

  /**
   * Kicks this member from the guild.
   * @param {string} [reason] Reason for kicking user
   * @returns {Promise<GuildMember>}
   */
  kick(reason) {
    return this.guild.members.kick(this, reason);
  }

  /**
   * Bans this guild member.
   * @param {BanOptions} [options] Options for the ban
   * @returns {Promise<GuildMember>}
   * @example
   * // Ban a guild member, deleting a week's worth of messages
   * guildMember.ban({ deleteMessageSeconds: 60 * 60 * 24 * 7, reason: 'They deserved it' })
   *   .then(console.log)
   *   .catch(console.error);
   */
  ban(options) {
    return this.guild.bans.create(this, options);
  }

  /**
   * Times this guild member out.
   * @param {?DateResolvable} communicationDisabledUntil The date or timestamp
   * for the member's communication to be disabled until. Provide `null` to remove the timeout.
   * @param {string} [reason] The reason for this timeout.
   * @returns {Promise<GuildMember>}
   * @example
   * // Time a guild member out for 5 minutes
   * guildMember.disableCommunicationUntil(Date.now() + (5 * 60 * 1000), 'They deserved it')
   *   .then(console.log)
   *   .catch(console.error);
   * @example
   * // Remove the timeout of a guild member
   * guildMember.disableCommunicationUntil(null)
   *   .then(member => console.log(`Removed timeout for ${member.displayName}`))
   *   .catch(console.error);
   */
  disableCommunicationUntil(communicationDisabledUntil, reason) {
    return this.edit({ communicationDisabledUntil, reason });
  }

  /**
   * Times this guild member out.
   * @param {?number} timeout The duration in milliseconds
   * for the member's communication to be disabled. Provide `null` to remove the timeout.
   * @param {string} [reason] The reason for this timeout.
   * @returns {Promise<GuildMember>}
   * @example
   * // Time a guild member out for 5 minutes
   * guildMember.timeout(5 * 60 * 1000, 'They deserved it')
   *   .then(console.log)
   *   .catch(console.error);
   */
  timeout(timeout, reason) {
    return this.disableCommunicationUntil(timeout && Date.now() + timeout, reason);
  }

  /**
   * Fetches this GuildMember.
   * @param {boolean} [force=true] Whether to skip the cache check and request the API
   * @returns {Promise<GuildMember>}
   */
  fetch(force = true) {
    return this.guild.members.fetch({ user: this.id, cache: true, force });
  }

  /**
   * Whether this guild member equals another guild member. It compares all properties, so for most
   * comparison it is advisable to just compare `member.id === member2.id` as it is significantly faster
   * and is often what most users need.
   * @param {GuildMember} member The member to compare with
   * @returns {boolean}
   */
  equals(member) {
    return (
      member instanceof this.constructor &&
      this.id === member.id &&
      this.partial === member.partial &&
      this.guild.id === member.guild.id &&
      this.joinedTimestamp === member.joinedTimestamp &&
      this.nickname === member.nickname &&
      this.avatar === member.avatar &&
      this.banner === member.banner &&
      this.pending === member.pending &&
      this.communicationDisabledUntilTimestamp === member.communicationDisabledUntilTimestamp &&
      this.flags.bitfield === member.flags.bitfield &&
      (this._roles === member._roles ||
        (this._roles.length === member._roles.length && this._roles.every((role, i) => role === member._roles[i])))
    );
  }

  /**
   * When concatenated with a string, this automatically returns the user's mention instead of the GuildMember object.
   * @returns {string}
   * @example
   * // Logs: Hello from <@123456789012345678>!
   * console.log(`Hello from ${member}!`);
   */
  toString() {
    return this.user.toString();
  }

  toJSON() {
    const json = super.toJSON({
      guild: 'guildId',
      user: 'userId',
      displayName: true,
      roles: true,
    });
    json.avatarURL = this.avatarURL();
    json.bannerURL = this.bannerURL();
    json.displayAvatarURL = this.displayAvatarURL();
    json.displayBannerURL = this.displayBannerURL();
    return json;
  }
}

/**
 * Sends a message to this user.
 * @method send
 * @memberof GuildMember
 * @instance
 * @param {string|MessagePayload|MessageCreateOptions} options The options to provide
 * @returns {Promise<Message>}
 * @example
 * // Send a direct message
 * guildMember.send('Hello!')
 *   .then(message => console.log(`Sent message: ${message.content} to ${guildMember.displayName}`))
 *   .catch(console.error);
 */

TextBasedChannel.applyToClass(GuildMember);

exports.GuildMember = GuildMember;
