/**
 *   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.
 *
 */

/**
 * Issue #2245 - New Wechaty User Module (WUM):
 *  `Post` for supporting Moments, Channel, Tweet, Weibo, Facebook feeds, etc.
 *
 *  @see https://github.com/wechaty/wechaty/issues/2245#issuecomment-914886835
 */
import * as PUPPET      from '@juzi/wechaty-puppet'
import { log }          from '@juzi/wechaty-puppet'

import type {
  Constructor,
}                     from 'clone-class'

import {
  validationMixin,
  wechatifyMixinBase,
}                       from '../user-mixins/mod.js'

import type { Sayable } from '../sayable/mod.js'
import {
  sayableToPayload,
  payloadToSayableWechaty,
}                       from '../sayable/mod.js'

import { ContactImpl, ContactInterface } from './contact.js'
import { concurrencyExecuter } from 'rx-queue'
import type { LocationInterface } from './location.js'

interface Tap {
  contact: ContactInterface
  type: PUPPET.types.Tap,
  date: Date
}

class PostBuilder {

  payload: PUPPET.payloads.PostClient

  /**
   * Wechaty Sayable List
   */
  sayableList: Sayable[] = []

  /**
   * Huan(202201): why use Impl as a parameter?
   */
  static new (Impl: typeof PostMixin) { return new this(Impl) }
  protected constructor (
    protected Impl: typeof PostMixin,
  ) {
    this.payload = {
      sayableList: [],  // Puppet Sayable Payload List
      type: PUPPET.types.Post.Unspecified,
    }
  }

  add (sayable: Sayable): this {
    this.sayableList.push(sayable)
    return this
  }

  type (type: PUPPET.types.Post): this {
    this.payload.type = type
    return this
  }

  reply (post: PostInterface): this {
    if (!post.id) {
      throw new Error('can not link to a post without id: ' + JSON.stringify(post))
    }

    this.payload.parentId = post.payload.id
    this.payload.rootId   = post.payload.rootId || post.payload.id

    return this
  }

  location (location: LocationInterface) {
    this.payload.location = {
      ...location.payload,
    }
  }

  visible (contactList: ContactInterface[]) {
    const contactIds = contactList.map(contact => {
      if (!ContactImpl.valid(contact)) {
        log.warn(`expect contact instance but ${contact} is not a contact`)
        return ''
      }
      return contact.id
    })
    this.payload.visibleList = contactIds.filter(id => !!id)
  }

  async build (): Promise<PostInterface> {
    const sayablePayloadListNested = await Promise.all(
      this.sayableList.map(sayableToPayload),
    )
    this.payload.sayableList = sayablePayloadListNested.filter(Boolean) as PUPPET.payloads.Sayable[]

    return this.Impl.create(this.payload)
  }

}

class PostMixin extends wechatifyMixinBase() {

  static builder (): PostBuilder { return PostBuilder.new(this) }

  /**
   *
   * Create
   *
   */
  static create (
    payload: PUPPET.payloads.PostClient,
  ): PostInterface {
    log.verbose('Post', 'create()')

    return new this(payload)
  }

  static load (id: string): PostInterface {
    log.verbose('Post', 'static load(%s)', id)

    /**
     * Must NOT use `Post` at here
     * MUST use `this` at here
     *
     * because the class will be `cloneClass`-ed
     */
    const post = new this(id)

    return post
  }

  static async find (
    filter: PUPPET.filters.Post,
  ): Promise<undefined | PostInterface> {
    log.verbose('Post', 'find(%s)',
      JSON.stringify(filter),
    )

    if (filter.id) {
      const post = this.wechaty.Post.load(filter.id)
      await post.ready()
      return post
    }

    const [ postList ] = await this.findAll(filter, { pageSize: 1 })
    if (postList.length > 0) {
      return postList[0]
    }
    return undefined
  }

  static async findAll (
    filter      : PUPPET.filters.Post,
    pagination? : PUPPET.filters.PaginationRequest,
  ): Promise<[
    postList       : PostInterface[],
    nextPageToken? : string,
  ]> {
    log.verbose('Post', 'findAll(%s%s)',
      JSON.stringify(filter),
      pagination ? ', ' + JSON.stringify(pagination) : '',
    )

    const {
      nextPageToken,
      response: postIdList,
    } = await this.wechaty.puppet.postSearch(
      filter,
      pagination,
    )

    const idToPost = async (id: string) =>
      this.wechaty.Post.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 postIterator = concurrencyExecuter(CONCURRENCY)(idToPost)(postIdList)

    const postList: PostInterface[] = []
    for await (const post of postIterator) {
      if (post) {
        postList.push(post)
      }
    }

    return [ postList, nextPageToken ]
  }

  protected _payload?: PUPPET.payloads.Post
  get payload (): PUPPET.payloads.Post {
    if (!this._payload) {
      throw new Error('no payload, need to call `ready()` first.')
    }
    return this._payload
  }

  readonly id?: string

