import { expect } from 'chai'
import { setTimeout } from 'node:timers/promises'
import UserHelper from './helpers/User.mjs'
import redis from './helpers/redis.mjs'
import Metrics from './helpers/metrics.mjs'
import UserSessionsRedis from '../../../app/src/Features/User/UserSessionsRedis.mjs'
import UserAnalyticsDataCache from '../../../app/src/Features/Analytics/UserAnalyticsDataCache.mjs'
import Features from '../../../app/src/infrastructure/Features.mjs'

const rclient = UserSessionsRedis.client()

const UserPromises = UserHelper.promises

describe('Sessions', function () {
  beforeEach(async function () {
    this.timeout(20000)
    this.user1 = new UserPromises()
    this.site_admin = new UserPromises({ email: 'admin@example.com' })
    await this.user1.login()
    await this.user1.logout()
  })

  describe('one session', function () {
    it('should have one session in UserSessions set', async function () {
      await redis.clearUserSessions(this.user1)

      // login, should add session to set
      await this.user1.login()

      const sessions = await redis.getUserSessions(this.user1)
      expect(sessions.length).to.equal(1)
      expect(sessions[0].slice(0, 5)).to.equal('sess:')

      // should be able to access project list page
      const statusCode = await this.user1.getProjectListPage()
      expect(statusCode).to.equal(200)

      // logout, should remove session from set
      await this.user1.logout()
      const sessionsAfterLogout = await redis.getUserSessions(this.user1)
      expect(sessionsAfterLogout.length).to.equal(0)
    })

    it('should update audit log on logout', async function () {
      await redis.clearUserSessions(this.user1)

      // login
      await this.user1.login()

      // logout, should add logout audit log entry (happens in background)
      await this.user1.logout()

      // poll for audit log entry since it's written in the background
      const findAuditLogEntry = async () => {
        for (let attempts = 0; attempts < 10; attempts++) {
          const auditLog = await this.user1.getAuditLogWithoutNoise()

          const logoutEntries = auditLog.filter(
            entry => entry.operation === 'logout'
          )

          // If we found the logout entry, we're done
          if (logoutEntries.length > 0) {
            return logoutEntries
          }

          await setTimeout(25)
        }

        throw new Error('Logout audit log entry not found after 10 attempts')
      }

      const logoutEntries = await findAuditLogEntry()
      expect(logoutEntries.length).to.be.greaterThan(0)
      const lastLogout = logoutEntries[logoutEntries.length - 1]
      expect(lastLogout.operation).to.equal('logout')
      expect(lastLogout.ipAddress).to.exist
      expect(lastLogout.initiatorId).to.exist
      expect(lastLogout.timestamp).to.exist
    })
  })

  describe('two sessions', function () {
    beforeEach(function () {
      // set up second session for this user
      this.user2 = new UserPromises()
      this.user2.email = this.user1.email
      this.user2.emails = this.user1.emails
      this.user2.password = this.user1.password
    })

    it('should have two sessions in UserSessions set', async function () {
      await redis.clearUserSessions(this.user1)

      // login, should add session to set
      await this.user1.login()

      const sessions1 = await redis.getUserSessions(this.user1)
      expect(sessions1.length).to.equal(1)
      expect(sessions1[0].slice(0, 5)).to.equal('sess:')

      // login again, should add the second session to set
      await this.user2.login()

      const sessions2 = await redis.getUserSessions(this.user1)
      expect(sessions2.length).to.equal(2)
      expect(sessions2[0].slice(0, 5)).to.equal('sess:')
      expect(sessions2[1].slice(0, 5)).to.equal('sess:')

      // both should be able to access project list page
      const statusCode1 = await this.user1.getProjectListPage()
      expect(statusCode1).to.equal(200)
      const statusCode2 = await this.user2.getProjectListPage()
      expect(statusCode2).to.equal(200)

      // logout first session, should remove session from set
      await this.user1.logout()

      const sessions3 = await redis.getUserSessions(this.user1)
      expect(sessions3.length).to.equal(1)

      // first session should not have access to project list page
      const statusCode3 = await this.user1.getProjectListPage()
      expect(statusCode3).to.equal(302)

      // second session should still have access to settings
      const statusCode4 = await this.user2.getProjectListPage()
      expect(statusCode4).to.equal(200)

      // logout second session, should remove last session from set
      await this.user2.logout()

      const sessions4 = await redis.getUserSessions(this.user1)
      expect(sessions4.length).to.equal(0)

      // second session should not have access to project list page
      const statusCode5 = await this.user2.getProjectListPage()
      expect(statusCode5).to.equal(302)
    })
  })

  describe('three sessions, password reset', function () {
    beforeEach(function () {
      // set up second session for this user
      this.user2 = new UserPromises()
      this.user2.email = this.user1.email
      this.user2.emails = this.user1.emails
      this.user2.password = this.user1.password
      this.user3 = new UserPromises()
      this.user3.email = this.user1.email
      this.user3.emails = this.user1.emails
      this.user3.password = this.user1.password
    })

    it('should erase both sessions when password is reset', async function () {
      await redis.clearUserSessions(this.user1)

      // login, should add session to set
      await this.user1.login()

      const sessions1 = await redis.getUserSessions(this.user1)
      expect(sessions1.length).to.equal(1)
      expect(sessions1[0].slice(0, 5)).to.equal('sess:')

      // login again, should add the second session to set
      await this.user2.login()

      const sessions2 = await redis.getUserSessions(this.user1)
      expect(sessions2.length).to.equal(2)
      expect(sessions2[0].slice(0, 5)).to.equal('sess:')
      expect(sessions2[1].slice(0, 5)).to.equal('sess:')

      // login third session, should add the second session to set
      await this.user3.login()

      const sessions3 = await redis.getUserSessions(this.user1)
      expect(sessions3.length).to.equal(3)
      expect(sessions3[0].slice(0, 5)).to.equal('sess:')
      expect(sessions3[1].slice(0, 5)).to.equal('sess:')

      // password reset from second session, should erase two of the three sessions
      await this.user2.changePassword(`password${Date.now()}`)

      const sessions4 = await redis.getUserSessions(this.user2)
      expect(sessions4.length).to.equal(1)

      // users one and three should not be able to access project list page
      const statusCode1 = await this.user1.getProjectListPage()
      expect(statusCode1).to.equal(302)
      const statusCode3 = await this.user3.getProjectListPage()
      expect(statusCode3).to.equal(302)

      // user two should still be logged in, and able to access project list page
      const statusCode2 = await this.user2.getProjectListPage()
      expect(statusCode2).to.equal(200)

      // logout second session, should remove last session from set
      await this.user2.logout()

      const sessions5 = await redis.getUserSessions(this.user1)
      expect(sessions5.length).to.equal(0)
    })
  })

  describe('three sessions, sessions page', function () {
    beforeEach(async function () {
      // set up second session for this user
      this.user2 = new UserPromises()
      this.user2.email = this.user1.email
      this.user2.emails = this.user1.emails
      this.user2.password = this.user1.password
      this.user3 = new UserPromises()
      this.user3.email = this.user1.email
      this.user3.emails = this.user1.emails
      this.user3.password = this.user1.password
      await this.user2.login()
    })

    it('should allow the user to erase the other two sessions', async function () {
      await redis.clearUserSessions(this.user1)

      // login, should add session to set
      await this.user1.login()

      const sessions1 = await redis.getUserSessions(this.user1)
      expect(sessions1.length).to.equal(1)
      expect(sessions1[0].slice(0, 5)).to.equal('sess:')

      // login again, should add the second session to set
      await this.user2.login()

      const sessions2 = await redis.getUserSessions(this.user1)
      expect(sessions2.length).to.equal(2)
      expect(sessions2[0].slice(0, 5)).to.equal('sess:')
      expect(sessions2[1].slice(0, 5)).to.equal('sess:')

      // login third session, should add the second session to set
      await this.user3.login()

      const sessions3 = await redis.getUserSessions(this.user1)
      expect(sessions3.length).to.equal(3)
      expect(sessions3[0].slice(0, 5)).to.equal('sess:')
      expect(sessions3[1].slice(0, 5)).to.equal('sess:')

      // check the sessions page
      const { response: sessionsPageResponse } = await this.user2.doRequest(
        'GET',
        '/user/sessions'
      )
      expect(sessionsPageResponse.statusCode).to.equal(200)

      // clear sessions from second session, should erase two of the three sessions
      await this.user2.getCsrfToken()
      await this.user2.doRequest('POST', '/user/sessions/clear')
      const sessions4 = await redis.getUserSessions(this.user2)
      expect(sessions4.length).to.equal(1)

      // users one and three should not be able to access project list page
      const statusCode1 = await this.user1.getProjectListPage()
      expect(statusCode1).to.equal(302)
      const statusCode3 = await this.user3.getProjectListPage()
      expect(statusCode3).to.equal(302)

      // user two should still be logged in, and able to access project list page
      const statusCode2 = await this.user2.getProjectListPage()
      expect(statusCode2).to.equal(200)

      // logout second session, should remove last session from set
      await this.user2.logout()
      const sessions5 = await redis.getUserSessions(this.user1)
      expect(sessions5.length).to.equal(0)

      // the user audit log should have been updated
      const auditLog = await this.user1.getAuditLogWithoutNoise()
      expect(auditLog).to.exist

      // find the clear-sessions entry
      const clearSessionsEntries = auditLog.filter(
        entry => entry.operation === 'clear-sessions'
      )
      expect(clearSessionsEntries.length).to.equal(1)
      expect(clearSessionsEntries[0].operation).to.equal('clear-sessions')
      expect(clearSessionsEntries[0].ipAddress).to.exist
      expect(clearSessionsEntries[0].initiatorId).to.exist
      expect(clearSessionsEntries[0].timestamp).to.exist
      expect(clearSessionsEntries[0].info.sessions.length).to.equal(2)
    })
  })

  describe('validationToken', function () {
    const User = UserHelper.promises

    async function tryWithValidationToken(validationToken) {
      const user = new User()
      await user.login()

      await checkSessionIsValid(user)

      const [, sid] = user.sessionCookie().value.match(/^s:(.+?)\./)
      const key = `sess:${sid}`
      const sess = JSON.parse(await rclient.get(key))

      expect(sess.validationToken).to.equal('v1:' + sid.slice(-4))

      sess.validationToken = validationToken
      await rclient.set(key, JSON.stringify(sess))

      {
        // The current code destroys the session and throws an error/500.
        // Check for login redirect on page reload.
        await user.doRequest('GET', '/project')

        const { response } = await user.doRequest('GET', '/project')
        expect(response.statusCode).to.equal(302)
        expect(response.headers.location).to.equal('/login?')
      }
    }

    async function getOtherUsersValidationToken() {
      const otherUser = new User()
      await otherUser.login()
      await checkSessionIsValid(otherUser)
      const { validationToken } = await otherUser.getSession()
      expect(validationToken).to.match(/^v1:.{4}$/)
      return validationToken
    }
    async function checkSessionIsValid(user) {
      const { response } = await user.doRequest('GET', '/project')
      expect(response.statusCode).to.equal(200)
    }

    it('should reject the redis value when missing', async function () {
      await tryWithValidationToken(undefined)
    })
    it('should reject the redis value when empty', async function () {
      await tryWithValidationToken('')
    })
    it('should reject the redis value when out of sync', async function () {
      await tryWithValidationToken(await getOtherUsersValidationToken())
    })
    it('should ignore overwrites in app code', async function () {
      const otherUsersValidationToken = await getOtherUsersValidationToken()

      const user = new User()
      await user.login()
      await checkSessionIsValid(user)

      const { validationToken: token1 } = await user.getSession()
      const allowedUpdateValue = 'allowed-update-value'
      await user.setInSession({
        validationToken: otherUsersValidationToken,
        // also update another field to check that the write operation went through
        allowedUpdate: allowedUpdateValue,
      })
      const { validationToken: token2, allowedUpdate } = await user.getSession()
      expect(allowedUpdate).to.equal(allowedUpdateValue)
      expect(token1).to.equal(token2)

      await checkSessionIsValid(user)
    })
  })

  describe('analyticsIdMiddleware', function () {
    async function getCacheMetric() {
      const hit = await Metrics.promises.getMetric(
        s => s.includes('analyticsIdMiddleware') && s.includes('hit')
      )
      const miss = await Metrics.promises.getMetric(
        s => s.includes('analyticsIdMiddleware') && s.includes('miss')
      )
      return { hit, miss }
    }
    let usersAnalyticsId
    beforeEach(function () {
      if (!Features.hasFeature('saas')) {
        this.skip()
      }
      usersAnalyticsId = this.user1.analyticsId
      expect(usersAnalyticsId).to.match(/^[0-9a-f-]{36}$/)
    })

    it('should resolve to the users analyticsId', async function () {
      await this.user1.login()
      const session = await this.user1.getSession()
      expect(session.analyticsId).to.equal(usersAnalyticsId)
    })

    it('should not lookup the users analyticsId after login', async function () {
      const prev = await getCacheMetric()
      await this.user1.login()
      for (let i = 0; i < 5; i++) {
        await this.user1.doRequest('GET', '/project')
      }
      const current = await getCacheMetric()
      expect(current).to.deep.equal(prev)
      const session = await this.user1.getSession()
      expect(session.analyticsId).to.equal(usersAnalyticsId)
    })

    it('should lookup the users analyticsId for old session', async function () {
      usersAnalyticsId = this.user1._id
      await this.user1.mongoUpdate({
        $set: { analyticsId: usersAnalyticsId },
      })
      const prev = await getCacheMetric()
      await this.user1.login()
      let session = await this.user1.getSession()
      await this.user1.setInSession({
        analyticsId: null,
        passport: {
          ...session.passport,
          user: {
            ...session.passport.user,
            analyticsId: null,
          },
        },
      })
      await UserAnalyticsDataCache.reset()
      for (let i = 0; i < 5; i++) {
        await this.user1.doRequest('GET', '/project')
      }
      const current = await getCacheMetric()
      expect(current).to.deep.equal({
        hit: prev.hit,
        miss: prev.miss + 1,
      })
      session = await this.user1.getSession()
      expect(session.analyticsId).to.equal(usersAnalyticsId)
    })
  })
})
