import { isPerProductConfig, selectSource, PerProductConfigCache } from '../perProductConfig'
import request from '../../utils/request'
import { createLogger } from '../../utils/logger'
import { makeResponse, makeContext, makeSettings } from '../../__tests__/helpers'
import { webcrypto } from 'crypto'

jest.mock('../../utils/request')
jest.mock('../../utils/logger')

const getMock = request.get as jest.Mock
const logMock = jest.fn()
;(createLogger as jest.Mock).mockReturnValue({ log: logMock })

const productCdnPath = 'https://salsify-ecdn.com/sdk/client-id/lang-code/BTF/id-type/existing-product'
const productWithConfigUrl = `${productCdnPath}/config.json`

const exampleConfigJson = `
  {
    "content": [
      { "source": "index.html", "weight": 0.95 },
      { "source": null, "weight": 0.05 }
    ]
  }
`

const exampleConfig = JSON.parse(exampleConfigJson)

const invalidConfigJson = `
  {
    "content": [
      {}
    ]
  }
`

const nonJson = '<h1>Hello There!</h1>'

describe('PerProductConfig (server)', () => {
  describe('isPerProductConfig', () => {
    test('empty config is invalid', () => {
      expect(
        isPerProductConfig(
          JSON.parse(`
            {}
          `)
        )
      ).toBeFalsy()
    })

    describe('experiments', () => {
      test('empty content list is invalid', () => {
        expect(
          isPerProductConfig(
            JSON.parse(`
              {
                "content": []
              }
            `)
          )
        ).toBeFalsy()
      })

      describe('Source', () => {
        test('empty source object is invalid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  {}
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('missing weight key is invalid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "index.html" }
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('missing source key is invalid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "weight": 1 }
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('number source is invalid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": 100, "weight": 1 }
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('string weight is invalid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "index.html", "weight": "1" }
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('weights that sum > 1 are invalid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "index.html", "weight": 0.95 },
                  { "source": null, "weight": 0.15 }
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('weights that sum < 1 are invalid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "index.html", "weight": 0.90 },
                  { "source": null, "weight": 0.05 }
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('weights with > two digits after the decimal that sum to 1 are valid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "a.html", "weight": 0.475 },
                  { "source": "b.html", "weight": 0.475 },
                  { "source": null, "weight": 0.05 }
                ]
              }
            `)
            )
          ).toBeTruthy()
        })

        test('weights that sum very close to 1 are valid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "a.html", "weight": 0.3333 },
                  { "source": "b.html", "weight": 0.3333 },
                  { "source": "c.html", "weight": 0.3333 }
                ]
              }
            `)
            )
          ).toBeTruthy()
        })

        test("weights that don't sum close enough to 1 are invalid", () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "a.html", "weight": 0.33 },
                  { "source": "b.html", "weight": 0.33 },
                  { "source": "c.html", "weight": 0.33 }
                ]
              }
            `)
            )
          ).toBeFalsy()
        })

        test('string source and number weight is valid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "index.html", "weight": 1 }
                ]
              }
            `)
            )
          ).toBeTruthy()
        })

        test('null source and number weight is valid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": null, "weight": 1 }
                ]
              }
            `)
            )
          ).toBeTruthy()
        })

        test('multiple sources is valid', () => {
          expect(
            isPerProductConfig(
              JSON.parse(`
              {
                "content": [
                  { "source": "index.html", "weight": 0.95 },
                  { "source": null, "weight": 0.05 }
                ]
              }
            `)
            )
          ).toBeTruthy()
        })
      })
    })
  })

  describe('selectSource', () => {
    test('it selects a source', () => {
      const productId = '1234'
      const content = [
        { source: 'a', weight: 0.25 },
        { source: 'b', weight: 0.25 },
        { source: 'c', weight: 0.25 },
        { source: 'd', weight: 0.25 },
      ]
      const sessionId = webcrypto.randomUUID()
      const source = selectSource({ productId, content, sessionId })
      expect(content).toContain(source)
    })

    test('it selects different sources for different sessionIds', () => {
      const productId = '1234567890'
      const content = [
        { source: 'a', weight: 0.25 },
        { source: 'b', weight: 0.25 },
        { source: 'c', weight: 0.25 },
        { source: 'd', weight: 0.25 },
      ]
      expect(selectSource({ productId, content, sessionId: '9b01d087-b763-4572-8ccd-b602e80cd9d0' })).toBe(content[0])
      expect(selectSource({ productId, content, sessionId: '8200229d-ebef-4063-b2e5-2c824c60e528' })).toBe(content[1])
      expect(selectSource({ productId, content, sessionId: 'cec4ec2a-0a80-4df1-9519-19f710335b99' })).toBe(content[2])
      expect(selectSource({ productId, content, sessionId: '35ea55ca-a19e-4075-9b2b-d9fd6fc7df4d' })).toBe(content[3])
    })

    test('it selects different sources for different productIds', () => {
      const content = [
        { source: 'a', weight: 0.25 },
        { source: 'b', weight: 0.25 },
        { source: 'c', weight: 0.25 },
        { source: 'd', weight: 0.25 },
      ]
      const sessionId = '9b01d087-b763-4572-8ccd-b602e80cd9d0'
      expect(selectSource({ productId: '1234567890', content, sessionId })).toBe(content[0])
      expect(selectSource({ productId: '1234567899', content, sessionId })).toBe(content[1])
      expect(selectSource({ productId: '1234567892', content, sessionId })).toBe(content[2])
      expect(selectSource({ productId: '1234567893', content, sessionId })).toBe(content[3])
    })

    test('it selects different sources for different content sources', () => {
      const productId = '1234567890'
      const sessionId = '9b01d087-b763-4572-8ccd-b602e80cd9d0'
      let content = [
        { source: 'a', weight: 0.25 },
        { source: 'b', weight: 0.25 },
        { source: 'c', weight: 0.25 },
        { source: 'd', weight: 0.25 },
      ]
      expect(selectSource({ productId, content, sessionId })).toBe(content[0])
      content = [
        { source: 'a', weight: 0.25 },
        { source: 'e', weight: 0.25 },
        { source: 'c', weight: 0.25 },
        { source: 'd', weight: 0.25 },
      ]
      expect(selectSource({ productId, content, sessionId })).toBe(content[2])
    })

    test('it selects different sources for different content weights', () => {
      const productId = '1234567890'
      const sessionId = '9b01d087-b763-4572-8ccd-b602e80cd9d0'
      let content = [
        { source: 'a', weight: 0.25 },
        { source: 'b', weight: 0.25 },
        { source: 'c', weight: 0.25 },
        { source: 'd', weight: 0.25 },
      ]
      expect(selectSource({ productId, content, sessionId })).toBe(content[0])
      content = [
        { source: 'a', weight: 0.3 },
        { source: 'b', weight: 0.2 },
        { source: 'c', weight: 0.3 },
        { source: 'd', weight: 0.2 },
      ]
      expect(selectSource({ productId, content, sessionId })).toBe(content[3])
    })

    test('it selects sources with uniform distribution across sessionIds', () => {
      const productId = '1234567890'
      const content = [
        { source: 'a', weight: 0.25 },
        { source: 'b', weight: 0.25 },
        { source: 'c', weight: 0.25 },
        { source: 'd', weight: 0.25 },
      ]
      const counts = { a: 0, b: 0, c: 0, d: 0 }
      const iterations = 100000
      for (let i = 0; i < iterations; i++) {
        const sessionId = webcrypto.randomUUID()
        const source = selectSource({ productId, content, sessionId })
        counts[source.source as keyof typeof counts]++
      }
      expect(counts.a / iterations).toBeCloseTo(0.25)
      expect(counts.b / iterations).toBeCloseTo(0.25)
      expect(counts.c / iterations).toBeCloseTo(0.25)
      expect(counts.d / iterations).toBeCloseTo(0.25)
    })

    test('it selects null source with low chance at correct rate', () => {
      const productId = '1234567890'
      const content = [
        { source: 'index.html', weight: 0.95 },
        { source: null, weight: 0.05 },
      ]
      let nullSourceCount = 0
      const iterations = 100000
      for (let i = 0; i < iterations; i++) {
        const sessionId = webcrypto.randomUUID()
        const source = selectSource({ productId, content, sessionId })
        if (source.source === null) {
          nullSourceCount++
        }
      }
      expect(nullSourceCount / iterations).toBeCloseTo(0.05)
    })
  })

  describe('PerProductConfigCache', () => {
    const logger = createLogger(makeContext(), makeSettings())

    afterEach(() => {
      jest.clearAllMocks()
    })

    describe('valid config file', () => {
      beforeEach(() => {
        getMock.mockImplementation(() => makeResponse(exampleConfigJson))
      })

      test('it fetches and returns config on first getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(config).toEqual(exampleConfig)
      })

      test('it returns config from cache on subsequent getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(config).toEqual(exampleConfig)
        const config2 = await perProductConfigCache.getConfig(productCdnPath)
        expect(config2).toEqual(exampleConfig)
        expect(getMock).toBeCalledTimes(1)
      })
    })

    describe('error fetching config file', () => {
      beforeEach(() => {
        getMock.mockRejectedValue(Error('network error'))
      })

      test('it handles rejection', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(logMock).toBeCalledWith('error', {
          errorContext: 'per-product config',
          errorType: 'fetch',
          errorMessage: `Error fetching ${productWithConfigUrl}: network error`,
        })
        expect(config).toBeUndefined()
      })
    })

    describe('no config file', () => {
      beforeEach(() => {
        getMock.mockImplementation(() => makeResponse(''))
      })

      test('it fetches and returns undefined on first getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(config).toBeUndefined()
      })

      test('it returns undefined from cache on subsequent getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(config).toBeUndefined()
        const config2 = await perProductConfigCache.getConfig(productCdnPath)
        expect(config2).toBeUndefined()
        expect(getMock).toBeCalledTimes(1)
      })
    })

    describe('invalid config file', () => {
      beforeEach(() => {
        getMock.mockImplementation(() => makeResponse(invalidConfigJson))
      })

      test('it fetches and returns undefined on first getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(logMock).toBeCalledWith('error', {
          errorContext: 'per-product config',
          errorType: 'validation',
          errorMessage: `'content' in ${productWithConfigUrl} contains a source that is missing a 'source' key`,
        })
        expect(config).toBeUndefined()
      })

      test('it returns undefined from cache on subsequent getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(config).toBeUndefined()
        const config2 = await perProductConfigCache.getConfig(productCdnPath)
        expect(config2).toBeUndefined()
        expect(getMock).toBeCalledTimes(1)
      })

      describe('specific validation error logging', () => {
        test('it logs non-object config', async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              42
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `${productWithConfigUrl} does not contain an object`,
          })
          expect(config).toBeUndefined()
        })

        test('it logs missing content key', async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {}
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `${productWithConfigUrl} does not contain a 'content' key`,
          })
          expect(config).toBeUndefined()
        })

        test('it logs content value not an array', async () => {
          getMock.mockImplementation(() => makeResponse('{ "content": {} }'))
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `'content' in ${productWithConfigUrl} is not an array`,
          })
          expect(config).toBeUndefined()
        })

        test('it logs content array is empty', async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {
                "content": []
              }
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `'content' array in ${productWithConfigUrl} has length 0`,
          })
          expect(config).toBeUndefined()
        })

        test('it logs content source that is not an object', async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {
                "content": [
                  { "source": "foo", "weight": 0.5 },
                  "bar",
                  { "source": "baz", "weight": 0.5 }
                ]
              }
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `'content' in ${productWithConfigUrl} contains a source that is not an object`,
          })
          expect(config).toBeUndefined()
        })

        test("it logs content source that is missing a 'source' key", async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {
                "content": [
                  { "source": "foo", "weight": 0.333 },
                  { "weight": 0.333 },
                  { "source": "baz", "weight": 0.333 }
                ]
              }
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `'content' in ${productWithConfigUrl} contains a source that is missing a 'source' key`,
          })
          expect(config).toBeUndefined()
        })

        test("it logs content source that has an invalid 'source' value", async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {
                "content": [
                  { "source": "foo", "weight": 0.333 },
                  { "source": 12345, "weight": 0.333 },
                  { "source": "baz", "weight": 0.333 }
                ]
              }
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `'content' in ${productWithConfigUrl} contains a source with an invalid 'source' value`,
          })
          expect(config).toBeUndefined()
        })

        test("it logs content source that is missing a 'weight' key", async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {
                "content": [
                  { "source": "foo", "weight": 0.333 },
                  { "source": "bar" },
                  { "source": "baz", "weight": 0.333 }
                ]
              }
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `'content' in ${productWithConfigUrl} contains a source that is missing a 'weight' key`,
          })
          expect(config).toBeUndefined()
        })

        test("it logs content source that has an invalid 'weight' value", async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {
                "content": [
                  { "source": "foo", "weight": 0.333 },
                  { "source": "bar", "weight": "0.333" },
                  { "source": "baz", "weight": 0.333 }
                ]
              }
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `'content' in ${productWithConfigUrl} contains a source with an invalid 'weight' value`,
          })
          expect(config).toBeUndefined()
        })

        test('it logs content sources whose weights do not sum to 1', async () => {
          getMock.mockImplementation(() =>
            makeResponse(`
              {
                "content": [
                  { "source": "foo", "weight": 0.3 },
                  { "source": "bar", "weight": 0.3 },
                  { "source": "baz", "weight": 0.3 }
                ]
              }
            `)
          )
          const perProductConfigCache = new PerProductConfigCache(logger)
          const config = await perProductConfigCache.getConfig(productCdnPath)
          expect(getMock).toBeCalledWith(productWithConfigUrl)
          expect(logMock).toBeCalledWith('error', {
            errorContext: 'per-product config',
            errorType: 'validation',
            errorMessage: `sum of source weights in 'content' in ${productWithConfigUrl} does not equal 1`,
          })
          expect(config).toBeUndefined()
        })
      })
    })

    describe('non-JSON config file', () => {
      beforeEach(() => {
        getMock.mockImplementation(() => makeResponse(nonJson))
      })

      test('it fetches and returns undefined on first getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(logMock).toBeCalledWith('error', {
          errorContext: 'per-product config',
          errorType: 'parse',
          errorMessage: `Error parsing ${productWithConfigUrl}: Unexpected token < in JSON at position 0`,
        })
        expect(config).toBeUndefined()
      })

      test('it returns undefined from cache on subsequent getConfig', async () => {
        const perProductConfigCache = new PerProductConfigCache(logger)
        const config = await perProductConfigCache.getConfig(productCdnPath)
        expect(getMock).toBeCalledWith(productWithConfigUrl)
        expect(config).toBeUndefined()
        const config2 = await perProductConfigCache.getConfig(productCdnPath)
        expect(config2).toBeUndefined()
        expect(getMock).toBeCalledTimes(1)
        expect(logMock).toBeCalledTimes(1)
      })
    })
  })
})
