import { proxyquire, sinon } from '../../spec_helper'
import path from 'path'
import os from 'os'
import type { AppCaptureProtocolInterface, ProtocolManagerShape } from '@packages/types'
import { expect } from 'chai'
import { EventEmitter } from 'stream'
import esbuild from 'esbuild'
import fs from 'fs-extra'
import type { SinonStub } from 'sinon'

class TestClient extends EventEmitter {
  send: SinonStub = sinon.stub()
}

const mockDb = sinon.stub()
const mockDatabase = sinon.stub().returns(mockDb)
const mockPutProtocolArtifact = sinon.stub()

const { ProtocolManager, DB_SIZE_LIMIT, DEFAULT_STREAM_SAMPLING_INTERVAL } = proxyquire('../lib/cloud/protocol', {
  'better-sqlite3': mockDatabase,
  './api/put_protocol_artifact': { putProtocolArtifact: mockPutProtocolArtifact },
}) as typeof import('@packages/server/lib/cloud/protocol')

const { outputFiles: [{ contents: stubProtocolRaw }] } = esbuild.buildSync({
  entryPoints: [path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'protocol', 'test-protocol.ts')],
  bundle: true,
  format: 'cjs',
  write: false,
  platform: 'node',
})
const stubProtocol = new TextDecoder('utf-8').decode(stubProtocolRaw)

