/**
 *   Wechaty Chatbot SDK - https://github.com/wechaty/wechaty
 *
 *   @copyright 2016 Huan LI (李卓桓) <https://github.com/huan>, and
 *                   Wechaty Contributors <https://github.com/wechaty>.
 *
 *   Licensed under the Apache License, Version 2.0 (the "License");
 *   you may not use this file except in compliance with the License.
 *   You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *   Unless required by applicable law or agreed to in writing, software
 *   distributed under the License is distributed on an "AS IS" BASIS,
 *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *   See the License for the specific language governing permissions and
 *   limitations under the License.
 *
 */
import * as PUPPET           from '@juzi/wechaty-puppet'
import { FileBox, type FileBoxInterface }  from 'file-box'
import { concurrencyExecuter }    from 'rx-queue'
import type {
  Constructor,
}                                 from 'clone-class'

import {
  FOUR_PER_EM_SPACE,
  log,
}                           from '../config.js'
import {
  wechatyCaptureException,
}                           from '../raven.js'

import {
  guardQrCodeValue,
}                           from '../pure-functions/guard-qr-code-value.js'
import {
  isTemplateStringArray,
}                           from '../pure-functions/is-template-string-array.js'

import { RoomEventEmitter }             from '../schemas/mod.js'
import {
  poolifyMixin,
  wechatifyMixin,
  validationMixin,
}                                       from '../user-mixins/mod.js'
import {
  deliverSayableConversationPuppet, SayOptions, SayOptionsObject,
}                                       from '../sayable/mod.js'
import type {
  SayableSayer,
  Sayable,
}                                       from '../sayable/mod.js'
import { stringifyFilter }              from '../helper-functions/stringify-filter.js'

import {
  ContactInterface,
  ContactImpl,
}                       from './contact.js'
import type {
  MessageInterface,
}                       from './message.js'
import { isSayOptionsObject } from '../sayable/types.js'

const MixinBase = wechatifyMixin(
  poolifyMixin(
    RoomEventEmitter,
  )<RoomImplInterface>(),
)

/**
 * All WeChat rooms(groups) will be encapsulated as a Room.
 *
 * [Examples/Room-Bot]{@link https://github.com/wechaty/wechaty/blob/1523c5e02be46ebe2cc172a744b2fbe53351540e/examples/room-bot.ts}
 *
 */
class RoomMixin extends MixinBase implements SayableSayer {

  /**
   * Create a new room.
   *
   * @static
   * @param {ContactInterface[]} contactList
   * @param {string} [topic]
   * @returns {Promise<RoomInterface>}
   * @example <caption>Creat a room with 'lijiarui' and 'huan', the room topic is 'ding - created'</caption>
   * const helperContactA = await Contact.find({ name: 'lijiarui' })  // change 'lijiarui' to any contact in your WeChat
   * const helperContactB = await Contact.find({ name: 'huan' })  // change 'huan' to any contact in your WeChat
   * const contactList = [helperContactA, helperContactB]
   * console.log('Bot', 'contactList: %s', contactList.join(','))
   * const room = await Room.create(contactList, 'ding')
   * console.log('Bot', 'createDingRoom() new ding room created: %s', room)
   * await room.topic('ding - created')
   * await room.say('ding - created')
   */
  static async create (
    contactList : ContactInterface[],
    topic?      : string,
  ): Promise<RoomInterface> {
    log.verbose('Room', 'create(%s, %s)', contactList.join(','), topic)

    // if (contactList.length < 2) {
    //   throw new Error('contactList need at least 2 contact to create a new room')
    // }
    // Let puppet decide the minium number of initial room members

    try {
      const contactIdList = contactList.map(contact => contact.id)
      const roomId = await this.wechaty.puppet.roomCreate(contactIdList, topic)
      const room = this.load(roomId)
      return room
    } catch (e) {
      this.wechaty.emitError(e)
      log.error('Room', 'create() exception: %s', (e && (e as Error).stack) || (e as Error).message || (e as Error))
      throw e
    }
  }

  /**
   * Parse the dynamic QR Code of the room
   * @param {string} url
   * @returns {Promise<PUPPET.types.RoomParseDynamicQRCode>}
   */
  static async parseDynamicQRCode (url: string): Promise<PUPPET.types.RoomParseDynamicQRCode> {
    log.info('Room', 'parseDynamicQRCode(%s)', url)
    if (!url) {
      throw new Error('parseDynamicQRCode() url is required')
    }
    return this.wechaty.puppet.roomParseDynamicQRCode(url)
  }

  /**
   * The filter to find the room:  {topic: string | RegExp}
   *
   * @typedef    RoomQueryFilter
   * @property   {string} topic
   */