  /*
   * @hideconstructor
   */
  constructor (
    idOrPayload: string | PUPPET.payloads.Post,
  ) {
    super()
    log.verbose('Post', 'constructor(%s)',
      typeof idOrPayload === 'string'
        ? idOrPayload
        : JSON.stringify(idOrPayload.id),
    )

    if (typeof idOrPayload === 'string') {
      this.id = idOrPayload
    } else {
      this._payload = idOrPayload
      this.id = idOrPayload.id
    }
  }

  counter (): PUPPET.payloads.PostServer['counter'] {
    return {
      children   : 0,
      descendant : 0,
      taps       : {},
      ...(PUPPET.payloads.isPostServer(this.payload) && this.payload.counter),
    }
  }

  async author (): Promise<ContactInterface> {
    log.silly('Post', 'author()')

    if (PUPPET.payloads.isPostClient(this.payload)) {
      return this.wechaty.currentUser
    }

    const author = await this.wechaty.Contact.find({ id: this.payload.contactId })
    if (!author) {
      throw new Error('no author for id: ' + this.payload.contactId)
    }
    return author
  }

  async root (): Promise<undefined | PostInterface> {
    log.silly('Post', 'root()')

    if (!this.payload.rootId) {
      return undefined
    }

    const post = this.wechaty.Post.load(this.payload.rootId)
    await post.ready()
    return post
  }

  async parent (): Promise<undefined | PostInterface> {
    log.silly('Post', 'parent()')
    if (!this.payload.parentId) {
      return undefined
    }

    const post = this.wechaty.Post.load(this.payload.parentId)
    await post.ready()
    return post
  }

  async sync (): Promise<void> {
    log.silly('Post', 'sync()')

    if (!this.id) {
      throw new Error('no post id found')
    }

    this._payload = await this.wechaty.puppet.postPayload(this.id)
  }

  async ready (): Promise<void> {
    log.silly('Post', 'ready()')

    if (!this.id) {
      throw new Error('no post id found')
    }

    if (this._payload) {
      return
    }

    await this.sync()
  }

  async * [Symbol.asyncIterator] (): AsyncIterableIterator<Sayable> {
    log.verbose('Post', '[Symbol.asyncIterator]()')

    const payloadToSayable = payloadToSayableWechaty(this.wechaty)

    if (PUPPET.payloads.isPostServer(this.payload)) {
      for (const sayableId of this.payload.sayableList) {
        const sayable = await this.getSayableWithId(sayableId)

        if (sayable) {
          yield sayable
        }
      }

    } else {  // client
      for (const sayablePayload of this.payload.sayableList) {
        const sayable = await payloadToSayable(sayablePayload)
        if (sayable) {
          yield sayable
        }
      }

    }
  }

  async getSayableWithIndex (sayableIndex: number) {
    log.verbose('Post', 'getSayableWithIndex(%s)', sayableIndex)

    const payloadToSayable = payloadToSayableWechaty(this.wechaty)

    if (PUPPET.payloads.isPostServer(this.payload)) {
      const sayablePayload = await this.wechaty.puppet.postPayloadSayable(this.id!, this.payload.sayableList[sayableIndex]!)
      const sayable = await payloadToSayable(sayablePayload)
      return sayable
    } else {
      const sayablePayload = this.payload.sayableList[sayableIndex]
      if (sayablePayload) {
        const sayable = await payloadToSayable(sayablePayload)
        return sayable
      } else {
        throw new Error(`post has no sayable with index ${sayableIndex}`)
      }
    }
  }

  async getSayableWithId (id: string) {
    log.verbose('Post', 'getSayableWithId(%s)', id)

    if (PUPPET.payloads.isPostServer(this.payload)) {
      const payloadToSayable = payloadToSayableWechaty(this.wechaty)
      const sayablePayload = await this.wechaty.puppet.postPayloadSayable(this.id!, id)
      const sayable = await payloadToSayable(sayablePayload)
      return sayable
    } else {
      throw new Error('client post sayable has no Id')
    }
  }

  async * children (
    filter: PUPPET.filters.Post = {},
  ): AsyncIterableIterator<PostInterface> {
    log.verbose('Post', '*children(%s)', Object.keys(filter).length ? JSON.stringify(filter) : '')

    const pagination: PUPPET.filters.PaginationRequest = {
      pageSize: 100,
    }

    const parentIdFilter = {
      ...filter,
      parentId: this.id,
    }

    let [ postList, nextPageToken ] = await this.wechaty.Post.findAll(
      parentIdFilter,
      pagination,
    )

    while (true) {
      yield * postList
      postList.length = 0

      if (!nextPageToken) {
        break
      }

      [ postList, nextPageToken ] = await this.wechaty.Post.findAll(
        parentIdFilter,
        {
          ...pagination,
          pageToken: nextPageToken,
        },
      )
    }
  }

  async * descendants (
    filter: PUPPET.filters.Post = {},
  ): AsyncIterableIterator<PostInterface> {
    log.verbose('Post', '*descendants(%s)', Object.keys(filter).length ? JSON.stringify(filter) : '')

    for await (const post of this.children(filter)) {
      yield post
      yield * post.descendants(filter)
    }
  }