describe('lib/cloud/protocol', () => {
  let protocolManager: ProtocolManagerShape
  let protocol: AppCaptureProtocolInterface

  beforeEach(async () => {
    protocolManager = new ProtocolManager()

    await protocolManager.prepareAndSetupProtocol(stubProtocol, {
      runId: '1',
      testingType: 'e2e',
      projectId: '1',
      cloudApi: { url: 'http://localhost:1234', retryWithBackoff: async () => {}, requestPromise: { get: async () => {} } },
      projectConfig: {
        devServerPublicPathRoute: '/__cypress-app/src',
        namespace: '__cypress-app',
        port: 1234,
        proxyUrl: 'http://localhost:1234',
      },
      mode: 'record',
    })

    protocol = (protocolManager as any)._protocol
    expect((protocol as any)).not.to.be.undefined
  })

  it('should be able to connect to the browser', async () => {
    const mockCdpClient = new TestClient()

    const connectToBrowserStub = sinon.stub(protocol, 'connectToBrowser').resolves()

    await protocolManager.connectToBrowser(mockCdpClient as any)

    const newCdpClient = connectToBrowserStub.getCall(0).args[0]

    newCdpClient.send('Page.enable')
    expect(mockCdpClient.send).to.be.calledWith('Page.enable')

    const mockSuccess = sinon.stub()

    newCdpClient.on('Page.loadEventFired', mockSuccess)

    const mockThrows = sinon.stub().throws()

    newCdpClient.on('Page.backForwardCacheNotUsed', mockThrows)

    mockCdpClient.emit('Page.loadEventFired')

    expect(mockSuccess).to.be.called
    expect((protocolManager as any)._errors).to.be.empty

    mockCdpClient.emit('Page.backForwardCacheNotUsed', { test: 'test1' })

    expect(mockThrows).to.be.called
    expect((protocolManager as any)._errors).to.have.length(1)
    expect((protocolManager as any)._errors[0].captureMethod).to.equal('cdpClient.on')
    expect((protocolManager as any)._errors[0].args).to.deep.equal([
      'Page.backForwardCacheNotUsed',
      {
        test: 'test1',
      },
    ])
  })

  it('should unregister listener when off() is called on wrapped CDP client', async () => {
    const mockCdpClient = new TestClient()

    sinon.stub(protocol, 'connectToBrowser').resolves()

    await protocolManager.connectToBrowser(mockCdpClient as any)

    const newCdpClient = (protocol.connectToBrowser as SinonStub).getCall(0).args[0]
    const listener = sinon.stub()

    newCdpClient.on('Page.loadEventFired', listener)
    mockCdpClient.emit('Page.loadEventFired')
    expect(listener).to.have.been.calledOnce

    newCdpClient.off('Page.loadEventFired', listener)
    mockCdpClient.emit('Page.loadEventFired')
    expect(listener).to.have.been.calledOnce
  })

  it('uses event+listener composite key so same listener on multiple events does not leak wrappers', async () => {
    const mockCdpClient = new TestClient()
    const onCalls: Array<{ event: string, listener: Function }> = []
    const offCalls: Array<{ event: string, listener: Function }> = []

    const originalOn = mockCdpClient.on.bind(mockCdpClient)
    const originalOff = mockCdpClient.off.bind(mockCdpClient)

    mockCdpClient.on = function (event: string, listener: Function) {
      onCalls.push({ event, listener })

      return originalOn(event, listener)
    }

    mockCdpClient.off = function (event: string, listener: Function) {
      offCalls.push({ event, listener })

      return originalOff(event, listener)
    }

    let capturedWrappedClient: any

    sinon.stub(protocolManager as any, 'invokeAsync').callsFake(async (_method: string, _opts: any, cdpClient: any) => {
      capturedWrappedClient = cdpClient
    })

    await protocolManager.connectToBrowser(mockCdpClient as any)

    expect(capturedWrappedClient).to.exist

    const sharedListener = sinon.stub()

    capturedWrappedClient.on('Page.frameAttached', sharedListener)
    capturedWrappedClient.on('Page.frameDetached', sharedListener)

    expect(onCalls).to.have.length(2)

    const wrapperForAttached = onCalls.find((c) => c.event === 'Page.frameAttached')!.listener
    const wrapperForDetached = onCalls.find((c) => c.event === 'Page.frameDetached')!.listener

    expect(wrapperForAttached).to.not.equal(wrapperForDetached)

    capturedWrappedClient.off('Page.frameAttached', sharedListener)
    expect(offCalls).to.have.length(1)
    expect(offCalls[0].event).to.equal('Page.frameAttached')
    expect(offCalls[0].listener).to.equal(wrapperForAttached)

    capturedWrappedClient.off('Page.frameDetached', sharedListener)
    expect(offCalls).to.have.length(2)
    expect(offCalls[1].event).to.equal('Page.frameDetached')
    expect(offCalls[1].listener).to.equal(wrapperForDetached)
  })

  it('should call cleanup on existing protocol when setupProtocol is called again', () => {
    const cleanupStub = sinon.stub(protocol, 'cleanup')

    protocolManager.setupProtocol()

    expect(cleanupStub).to.have.been.calledOnce
  })

  it('should be able to initialize a new spec', () => {
    sinon.stub(protocol, 'beforeSpec')

    ;(protocolManager as any)._errors = [
      {
        captureMethod: 'cdpClient.on',
      },
    ]

    const spec = {
      instanceId: 'instanceId',
      absolute: '/path/to/spec',
      relative: 'spec',
      relativeToCommonRoot: 'common/root',
      specFileExtension: '.ts',
      fileExtension: '.ts',
      specType: 'integration' as Cypress.CypressSpecType,
      baseName: 'spec',
      name: 'spec',
      fileName: 'spec.ts',
    }

    protocolManager.beforeSpec(spec)

    expect((protocolManager as any)._errors).to.be.empty

    expect(protocol.beforeSpec).to.be.calledWith({
      workingDirectory: path.join(os.tmpdir(), 'cypress', 'protocol'),
      archivePath: path.join(os.tmpdir(), 'cypress', 'protocol', 'instanceId.tar'),
      dbPath: path.join(os.tmpdir(), 'cypress', 'protocol', 'instanceId.db'),
      db: mockDb,
      spec,
    })

    expect(mockDatabase).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'protocol', 'instanceId.db'), {
      nativeBinding: path.join(require.resolve('better-sqlite3/build/Release/better_sqlite3.node')),
      verbose: sinon.match.func,
    })

    expect(protocolManager['_instanceId']).to.equal('instanceId')
    expect(protocolManager['_specName']).to.equal('spec')
  })

  it('should be able to initialize a new test', async () => {
    sinon.stub(protocol, 'beforeTest')

    await protocolManager.beforeTest({
      id: 'id',
      title: 'test',
    })

    expect(protocol.beforeTest).to.be.calledWith({
      id: 'id',
      title: 'test',
    })
  })

  describe('.afterSpec', () => {
    it('invokes the protocol manager afterSpec fn', async () => {
      sinon.stub(protocol, 'afterSpec')

      await protocolManager.afterSpec()

      expect(protocol.afterSpec).to.be.called
    })
  })

  it('should be able to handle pre-after test', async () => {
    sinon.stub(protocol, 'preAfterTest')

    await protocolManager.preAfterTest({ id: 'id', title: 'test' }, { nextTestHasTestIsolationOn: true })

    expect(protocol.preAfterTest).to.be.calledWith({ id: 'id', title: 'test' }, { nextTestHasTestIsolationOn: true })
  })

  it('should be able to clean up after a test', async () => {
    sinon.stub(protocol, 'afterTest')

    await protocolManager.afterTest({
      id: 'id',
      title: 'test',
    })

    expect(protocol.afterTest).to.be.calledWith({
      id: 'id',
      title: 'test',
    })
  })

  it('should be able to add runnables', () => {
    sinon.stub(protocol, 'addRunnables')

    const rootRunnable = {
      id: 'r1',
      type: 'suite',
      suites: [],
      tests: [
        {
          id: 'r2',
          name: 'test body',
          title: 'test 1',
          type: 'test',
        },
      ],
      hooks: [],
    }

    protocolManager.addRunnables(rootRunnable)

    expect(protocol.addRunnables).to.be.calledWith(rootRunnable)
  })

  it('should be able to add a command log', () => {
    sinon.stub(protocol, 'commandLogAdded')

    const log = {
      id: 'log-https://example.cypress.io-17',
      alias: 'getComment',
      aliasType: 'route',
      displayName: 'xhr',
      event: true,
      hookId: 'r4',
      instrument: 'command',
      message: '',
      method: 'GET',
      name: 'request',
      renderProps: {},
      state: 'pending',
      testId: 'r4',
      timeout: 0,
      createdAtTimestamp: 1689619127850.2854,
      updatedAtTimestamp: 1689619127851.2854,
      type: 'parent',
      url: 'https://jsonplaceholder.cypress.io/comments/1',
      wallClockStartedAt: '2023-03-30T21:58:08.456Z',
      testCurrentRetry: 0,
      hasSnapshot: false,
      hasConsoleProps: true,
    }

    protocolManager.commandLogAdded(log)

    expect(protocol.commandLogAdded).to.be.calledWith(log)
  })

  it('should be able to change a command log', () => {
    sinon.stub(protocol, 'commandLogChanged')

    const log = {
      id: 'log-https://example.cypress.io-17',
      alias: 'getComment',
      aliasType: 'route',
      displayName: 'xhr',
      event: true,
      hookId: 'r4',
      instrument: 'command',
      message: '',
      method: 'GET',
      name: 'request',
      renderProps: {},
      state: 'pending',
      testId: 'r4',
      timeout: 0,
      createdAtTimestamp: 1689619127850.2854,
      updatedAtTimestamp: 1689619127851.2854,
      type: 'parent',
      url: 'https://jsonplaceholder.cypress.io/comments/1',
      wallClockStartedAt: '2023-03-30T21:58:08.456Z',
      testCurrentRetry: 0,
      hasSnapshot: false,
      hasConsoleProps: true,
    }

    protocolManager.commandLogChanged(log)

    expect(protocol.commandLogChanged).to.be.calledWith(log)
  })

  it('should be able to handle changing the viewport', () => {
    sinon.stub(protocol, 'viewportChanged')

    const input = {
      viewport: {
        width: 100,
        height: 200,
      },
      timestamp: 1234,
    }

    protocolManager.viewportChanged(input)

    expect(protocol.viewportChanged).to.be.calledWith(input)
  })

  it('should be able to handle changing the url', () => {
    sinon.stub(protocol, 'urlChanged')

    const input = {
      url: 'https://example.cypress.io',
      timestamp: 1234,
    }

    protocolManager.urlChanged(input)

    expect(protocol.urlChanged).to.be.calledWith(input)
  })

  it('should be able to handle the page loading', () => {
    sinon.stub(protocol, 'pageLoading')

    const input = {
      loading: true,
      timestamp: 1234,
    }

    protocolManager.pageLoading(input)

    expect(protocol.pageLoading).to.be.calledWith(input)
  })

  describe('.resetTest', () => {
    it('should be able to reset the test with no current retry', () => {
      sinon.stub(protocol, 'resetTest')

      const testId = 'r3'

      protocolManager.resetTest(testId)

      expect(protocol.resetTest).to.be.calledWith(testId)
    })

    it('should be able to reset the test with a current retry', () => {
      sinon.stub(protocol, 'resetTest')

      const testId = 'r3'
      const currentRetry = 1

      protocolManager.resetTest(testId, currentRetry)

      expect(protocol.resetTest).to.be.calledWith(testId, currentRetry)
    })
  })

  describe('.reset', () => {
    it('closes the protocol manager', () => {
      const mockClose = sinon.stub()

      protocolManager['_db'] = {
        close: mockClose,
      }

      protocolManager['_dbPath'] = '/path/to/db'
      sinon.stub(fs, 'unlink').resolves()
      protocolManager['_archivePath'] = '/path/to/archive'
      protocolManager['_instanceId'] = 'abc123'
      protocolManager['_runId'] = '1'
      protocolManager['_errors'] = [{ captureMethod: 'cdpClient.on' }]

      protocolManager.close()

      expect(mockClose).to.be.called
      expect(protocolManager['_db']).to.be.undefined
      expect(protocolManager['_dbPath']).to.be.undefined
      expect(fs.unlink).to.be.calledWith('/path/to/db')
      expect(protocolManager['_archivePath']).to.be.undefined
      expect(fs.unlink).to.be.calledWith('/path/to/archive')
      expect(protocolManager['_instanceId']).to.be.undefined
      expect(protocolManager['_runId']).to.be.undefined
      expect(protocolManager['_errors']).to.be.empty
      expect(protocolManager['_protocol']).to.be.undefined
    })

    it('calls cleanup on protocol before clearing it', () => {
      const cleanupStub = sinon.stub(protocol, 'cleanup')

      protocolManager['_db'] = { close: sinon.stub() }
      protocolManager['_dbPath'] = '/path/to/db'
      protocolManager['_archivePath'] = '/path/to/archive'
      sinon.stub(fs, 'unlink').resolves()

      protocolManager.close()

      expect(cleanupStub).to.have.been.calledOnce
      expect(protocolManager['_protocol']).to.be.undefined
    })
  })

  describe('.dbPath', () => {
    it('returns the database path', () => {
      const mockDbPath = '/path/to/db'

      protocolManager['_dbPath'] = mockDbPath

      expect(protocolManager.dbPath).to.equal(mockDbPath)
    })

    it('returns undefined when no database path is set', () => {
      expect(protocolManager.dbPath).to.be.undefined
    })
  })

  describe('.uploadCaptureArtifact()', () => {
    let filePath: string
    let fileSize: number
    let uploadUrl: string
    let expectedAfterSpecTotal: number
    let offset: number
    let size: number
    let instanceId: string
    let clock

    describe('when protocol is initialized, and spec has finished', () => {
      const expectedAfterSpecDurations = {
        durations: {
          drainCDPEvents: 1,
          drainAUTEvents: 5,
          resolveBodyPromises: 7,
          closeDb: 11,
          teardownBindings: 13,
        },
      }

      beforeEach(async () => {
        filePath = '/foo/bar'
        fileSize = 1000
        uploadUrl = 'http://fake.test/upload_url'
        offset = 10
        size = 100
        instanceId = 'abc123'

        sinon.stub(protocol, 'getDbMetadata').returns({ offset, size })
        sinon.stub(fs, 'unlink').withArgs(filePath).resolves()
        protocolManager.beforeSpec({ instanceId, absolute: '/path/to/spec', relative: 'spec', specFileExtension: '.ts', fileExtension: '.ts', specType: 'integration', baseName: 'spec', name: 'spec', fileName: 'spec.ts' })

        expectedAfterSpecTotal = 225

        clock = sinon.useFakeTimers()
        sinon.stub(performance, 'timeOrigin').value(0)
        sinon.stub(protocol, 'afterSpec').callsFake(async () => {
          await clock.tickAsync(expectedAfterSpecTotal)

          return expectedAfterSpecDurations
        })

        await protocolManager.afterSpec()
      })

      afterEach(() => {
        clock.restore()
      })

      describe('when upload succeeds', () => {
        let defaultInterval

        beforeEach(() => {
          defaultInterval = DEFAULT_STREAM_SAMPLING_INTERVAL
        })

        describe('with default sampling rate', () => {
          beforeEach(() => {
            mockPutProtocolArtifact.withArgs(filePath, DB_SIZE_LIMIT, uploadUrl, defaultInterval).resolves()
          })

          it('uses 5000ms as the default stream monitoring sample rate', async () => {
            await protocolManager.uploadCaptureArtifact({ uploadUrl, filePath, fileSize })

            expect(mockPutProtocolArtifact).to.have.been.calledWith(filePath, DB_SIZE_LIMIT, uploadUrl, defaultInterval)
          })

          it('unlinks the db & returns fileSize, afterSpec durations, success=true, and the db metadata', async () => {
            const res = await protocolManager.uploadCaptureArtifact({ uploadUrl, filePath, fileSize })

            expect(res).not.to.be.undefined
            expect(res).to.include({
              fileSize,
              success: true,
            })

            expect(res?.afterSpecDurations).to.include({
              afterSpecTotal: expectedAfterSpecTotal,
              ...expectedAfterSpecDurations.durations,
            })

            // @ts-ignore
            expect(res?.specAccess.offset).to.eq(offset)
            // @ts-ignore
            expect(res?.specAccess.size).to.eq(size)

            expect(fs.unlink).to.have.been.called
          })
        })

        describe('when protocol exports a sampling rate', () => {
          let appCaptureProtocolInterval

          beforeEach(() => {
            appCaptureProtocolInterval = 7500

            protocol.uploadStallSamplingInterval = sinon.stub().callsFake(() => {
              return appCaptureProtocolInterval
            })
          })

          afterEach(() => {
            // @ts-ignore
            protocol.uploadStallSamplingInterval = undefined
          })

          it('uses the sampling rate defined by protocol', async () => {
            mockPutProtocolArtifact.withArgs(filePath, DB_SIZE_LIMIT, uploadUrl, appCaptureProtocolInterval).resolves()
            await protocolManager.uploadCaptureArtifact({ uploadUrl, filePath, fileSize })
            expect(mockPutProtocolArtifact).to.have.been.calledWith(filePath, DB_SIZE_LIMIT, uploadUrl, appCaptureProtocolInterval)
          })

          describe('and the user specifies a sampling rate env var', () => {
            let userDefinedInterval

            beforeEach(() => {
              userDefinedInterval = 10000
              process.env.CYPRESS_TEST_REPLAY_UPLOAD_SAMPLING_INTERVAL = '10000'
            })

            afterEach(() => {
              process.env.CYPRESS_TEST_REPLAY_UPLOAD_SAMPLING_INTERVAL = undefined
            })

            it('uses the override value from the env var', async () => {
              mockPutProtocolArtifact.withArgs(filePath, DB_SIZE_LIMIT, uploadUrl, userDefinedInterval).resolves()
              await protocolManager.uploadCaptureArtifact({ uploadUrl, filePath, fileSize })
              expect(mockPutProtocolArtifact).to.have.been.calledWith(filePath, DB_SIZE_LIMIT, uploadUrl, userDefinedInterval)
            })
          })

          describe('and the user specifies a sampling rate env var that parses to NaN', () => {
            beforeEach(() => {
              process.env.CYPRESS_TEST_REPLAY_UPLOAD_SAMPLING_INTERVAL = 'not-a-number'
            })

            afterEach(() => {
              process.env.CYPRESS_TEST_REPLAY_UPLOAD_SAMPLING_INTERVAL = undefined
            })

            it('uses the value from app capture protocol', async () => {
              mockPutProtocolArtifact.withArgs(filePath, DB_SIZE_LIMIT, uploadUrl, appCaptureProtocolInterval).resolves()
              await protocolManager.uploadCaptureArtifact({ uploadUrl, filePath, fileSize })
              expect(mockPutProtocolArtifact).to.have.been.calledWith(filePath, DB_SIZE_LIMIT, uploadUrl, appCaptureProtocolInterval)
            })
          })
        })
      })

      describe('when upload fails', () => {
        let err

        beforeEach(() => {
          err = new Error()

          ;(mockPutProtocolArtifact as SinonStub).withArgs(filePath, DB_SIZE_LIMIT, uploadUrl, DEFAULT_STREAM_SAMPLING_INTERVAL).rejects(err)
        })

        describe('and there is no local protocol path in env', () => {
          let prevLocalProtocolPath

          beforeEach(() => {
            prevLocalProtocolPath = process.env.CYPRESS_LOCAL_PROTOCOL_PATH
            process.env.CYPRESS_LOCAL_PROTOCOL_PATH = undefined
          })

          afterEach(() => {
            process.env.CYPRESS_LOCAL_PROTOCOL_PATH = prevLocalProtocolPath
          })

          it('unlinks the db & rethrows the error', async () => {
            let threw = false

            try {
              await protocolManager.uploadCaptureArtifact({ uploadUrl, filePath, fileSize })
            } catch (e) {
              threw = true
              expect(e).to.eq(err)
            }
            expect(threw).to.be.true
            expect(fs.unlink).to.be.called
          })
        })

        describe('and process.env.CYPRESS_LOCAL_PROTOCOL_PATH is truthy', () => {
          let prevLocalProtocolPath

          beforeEach(() => {
            prevLocalProtocolPath = process.env.CYPRESS_LOCAL_PROTOCOL_PATH
            process.env.CYPRESS_LOCAL_PROTOCOL_PATH = '/path'
          })

          afterEach(() => {
            process.env.CYPRESS_LOCAL_PROTOCOL_PATH = prevLocalProtocolPath
          })

          it('unlinks the db and does not rethrow', async () => {
            let threw = false

            try {
              await protocolManager.uploadCaptureArtifact({ uploadUrl, filePath, fileSize })
            } catch (e) {
              threw = true
            }
            expect(threw).to.be.false
            expect(fs.unlink).to.be.called
          })
        })
      })
    })
  })

  describe('.captureError', () => {
    beforeEach(() => {
      sinon.stub(protocolManager, 'dispatchErrors').resolves()
    })

    describe('when mode is `record`', () => {
      beforeEach(() => {
        protocolManager['options']['mode'] = 'record'
      })

      it('should store error into array for later reporting', () => {
        const err = { captureMethod: 'cdpClient.on', error: new Error(), args: { test: 'test1' } }

        protocolManager['captureError'](err)

        expect(protocolManager['_errors']).to.have.length(1)
        expect(protocolManager['_errors'][0]).to.include(err)
        expect(protocolManager['dispatchErrors']).not.to.have.been.called
      })
    })

    describe('when mode is `studio`', () => {
      beforeEach(() => {
        protocolManager['options']['mode'] = 'studio'
      })

      it('should immediately dispatch errors to the cloud', () => {
        const err = { captureMethod: 'cdpClient.on', error: new Error(), args: { test: 'test1' } }

        protocolManager['captureError'](err)

        expect(protocolManager['_errors']).to.have.length(0)
        expect(protocolManager['dispatchErrors']).to.have.been.called
        expect(protocolManager['dispatchErrors'].getCall(0).args[0]).to.deep.equal([err])
        expect(protocolManager['dispatchErrors'].getCall(0).args[1]).to.deep.equal({
          osName: os.platform(),
          projectSlug: protocolManager['options']['projectId'],
          specName: protocolManager['_specName'],
          mode: 'studio',
        })
      })
    })
  })
})