  /**
   * Find room by filter: {topic: string | RegExp}, return all the matched room.
   *
   * NOTE: The returned list would be limited by the underlying puppet
   * implementation of `puppet.roomList`. Some implementation (i.e.
   * wechaty-puppet-wechat) would only return rooms which have received messges
   * after a log-in.
   *
   * @static
   * @param {RoomQueryFilter} [query]
   * @returns {Promise<RoomInterface[]>}
   * @example
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in
   * const roomList = await bot.Room.findAll()                    // get the room list of the bot
   * const roomList = await bot.Room.findAll({topic: 'wechaty'})  // find all of the rooms with name 'wechaty'
   */
  static async findAll (
    query? : PUPPET.filters.Room,
  ): Promise<RoomInterface[]> {
    log.verbose('Room', 'findAll(%s)', JSON.stringify(query, stringifyFilter) || '')

    const roomIdList = await this.wechaty.puppet.roomSearch(query)

    let continuousErrorCount = 0
    let totalErrorCount = 0
    const totalErrorThreshold = Math.round(roomIdList.length / 5)
    const idToRoom = async (id: string) => {
      if (!this.wechaty.isLoggedIn) {
        throw new Error('wechaty not logged in')
      }
      const result = await this.wechaty.Room.find({ id }).catch(e => {
        this.wechaty.emitError(e)
        continuousErrorCount++
        totalErrorCount++
        if (continuousErrorCount > 5) {
          throw new Error('5 continuous errors!')
        }
        if (totalErrorCount > totalErrorThreshold) {
          throw new Error(`${totalErrorThreshold} total errors!`)
        }
      })
      continuousErrorCount = 0
      return result
    }

    /**
     * we need to use concurrencyExecuter to reduce the parallel number of the requests
     */
    const CONCURRENCY = 17
    const roomIterator = concurrencyExecuter(CONCURRENCY)(idToRoom)(roomIdList)

    const roomList: RoomInterface[] = []

    for await (const room of roomIterator) {
      if (room) {
        roomList.push(room)
      }
    }

    return roomList
  }

  /**
   * Try to find a room by filter: {topic: string | RegExp}. If get many, return the first one.
   *
   * NOTE: The search space is limited by the underlying puppet
   * implementation of `puppet.roomList`. Some implementation (i.e.
   * wechaty-puppet-wechat) would only return rooms which have received messges
   * after a log-in.
   *
   * @param {RoomQueryFilter} query
   * @returns {Promise<undefined | RoomInterface>} If can find the room, return Room, or return null
   * @example
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const roomList = await bot.Room.find()
   * const roomList = await bot.Room.find({topic: 'wechaty'})
   */

  static async find (
    query : string | PUPPET.filters.Room,
  ): Promise<undefined | RoomInterface> {
    log.silly('Room', 'find(%s)', JSON.stringify(query, stringifyFilter))

    if (typeof query === 'string') {
      query = { topic: query }
    }

    if (query.id) {
      const room = (this.wechaty.Room as any as typeof RoomImpl).load(query.id)
      try {
        await room.ready()
      } catch (e) {
        this.wechaty.emitError(e)
        return undefined
      }

      return room
    }

    const roomList = await this.findAll(query)
    // if (!roomList) {
    //   return null
    // }
    if (roomList.length < 1) {
      return undefined
    }

    if (roomList.length > 1) {
      log.warn('Room', 'find() got more than one(%d) result', roomList.length)
    }

    for (const [ idx, room ] of roomList.entries()) {
      // use puppet.roomValidate() to confirm double confirm that this roomId is valid.
      // https://github.com/wechaty/wechaty-puppet-padchat/issues/64
      // https://github.com/wechaty/wechaty/issues/1345
      const valid = await this.wechaty.puppet.roomValidate(room.id)
      if (valid) {
        log.verbose('Room', 'find() room<id=%s> is valid: return it', idx, room.id)
        return room
      } else {
        log.verbose('Room', 'find() room<id=%s> is invalid: skip it', idx, room.id)
      }
    }
    log.warn('Room', 'find() all %d rooms are invalid', roomList.length)
    return undefined
  }

  static async batchLoadRooms (roomIdList: string[]) {
    let continuousErrorCount = 0
    let totalErrorCount = 0
    const totalErrorThreshold = Math.round(roomIdList.length / 5)

    const idToRoom = async (id: string) => {
      if (!this.wechaty.isLoggedIn) {
        throw new Error('wechaty not logged in')
      }
      const result = await this.wechaty.Room.find({ id }).catch(e => {
        this.wechaty.emitError(e)
        continuousErrorCount++
        totalErrorCount++
        if (continuousErrorCount > 5) {
          throw new Error('5 continuous errors!')
        }
        if (totalErrorCount > totalErrorThreshold) {
          throw new Error(`${totalErrorThreshold} total errors!`)
        }
      })
      continuousErrorCount = 0
      return result
    }

    /**
     * we need to use concurrencyExecuter to reduce the parallel number of the requests
     */
    const CONCURRENCY = 17
    const roomIterator = concurrencyExecuter(CONCURRENCY)(idToRoom)(roomIdList)

    const roomList: RoomInterface[] = []

    for await (const room of roomIterator) {
      if (room) {
        roomList.push(room)
      }
    }
    return roomList
  }

  /**      const roomList: RoomInterface[] = []

   * @ignore
   *
   * Instance Properties
   *
   *
   */
  payload?: PUPPET.payloads.Room

  /**
   * @hideconstructor
   * @property {string}  id - Room id.
   * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   */
  constructor (
    public readonly id: string,
  ) {
    super()
    log.silly('Room', `constructor(${id})`)
  }

  /**
   * @ignore
   */
  override toString () {
    if (!this.payload) {
      return this.constructor.name
    }

    return `Room<${this.payload.topic || 'loading...'}>`
  }

  async * [Symbol.asyncIterator] (): AsyncIterableIterator<ContactInterface> {
    const memberList = await this.memberList()
    for (const contact of memberList) {
      yield contact
    }
  }

  /**
   * Proposal: add a handle field to RoomPayload #181
   *  @link https://github.com/wechaty/puppet/issues/181
   */
  handle (): undefined | string {
    return this.payload?.handle
  }