  async * likes (
    filter: PUPPET.filters.Post = {},
  ): AsyncIterableIterator<Tap> {
    log.verbose('Post', '*likes(%s)', Object.keys(filter).length ? JSON.stringify(filter) : '')
    return this.taps({
      ...filter,
      type: PUPPET.types.Tap.Like,
    })
  }

  async * taps (
    filter: PUPPET.filters.Tap = {},
  ): AsyncIterableIterator<Tap> {
    log.verbose('Post', '*taps(%s)', Object.keys(filter).length ? JSON.stringify(filter) : '')

    const pagination: PUPPET.filters.PaginationRequest = {}

    let [ tapList, nextPageToken ] = await this.tapFind(
      filter,
      pagination,
    )

    while (true) {
      yield * tapList
      tapList.length = 0

      if (!nextPageToken) {
        break
      }

      [ tapList, nextPageToken ] = await this.tapFind(
        filter,
        { ...pagination, pageToken: nextPageToken },
      )
    }
  }

  async reply (
    sayable:
      | Exclude<Sayable, PostInterface>
      | Exclude<Sayable, PostInterface>[],
  ): Promise<void | PostInterface> {
    log.verbose('Post', 'reply(%s)', sayable)

    if (!this.id) {
      console.error('You can only call `reply()` on received posts, but it seems that you are trying to call reply on a post created from local.')
      throw new Error('no post id found')
    }

    const builder = this.wechaty.Post.builder()

    if (Array.isArray(sayable)) {
      sayable.forEach(s => builder.add(s))
    } else {
      builder.add(sayable)
    }

    const post = await builder
      .reply(this)
      .build()

    const postId = await this.wechaty.puppet.postPublish(post.payload)

    if (postId) {
      const newPost = this.wechaty.Post.load(postId)
      await newPost.ready()
      return newPost
    }
  }

  async like (status: boolean) : Promise<void>
  async like ()                : Promise<boolean>

  async like (status?: boolean): Promise<void | boolean> {
    log.verbose('Post', 'like(%s)', typeof status === 'undefined' ? '' : status)

    if (typeof status === 'undefined') {
      return this.tap(
        PUPPET.types.Tap.Like,
      )
    } else {
      return this.tap(
        PUPPET.types.Tap.Like,
        status,
      )
    }
  }

  /**
   * Return Date if the bot has tapped the post, otherwise return undefined
   */
  async tap (type: PUPPET.types.Tap)                  : Promise<boolean>
  async tap (type: PUPPET.types.Tap, status: boolean) : Promise<void>

  async tap (
    type    : PUPPET.types.Tap,
    status? : boolean,
  ): Promise<void | boolean> {
    log.verbose('Post', 'tap(%s%s)',
      PUPPET.types.Tap[type],
      typeof status === 'undefined'
        ? ''
        : ', ' + status,
    )

    if (!this.id) {
      throw new Error('can not tap for post without id')
    }

    return this.wechaty.puppet.tap(this.id, type, status)
  }

  async tapFind (
    filter      : PUPPET.filters.Tap,
    pagination? : PUPPET.filters.PaginationRequest,
  ): Promise<[
    tapList        : Tap[],
    nextPageToken? : string,
  ]> {
    log.verbose('Post', 'tapFind()')

    if (!this.id) {
      throw new Error('can not get tapFind for client created post')
    }

    const {
      nextPageToken,
      response,
    } = await this.wechaty.puppet.tapSearch(
      this.id,
      filter,
      pagination,
    )

    const tapList: Tap[] = []
    for (const [ type, data ] of Object.entries(response)) {
      for (const [ i, contactId ] of data.contactId.entries()) {
        const contact = await this.wechaty.Contact.find({ id: contactId })
        if (!contact) {
          log.warn('Post', 'tapFind() contact not found for id: %s', contactId)
          continue
        }

        const timestamp = data.timestamp[i]
        const date = timestamp ? new Date(timestamp) : new Date()

        tapList.push({
          contact,
          date,
          type: Number(type) as PUPPET.types.Tap,
        })
      }
    }

    return [ tapList, nextPageToken ]
  }

  location (): LocationInterface | undefined {
    log.verbose('Post', 'location()')

    if (!this.payload.location) {
      log.warn('this post has no location info')
      return
    }
    return new this.wechaty.Location(this.payload.location)
  }

  async visibleList (): Promise<ContactInterface[]> {
    log.verbose('Post', 'visibleList()')

    if (!this.payload.visibleList) {
      return []
    }

    const contactIdList: string[] = this.payload.visibleList

    const idToContact = async (id: string) => 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)(idToContact)(contactIdList)

    const contactList: ContactInterface[] = []

    for await (const contact of contactIterator) {
      if (contact) {
        contactList.push(contact)
      }
    }

    return contactList
  }

}

class PostImpl extends validationMixin(PostMixin)<PostInterface>() {}
interface PostInterface extends PostImpl {}

type PostConstructor = Constructor<
  PostInterface,
  typeof PostImpl
>

export type {
  PostConstructor,
  PostInterface,
}
export {
  PostBuilder,
  PostImpl,
}
