import './testUtility/asciiTimelineSerializer'
import {
  afterEach,
  beforeEach,
  describe,
  expect,
  it,
  type Mock,
  vitest,
} from 'vitest'
import * as matchSpan from './matchSpan'
import { type TicketIdRelationSchemasFixture } from './testUtility/fixtures/relationSchemas'
import { Check, getSpansFromTimeline, Render } from './testUtility/makeTimeline'
import { processSpans } from './testUtility/processSpans'
import { TraceManager } from './TraceManager'
import type { AnyPossibleReportFn, GenerateIdFn } from './types'

describe('TraceManager', () => {
  let reportFn: Mock<AnyPossibleReportFn<TicketIdRelationSchemasFixture>>
  let generateId: Mock<GenerateIdFn>
  let reportErrorFn: Mock
  let reportWarningFn: Mock

  const DEFAULT_COLDBOOT_TIMEOUT_DURATION = 45_000
  vitest.useFakeTimers({
    now: 0,
  })

  let idPerType = {
    span: 0,
    trace: 0,
    tick: 0,
  }

  beforeEach(() => {
    idPerType = {
      span: 0,
      trace: 0,
      tick: 0,
    }
    generateId = vitest.fn((type) => {
      const seq = idPerType[type]++
      return type === 'span'
        ? `id-${seq}`
        : type === 'trace'
          ? `trace-${seq}`
          : `tick-${seq}`
    })
    reportFn = vitest.fn<AnyPossibleReportFn<TicketIdRelationSchemasFixture>>()
    reportErrorFn = vitest.fn()
    reportWarningFn = vitest.fn()
  })

  afterEach(() => {
    vitest.clearAllMocks()
    vitest.clearAllTimers()
  })

  it('tracks trace after creating a draft and transitioning to active trace', () => {
    const traceManager = new TraceManager({
      relationSchemas: { ticket: { ticketId: String } },
      // @ts-expect-error type mismatch
      reportFn,
      generateId,
      reportErrorFn,
      reportWarningFn,
    })
    const tracer = traceManager.createTracer({
      name: 'ticket.basic-operation',
      type: 'operation',
      relationSchemaName: 'ticket',
      requiredSpans: [{ name: 'end' }],
      variants: {
        cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
      },
    })
    const traceId = tracer.createDraft({
      variant: 'cold_boot',
    })
    expect(traceId).toBe('trace-0')

    // prettier-ignore
    const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
      Events: ${Render('start', 0)}-----${Render('middle', 0)}-----${Render('end', 0)}---<===+2s===>----${Check}
      Time:   ${0}                      ${50}                      ${100}                               ${2_100}
      `

    processSpans(spans, traceManager)

    tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })

    expect(reportFn).toHaveBeenCalled()

    const report: Parameters<
      AnyPossibleReportFn<TicketIdRelationSchemasFixture>
    >[0] = reportFn.mock.calls[0]![0]
    expect(
      report.entries.map(
        (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry,
      ),
    ).toMatchInlineSnapshot(`
        events    | start       middle      end
        timeline  | |-<⋯ +50 ⋯>-|-<⋯ +50 ⋯>-|
        time (ms) | 0           50          100
      `)
    expect(report.name).toBe('ticket.basic-operation')
    expect(report.duration).toBe(100)
    expect(report.status).toBe('ok')
    expect(report.interruption).toBeUndefined()
  })

  it('timeouts when the basic trace when an timeout duration from variant is reached', () => {
    const traceManager = new TraceManager({
      relationSchemas: { ticket: { ticketId: String } },
      // @ts-expect-error type mismatch
      reportFn,
      generateId,
      reportErrorFn,
      reportWarningFn,
    })
    const tracer = traceManager.createTracer({
      name: 'ticket.timeout-operation',
      type: 'operation',
      relationSchemaName: 'ticket',
      requiredSpans: [{ name: 'timed-out-render' }],
      variants: { cold_boot: { timeout: 500 } },
    })
    const traceId = tracer.createDraft({
      startTime: { now: 0, epoch: 0 },
      variant: 'cold_boot',
    })
    expect(traceId).toBe('trace-0')

    tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })

    // prettier-ignore
    const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
        Events: ${Render('start', 0)}------${Render('timed-out-render', 0)}
        Time:   ${0}                       ${500 + 1}
        `
    processSpans(spans, traceManager)

    expect(reportFn).toHaveBeenCalled()

    const report: Parameters<
      AnyPossibleReportFn<TicketIdRelationSchemasFixture>
    >[0] = reportFn.mock.calls[0]![0]

    expect(
      report.entries.map(
        (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry,
      ),
    ).toMatchInlineSnapshot(`
          events    | start
          timeline  | |
          time (ms) | 0
        `)
    expect(report.interruption).toMatchObject({ reason: 'timeout' })
    expect(report.duration).toBeNull()

    expect(report.name).toBe('ticket.timeout-operation')
    expect(report.status).toBe('interrupted')

    expect(report.interruption).toMatchObject({ reason: 'timeout' })
    expect(report.duration).toBeNull()
  })

  describe('transitionDraftToActive()', () => {
    it('after a trace is active: reports warning', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })

      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })

      tracer.createDraft({
        variant: 'cold_boot',
      })

      tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })
      tracer.transitionDraftToActive({ relatedTo: { ticketId: '2' } })

      expect(reportWarningFn).toHaveBeenCalledWith(
        expect.objectContaining({
          message: expect.stringContaining(
            'You are trying to activate a trace that has already been activated',
          ),
        }),
        expect.objectContaining({
          definition: expect.objectContaining({
            name: 'ticket.basic-operation',
          }),
        }),
      )
    })

    it('after a trace is active and previouslyActivatedBehavior is "warn-and-continue": reports error and continues trace with new trace modification', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })

      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })

      tracer.createDraft({
        variant: 'cold_boot',
      })

      tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })
      tracer.transitionDraftToActive(
        { relatedTo: { ticketId: '2' } },
        { previouslyActivatedBehavior: 'warn-and-continue' },
      )

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
      Events: ${Render('Component', 0, {type: 'component-render-start'})}--${Render('Component', 50)}--${Render('end', 50,)}
      Time:   ${0}                                                         ${50}                       ${100}
      `
      processSpans(spans, traceManager)

      expect(reportWarningFn).toHaveBeenCalledWith(
        expect.objectContaining({
          message: expect.stringContaining(
            'You are trying to activate a trace that has already been activated',
          ),
        }),
        expect.objectContaining({
          definition: expect.objectContaining({
            name: 'ticket.basic-operation',
          }),
        }),
      )

      expect(reportFn).toHaveBeenCalled()
      const report = reportFn.mock.calls[0]![0]

      expect(report.relatedTo).toEqual({
        ticketId: '2', // relatedTo successfully replaced
      })
    })

    it('after a trace is active and previouslyActivatedBehavior is "error-and-continue": reports error and continues trace with new trace modification', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })

      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })

      tracer.createDraft({
        variant: 'cold_boot',
      })

      tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })
      tracer.transitionDraftToActive(
        { relatedTo: { ticketId: '2' } },
        { previouslyActivatedBehavior: 'error-and-continue' },
      )

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
      Events: ${Render('Component', 0, {type: 'component-render-start'})}--${Render('Component', 50)}--${Render('end', 50,)}
      Time:   ${0}                                                         ${50}                       ${100}
      `
      processSpans(spans, traceManager)

      expect(reportErrorFn).toHaveBeenCalledWith(
        expect.objectContaining({
          message: expect.stringContaining(
            'You are trying to activate a trace that has already been activated',
          ),
        }),
        expect.objectContaining({
          definition: expect.objectContaining({
            name: 'ticket.basic-operation',
          }),
        }),
      )

      expect(reportFn).toHaveBeenCalled()
      const report = reportFn.mock.calls[0]![0]

      expect(report.relatedTo).toEqual({
        ticketId: '2', // relatedTo successfully replaced
      })
    })

    it('after a trace is active and previouslyActivatedBehavior is "error": reports error and continues trace with original trace definition', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })

      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })

      tracer.createDraft({
        variant: 'cold_boot',
      })

      tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })
      tracer.transitionDraftToActive(
        { relatedTo: { ticketId: '2' } },
        { previouslyActivatedBehavior: 'error' },
      )

      expect(reportErrorFn).toHaveBeenCalledWith(
        expect.objectContaining({
          message: expect.stringContaining(
            'You are trying to activate a trace that has already been activated',
          ),
        }),
        expect.objectContaining({
          definition: expect.objectContaining({
            name: 'ticket.basic-operation',
          }),
        }),
      )

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
      Events: ${Render('Component', 0, {type: 'component-render-start'})}--${Render('Component', 50)}--${Render('end', 50,)}
      Time:   ${0}                                                         ${50}                       ${100}
      `
      processSpans(spans, traceManager)

      expect(reportErrorFn).toHaveBeenCalledWith(
        expect.objectContaining({
          message: expect.stringContaining(
            'You are trying to activate a trace that has already been activated',
          ),
        }),
        expect.objectContaining({
          definition: expect.objectContaining({
            name: 'ticket.basic-operation',
          }),
        }),
      )

      expect(reportFn).toHaveBeenCalled()
      const report = reportFn.mock.calls[0]![0]

      expect(report.relatedTo).toEqual({
        ticketId: '1', // relatedTo was not replaced!
      })
    })
  })

  describe('interrupt()', () => {
    it('interrupts a basic trace when interruptOnSpans criteria is met in draft mode, trace stops immediately', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })
      const tracer = traceManager.createTracer({
        name: 'ticket.interrupt-on-basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [matchSpan.withName('end')],
        interruptOnSpans: [matchSpan.withName('interrupt')],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })
      tracer.createDraft({
        variant: 'cold_boot',
      })

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
        Events: ${Render('start', 0)}-----${Render('interrupt', 0)}-----${Render('end', 0)}
        Time:   ${0}                      ${100}                        ${200}
        `
      processSpans(spans, traceManager)

      expect(reportFn).toHaveBeenCalled()

      const report: Parameters<
        AnyPossibleReportFn<TicketIdRelationSchemasFixture>
      >[0] = reportFn.mock.calls[0]![0]

      // this trace was interrupted before transitioning from draft to active, so the only available entries are up to the interrupt
      expect(report.entries).toHaveLength(2)
      expect(report.name).toBe('ticket.interrupt-on-basic-operation')
      expect(report.duration).toBeNull()
      expect(report.interruption).toMatchObject({
        reason: 'matched-on-interrupt',
      })
      expect(report.status).toBe('interrupted')
    })

    it('interrupts a draft trace when interrupt() is called with error', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })
      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })
      tracer.createDraft({
        variant: 'cold_boot',
      })

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
        Events: ${Render('start', 0)}-----${Render('middle', 0)}
        Time:   ${0}                      ${50}
      `
      processSpans(spans, traceManager)

      const error = new Error('Test error')
      tracer.interrupt({ error })

      expect(reportFn).toHaveBeenCalled()
      const report = reportFn.mock.calls[0]![0]
      expect(report.entries.length).toBe(3) // start, middle, and error mark
      expect(report.status).toBe('interrupted')
      expect(report.interruption).toMatchObject({ reason: 'aborted' })
      // Last entry should be the error mark
      expect(report.entries[report.entries.length - 1]!.span.error).toBe(error)
    })

    it('interrupts a draft trace when interrupt() is called without error', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })
      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })
      tracer.createDraft({
        variant: 'cold_boot',
      })

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
        Events: ${Render('start', 0)}-----${Render('middle', 0)}
        Time:   ${0}                      ${50}
      `
      processSpans(spans, traceManager)

      tracer.interrupt()

      expect(reportFn).toHaveBeenCalled()
      const report = reportFn.mock.calls[0]![0]
      expect(report.status).toBe('interrupted')
      expect(report.entries.length).toBe(0)
      expect(report.interruption).toMatchObject({ reason: 'draft-cancelled' })
    })

    it('interrupts an active trace when interrupt() is called with error', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })
      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })
      tracer.createDraft({
        variant: 'cold_boot',
      })
      tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
        Events: ${Render('start', 0)}-----${Render('middle', 0)}
        Time:   ${0}                      ${50}
      `
      processSpans(spans, traceManager)

      const error = new Error('Test error')
      tracer.interrupt({ error })

      expect(reportFn).toHaveBeenCalled()
      const report = reportFn.mock.calls[0]![0]
      expect(report.entries.length).toBe(3) // start, middle, and error mark
      expect(report.status).toBe('interrupted')
      expect(report.interruption).toMatchObject({ reason: 'aborted' })
      // Last entry should be the error mark
      expect(report.entries[report.entries.length - 1]?.span.error).toBe(error)
    })

    it('interrupts an active trace when interrupt() is called without error', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })
      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })
      tracer.createDraft({
        variant: 'cold_boot',
      })
      tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } })

      // prettier-ignore
      const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>`
        Events: ${Render('start', 0)}-----${Render('middle', 0)}
        Time:   ${0}                      ${50}
      `
      processSpans(spans, traceManager)

      tracer.interrupt()

      expect(reportFn).toHaveBeenCalled()
      const report = reportFn.mock.calls[0]![0]
      expect(report.status).toBe('interrupted')
      expect(report.entries.length).toBe(2) // start, middle
      expect(report.interruption).toMatchObject({ reason: 'aborted' })
    })

    it('reports warning when interrupting a non-existent trace', () => {
      const traceManager = new TraceManager({
        relationSchemas: { ticket: { ticketId: String } },
        // @ts-expect-error type mismatch
        reportFn,
        generateId,
        reportErrorFn,
        reportWarningFn,
      })
      const tracer = traceManager.createTracer({
        name: 'ticket.basic-operation',
        type: 'operation',
        relationSchemaName: 'ticket',
        requiredSpans: [{ name: 'end' }],
        variants: {
          cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION },
        },
      })

      tracer.interrupt({})

      expect(reportWarningFn).toHaveBeenCalledWith(
        expect.objectContaining({
          message: expect.stringContaining(
            'No current active trace when initializing a trace',
          ),
        }),
        expect.objectContaining({
          definition: expect.any(Object),
        }),
      )
    })
  })
})