  /**
   * Force reload data for Room, Sync data from puppet API again.
   *
   * @returns {Promise<void>}
   * @example
   * await room.sync()
   */
  async sync (): Promise<void> {
    await this.wechaty.puppet.roomPayloadDirty(this.id)
    await this.wechaty.puppet.roomMemberPayloadDirty(this.id)
    await this.ready(true)
  }

  /**
   * Warning: `ready()` is for the framework internally use ONLY!
   *
   * Please not to use `ready()` at the user land.
   * If you want to sync data, use `sync()` instead.
   *
   * @ignore
   */
  async ready (
    forceSync = false,
  ): Promise<void> {
    log.silly('Room', 'ready()')

    if (!forceSync && this.isReady()) {
      return
    }

    this.payload = await this.wechaty.puppet.roomPayload(this.id)

    /**
     * Sync all room member contacts
     */
    const memberIdList = await this.wechaty.puppet.roomMemberList(this.id)

    const doReady = async (id: string): Promise<void> => {
      try {
        await this.wechaty.Contact.find({ id })
      } catch (e) {
        this.wechaty.emitError(e)
      }
    }

    /**
     * we need to use concurrencyExecuter to reduce the parallel number of the requests
     */
    const CONCURRENCY = 17
    const contactIterator = concurrencyExecuter(CONCURRENCY)(doReady)(memberIdList)

    for await (const contact of contactIterator) {
      void contact  // just a empty loop to wait all iterator finished
    }

  }

  /**
   * @ignore
   */
  isReady (): boolean {
    return !!(this.payload)
  }

  say (sayable:  Sayable)                                 : Promise<void | MessageInterface>
  say (text: string, options?: SayOptions): Promise<void | MessageInterface>
  say (text: string, ...options: SayOptions[]): Promise<void | MessageInterface>
  say (textList: TemplateStringsArray, ...varList: any[]) : Promise<void | MessageInterface>

  // Huan(202006): allow fall down to the defination to get more flexibility.
  // public say (...args: never[]): never

  /**
   * Send message inside Room, if set [replyTo], wechaty will mention the contact as well.
   * > Tips:
   * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   *
   * @param {(string | ContactInterface | FileBox)} textOrContactOrFileOrUrlOrMini - Send `text` or `media file` inside Room. <br>
   * You can use {@link https://www.npmjs.com/package/file-box|FileBox} to send file
   * @param {(ContactInterface | ContactInterface[])} [mention] - Optional parameter, send content inside Room, and mention @replyTo contact or contactList.
   * @returns {Promise<void | MessageInterface>}
   *
   * @example
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'wechaty'})
   *
   * // 1. Send text inside Room
   *
   * await room.say('Hello world!')
   * const msg = await room.say('Hello world!') // only supported by puppet-padplus
   *
   * // 2. Send media file inside Room
   * import { FileBox }  from 'wechaty'
   * const fileBox1 = FileBox.fromUrl('https://wechaty.github.io/wechaty/images/bot-qr-code.png')
   * const fileBox2 = FileBox.fromLocal('/tmp/text.txt')
   * await room.say(fileBox1)
   * const msg1 = await room.say(fileBox1) // only supported by puppet-padplus
   * await room.say(fileBox2)
   * const msg2 = await room.say(fileBox2) // only supported by puppet-padplus
   *
   * // 3. Send Contact Card in a room
   * const contactCard = await bot.Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any of the room member
   * await room.say(contactCard)
   * const msg = await room.say(contactCard) // only supported by puppet-padplus
   *
   * // 4. Send text inside room and mention @mention contact
   * const contact = await bot.Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any of the room member
   * await room.say('Hello world!', contact)
   * const msg = await room.say('Hello world!', contact) // only supported by puppet-padplus
   *
   * // 5. Send text inside room and mention someone with Tagged Template
   * const contact2 = await bot.Contact.find({name: 'zixia'}) // change 'zixia' to any of the room member
   * await room.say`Hello ${contact}, here is the world ${contact2}`
   * const msg = await room.say`Hello ${contact}, here is the world ${contact2}` // only supported by puppet-padplus
   *
   * // 6. send url link in a room
   *
   * const urlLink = new UrlLink ({
   *   description : 'WeChat Bot SDK for Individual Account, Powered by TypeScript, Docker, and Love',
   *   thumbnailUrl: 'https://avatars0.githubusercontent.com/u/25162437?s=200&v=4',
   *   title       : 'Welcome to Wechaty',
   *   url         : 'https://github.com/wechaty/wechaty',
   * })
   * await room.say(urlLink)
   * const msg = await room.say(urlLink) // only supported by puppet-padplus
   *
   * // 7. send mini program in a room
   *
   * const miniProgram = new MiniProgram ({
   *   username           : 'gh_xxxxxxx',     //get from mp.weixin.qq.com
   *   appid              : '',               //optional, get from mp.weixin.qq.com
   *   title              : '',               //optional
   *   pagepath           : '',               //optional
   *   description        : '',               //optional
   *   thumbnailurl       : '',               //optional
   * })
   * await room.say(miniProgram)
   * const msg = await room.say(miniProgram) // only supported by puppet-padplus
   *
   * // 8. send location in a room
   * const location = new Location ({
   *   accuracy  : 15,
   *   address   : '北京市北京市海淀区45 Chengfu Rd',
   *   latitude  : 39.995120999999997,
   *   longitude : 116.334154,
   *   name      : '东升乡人民政府(海淀区成府路45号)',
   * })
   * await room.say(location)
   * const msg = await room.say(location)
   */
  async say (
    sayable    : Sayable | TemplateStringsArray,
    ...varList : unknown[]
  ): Promise<void | MessageInterface> {

    log.verbose('Room', 'say(%s, %s)',
      sayable,
      varList.join(', '),
    )

    let msgId
    let text: string

    if (isTemplateStringArray(sayable)) {
      msgId = await this.sayTemplateStringsArray(
        sayable as TemplateStringsArray,
        ...varList,
      )
    } else if (typeof sayable === 'string') {
      /**
       * 1. string
       */
      let mentionList: (ContactInterface | '@all')[] = []
      let quoteMessage: MessageInterface | undefined
      let options: SayOptionsObject

      if (varList.length > 0) {
        if (isSayOptionsObject(varList[0])) {
          options = varList[0] as SayOptionsObject
          mentionList = options.mentionList || []
          quoteMessage = options.quoteMessage
        } else {
          mentionList = varList as ContactInterface[]
        }

        const allIsContact = mentionList.every(c => ContactImpl.valid(c) || (c as string) === '@all')
        if (!allIsContact) {
          throw new Error('mentionList must be contact when not using TemplateStringsArray function call.')
        }

        const AT_SEPARATOR = FOUR_PER_EM_SPACE
        const mentionAlias = await Promise.all(mentionList.map(async contact =>
          '@' + (contact === '@all' ? 'all' : await this.alias(contact) || contact.name()),
        ))
        const mentionText = mentionAlias.join(AT_SEPARATOR)

        text = mentionText + ' ' + sayable
      } else {
        text = sayable
      }
      // const receiver = {
      //   contactId : (mentionList.length && mentionList[0].id) || undefined,
      //   roomId    : this.id,
      // }
      if (quoteMessage) {
        msgId = await this.wechaty.puppet.messageSendText(
          this.id,
          text,
          {
            mentionIdList: mentionList.map(c => c === '@all' ? '@all' : c.id),
            quoteId: quoteMessage.id,
          },
        )
      } else {
        msgId = await this.wechaty.puppet.messageSendText(
          this.id,
          text,
          mentionList.map(c => c === '@all' ? '@all' : c.id),
        )
      }

    } else {
      msgId = await deliverSayableConversationPuppet(this.wechaty.puppet)(this.id)(sayable)
    }

    if (msgId) {
      const msg = await this.wechaty.Message.find({ id: msgId })
      return msg
    }
  }

