import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderWithProviders, waitFor } from './helpers/render'
import CanvasEditor from '../src/features/annotations/CanvasEditor'
import {
  AnnotationSource,
  AnnotationStatus,
  Affiliation,
  CombatReadiness,
  MediaType,
  MediaStatus,
} from '../src/types'
import type { Media, AnnotationListItem, Detection } from '../src/types'

// AZ-462 — Overlay membership at the in-window edges
//
// AC-1 (FT-P-14, FT-P-15): annotation EXACTLY on `lowerBound` / `upperBound`
//                          IS rendered (inclusive boundary).
// AC-2 (FT-N-01, FT-N-02): annotation one frame interval beyond the bound is
//                          NOT rendered (strict exclusion outside the window).
// AC-3:                    assertion reads the canvas draw output, not React
//                          internal state. We mock `HTMLCanvasElement.getContext`
//                          to capture every `strokeRect` call — each rendered
//                          detection produces one. This is the closest to "DOM
//                          query" available for canvas-based rendering.
//
// Production drift (`src/features/annotations/CanvasEditor.tsx:215-220`):
//   `getTimeWindowDetections` filters with `Math.abs(annTime - timeTicks) < 2_000_000`
//   (strict `<`). The contract per AZ-462 is `<=` (inclusive). FT-P-14/15 are
//   recorded as `it.fails()` until production lifts the operator.

// Tick rate: production uses 10_000_000 ticks per second (.NET DateTime ticks);
// the overlay window is ±2_000_000 ticks (= ±0.2 s) around `currentTime`.
const TICKS_PER_SECOND = 10_000_000
const HALF_WINDOW_TICKS = 2_000_000
const HALF_WINDOW_SECONDS = HALF_WINDOW_TICKS / TICKS_PER_SECOND // 0.2 s
const ONE_FRAME_TICKS = 333_333 // ~30 fps; small step beyond the boundary

function ticksToTimecode(ticks: number): string {
  // Mirror `formatTicks` in AnnotationsPage (HH:MM:SS.mmm) but accept ticks input.
  const totalSeconds = ticks / TICKS_PER_SECOND
  const h = Math.floor(totalSeconds / 3600)
  const m = Math.floor((totalSeconds % 3600) / 60)
  const wholeS = Math.floor(totalSeconds % 60)
  const ms = Math.floor((totalSeconds - Math.floor(totalSeconds)) * 1000)
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(wholeS).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
}

function makeDetection(idx: number): Detection {
  return {
    id: `det-${idx}`,
    classNum: 0,
    label: `class-${idx}`,
    confidence: 0.9,
    affiliation: Affiliation.Hostile,
    combatReadiness: CombatReadiness.NotReady,
    centerX: 0.5,
    centerY: 0.5,
    width: 0.1,
    height: 0.1,
  }
}

function makeAnnotation(id: string, atTicks: number): AnnotationListItem {
  return {
    id,
    mediaId: 'media-az462',
    time: ticksToTimecode(atTicks),
    createdDate: '2026-05-11T00:00:00Z',
    userId: 'user-az462',
    source: AnnotationSource.Manual,
    status: AnnotationStatus.Created,
    isSplit: false,
    splitTile: null,
    detections: [makeDetection(parseInt(id.split('-').pop() ?? '0', 10) || 0)],
  }
}

const videoMedia: Media = {
  id: 'media-az462',
  name: 'overlay-edge.mp4',
  path: '/media/overlay-edge.mp4',
  mediaType: MediaType.Video,
  mediaStatus: MediaStatus.New,
  duration: '00:00:30',
  annotationCount: 4,
  waypointId: null,
  userId: 'user-az462',
}

interface CanvasSpy {
  strokeRectCalls: number
  reset(): void
}

function installCanvasSpy(): CanvasSpy {
  const state: CanvasSpy = {
    strokeRectCalls: 0,
    reset() {
      this.strokeRectCalls = 0
    },
  }
  const stub: Partial<CanvasRenderingContext2D> = {
    clearRect: vi.fn(),
    save: vi.fn(),
    restore: vi.fn(),
    drawImage: vi.fn(),
    fillRect: vi.fn(),
    strokeRect: vi.fn(() => {
      state.strokeRectCalls += 1
    }),
    fillText: vi.fn(),
    measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
    arc: vi.fn(),
    beginPath: vi.fn(),
    fill: vi.fn(),
    setLineDash: vi.fn(),
    fillStyle: '',
    strokeStyle: '',
    lineWidth: 1,
    font: '',
    globalAlpha: 1,
  }
  // jsdom has no canvas implementation — getContext returns null by default.
  // We override it on the prototype so every <canvas> mounted by CanvasEditor
  // resolves to our recording stub.
  HTMLCanvasElement.prototype.getContext = vi.fn(() => stub as CanvasRenderingContext2D) as unknown as typeof HTMLCanvasElement.prototype.getContext
  return state
}

function renderOverlay(annotations: AnnotationListItem[], currentTimeSeconds: number) {
  return renderWithProviders(
    <CanvasEditor
      media={videoMedia}
      annotation={null}
      detections={[]}
      onDetectionsChange={() => {}}
      selectedClassNum={0}
      currentTime={currentTimeSeconds}
      annotations={annotations}
    />,
  )
}

