import { pick } from '@peertube/peertube-core-utils'
import {
  ActivityTagObject,
  ActivityTombstoneObject,
  UserRight,
  VideoComment,
  VideoCommentForAdminOrUser,
  VideoCommentObject
} from '@peertube/peertube-models'
import { afterCommitIfTransaction, retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js'
import { extractMentions } from '@server/helpers/mentions.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js'
import { getServerActor } from '@server/models/application/application.js'
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js'
import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
import {
  AfterCreate,
  AfterDestroy,
  AfterUpdate,
  AllowNull,
  BelongsTo,
  Column,
  CreatedAt,
  DataType,
  ForeignKey,
  HasMany,
  Is,
  Scopes,
  Table,
  UpdatedAt
} from 'sequelize-typescript'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc.js'
import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
import {
  MComment,
  MCommentAdminOrUserFormattable,
  MCommentAP,
  MCommentExport,
  MCommentFormattable,
  MCommentId,
  MCommentOwner,
  MCommentOwnerReplyVideoImmutable,
  MCommentOwnerVideoFeed,
  MCommentOwnerVideoReply,
  MVideo,
  MVideoImmutable
} from '../../types/models/video/index.js'
import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse.js'
import { AccountModel } from '../account/account.js'
import { ActorModel } from '../actor/actor.js'
import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js'
import { buildLocalAccountIdsIn, buildSQLAttributes, SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js'
import { VideoChannelModel } from './video-channel.js'
import { VideoModel } from './video.js'

export enum ScopeNames {
  WITH_ACCOUNT = 'WITH_ACCOUNT',
  WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
  WITH_VIDEO = 'WITH_VIDEO'
}

@Scopes(() => ({
  [ScopeNames.WITH_ACCOUNT]: {
    include: [
      {
        model: AccountModel
      }
    ]
  },
  [ScopeNames.WITH_IN_REPLY_TO]: {
    include: [
      {
        model: VideoCommentModel,
        as: 'InReplyToVideoComment'
      }
    ]
  },
  [ScopeNames.WITH_VIDEO]: {
    include: [
      {
        model: VideoModel,
        required: true,
        include: [
          {
            model: VideoChannelModel.unscoped(),
            attributes: [ 'id', 'accountId' ],
            required: true,
            include: [
              {
                attributes: [ 'id', 'url' ],
                model: ActorModel.unscoped(),
                required: true
              },
              {
                attributes: [ 'id' ],
                model: AccountModel.unscoped(),
                required: true,
                include: [
                  {
                    attributes: [ 'id', 'url' ],
                    model: ActorModel.unscoped(),
                    required: true
                  }
                ]
              }
            ]
          }
        ]
      }
    ]
  }
}))
@Table({
  tableName: 'videoComment',
  indexes: [
    {
      fields: [ 'videoId' ]
    },
    {
      fields: [ 'videoId', 'originCommentId' ]
    },
    {
      fields: [ 'url' ],
      unique: true
    },
    {
      fields: [ 'accountId' ]
    },
    {
      fields: [
        { name: 'createdAt', order: 'DESC' }
      ]
    }
  ]
})
export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
  @CreatedAt
  declare createdAt: Date

  @UpdatedAt
  declare updatedAt: Date

  @AllowNull(true)
  @Column(DataType.DATE)
  declare deletedAt: Date

  @AllowNull(false)
  @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
  declare url: string

  @AllowNull(false)
  @Column(DataType.TEXT)
  declare text: string

  @AllowNull(false)
  @Column
  declare heldForReview: boolean

  @AllowNull(true)
  @Column
  declare replyApproval: string

  @ForeignKey(() => VideoCommentModel)
  @Column
  declare originCommentId: number

  @BelongsTo(() => VideoCommentModel, {
    foreignKey: {
      name: 'originCommentId',
      allowNull: true
    },
    as: 'OriginVideoComment',
    onDelete: 'CASCADE'
  })
  declare OriginVideoComment: Awaited<VideoCommentModel>

  @ForeignKey(() => VideoCommentModel)
  @Column
  declare inReplyToCommentId: number

  @BelongsTo(() => VideoCommentModel, {
    foreignKey: {
      name: 'inReplyToCommentId',
      allowNull: true
    },
    as: 'InReplyToVideoComment',
    onDelete: 'CASCADE'
  })
  declare InReplyToVideoComment: Awaited<VideoCommentModel> | null

  @ForeignKey(() => VideoModel)
  @Column
  declare videoId: number

  @BelongsTo(() => VideoModel, {
    foreignKey: {
      allowNull: false
    },
    onDelete: 'CASCADE'
  })
  declare Video: Awaited<VideoModel>

  @ForeignKey(() => AccountModel)
  @Column
  declare accountId: number

  @BelongsTo(() => AccountModel, {
    foreignKey: {
      allowNull: true
    },
    onDelete: 'CASCADE'
  })
  declare Account: Awaited<AccountModel>

  @HasMany(() => VideoCommentAbuseModel, {
    foreignKey: {
      name: 'videoCommentId',
      allowNull: true
    },
    onDelete: 'set null'
  })
  declare CommentAbuses: Awaited<VideoCommentAbuseModel>[]

  @HasMany(() => CommentAutomaticTagModel, {
    foreignKey: 'commentId',
    onDelete: 'CASCADE'
  })
  declare CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]

  @AfterCreate
  @AfterDestroy
  static incrementCommentCount (instance: VideoCommentModel, options: any) {
    if (instance.heldForReview) return // Don't count held comments

    return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
  }

  @AfterUpdate
  static updateCommentCountOnHeldStatusChange (instance: VideoCommentModel, options: any) {
    return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
  }

  // ---------------------------------------------------------------------------

  static async rebuildCommentsCount (videoId: number) {
    try {
      await retryTransactionWrapper(() => {
        return sequelizeTypescript.transaction(async transaction => {
          const video = await VideoModel.load(videoId, transaction)
          if (!video) return

          video.comments = await new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, {
            selectType: 'comment-only',
            videoId: video.id,
            heldForReview: false,
            notDeleted: true,
            transaction
          }).count()

          await video.save({ transaction })
        })
      })
    } catch (err) {
      logger.error('Cannot rebuild comments count for video ' + videoId, { err })
    }
  }

  // ---------------------------------------------------------------------------

  static getSQLAttributes (tableName: string, aliasPrefix = '') {
    return buildSQLAttributes({
      model: this,
      tableName,
      aliasPrefix
    })
  }

  // ---------------------------------------------------------------------------

  static loadById (id: number, transaction?: Transaction): Promise<MComment> {
    const query = {
      where: {
        id
      },
      transaction
    }

    return VideoCommentModel.findOne(query)
  }

  static loadByIdAndPopulateVideoAndAccountAndReply (id: number, transaction?: Transaction): Promise<MCommentOwnerVideoReply> {
    const query = {
      where: {
        id
      },
      transaction
    }

    return VideoCommentModel
      .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
      .findOne(query)
  }

  // ---------------------------------------------------------------------------

  static loadByUrlAndPopulateAccountAndVideoAndReply (url: string, transaction?: Transaction): Promise<MCommentOwnerVideoReply> {
    const query = {
      where: {
        url
      },
      transaction
    }

    return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO, ScopeNames.WITH_IN_REPLY_TO ]).findOne(query)
  }

  static loadByUrlAndPopulateReplyAndVideoImmutableAndAccount (
    url: string,
    transaction?: Transaction
  ): Promise<MCommentOwnerReplyVideoImmutable> {
    const query = {
      where: {
        url
      },
      include: [
        {
          attributes: [ 'id', 'uuid', 'url', 'remote' ],
          model: VideoModel.unscoped()
        }
      ],
      transaction
    }

    return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
  }

  // ---------------------------------------------------------------------------

  static listForApi (parameters: {
    start: number
    count: number
    sort: string

    autoTagOfAccountId: number

    videoAccountOwnerId?: number
    videoAccountOwnerIncludeCollaborations?: boolean

    videoChannelOwnerId?: number

    onLocalVideo?: boolean
    isLocal?: boolean

    search?: string
    searchAccount?: string
    searchVideo?: string

    heldForReview: boolean

    videoId?: number
    videoChannelId?: number
    autoTagOneOf?: string[]
  }) {
    const queryOptions: ListVideoCommentsOptions = {
      ...pick(parameters, [
        'start',
        'count',
        'sort',
        'isLocal',
        'search',
        'searchVideo',
        'searchAccount',
        'onLocalVideo',
        'videoId',
        'videoChannelId',
        'autoTagOneOf',
        'autoTagOfAccountId',
        'videoAccountOwnerId',
        'videoAccountOwnerIncludeCollaborations',
        'videoChannelOwnerId',
        'heldForReview'
      ]),

      selectType: 'api-list',
      notDeleted: true
    }

    return Promise.all([
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).list<MCommentAdminOrUserFormattable>(),
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).count()
    ]).then(([ rows, count ]) => {
      return { total: count, data: rows }
    })
  }

  static async listThreadsForApi (parameters: {
    video: MVideo
    start: number
    count: number
    sort: string
    user?: MUserAccountId
  }) {
    const { video, user } = parameters

    const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video })

    const commonOptions: ListVideoCommentsOptions = {
      selectType: 'api-video',
      videoId: video.id,
      blockerAccountIds,

      heldForReview: canSeeHeldForReview
        ? undefined // Display all comments for video owner or moderator
        : false,
      heldForReviewAccountIdException: user?.Account?.id
    }

    const listOptions: ListVideoCommentsOptions = {
      ...commonOptions,
      ...pick(parameters, [ 'sort', 'start', 'count' ]),

      isThread: true,
      includeReplyCounters: true
    }

    const countOptions: ListVideoCommentsOptions = {
      ...commonOptions,

      isThread: true
    }

    const notDeletedCountOptions: ListVideoCommentsOptions = {
      ...commonOptions,

      notDeleted: true
    }

    return Promise.all([
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).list<MCommentAdminOrUserFormattable>(),
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).count(),
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).count()
    ]).then(([ rows, count, totalNotDeletedComments ]) => {
      return { total: count, data: rows, totalNotDeletedComments }
    })
  }

  static async listThreadCommentsForApi (parameters: {
    video: MVideo
    threadId: number
    user?: MUserAccountId
  }) {
    const { user, video, threadId } = parameters

    const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video })

    const queryOptions: ListVideoCommentsOptions = {
      threadId,

      videoId: video.id,
      selectType: 'api-video',
      sort: 'createdAt',

      blockerAccountIds,
      includeReplyCounters: true,

      heldForReview: canSeeHeldForReview
        ? undefined // Display all comments for video owner or moderator
        : false,
      heldForReviewAccountIdException: user?.Account?.id
    }

    return Promise.all([
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).list<MCommentAdminOrUserFormattable>(),
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).count()
    ]).then(([ rows, count ]) => {
      return { total: count, data: rows }
    })
  }

  static listThreadParentComments (options: {
    comment: MCommentId
    transaction?: Transaction
    order?: 'ASC' | 'DESC'
  }): Promise<MCommentOwner[]> {
    const { comment, transaction, order = 'ASC' } = options

    const query = {
      order: [ [ 'createdAt', order ] ] as Order,
      where: {
        id: {
          [Op.in]: Sequelize.literal(
            '(' +
              'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
              `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
              'UNION ' +
              'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
              'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
              ') ' +
              'SELECT id FROM children' +
              ')'
          ),
          [Op.ne]: comment.id
        }
      },
      transaction
    }

    return VideoCommentModel
      .scope([ ScopeNames.WITH_ACCOUNT ])
      .findAll(query)
  }

  static async listAndCountByVideoForAP (parameters: {
    video: MVideoImmutable
    start: number
    count: number
  }) {
    const { video } = parameters

    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })

    const queryOptions: ListVideoCommentsOptions = {
      ...pick(parameters, [ 'start', 'count' ]),

      selectType: 'comment-only',
      videoId: video.id,
      sort: 'createdAt',

      heldForReview: false,

      blockerAccountIds
    }

    return Promise.all([
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).list<MComment>(),
      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).count()
    ]).then(([ rows, count ]) => {
      return { total: count, data: rows }
    })
  }

  static async listForFeed (parameters: {
    start: number
    count: number
    videoId?: number
    videoAccountOwnerId?: number
    videoChannelOwnerId?: number
  }) {
    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })

    const queryOptions: ListVideoCommentsOptions = {
      ...pick(parameters, [ 'start', 'count', 'videoAccountOwnerId', 'videoId', 'videoChannelOwnerId' ]),

      selectType: 'feed',

      sort: '-createdAt',
      onPublicVideo: true,

      notDeleted: true,
      heldForReview: false,

      blockerAccountIds
    }

    return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).list<MCommentOwnerVideoFeed>()
  }

  static listForBulkDelete (
    ofAccount: MAccount,
    filter: {
      onVideosOfAccount?: MAccountId
      includeCollaborations?: boolean
    } = {}
  ) {
    const queryOptions: ListVideoCommentsOptions = {
      selectType: 'comment-only',

      accountId: ofAccount.id,
      videoAccountOwnerId: filter.onVideosOfAccount?.id,
      videoAccountOwnerIncludeCollaborations: filter.includeCollaborations,

      heldForReview: undefined,

      notDeleted: true,
      count: 5000
    }

    return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).list<MComment>()
  }

  static listForExport (ofAccountId: number): Promise<MCommentExport[]> {
    return VideoCommentModel.findAll({
      attributes: [ 'id', 'url', 'text', 'createdAt' ],
      where: {
        accountId: ofAccountId,
        deletedAt: null
      },
      include: [
        {
          attributes: [ 'id', 'uuid', 'url' ],
          required: true,
          model: VideoModel.unscoped()
        },
        {
          attributes: [ 'url' ],
          required: false,
          model: VideoCommentModel,
          as: 'InReplyToVideoComment'
        }
      ],
      limit: USER_EXPORT_MAX_ITEMS
    })
  }

  // ---------------------------------------------------------------------------

  static async getStats () {
    const where = {
      deletedAt: null,
      heldForReview: false
    }

    const totalLocalVideoComments = await VideoCommentModel.count({
      where,
      include: [
        {
          model: AccountModel.unscoped(),
          required: true,
          include: [
            {
              model: ActorModel.unscoped(),
              required: true,
              where: {
                serverId: null
              }
            }
          ]
        }
      ]
    })
    const totalVideoComments = await VideoCommentModel.count({ where })

    return {
      totalLocalVideoComments,
      totalVideoComments
    }
  }

  // ---------------------------------------------------------------------------

  static listRemoteCommentUrlsOfLocalVideos () {
    const query = `SELECT "videoComment".url FROM "videoComment" ` +
      `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
      `INNER JOIN actor ON actor."accountId" = "account"."id" AND actor."serverId" IS NOT NULL ` +
      `INNER JOIN video ON video.id = "videoComment"."videoId" AND video.remote IS FALSE`

    return VideoCommentModel.sequelize.query<{ url: string }>(query, {
      type: QueryTypes.SELECT,
      raw: true
    }).then(rows => rows.map(r => r.url))
  }

  static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
    const query = {
      where: {
        updatedAt: {
          [Op.lt]: beforeUpdatedAt
        },
        videoId,
        accountId: {
          [Op.notIn]: buildLocalAccountIdsIn()
        },
        // Do not delete Tombstones
        deletedAt: null
      }
    }

    return VideoCommentModel.destroy(query)
  }

  // ---------------------------------------------------------------------------

  getCommentStaticPath () {
    return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
  }

  getCommentUserReviewPath () {
    return '/my-account/videos/comments?search=heldForReview:true'
  }

  getThreadId (): number {
    return this.originCommentId || this.id
  }

  isLocal () {
    if (!this.Account) return false

    return this.Account.isLocal()
  }

  markAsDeleted () {
    this.text = ''
    this.deletedAt = new Date()
    this.accountId = null
  }

  isDeleted () {
    return this.deletedAt !== null
  }

  extractMentions () {
    return extractMentions(this.text, this.isLocal())
  }

  toFormattedJSON (this: MCommentFormattable) {
    return {
      id: this.id,
      url: this.url,
      text: this.text,

      threadId: this.getThreadId(),
      inReplyToCommentId: this.inReplyToCommentId || null,
      videoId: this.videoId,

      createdAt: this.createdAt,
      updatedAt: this.updatedAt,
      deletedAt: this.deletedAt,

      heldForReview: this.heldForReview,

      isDeleted: this.isDeleted(),

      totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
      totalReplies: this.get('totalReplies') || 0,

      account: this.Account
        ? this.Account.toFormattedJSON()
        : null
    } as VideoComment
  }

  toFormattedForAdminOrUserJSON (this: MCommentAdminOrUserFormattable) {
    return {
      id: this.id,
      url: this.url,
      text: this.text,

      threadId: this.getThreadId(),
      inReplyToCommentId: this.inReplyToCommentId || null,
      videoId: this.videoId,

      createdAt: this.createdAt,
      updatedAt: this.updatedAt,

      heldForReview: this.heldForReview,
      automaticTags: (this.CommentAutomaticTags || []).map(m => m.AutomaticTag.name),

      video: {
        id: this.Video.id,
        uuid: this.Video.uuid,
        name: this.Video.name,

        channel: this.Video.VideoChannel.toFormattedSummaryJSON()
      },

      account: this.Account
        ? this.Account.toFormattedJSON()
        : null
    } as VideoCommentForAdminOrUser
  }

  toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
    const inReplyTo = this.inReplyToCommentId === null
      ? this.Video.url // New thread, so we reply to the video
      : this.InReplyToVideoComment.url

    if (this.isDeleted()) {
      return {
        id: this.url,
        type: 'Tombstone',
        formerType: 'Note',
        inReplyTo,
        published: this.createdAt.toISOString(),
        updated: this.updatedAt.toISOString(),
        deleted: this.deletedAt.toISOString()
      }
    }

    const tag: ActivityTagObject[] = []
    for (const parentComment of threadParentComments) {
      if (!parentComment.Account) continue

      const actor = parentComment.Account.Actor

      tag.push({
        type: 'Mention',
        href: actor.url,
        name: `@${actor.preferredUsername}@${actor.getHost()}`
      })
    }

    let replyApproval = this.replyApproval
    if (this.Video.isLocal() && !this.heldForReview) {
      replyApproval = getLocalApproveReplyActivityPubUrl(this.Video, this)
    }

    return {
      type: 'Note' as 'Note',
      id: this.url,

      content: this.text,
      mediaType: 'text/markdown',

      inReplyTo,
      updated: this.updatedAt.toISOString(),
      published: this.createdAt.toISOString(),
      url: this.url,
      attributedTo: this.Account.Actor.url,
      replyApproval,
      tag
    }
  }

  private static async buildBlockerAccountIds (options: {
    user: MUserAccountId
  }): Promise<number[]> {
    const { user } = options

    const serverActor = await getServerActor()
    const blockerAccountIds = [ serverActor.Account.id ]

    if (user) blockerAccountIds.push(user.Account.id)

    return blockerAccountIds
  }

  private static buildBlockerAccountIdsAndCanSeeHeldForReview (options: {
    video: MVideo
    user: MUserAccountId
  }) {
    const { video, user } = options
    const blockerAccountIdsPromise = this.buildBlockerAccountIds(options)

    let canSeeHeldForReviewPromise: Promise<boolean>
    if (user) {
      if (user.hasRight(UserRight.SEE_ALL_COMMENTS)) {
        canSeeHeldForReviewPromise = Promise.resolve(true)
      } else {
        canSeeHeldForReviewPromise = VideoChannelModel.loadAndPopulateAccount(video.channelId)
          .then(c => c.accountId === user.Account.id)
      }
    } else {
      canSeeHeldForReviewPromise = Promise.resolve(false)
    }

    return Promise.all([ blockerAccountIdsPromise, canSeeHeldForReviewPromise ])
      .then(([ blockerAccountIds, canSeeHeldForReview ]) => ({ blockerAccountIds, canSeeHeldForReview }))
  }
}