  private async sayTemplateStringsArray (
    textList: TemplateStringsArray,
    ...varList: unknown[]
  ) {
    const mentionList = varList.filter(c => ContactImpl.valid(c) || c === '@all') as (ContactInterface | '@all')[]

    // const receiver = {
    //   contactId : (mentionList.length && mentionList[0].id) || undefined,
    //   roomId    : this.id,
    // }
    if (varList.length === 0) {
      /**
       * No mention in the string
       */
      return this.wechaty.puppet.messageSendText(
        this.id,
        textList[0]!,
      )
    // TODO(huan) 20191222 it seems the following code will not happen,
    //            because it's equal the mentionList.length === 0 situation?
    //
    // XXX(huan) 20200101: See issue https://github.com/wechaty/wechaty/issues/1893
    //           This is an anti-pattern usage.
    //
    // } else if (textList.length === 1) {
    //   /**
    //    * Constructed mention string, skip inserting @ signs
    //    */
    //   return this.wechaty.puppet.messageSendText(
    //     receiver,
    //     textList[0],
    //     mentionList.map(c => c.id),
    //   )
    } else {  // mentionList.length > 0
      /**
       * Mention in the string
       */
      const textListLength = textList.length
      const varListLength  = varList.length
      if (textListLength - varListLength !== 1) {
        throw new Error('Can not say message, invalid Template String Array.')
      }
      let finalText = ''

      let i = 0
      for (; i < varListLength; i++) {
        if (ContactImpl.valid(varList[i])) {
          const mentionContact: ContactInterface = varList[i] as any
          const mentionName = await this.alias(mentionContact) || mentionContact.name()
          finalText += textList[i] + '@' + mentionName
        } else {
          finalText += textList[i]! + varList[i]!
        }
      }
      finalText += textList[i]

      return this.wechaty.puppet.messageSendText(
        this.id,
        finalText,
        mentionList.map(c => c === '@all' ? '@all' : c.id),
      )
    }
  }

  /**
   * @desc       Room Class Event Type
   * @typedef    RoomEventName
   * @property   {string}  join  - Emit when anyone join any room.
   * @property   {string}  topic - Get topic event, emitted when someone change room topic.
   * @property   {string}  leave - Emit when anyone leave the room.<br>
   *                               If someone leaves the room by themselves, WeChat will not notice other people in the room, so the bot will never get the "leave" event.
   */

  /**
   * @desc       Room Class Event Function
   * @typedef    RoomEventFunction
   * @property   {Function} room-join       - (this: Room, inviteeList: Contact[] , inviter: Contact)  => void
   * @property   {Function} room-topic      - (this: Room, topic: string, oldTopic: string, changer: Contact) => void
   * @property   {Function} room-leave      - (this: Room, leaver: Contact) => void
   */