describe('AZ-462 — overlay membership at in-window edges', () => {
  let spy: CanvasSpy
  let originalRaf: typeof globalThis.requestAnimationFrame

  beforeEach(() => {
    spy = installCanvasSpy()
    // Force RAF to fire synchronously so the first draw lands before the
    // assertion runs (jsdom's RAF queues to a microtask which is fine, but
    // syncing avoids flakes when the test environment under-schedules it).
    originalRaf = globalThis.requestAnimationFrame
    globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
      cb(performance.now())
      return 0
    }) as typeof globalThis.requestAnimationFrame
  })

  afterEach(() => {
    globalThis.requestAnimationFrame = originalRaf
  })

  describe('AC-1 — inclusive boundary (annotation exactly on bound IS rendered)', () => {
    it.fails(
      'FT-P-14: annotation at the LOWER in-window edge is rendered',
      async () => {
        // Arrange — currentTime = 5s; lower bound = 5s − 0.2s = 4.8s.
        const currentTimeSeconds = 5
        const lowerBoundTicks = (currentTimeSeconds - HALF_WINDOW_SECONDS) * TICKS_PER_SECOND
        const annOnLowerBound = makeAnnotation('ann-1', lowerBoundTicks)

        // Act
        renderOverlay([annOnLowerBound], currentTimeSeconds)

        // Assert — exactly one strokeRect (one detection, on bound).
        // Production uses strict `<` ⇒ boundary excluded ⇒ 0 strokeRect calls ⇒ this fails.
        await waitFor(() => expect(spy.strokeRectCalls).toBeGreaterThanOrEqual(1), {
          timeout: 1000,
        })
      },
    )

    it.fails(
      'FT-P-15: annotation at the UPPER in-window edge is rendered',
      async () => {
        const currentTimeSeconds = 5
        const upperBoundTicks = (currentTimeSeconds + HALF_WINDOW_SECONDS) * TICKS_PER_SECOND
        const annOnUpperBound = makeAnnotation('ann-2', upperBoundTicks)

        renderOverlay([annOnUpperBound], currentTimeSeconds)

        await waitFor(() => expect(spy.strokeRectCalls).toBeGreaterThanOrEqual(1), {
          timeout: 1000,
        })
      },
    )

    it('control: production uses strict `<`, so the EXACT boundary is excluded today', async () => {
      // This positive control pins the CURRENT (drift) behavior so a regression
      // that flips the operator to `<=` without lifting the AC drift gets caught.
      // When AC-1 is fixed, this test goes red and is removed alongside.
      const currentTimeSeconds = 5
      const lowerBoundTicks = (currentTimeSeconds - HALF_WINDOW_SECONDS) * TICKS_PER_SECOND
      const annOnLowerBound = makeAnnotation('ann-3', lowerBoundTicks)

      renderOverlay([annOnLowerBound], currentTimeSeconds)

      // Wait for at least one tick so RAF would have fired if it were going to.
      await new Promise(r => setTimeout(r, 10))
      expect(spy.strokeRectCalls).toBe(0)
    })
  })

  describe('AC-2 — strict exclusion (annotation outside the window NOT rendered)', () => {
    it('FT-N-01: annotation BEFORE the lower bound is not rendered', async () => {
      // Arrange — annotation at lowerBound − 1 frame.
      const currentTimeSeconds = 5
      const beforeLowerTicks =
        (currentTimeSeconds - HALF_WINDOW_SECONDS) * TICKS_PER_SECOND - ONE_FRAME_TICKS
      const annBeforeLower = makeAnnotation('ann-4', beforeLowerTicks)

      // Act
      renderOverlay([annBeforeLower], currentTimeSeconds)

      // Assert — no strokeRect calls (annotation rejected by the time-window filter).
      await new Promise(r => setTimeout(r, 10))
      expect(spy.strokeRectCalls).toBe(0)
    })

    it('FT-N-02: annotation AFTER the upper bound is not rendered', async () => {
      const currentTimeSeconds = 5
      const afterUpperTicks =
        (currentTimeSeconds + HALF_WINDOW_SECONDS) * TICKS_PER_SECOND + ONE_FRAME_TICKS
      const annAfterUpper = makeAnnotation('ann-5', afterUpperTicks)

      renderOverlay([annAfterUpper], currentTimeSeconds)

      await new Promise(r => setTimeout(r, 10))
      expect(spy.strokeRectCalls).toBe(0)
    })

    it('control: an annotation comfortably inside the window IS rendered', async () => {
      // Positive control — proves the test apparatus would observe a render
      // when the time-window filter accepts an annotation. Without this, a
      // canvas-stub failure would cause every assertion to vacuously pass.
      const currentTimeSeconds = 5
      const insideTicks = currentTimeSeconds * TICKS_PER_SECOND
      const annInside = makeAnnotation('ann-6', insideTicks)

      renderOverlay([annInside], currentTimeSeconds)

      await waitFor(() => expect(spy.strokeRectCalls).toBeGreaterThanOrEqual(1), {
        timeout: 1000,
      })
    })
  })
})
