import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'

describe('ProjectSnapshot', function () {
  let snapshot: ProjectSnapshot
  const projectId = 'project-id'

  beforeEach(function () {
    snapshot = new ProjectSnapshot(projectId)
  })

  describe('before initialization', function () {
    describe('getDocPaths()', function () {
      it('returns an empty string', function () {
        expect(snapshot.getDocPaths()).to.deep.equal([])
      })
    })

    describe('getDocContents()', function () {
      it('returns null', function () {
        expect(snapshot.getDocContents('main.tex')).to.be.null
      })
    })
  })

  const files = {
    'main.tex': {
      contents: '\\documentclass{article}\netc.',
      hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
    },
    'hello.txt': {
      contents: 'Hello history!',
      hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
    },
    'goodbye.txt': {
      contents: "We're done here",
      hash: 'dddddddddddddddddddddddddddddddddddddddd',
    },
  }

  const chunk = {
    history: {
      snapshot: {
        files: {},
      },
      changes: [
        {
          operations: [
            {
              pathname: 'hello.txt',
              file: {
                hash: files['hello.txt'].hash,
                stringLength: files['hello.txt'].contents.length,
              },
            },
            {
              pathname: 'main.tex',
              file: {
                hash: files['main.tex'].hash,
                stringLength: files['main.tex'].contents.length,
              },
            },
            {
              pathname: 'frog.jpg',
              file: {
                hash: 'cccccccccccccccccccccccccccccccccccccccc',
                byteLength: 97080,
              },
            },
          ],
          timestamp: '2025-01-01T12:00:00.000Z',
        },
      ],
    },
    startVersion: 0,
  }

  const changes = [
    {
      operations: [
        {
          pathname: 'hello.txt',
          textOperation: ['Quote: ', files['hello.txt'].contents.length],
        },
        {
          pathname: 'goodbye.txt',
          file: {
            hash: files['goodbye.txt'].hash,
            stringLength: files['goodbye.txt'].contents.length,
          },
        },
      ],
      timestamp: '2025-01-01T13:00:00.000Z',
    },
  ]

  function mockFlush(
    opts: { repeat?: number; failOnCall?: (call: number) => boolean } = {}
  ) {
    let currentCall = 0
    const getResponse = () => {
      currentCall += 1
      return opts.failOnCall?.(currentCall) ? 500 : 200
    }

    fetchMock.post(`/project/${projectId}/flush`, getResponse, {
      name: 'flush',
      repeat: opts.repeat ?? 1,
    })
  }

  function mockLatestChunk() {
    fetchMock.getOnce(
      `/project/${projectId}/latest/history`,
      { chunk },
      { name: 'latest-chunk' }
    )
  }

  function mockChanges() {
    fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes, {
      name: 'changes-1',
    })
    fetchMock.get(`/project/${projectId}/changes?since=2`, [], {
      name: 'changes-2',
    })
  }

  function mockBlobs(paths = Object.keys(files) as (keyof typeof files)[]) {
    for (const path of paths) {
      const file = files[path]
      fetchMock.get(`/project/${projectId}/blob/${file.hash}`, file.contents)
    }
  }

  async function initializeSnapshot() {
    mockFlush()
    mockLatestChunk()
    mockBlobs(['main.tex', 'hello.txt'])
    await snapshot.refresh()
    fetchMock.removeRoutes().clearHistory()
  }

  describe('after initialization', function () {
    beforeEach(initializeSnapshot)

    describe('getDocPaths()', function () {
      it('returns the editable docs', function () {
        expect(snapshot.getDocPaths()).to.have.members([
          'main.tex',
          'hello.txt',
        ])
      })
    })

    describe('getDocContents()', function () {
      it('returns the doc contents', function () {
        expect(snapshot.getDocContents('main.tex')).to.equal(
          files['main.tex'].contents
        )
      })

      it('returns null for binary files', function () {
        expect(snapshot.getDocContents('frog.jpg')).to.be.null
      })

      it('returns null for inexistent files', function () {
        expect(snapshot.getDocContents('does-not-exist.txt')).to.be.null
      })
    })
  })

  async function refreshSnapshot() {
    mockFlush()
    mockChanges()
    mockBlobs(['goodbye.txt'])
    await snapshot.refresh()
    fetchMock.removeRoutes().clearHistory()
  }

  describe('after refresh', function () {
    beforeEach(initializeSnapshot)
    beforeEach(refreshSnapshot)

    afterEach(function () {
      fetchMock.removeRoutes().clearHistory()
    })

    describe('getDocPaths()', function () {
      it('returns the editable docs', function () {
        expect(snapshot.getDocPaths()).to.have.members([
          'main.tex',
          'hello.txt',
          'goodbye.txt',
        ])
      })
    })

    describe('getDocContents()', function () {
      it('returns the up to date content', function () {
        expect(snapshot.getDocContents('hello.txt')).to.equal(
          `Quote: ${files['hello.txt'].contents}`
        )
      })

      it('returns contents of new files', function () {
        expect(snapshot.getDocContents('goodbye.txt')).to.equal(
          files['goodbye.txt'].contents
        )
      })
    })
  })

  describe('concurrency', function () {
    afterEach(function () {
      fetchMock.removeRoutes().clearHistory()
    })

    specify('two concurrent inits', async function () {
      mockFlush({ repeat: 2 })
      mockLatestChunk()
      mockChanges()
      mockBlobs()

      await Promise.all([snapshot.refresh(), snapshot.refresh()])

      // The first request initializes, the second request loads changes
      expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
    })

    specify('three concurrent inits', async function () {
      mockFlush({ repeat: 2 })
      mockLatestChunk()
      mockChanges()
      mockBlobs()

      await Promise.all([
        snapshot.refresh(),
        snapshot.refresh(),
        snapshot.refresh(),
      ])

      // The first request initializes, the second and third are combined and
      // load changes
      expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
    })

    specify('two concurrent inits - first fails', async function () {
      mockFlush({ repeat: 2, failOnCall: call => call === 1 })
      mockLatestChunk()
      mockBlobs()

      const results = await Promise.allSettled([
        snapshot.refresh(),
        snapshot.refresh(),
      ])

      // The first init fails, but the second succeeds
      expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
      expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(0)
    })

    specify('three concurrent inits - second fails', async function () {
      mockFlush({ repeat: 4, failOnCall: call => call === 2 })
      mockLatestChunk()
      mockChanges()
      mockBlobs()

      const results = await Promise.allSettled([
        snapshot.refresh(),
        snapshot.refresh(),
        snapshot.refresh(),
      ])

      // Another request afterwards
      await snapshot.refresh()

      // The first init succeeds, the two queued requests fail, the last request
      // succeeds
      expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
    })

    specify('two concurrent load changes', async function () {
      mockFlush({ repeat: 3 })
      mockLatestChunk()
      mockChanges()
      mockBlobs()

      // Initialize
      await snapshot.refresh()

      // Two concurrent load changes
      await Promise.all([snapshot.refresh(), snapshot.refresh()])

      // One init, two load changes
      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
    })

    specify('three concurrent load changes', async function () {
      mockFlush({ repeat: 3 })
      mockLatestChunk()
      mockChanges()
      mockBlobs()

      // Initialize
      await snapshot.refresh()

      // Three concurrent load changes
      await Promise.all([
        snapshot.refresh(),
        snapshot.refresh(),
        snapshot.refresh(),
      ])

      // One init, two load changes (the two last are queued and combined)
      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
    })

    specify('two concurrent load changes - first fails', async function () {
      mockFlush({ repeat: 3, failOnCall: call => call === 2 })
      mockLatestChunk()
      mockChanges()
      mockBlobs()

      // Initialize
      await snapshot.refresh()

      // Two concurrent load changes
      const results = await Promise.allSettled([
        snapshot.refresh(),
        snapshot.refresh(),
      ])

      // One init, one load changes fails, the second succeeds
      expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
      expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
    })

    specify('three concurrent load changes - second fails', async function () {
      mockFlush({ repeat: 4, failOnCall: call => call === 3 })
      mockLatestChunk()
      mockChanges()
      mockBlobs()

      // Initialize
      await snapshot.refresh()

      // Two concurrent load changes
      const results = await Promise.allSettled([
        snapshot.refresh(),
        snapshot.refresh(),
        snapshot.refresh(),
      ])

      // Another request afterwards
      await snapshot.refresh()

      // One init, one load changes succeeds, the second and third are combined
      // and fail, the last request succeeds
      expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
      expect(fetchMock.callHistory.calls('flush')).to.have.length(4)
      expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
      expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
    })
  })
})