  /**
   * @listens Room
   * @param   {RoomEventName}      event      - Emit WechatyEvent
   * @param   {RoomEventFunction}  listener   - Depends on the WechatyEvent
   * @return  {this}                          - this for chain
   *
   * @example <caption>Event:join </caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'topic of your room'}) // change `event-room` to any room topic in your WeChat
   * if (room) {
   *   room.on('join', (room, inviteeList, inviter) => {
   *     const nameList = inviteeList.map(c => c.name()).join(',')
   *     console.log(`Room got new member ${nameList}, invited by ${inviter}`)
   *   })
   * }
   *
   * @example <caption>Event:leave </caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'topic of your room'}) // change `event-room` to any room topic in your WeChat
   * if (room) {
   *   room.on('leave', (room, leaverList) => {
   *     const nameList = leaverList.map(c => c.name()).join(',')
   *     console.log(`Room lost member ${nameList}`)
   *   })
   * }
   *
   * @example <caption>Event:message </caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'topic of your room'}) // change `event-room` to any room topic in your WeChat
   * if (room) {
   *   room.on('message', (message) => {
   *     console.log(`Room received new message: ${message}`)
   *   })
   * }
   *
   * @example <caption>Event:topic </caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'topic of your room'}) // change `event-room` to any room topic in your WeChat
   * if (room) {
   *   room.on('topic', (room, topic, oldTopic, changer) => {
   *     console.log(`Room topic changed from ${oldTopic} to ${topic} by ${changer.name()}`)
   *   })
   * }
   *
   * @example <caption>Event:invite </caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'topic of your room'}) // change `event-room` to any room topic in your WeChat
   * if (room) {
   *   room.on('invite', roomInvitation => roomInvitation.accept())
   * }
   *
   */
  // public on (event: RoomEventName, listener: (...args: any[]) => any): this {
  //   log.verbose('Room', 'on(%s, %s)', event, typeof listener)

  //   super.on(event, listener) // Room is `Sayable`
  //   return this
  // }

  /**
   * Add contact in a room
   *
   * > Tips:
   * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   * >
   * > see {@link https://github.com/wechaty/wechaty/issues/1441|Web version of WeChat closed group interface}
   *
   * @param {ContactInterface} contact
   * @returns {Promise<void>}
   * @example
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const contact = await bot.Contact.find({name: 'lijiarui'}) // change 'lijiarui' to any contact in your WeChat
   * const room = await bot.Room.find({topic: 'WeChat'})        // change 'WeChat' to any room topic in your WeChat
   * if (room) {
   *   try {
   *      await room.add(contact)
   *   } catch(e) {
   *      console.error(e)
   *   }
   * }
   */
  async add (contact: ContactInterface, quoteIds?: string[]): Promise<void> {
    log.verbose('Room', 'add(%s)', contact)
    await this.wechaty.puppet.roomAdd(this.id, contact.id, false, quoteIds)
  }

  /**
   * Remove a contact from the room
   * It works only when the bot is the owner of the room
   *
   * > Tips:
   * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   * >
   * > see {@link https://github.com/wechaty/wechaty/issues/1441|Web version of WeChat closed group interface}
   *
   * @param {ContactInterface} contact
   * @returns {Promise<void>}
   * @example
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'WeChat'})          // change 'WeChat' to any room topic in your WeChat
   * const contact = await bot.Contact.find({name: 'lijiarui'})   // change 'lijiarui' to any room member in the room you just set
   * if (room) {
   *   try {
   *      await room.remove(contact)
   *   } catch(e) {
   *      console.error(e)
   *   }
   * }
   */
  async remove (contacts: ContactInterface | ContactInterface[]): Promise<void> {
    log.verbose('Room', 'del(%s)', contacts)

    let contactIds: string[]
    if (Array.isArray(contacts)) {
      contactIds = contacts.map(c => c.id)
    } else {
      contactIds = [ contacts.id ]
    }
    await this.wechaty.puppet.roomDel(this.id, contactIds)
    // this.delLocal(contact)
  }

  /**
   * Huan(202106): will be removed after Dec 31, 2023
   * @deprecated use remove(contact) instead.
   */
  async del (contact: ContactImpl | ContactImpl[]): Promise<void> {
    log.warn('Room', 'del() is DEPRECATED, use remove() instead.\n%s', new Error().stack)
    return this.remove(contact)
  }

  async dismiss (): Promise<void> {
    log.verbose('Room', 'dismiss()')

    if (!this.owner()?.self()) {
      throw new Error('you cannot dismiss a room you don\'t own')
    }

    return this.wechaty.puppet.roomDismiss(this.id)
  }

  // private delLocal(contact: Contact): void {
  //   log.verbose('Room', 'delLocal(%s)', contact)

  //   const memberIdList = this.payload && this.payload.memberIdList
  //   if (memberIdList && memberIdList.length > 0) {
  //     for (let i = 0; i < memberIdList.length; i++) {
  //       if (memberIdList[i] === contact.id) {
  //         memberIdList.splice(i, 1)
  //         break
  //       }
  //     }
  //   }
  // }

  /**
   * Bot quit the room itself
   *
   * > Tips:
   * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   *
   * @returns {Promise<void>}
   * @example
   * await room.quit()
   */
  async quit (): Promise<void> {
    log.verbose('Room', 'quit() %s', this)
    await this.wechaty.puppet.roomQuit(this.id)
  }

  async topic ()                : Promise<string>
  async topic (newTopic: string): Promise<void>

