import request from '../../request'
import Transport from '../log-service-transport'
import { makeContext, makeResponse, makeSettings } from '../../../__tests__/helpers'
import { webcrypto } from 'crypto'
import HttpStatus from 'http-status-codes'

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

const flushPromises = (): Promise<void> => new Promise(process.nextTick)

describe('log-service-transport', () => {
  beforeEach(() => {
    jest.useFakeTimers({ doNotFake: ['nextTick'] })
    jest.spyOn(request, 'post').mockImplementation(async () => makeResponse('', HttpStatus.OK))
  })

  afterEach(() => {
    jest.resetAllMocks()
    jest.useRealTimers()
  })

  test('should transform to log-service data model before sending', async () => {
    const context = makeContext()
    const settings = makeSettings()
    const transport = new Transport(context, settings)

    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    await flushPromises()

    expect(request.post).toHaveBeenCalledTimes(1)
    const call = (request.post as jest.Mock).mock.calls[0]
    expect(call[0]).toBe('https://retail-client-events-service.internal.salsify.com/events')

    expect(call[1].app).toBe('sxp_sdk')
    expect(call[1].channel).toBe(context.clientId)
    expect(call[1].csid).toBe(context.sessionId)
    expect(call[1].pagesessionid).toBe(context.pageSessionId)
    expect(call[1].jsSource).toBe(context.jsSource)
    expect(typeof call[1].timestamp).toBe('number')

    expect(call[1].logs).toHaveLength(1)
    expect(call[1].logs[0].code).toBe('sdk_foo')
    expect(call[1].logs[0].properties.bar).toBe('zaz')
    expect(call[1].logs[0].context.url).toBe('example.com')
    expect(typeof call[1].logs[0].timestamp).toBe('number')
  })

  test('should use new pageSessionId when updated', async () => {
    const context = makeContext()
    const settings = makeSettings()
    const transport = new Transport(context, settings)

    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    await flushPromises()

    expect(request.post).toHaveBeenCalledTimes(1)
    const call = (request.post as jest.Mock).mock.calls[0]
    expect(call[1].pagesessionid).toBe(context.pageSessionId)

    const newPageSessionId = webcrypto.randomUUID()
    context.pageSessionId = newPageSessionId

    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    await flushPromises()

    expect(request.post).toHaveBeenCalledTimes(2)
    const call2 = (request.post as jest.Mock).mock.calls[1]
    expect(call2[1].pagesessionid).toBe(newPageSessionId)
  })

  test('should send to staging when configured', async () => {
    const context = makeContext()
    const settings = makeSettings({ staging: true })
    const transport = new Transport(context, settings)

    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    await flushPromises()

    expect(request.post).toHaveBeenCalledTimes(1)
    const call = (request.post as jest.Mock).mock.calls[0]
    expect(call[0]).toBe('https://retail-client-events-service-staging.internal.salsify.com/events')
  })

  test('it should resend failed events on network error', async () => {
    const context = makeContext()
    const settings = makeSettings()
    const transport = new Transport(context, settings)

    const postSpy = jest.spyOn(request, 'post').mockRejectedValue(new Error())
    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    expect(request.post).toHaveBeenCalledTimes(1)
    expect(postSpy.mock.calls[0][1].logs).toEqual([expect.objectContaining({ code: 'sdk_foo' })])
    await flushPromises()
    transport.log('foo2', { bar: 'zaz2' }, { url: 'example2.com' })
    expect(request.post).toHaveBeenCalledTimes(2)
    await flushPromises()

    jest.advanceTimersByTime(5000)
    // All failed events are included in retry
    expect(request.post).toHaveBeenCalledTimes(3)
    expect(postSpy.mock.calls[2][1].logs).toEqual([
      expect.objectContaining({ code: 'sdk_foo' }),
      expect.objectContaining({ code: 'sdk_foo2' }),
    ])
    await flushPromises()

    jest.advanceTimersByTime(20000)
    // Retries happen with exponential backoff
    expect(request.post).toHaveBeenCalledTimes(4)
    expect(postSpy.mock.calls[3][1].logs).toEqual([
      expect.objectContaining({ code: 'sdk_foo' }),
      expect.objectContaining({ code: 'sdk_foo2' }),
    ])
    await flushPromises()
  })

  test('it should resend failed events on HTTP error', async () => {
    const context = makeContext()
    const settings = makeSettings()
    const transport = new Transport(context, settings)

    const postSpy = jest
      .spyOn(request, 'post')
      .mockImplementation(async () => makeResponse('', HttpStatus.SERVICE_UNAVAILABLE))
    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    expect(request.post).toHaveBeenCalledTimes(1)
    expect(postSpy.mock.calls[0][1].logs).toEqual([expect.objectContaining({ code: 'sdk_foo' })])
    await flushPromises()
    transport.log('foo2', { bar: 'zaz2' }, { url: 'example2.com' })
    expect(request.post).toHaveBeenCalledTimes(2)
    await flushPromises()

    jest.advanceTimersByTime(5000)
    // All failed events are included in retry
    expect(request.post).toHaveBeenCalledTimes(3)
    expect(postSpy.mock.calls[2][1].logs).toEqual([
      expect.objectContaining({ code: 'sdk_foo' }),
      expect.objectContaining({ code: 'sdk_foo2' }),
    ])
    await flushPromises()

    jest.advanceTimersByTime(20000)
    // Retries happen with exponential backoff
    expect(request.post).toHaveBeenCalledTimes(4)
    expect(postSpy.mock.calls[3][1].logs).toEqual([
      expect.objectContaining({ code: 'sdk_foo' }),
      expect.objectContaining({ code: 'sdk_foo2' }),
    ])
    await flushPromises()
  })

  test('it should clear successfully resent events from the queue and reset the timer', async () => {
    const context = makeContext()
    const settings = makeSettings()
    const transport = new Transport(context, settings)

    jest.spyOn(request, 'post').mockRejectedValueOnce(new Error())
    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    await flushPromises()
    expect(request.post).toHaveBeenCalledTimes(1)
    jest.advanceTimersByTime(5000)
    await flushPromises()
    expect(request.post).toHaveBeenCalledTimes(2)
    jest.advanceTimersByTime(20000)
    await flushPromises()
    expect(request.post).toHaveBeenCalledTimes(2)

    jest.spyOn(request, 'post').mockImplementationOnce(async () => makeResponse('', HttpStatus.SERVICE_UNAVAILABLE))
    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    await flushPromises()
    expect(request.post).toHaveBeenCalledTimes(3)
    jest.advanceTimersByTime(5000)
    await flushPromises()
    expect(request.post).toHaveBeenCalledTimes(4)
  })

  test('it should still resend events from the queue even if the timer is cleared before going off', async () => {
    const context = makeContext()
    const settings = makeSettings()
    const transport = new Transport(context, settings)

    const postSpy = jest.spyOn(request, 'post').mockRejectedValueOnce(new Error())
    transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
    expect(request.post).toHaveBeenCalledTimes(1)
    expect(postSpy.mock.calls[0][1].logs).toEqual([expect.objectContaining({ code: 'sdk_foo' })])
    await flushPromises()
    transport.log('foo2', { bar: 'zaz2' }, { url: 'example2.com' })
    expect(request.post).toHaveBeenCalledTimes(2)
    expect(postSpy.mock.calls[1][1].logs).toEqual([
      expect.objectContaining({ code: 'sdk_foo' }),
      expect.objectContaining({ code: 'sdk_foo2' }),
    ])
    await flushPromises()
  })
})
