/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */

import {
  ActivityApproveReply,
  ActivityPubOrderedCollection,
  HttpStatusCode,
  UserRole,
  VideoCommentObject,
  VideoCommentPolicy,
  VideoCommentPolicyType,
  VideoPrivacy
} from '@peertube/peertube-models'
import {
  PeerTubeServer,
  cleanupTests,
  createMultipleServers,
  doubleFollow,
  makeActivityPubGetRequest,
  makeActivityPubRawRequest,
  setAccessTokensToServers,
  setDefaultAccountAvatar,
  waitJobs
} from '@peertube/peertube-server-commands'
import { expectStartWith } from '@tests/shared/checks.js'
import { expect } from 'chai'

describe('Test comments approval', function () {
  let servers: PeerTubeServer[]
  let userToken: string
  let anotherUserToken: string
  let moderatorToken: string

  async function createVideo (commentsPolicy: VideoCommentPolicyType) {
    const { uuid } = await servers[0].videos.upload({
      token: userToken,
      attributes: {
        name: 'review policy: ' + commentsPolicy,
        privacy: VideoPrivacy.PUBLIC,
        commentsPolicy
      }
    })

    await waitJobs(servers)

    return uuid
  }

  before(async function () {
    this.timeout(120000)

    servers = await createMultipleServers(3)
    await setAccessTokensToServers(servers)
    await setDefaultAccountAvatar(servers)

    await doubleFollow(servers[0], servers[1])
    await doubleFollow(servers[0], servers[2])
    await doubleFollow(servers[1], servers[2])

    userToken = await servers[0].users.generateUserAndToken('user1')
    anotherUserToken = await servers[0].users.generateUserAndToken('user2')
    moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR)
  })

  describe('On video with comments requiring approval', function () {
    let videoId: string

    before(async function () {
      this.timeout(30000)

      videoId = await createVideo(VideoCommentPolicy.REQUIRES_APPROVAL)
    })

    it('Should create a local and remote comment that require approval', async function () {
      this.timeout(30000)

      await servers[0].comments.createThread({ text: 'local', videoId, token: anotherUserToken })
      await servers[1].comments.createThread({ text: 'remote', videoId })
      await waitJobs(servers)

      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      expect(data).to.have.lengthOf(2)

      for (const c of data) {
        expect(c.heldForReview).to.be.true
      }
    })

    it('Should display comments depending on the user', async function () {
      // Owner see the comments
      {
        const { data } = await servers[0].comments.listThreads({ videoId, token: userToken })
        expect(data).to.have.lengthOf(2)

        for (const c of data) {
          expect(c.heldForReview).to.be.true
        }
      }

      // Anonymous doesn't see the comments
      for (const server of servers) {
        const { data } = await server.comments.listThreads({ videoId })
        expect(data).to.have.lengthOf(0)
      }

      // Owner of the comment can see it
      {
        const { data } = await servers[1].comments.listThreads({ videoId, token: servers[1].accessToken })
        expect(data).to.have.lengthOf(1)
        expect(data[0].heldForReview).to.be.true
        expect(data[0].text).to.equal('remote')
      }
    })

    it('Should create a local and remote reply and require approval', async function () {
      await servers[0].comments.addReplyToLastThread({ text: 'local reply', token: anotherUserToken })
      await servers[1].comments.addReplyToLastThread({ text: 'remote reply' })
      await waitJobs(servers)

      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      expect(data).to.have.lengthOf(4)

      for (const c of data) {
        expect(c.heldForReview).to.be.true
      }
    })

    it('Should approve a thread comment', async function () {
      {
        const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
        const commentId = data.find(c => c.text === 'remote').id
        await servers[0].comments.approve({ commentId, videoId, token: userToken })
        await waitJobs(servers)
      }

      // Owner and moderators
      for (const token of [ userToken, moderatorToken ]) {
        const { data: threads } = await servers[0].comments.listThreads({ videoId, token })
        expect(threads).to.have.lengthOf(2)

        for (const c of threads) {
          if (c.text === 'remote') expect(c.heldForReview).to.be.false
          else expect(c.heldForReview).to.be.true

          const thread = await servers[0].comments.getThread({ videoId, threadId: c.id, token })
          expect(thread.children).to.have.lengthOf(1)
          expect(thread.children[0].comment.heldForReview).to.equal(true)
        }
      }

      // Anonymous
      for (const server of servers) {
        const { data } = await server.comments.listThreads({ videoId })
        expect(data).to.have.lengthOf(1)
        expect(data[0].heldForReview).to.be.false
        expect(data[0].text).to.equal('remote')

        const thread = await server.comments.getThread({ videoId, threadId: data[0].id })
        expect(thread.children).to.have.lengthOf(0)
      }

      // Owner of the comment can see it
      {
        const { data } = await servers[1].comments.listThreads({ videoId, token: servers[1].accessToken })
        expect(data).to.have.lengthOf(1)
        expect(data[0].heldForReview).to.be.false
        expect(data[0].text).to.equal('remote')

        const thread = await servers[1].comments.getThread({ videoId, threadId: data[0].id, token: servers[1].accessToken })
        expect(thread.children).to.have.lengthOf(1)
        expect(thread.children[0].comment.heldForReview).to.equal(true)
      }
    })

    it('Should approve a reply comment', async function () {
      {
        const commentId = await servers[0].comments.findCommentId({ videoId, text: 'remote reply' })
        await servers[0].comments.approve({ commentId, videoId, token: userToken })
        await waitJobs(servers)

        // Owner
        {
          const { data } = await servers[0].comments.listThreads({ videoId, token: userToken })
          expect(data.filter(c => c.heldForReview)).to.have.lengthOf(1)

          const thread = await servers[0].comments.getThreadOf({ videoId, text: 'remote', token: userToken })
          expect(thread.children).to.have.lengthOf(1)
          expect(thread.children[0].comment.text).to.equal('remote reply')
          expect(thread.children[0].comment.heldForReview).to.be.false
        }

        // Other users
        for (const server of servers) {
          const thread = await server.comments.getThreadOf({ videoId, text: 'remote' })
          expect(thread.children).to.have.lengthOf(1)
          expect(thread.children[0].comment.text).to.equal('remote reply')
          expect(thread.children[0].comment.heldForReview).to.be.false
        }
      }
    })

    it('Should list and filter on comments awaiting approval', async function () {
      {
        const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken })
        expect(total).to.equal(4)
        expect(data).to.have.lengthOf(4)
      }

      {
        const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken, isHeldForReview: true })
        expect(total).to.equal(2)
        expect(data).to.have.lengthOf(2)
        expect(data.filter(c => c.heldForReview)).to.have.lengthOf(2)
      }

      {
        const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken, isHeldForReview: false })
        expect(total).to.equal(2)
        expect(data).to.have.lengthOf(2)
        expect(data.filter(c => !c.heldForReview)).to.have.lengthOf(2)
      }
    })

    it('Should approve a reply of a non approved reply', async function () {
      const threadId = await servers[0].comments.findCommentId({ videoId, text: 'local' })

      const { id: replyId } = await servers[0].comments.addReply({
        videoId,
        toCommentId: await servers[0].comments.findCommentId({ videoId, text: 'local reply' }),
        text: 'local reply 2',
        token: anotherUserToken
      })

      await servers[0].comments.approve({ commentId: replyId, videoId, token: userToken })
      await servers[0].comments.approve({ commentId: threadId, videoId, token: userToken })
      await waitJobs(servers)

      // Owner
      {
        const { data } = await servers[0].comments.listThreads({ videoId, token: userToken })
        expect(data.filter(c => c.heldForReview)).to.have.lengthOf(0)

        const thread = await servers[0].comments.getThreadOf({ videoId, text: 'local', token: userToken })
        expect(thread.children).to.have.lengthOf(1)
        expect(thread.children[0].comment.text).to.equal('local reply')
        expect(thread.children[0].comment.heldForReview).to.be.true

        expect(thread.children[0].children).to.have.lengthOf(1)
        expect(thread.children[0].children[0].comment.text).to.equal('local reply 2')
        expect(thread.children[0].children[0].comment.heldForReview).to.be.false
      }

      // Other users
      for (const server of servers) {
        const thread = await server.comments.getThreadOf({ videoId, text: 'local' })
        expect(thread.children).to.have.lengthOf(0)
      }
    })

    it('Should have appropriate ActivityPub representation', async function () {
      const localNonApprovedId = await servers[0].comments.findCommentId({ text: 'local reply', videoId })
      const localApprovedId = await servers[0].comments.findCommentId({ text: 'local', videoId })
      const remoteApprovedId = await servers[0].comments.findCommentId({ text: 'remote', videoId })

      {
        for (const page of [ 1, 2 ]) {
          const res = await makeActivityPubGetRequest(servers[0].url, `/videos/watch/${videoId}/comments?page=${page}`)
          const { totalItems, orderedItems } = res.body as ActivityPubOrderedCollection<string>

          expect(totalItems).to.equal(4)
          expect(orderedItems.some(url => url === `${servers[0].url}/videos/watch/${videoId}/comments/${localNonApprovedId}`)).to.be.false
        }
      }

      {
        await makeActivityPubGetRequest(
          servers[0].url,
          `/videos/watch/${videoId}/comments/${localNonApprovedId}`,
          HttpStatusCode.NOT_FOUND_404
        )

        await makeActivityPubGetRequest(
          servers[0].url,
          `/videos/watch/${videoId}/comments/${localNonApprovedId}/approve-reply`,
          HttpStatusCode.NOT_FOUND_404
        )
      }

      const toTest = [ { server: servers[0], commentId: localApprovedId }, { server: servers[1], commentId: remoteApprovedId } ]
      for (const { server, commentId } of toTest) {
        const res = await makeActivityPubGetRequest(server.url, `/videos/watch/${videoId}/comments/${commentId}`)
        const { replyApproval } = res.body as VideoCommentObject

        expectStartWith(replyApproval, `${servers[0].url}/videos/watch/${videoId}/comments/`)
        const res2 = await makeActivityPubRawRequest(replyApproval, HttpStatusCode.OK_200)

        const object = res2.body as ActivityApproveReply
        expect(object.type).to.equal('ApproveReply')
      }
    })

    it('Should remove an approved/non-approved comments', async function () {
      this.timeout(60000)

      {
        const commentId = await servers[1].comments.findCommentId({ videoId, text: 'local' })
        await servers[1].comments.addReply({ videoId, toCommentId: commentId, text: 'remote reply on local' })
        await waitJobs(servers)
      }

      for (const text of [ 'remote', 'local reply', 'remote reply on local' ]) {
        const commentId = await servers[0].comments.findCommentId({ videoId, text })
        await servers[0].comments.delete({ videoId, commentId })
      }

      await waitJobs(servers)

      // Owner
      {
        const { data } = await servers[0].comments.listThreads({ videoId, token: userToken, sort: '-createdAt' })
        expect(data).to.have.lengthOf(2)

        {
          const remote = data[0]
          expect(remote.isDeleted).to.be.true

          const thread = await servers[0].comments.getThread({ videoId, token: userToken, threadId: remote.id })
          expect(thread.children).to.have.lengthOf(1)
          expect(thread.children[0].comment.text).to.equal('remote reply')
        }

        {
          const local = data[1]
          expect(local.isDeleted).to.be.false

          const thread = await servers[0].comments.getThread({ videoId, token: userToken, threadId: local.id })
          expect(thread.children).to.have.lengthOf(2)

          {
            const localReply = thread.children[0]
            expect(localReply.comment.deletedAt).to.exist
            expect(localReply.comment.heldForReview).to.be.true
            expect(localReply.children).to.have.lengthOf(1)

            expect(localReply.children).to.have.lengthOf(1)
            expect(localReply.children[0].comment.text).to.equal('local reply 2')
            expect(localReply.children[0].comment.heldForReview).to.be.false
            expect(localReply.children[0].children).to.have.lengthOf(0)
          }

          {
            expect(thread.children[1].comment.deletedAt).to.exist
          }
        }
      }

      // Other users
      for (const server of servers) {
        const { data } = await server.comments.listThreads({ videoId, sort: '-createdAt' })
        expect(data).to.have.lengthOf(2)

        {
          const remote = data[0]
          expect(remote.isDeleted).to.be.true

          const thread = await server.comments.getThread({ videoId, threadId: remote.id })
          expect(thread.children).to.have.lengthOf(1)
          expect(thread.children[0].comment.text).to.equal('remote reply')
        }

        {
          const local = data[1]
          expect(local.isDeleted).to.be.false

          const thread = await server.comments.getThread({ videoId, threadId: local.id })
          // Anonymous users cannot see the thread because the delete comment was held for review
          expect(thread.children).to.have.lengthOf(0)
        }
      }
    })

    it('Should not require review for video uploader, admins and moderators', async function () {
      for (const token of [ userToken, moderatorToken, servers[0].accessToken ]) {
        await servers[0].comments.createThread({ videoId, text: 'right', token })
      }

      await waitJobs(servers)

      for (const server of servers) {
        const { data } = await server.comments.listThreads({ videoId, sort: '-createdAt' })
        expect(data.filter(c => c.text === 'right')).to.have.lengthOf(3)
      }
    })
  })

  describe('On video with comments with some tags requiring approval', function () {
    let videoId: string

    before(async function () {
      this.timeout(30000)

      videoId = await createVideo(VideoCommentPolicy.ENABLED)
    })

    it('Should only have built-in auto tag policies and no policies set', async function () {
      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
      expect(review).to.have.lengthOf(0)

      const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken })
      expect(available.map(a => a.name)).to.deep.equal([ 'external-link' ])
    })

    it('Should add watched words and so available tag policies', async function () {
      await servers[0].watchedWordsLists.createList({
        token: userToken,
        listName: 'forbidden-list',
        words: [ 'forbidden' ],
        accountName: 'user1'
      })

      await servers[0].watchedWordsLists.createList({
        token: userToken,
        listName: 'allowed-list',
        words: [ 'allowed' ],
        accountName: 'user1'
      })

      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
      expect(review).to.have.lengthOf(0)

      const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken })
      expect(available.map(a => a.name)).to.have.deep.members([ 'external-link', 'forbidden-list', 'allowed-list' ])
    })

    it('Should update policies', async function () {
      await servers[0].autoTags.updateCommentPolicies({
        accountName: 'user1',
        review: [ 'external-link', 'forbidden-list' ],
        token: userToken
      })

      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
      expect(review).to.have.deep.members([ 'external-link', 'forbidden-list' ])
    })

    it('Should publish a comment without approval', async function () {
      const threadText = '1 - framasoft and allowed'
      const replyText = '1 - frama and allowed'

      await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: threadText })
      await waitJobs(servers)

      const commentId = await servers[1].comments.findCommentId({ videoId, text: threadText })
      await servers[1].comments.addReply({ text: replyText, videoId, toCommentId: commentId })

      await waitJobs(servers)

      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      const t = data.find(c => c.text === threadText)
      const r = data.find(c => c.text === replyText)

      expect(t.automaticTags).to.have.members([ 'allowed-list' ])
      expect(t.heldForReview).to.be.false

      expect(r.automaticTags).to.have.members([ 'allowed-list' ])
      expect(r.heldForReview).to.be.false
    })

    it('Should publish a comment with approval', async function () {
      const threadText = '2 - framasoft.org and allowed'
      const replyText = '2 - https://framasoft.org and forbidden'

      await servers[1].comments.createThread({ videoId, text: threadText })
      await waitJobs(servers)

      const commentId = await servers[0].comments.findCommentId({ videoId, text: threadText })
      await servers[0].comments.addReply({ token: anotherUserToken, text: replyText, videoId, toCommentId: commentId })

      await waitJobs(servers)

      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      const t = data.find(c => c.text === threadText)
      const r = data.find(c => c.text === replyText)

      expect(t.automaticTags).to.have.members([ 'external-link', 'allowed-list' ])
      expect(t.heldForReview).to.be.true

      expect(r.automaticTags).to.have.members([ 'external-link', 'forbidden-list' ])
      expect(r.heldForReview).to.be.true
    })

    it('Should update policies and not update previously tags set', async function () {
      await servers[0].autoTags.updateCommentPolicies({ accountName: 'user1', review: [ 'forbidden-list' ], token: userToken })

      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
      expect(review).to.have.deep.members([ 'forbidden-list' ])

      const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken })
      expect(available.map(a => a.name)).to.have.deep.members([ 'external-link', 'forbidden-list', 'allowed-list' ])

      const { data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken })
      expect(data.filter(c => c.heldForReview)).to.have.lengthOf(2)
    })

    it('Should publish a comment with and without approval base on the new policies', async function () {
      const threadText = '3 - framasoft.org and allowed'
      const replyText = '3 - forbidden'

      await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: threadText })
      await servers[0].comments.addReplyToLastThread({ token: anotherUserToken, text: replyText })
      await waitJobs(servers)

      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      const t = data.find(c => c.text === threadText)
      const r = data.find(c => c.text === replyText)

      expect(t.automaticTags).to.have.members([ 'external-link', 'allowed-list' ])
      expect(t.heldForReview).to.be.false

      expect(r.automaticTags).to.have.members([ 'forbidden-list' ])
      expect(r.heldForReview).to.be.true
    })

    it('Should not require approval for a moderator but it should have the tag set', async function () {
      await servers[0].comments.createThread({ token: moderatorToken, videoId, text: 'forbidden' })
      await waitJobs(servers)

      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      const t = data.find(c => c.text === 'forbidden')

      expect(t.automaticTags).to.have.members([ 'forbidden-list' ])
      expect(t.heldForReview).to.be.false
    })

    it('Should not have threads waiting for approval before approbation for anonymous users on server 1 and 3', async function () {
      for (const server of [ servers[0], servers[2] ]) {
        const { data } = await server.comments.listThreads({ videoId })
        expect(data).to.have.lengthOf(3)

        expect(data.some(c => c.text === '2 - framasoft.org and allowed')).to.be.false
      }
    })

    it('Should see threads waiting for approval before approbation for anonymous users on server 2', async function () {
      const { data } = await servers[1].comments.listThreads({ videoId })
      expect(data).to.have.lengthOf(4)

      expect(data.some(c => c.text === '2 - framasoft.org and allowed')).to.be.true
      expect(data.some(c => c.text === '2 - https://framasoft.org and forbidden')).to.be.false
    })
  })

  describe('Comment count with moderation', function () {
    let videoId: string

    before(async function () {
      videoId = await createVideo(VideoCommentPolicy.REQUIRES_APPROVAL)
    })

    it('Should not increment comment count when comment is held for review', async function () {
      await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'held comment' })
      await waitJobs(servers)

      const video = await servers[0].videos.get({ id: videoId })
      expect(video.comments).to.equal(0)
    })

    it('Should increment comment count after approving comment', async function () {
      // Create a new comment that will be held for review
      await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'test comment' })
      await waitJobs(servers)

      // Get the held comment
      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      const heldComment = data.find(c => c.text === 'test comment')
      expect(heldComment.heldForReview).to.be.true

      // Verify initial count is 0
      let video = await servers[0].videos.get({ id: videoId })
      expect(video.comments).to.equal(0)

      // Approve the comment
      await servers[0].comments.approve({ token: userToken, videoId, commentId: heldComment.id })
      await waitJobs(servers)

      // Verify count incremented after approval
      video = await servers[0].videos.get({ id: videoId })
      expect(video.comments).to.equal(1)
    })

    it('Should not increment comment count when deleting held comment', async function () {
      // Get initial comment count
      let video = await servers[0].videos.get({ id: videoId })
      const initialCount = video.comments

      // Create a new comment that will be held for review
      await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'to be deleted' })
      await waitJobs(servers)

      // Get the held comment
      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
      const heldComment = data.find(c => c.text === 'to be deleted')
      expect(heldComment.heldForReview).to.be.true

      // Delete the held comment
      await servers[0].comments.delete({ token: userToken, videoId, commentId: heldComment.id })
      await waitJobs(servers)

      // Verify count remains unchanged after deleting held comment
      video = await servers[0].videos.get({ id: videoId })
      expect(video.comments).to.equal(initialCount)
    })
  })

  after(async function () {
    await cleanupTests(servers)
  })
})