  /**
   * SET/GET topic from the room
   *
   * @param {string} [newTopic] If set this para, it will change room topic.
   * @returns {Promise<string | void>}
   *
   * @example <caption>When you say anything in a room, it will get room topic. </caption>
   * const bot = new Wechaty()
   * bot
   * .on('message', async m => {
   *   const room = m.room()
   *   if (room) {
   *     const topic = await room.topic()
   *     console.log(`room topic is : ${topic}`)
   *   }
   * })
   * .start()
   *
   * @example <caption>When you say anything in a room, it will change room topic. </caption>
   * const bot = new Wechaty()
   * bot
   * .on('message', async m => {
   *   const room = m.room()
   *   if (room) {
   *     const oldTopic = await room.topic()
   *     await room.topic('change topic to wechaty!')
   *     console.log(`room topic change from ${oldTopic} to ${room.topic()}`)
   *   }
   * })
   * .start()
   */
  async topic (newTopic?: string): Promise<void | string> {
    log.verbose('Room', 'topic(%s)', newTopic || '')
    if (!this.isReady()) {
      log.warn('Room', 'topic() room not ready')
      throw new Error('not ready')
    }

    if (typeof newTopic === 'undefined') {
      if (this.payload && this.payload.topic) {
        return this.payload.topic
      } else {
        const memberIdList = await this.wechaty.puppet.roomMemberList(this.id)
        const memberIdListWithoutBot = memberIdList
          .filter(id => id !== this.wechaty.puppet.currentUserId)
        const memberList = await (this.wechaty.Contact as any as typeof ContactImpl).batchLoadContacts(memberIdListWithoutBot)

        let defaultTopic = (memberList[0] && memberList[0].name()) || ''
        for (let i = 1; i < 3 && memberList[i]; i++) {
          defaultTopic += ',' + memberList[i]!.name()
        }
        return defaultTopic
      }
    }

    const future = this.wechaty.puppet
      .roomTopic(this.id, newTopic)
      .catch(e => {
        log.warn('Room', 'topic(newTopic=%s) exception: %s',
          newTopic, (e && e.message) || e,
        )
        wechatyCaptureException(e)
      })

    return future
  }

  async announce ()             : Promise<string>
  async announce (text: string) : Promise<void>

  /**
   * SET/GET announce from the room
   * > Tips: It only works when bot is the owner of the room.
   * >
   * > This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   *
   * @param {string} [text] If set this para, it will change room announce.
   * @returns {(Promise<void | string>)}
   *
   * @example <caption>When you say anything in a room, it will get room announce. </caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'your room'})
   * const announce = await room.announce()
   * console.log(`room announce is : ${announce}`)
   *
   * @example <caption>When you say anything in a room, it will change room announce. </caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'your room'})
   * const oldAnnounce = await room.announce()
   * await room.announce('change announce to wechaty!')
   * console.log(`room announce change from ${oldAnnounce} to ${room.announce()}`)
   */
  async announce (text?: string): Promise<void | string> {
    log.verbose('Room', 'announce(%s)',
      typeof text === 'undefined'
        ? ''
        : `"${text || ''}"`,
    )

    if (typeof text === 'undefined') {
      const announcement = await this.wechaty.puppet.roomAnnounce(this.id)
      return announcement
    } else {
      await this.wechaty.puppet.roomAnnounce(this.id, text)
    }
  }

  /**
   * Get QR Code Value of the Room from the room, which can be used as scan and join the room.
   * > Tips:
   * 1. This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   * 2. The return should be the QR Code Data, instead of the QR Code Image. (the data should be less than 8KB. See: https://stackoverflow.com/a/12764370/1123955 )
   * @returns {Promise<string>}
   */
  async qrCode (): Promise<string> {
    log.verbose('Room', 'qrCode()')
    const qrcodeValue = await this.wechaty.puppet.roomQRCode(this.id)
    return guardQrCodeValue(qrcodeValue)
  }

  /**
   * Return contact's roomAlias in the room
   * @param {ContactInterface} contact
   * @returns {Promise<string | null>} - If a contact has an alias in room, return string, otherwise return null
   * @example
   * const bot = new Wechaty()
   * bot
   * .on('message', async m => {
   *   const room = m.room()
   *   const contact = m.from()
   *   if (room) {
   *     const alias = await room.alias(contact)
   *     console.log(`${contact.name()} alias is ${alias}`)
   *   }
   * })
   * .start()
   */
  async alias (contact: ContactInterface): Promise<undefined | string> {
    const memberPayload = await this.wechaty.puppet.roomMemberPayload(this.id, contact.id)

    if (memberPayload.roomAlias) {
      return memberPayload.roomAlias
    }

    return undefined
  }

  async joinScene (contact: ContactInterface): Promise<PUPPET.types.RoomMemberJoinScene> {
    const memberPayload = await this.wechaty.puppet.roomMemberPayload(this.id, contact.id)

    return memberPayload.joinScene || PUPPET.types.RoomMemberJoinScene.Unknown
  }

  async joinTime (contact: ContactInterface): Promise<undefined | number> {
    const memberPayload = await this.wechaty.puppet.roomMemberPayload(this.id, contact.id)
    if (memberPayload.joinTime) {
      return memberPayload.joinTime
    }
    return undefined
  }

  async joinInviter (contact: ContactInterface): Promise<undefined | ContactInterface> {
    const memberPayload = await this.wechaty.puppet.roomMemberPayload(this.id, contact.id)
    const inviterId = memberPayload.inviterId
    if (!inviterId) {
      return
    }
    return this.wechaty.Contact.find({ id: memberPayload.inviterId })
  }

  async readMark (hasRead: boolean): Promise<void>
  async readMark (): Promise<boolean>

  /**
   * Mark the conversation as read
   * @param { undefined | boolean } hasRead
   *
   * @example
   * const bot = new Wechaty()
   * const room = await bot.Room.find({topic: 'xxx'})
   * await room.readMark()
   */
  async readMark (hasRead?: boolean): Promise<void | boolean> {
    try {
      if (typeof hasRead === 'undefined') {
        return this.wechaty.puppet.conversationReadMark(this.id)
      } else {
        await this.wechaty.puppet.conversationReadMark(this.id, hasRead)
      }
    } catch (e) {
      this.wechaty.emitError(e)
      log.error('Room', 'readMark() exception: %s', (e as Error).message)
    }
  }

  /**
   * Check if the room has member `contact`, the return is a Promise and must be `await`-ed
   *
   * @param {ContactInterface} contact
   * @returns {Promise<boolean>} Return `true` if has contact, else return `false`.
   * @example <caption>Check whether 'lijiarui' is in the room 'wechaty'</caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const contact = await bot.Contact.find({name: 'lijiarui'})   // change 'lijiarui' to any of contact in your WeChat
   * const room = await bot.Room.find({topic: 'wechaty'})         // change 'wechaty' to any of the room in your WeChat
   * if (contact && room) {
   *   if (await room.has(contact)) {
   *     console.log(`${contact.name()} is in the room wechaty!`)
   *   } else {
   *     console.log(`${contact.name()} is not in the room wechaty!`)
   *   }
   * }
   */
  async has (contact: ContactInterface): Promise<boolean> {
    const memberIdList = await this.wechaty.puppet.roomMemberList(this.id)

    // if (!memberIdList) {
    //   return false
    // }

    return memberIdList
      .filter(id => id === contact.id)
      .length > 0
  }

  async memberAll ()                                : Promise<ContactInterface[]>
  async memberAll (name: string)                    : Promise<ContactInterface[]>
  async memberAll (filter: PUPPET.filters.RoomMember) : Promise<ContactInterface[]>

  /**
   * The way to search member by Room.member()
   *
   * @typedef    RoomMemberQueryFilter
   * @property   {string} name            -Find the contact by WeChat name in a room, equal to `Contact.name()`.
   * @property   {string} roomAlias       -Find the contact by alias set by the bot for others in a room.
   * @property   {string} contactAlias    -Find the contact by alias set by the contact out of a room, equal to `Contact.alias()`.
   * [More Detail]{@link https://github.com/wechaty/wechaty/issues/365}
   */

  /**
   * Find all contacts in a room
   *
   * #### definition
   * - `name`                 the name-string set by user-self, should be called name, equal to `Contact.name()`
   * - `roomAlias`            the name-string set by user-self in the room, should be called roomAlias
   * - `contactAlias`         the name-string set by bot for others, should be called alias, equal to `Contact.alias()`
   * @param {(RoomMemberQueryFilter | string)} [query] -Optional parameter, When use memberAll(name:string), return all matched members, including name, roomAlias, contactAlias
   * @returns {Promise<ContactInterface[]>}
   * @example
   * const roomList:Contact[] | null = await room.findAll()
   * if(roomList)
   *  console.log(`room all member list: `, roomList)
   * const memberContactList: Contact[] | null =await room.findAll(`abc`)
   * console.log(`contact list with all name, room alias, alias are abc:`, memberContactList)
   */
  async memberAll (
    query?: string | PUPPET.filters.RoomMember,
  ): Promise<ContactInterface[]> {
    log.silly('Room', 'memberAll(%s)',
      JSON.stringify(query) || '',
    )

    if (!query) {
      return this.memberList()
    }

    const contactIdList   = await this.wechaty.puppet.roomMemberSearch(this.id, query)
    const contactList = await (this.wechaty.Contact as any as typeof ContactImpl).batchLoadContacts(contactIdList)

    return contactList
  }

  async member (name  : string)                 : Promise<undefined | ContactInterface>
  async member (filter: PUPPET.filters.RoomMember): Promise<undefined | ContactInterface>

  /**
   * Find all contacts in a room, if get many, return the first one.
   *
   * @param {(RoomMemberQueryFilter | string)} queryArg -When use member(name:string), return all matched members, including name, roomAlias, contactAlias
   * @returns {Promise<undefined | ContactInterface>}
   *
   * @example <caption>Find member by name</caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'wechaty'})           // change 'wechaty' to any room name in your WeChat
   * if (room) {
   *   const member = await room.member('lijiarui')             // change 'lijiarui' to any room member in your WeChat
   *   if (member) {load
   * @example <caption>Find member by MemberQueryFilter</caption>
   * const bot = new Wechaty()
   * await bot.start()
   * // after logged in...
   * const room = await bot.Room.find({topic: 'wechaty'})          // change 'wechaty' to any room name in your WeChat
   * if (room) {
   *   const member = await room.member({name: 'lijiarui'})        // change 'lijiarui' to any room member in your WeChat
   *   if (member) {
   *     console.log(`wechaty room got the member: ${member.name()}`)
   *   } else {
   *     console.log(`cannot get member in wechaty room!`)
   *   }
   * }
   */
  async member (
    queryArg: string | PUPPET.filters.RoomMember,
  ): Promise<undefined | ContactInterface> {
    log.verbose('Room', 'member(%s)', JSON.stringify(queryArg))

    let memberList: ContactInterface[]
    // ISSUE #622
    // error TS2345: Argument of type 'string | MemberQueryFilter' is not assignable to parameter of type 'MemberQueryFilter' #622
    if (typeof queryArg === 'string') {
      memberList =  await this.memberAll(queryArg)
    } else {
      memberList =  await this.memberAll(queryArg)
    }

    if (memberList.length <= 0) {
      return undefined
    }

    if (memberList.length > 1) {
      log.warn('Room', 'member(%s) get %d contacts, use the first one by default', JSON.stringify(queryArg), memberList.length)
    }
    return memberList[0]!
  }

  /**
   * Huan(202110):
   *  - Q: why this method marked as `privated` before?
   *  - A: it is for supporting the `memberAll()` API
   *
   * Get all room member from the room
   *
   * @returns {Promise<ContactInterface[]>}
   * @example
   * await room.memberList()
   */
  protected async memberList (): Promise<ContactInterface[]> {
    log.verbose('Room', 'memberList()')

    const memberIdList = await this.wechaty.puppet.roomMemberList(this.id)

    // if (!memberIdList) {
    //   log.warn('Room', 'memberList() not ready')
    //   return []
    // }

    const contactList = await (this.wechaty.Contact as any as typeof ContactImpl).batchLoadContacts(memberIdList)

    return contactList
  }

  /**
   * Get room's owner from the room.
   * > Tips:
   * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   * @returns {(ContactInterface | undefined)}
   * @example
   * const owner = room.owner()
   */
  owner (): undefined | ContactInterface {
    log.verbose('Room', 'owner()')

    const ownerId = this.payload && this.payload.ownerId
    if (!ownerId) {
      return undefined
    }

    const owner = (this.wechaty.Contact as typeof ContactImpl).load(ownerId)
    return owner
  }

  /**
   * Get room's admin list from the room.
   * > Tips:
   * This function is depending on the Puppet Implementation, see [puppet-compatible-table](https://github.com/wechaty/wechaty/wiki/Puppet#3-puppet-compatible-table)
   * @returns {(ContactInterface[])}
   * @example
   * const adminList = room.adminList()
   */
  async adminList (): Promise<ContactInterface[]> {
    log.verbose('Room', 'adminList()')

    if (!this.isReady()) {
      log.warn('Room', 'adminList() room not ready')
      return []
    }

    if (this.payload!.adminIdList.length === 0) {
      return []
    }

    const adminList = await (this.wechaty.Contact as any as typeof ContactImpl).batchLoadContacts(this.payload!.adminIdList)
    return adminList
  }

  /**
   * Get avatar from the room.
   * @returns {FileBox}
   * @example
   * const fileBox = await room.avatar()
   * const name = fileBox.name
   * fileBox.toFile(name)
   */
  async avatar (): Promise<FileBoxInterface>
  async avatar (avatar: FileBoxInterface): Promise<void>
  async avatar (avatar?: FileBoxInterface): Promise<FileBoxInterface | void> {
    log.verbose('Room', 'avatar()')

    if (!avatar && this.payload?.avatar) {
      return FileBox.fromUrl(this.payload.avatar)
    }

    return this.wechaty.puppet.roomAvatar(this.id, avatar)
  }

  additionalInfo (): undefined | any {
    let additionalInfoObj = {}
    if (this.payload?.additionalInfo) {
      try {
        additionalInfoObj = JSON.parse(this.payload.additionalInfo)
      } catch (e) {
        log.warn('Room', 'additionalInfo() parse failed, additionalInfo: %s', this.payload.additionalInfo)
      }
    }
    return additionalInfoObj
  }

  async remark (remark?: string): Promise<undefined | string> {
    if (typeof remark === 'string') {
      await this.wechaty.puppet.roomRemark(this.id, remark)
      await this.sync()
    } else {
      return this.payload?.remark
    }
    return undefined
  }

  permission (permission?: Partial<PUPPET.types.RoomPermission>): Promise<void | Partial<PUPPET.types.RoomPermission>> {
    return this.wechaty.puppet.roomPermission(this.id, permission)
  }

  async addAdmins (contactList: ContactInterface[]): Promise<void> {
    log.verbose('Room', 'addAdmins(%s)', contactList)
    await this.wechaty.puppet.roomAddAdmins(this.id, contactList.map(c => c.id))
  }

  async delAdmins (contactList: ContactInterface[]): Promise<void> {
    log.verbose('Room', 'delAdmins(%s)', contactList)
    await this.wechaty.puppet.roomDelAdmins(this.id, contactList.map(c => c.id))
  }

  async transfer (contact: ContactInterface): Promise<void> {
    if (!(await this.has(contact))) {
      throw new Error('cannot transfer room owner to a contact that\'s not in room.')
    }

    await this.wechaty.puppet.roomOwnerTransfer(this.id, contact.id)
  }

  external (): boolean | undefined {
    return this.payload?.external
  }

  createDate (): Date | undefined {
    const timestamp = this.payload?.createTime
    if (timestamp) {
      return new Date(timestamp)
    }
    return undefined
  }

  async memberPayloads () {
    const memberIdList = await this.wechaty.puppet.roomMemberList(this.id)
    if (typeof this.wechaty.puppet.batchRoomMemberPayload === 'function') {
      const payloadMap = await this.wechaty.puppet.batchRoomMemberPayload(this.id, memberIdList)
      return payloadMap
    } else {
      const map = new Map<string, PUPPET.payloads.RoomMember>()
      for (const id of memberIdList) {
        const payload = await this.wechaty.puppet.roomMemberPayload(this.id, id)
        map.set(id, payload)
      }
      return map
    }
  }

}

class RoomImpl extends validationMixin(RoomMixin)<RoomImplInterface>() {}
interface RoomImplInterface extends RoomImpl {}

type RoomProtectedProperty =
  | 'ready'

type RoomInterface = Omit<RoomImplInterface, RoomProtectedProperty>

type RoomConstructor = Constructor<
  RoomImplInterface,
  Omit<typeof RoomImpl, 'load'>
>

export type {
  RoomConstructor,
  RoomProtectedProperty,
  RoomInterface,
}
export {
  RoomImpl,
}
