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

// AZ-471 — CanvasEditor: manual draw + 8-handle resize + Ctrl+click multi-select
//                       + Ctrl+wheel zoom-around-cursor + Ctrl+drag pan.
//
// AC-1 (FT-P-39): manual bbox draw — pointer drag commits a normalised
//                 detection within ±0.5 px of the drawn rect.
// AC-2 (FT-P-40): each of the 8 resize handles moves only its adjacent edges;
//                 the opposite anchor (corner / midpoint) is invariant.
// AC-3 (FT-P-41): Ctrl+click on a second bbox toggles it INTO the selection
//                 set; selection ring is rendered for both.
// AC-4 (FT-P-42): Ctrl+wheel at (cx, cy) keeps the canvas pixel under the
//                 cursor invariant (within ±0.5 px) before/after zoom.
// AC-5 (FT-P-43): Ctrl+drag on empty canvas pans the viewport by (dx, dy);
//                 detection canvas-coords are unchanged.
//
// Documented production drifts (`src/features/annotations/CanvasEditor.tsx`):
//   * AC-3: `handleMouseDown` returns early on `e.ctrlKey && e.button === 0`
//           (the "draw" gate), making the Ctrl+click multi-select branch
//           unreachable. `it.fails()` until the Ctrl-handler order is fixed.
//   * AC-4: `handleWheel` updates `zoom` only — `pan` is not adjusted, so
//           the pixel under the cursor shifts by `Δzoom · cursor_offset`.
//           `it.fails()` until pan is corrected on every wheel.
//   * AC-5: same Ctrl+button-0 early-return as AC-3 — Ctrl+drag enters
//           "draw" mode, not "pan", so a bounding-box gets created and pan
//           never moves. `it.fails()` until empty-canvas pan is wired.
//
// Render strategy — production CanvasEditor depends on:
//   1. `containerRef.current.clientWidth/Height` to drive `imgSize` (video path).
//   2. `canvas.getBoundingClientRect()` to translate `e.clientX/Y` → canvas px.
//   3. `requestAnimationFrame` to run `draw()` after state updates.
// Each is stubbed at the prototype level so the math is deterministic.

const W = 640
const H = 480

const videoMedia: Media = {
  id: 'media-az471',
  name: 'canvas-fixture.mp4',
  path: '/media/canvas-fixture.mp4',
  mediaType: MediaType.Video,
  mediaStatus: MediaStatus.New,
  duration: '00:00:10',
  annotationCount: 0,
  waypointId: null,
  userId: 'user-az471',
}

interface CanvasSpy {
  strokeRectCalls: { x: number; y: number; w: number; h: number; lineWidth: number }[]
  reset(): void
}

function installCanvasSpy(): CanvasSpy {
  const state: CanvasSpy = {
    strokeRectCalls: [],
    reset() {
      this.strokeRectCalls = []
    },
  }
  // The stub is a closure over `lineWidth` — ctx setters in production assign
  // `ctx.lineWidth = isSelected ? 2 : 1` before `strokeRect`, so we capture
  // the line width at the moment of each stroke. This lets AC-3 distinguish
  // "selected" boxes (lineWidth=2) from "unselected" (lineWidth=1) without
  // also counting handle outlines or label fills.
  let currentLineWidth = 1
  const stub = {
    clearRect: vi.fn(),
    save: vi.fn(),
    restore: vi.fn(),
    drawImage: vi.fn(),
    fillRect: vi.fn(),
    strokeRect: vi.fn((x: number, y: number, w: number, h: number) => {
      state.strokeRectCalls.push({ x, y, w, h, lineWidth: currentLineWidth })
    }),
    fillText: vi.fn(),
    measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
    arc: vi.fn(),
    beginPath: vi.fn(),
    fill: vi.fn(),
    setLineDash: vi.fn(),
    fillStyle: '',
    strokeStyle: '',
    font: '',
    globalAlpha: 1,
  }
  Object.defineProperty(stub, 'lineWidth', {
    get() { return currentLineWidth },
    set(v: number) { currentLineWidth = v },
  })
  HTMLCanvasElement.prototype.getContext = vi.fn(
    () => stub as unknown as CanvasRenderingContext2D,
  ) as unknown as typeof HTMLCanvasElement.prototype.getContext
  return state
}

function makeDetection(
  centerX: number,
  centerY: number,
  width: number,
  height: number,
  classNum = 0,
): Detection {
  return {
    id: `det-${classNum}-${centerX}-${centerY}`,
    classNum,
    label: '',
    confidence: 1,
    affiliation: Affiliation.Hostile,
    combatReadiness: CombatReadiness.NotReady,
    centerX,
    centerY,
    width,
    height,
  }
}

interface Harness {
  spy: CanvasSpy
  changes: Detection[][]
  rerenderWithDetections(dets: Detection[]): void
  unmount(): void
  getCanvas(): HTMLCanvasElement
}

function renderHarness(initialDetections: Detection[] = []): Harness {
  const spy = installCanvasSpy()
  const changes: Detection[][] = []
  let currentDets = initialDetections
  const onChange = (next: Detection[]) => {
    currentDets = next
    changes.push(next)
  }
  const { rerender, unmount, container } = renderWithProviders(
    <CanvasEditor
      media={videoMedia}
      annotation={null}
      detections={currentDets}
      onDetectionsChange={onChange}
      selectedClassNum={0}
      currentTime={0}
      annotations={[]}
    />,
  )
  function rerenderWithDetections(dets: Detection[]): void {
    currentDets = dets
    rerender(
      <CanvasEditor
        media={videoMedia}
        annotation={null}
        detections={currentDets}
        onDetectionsChange={onChange}
        selectedClassNum={0}
        currentTime={0}
        annotations={[]}
      />,
    )
  }
  const canvas = container.querySelector('canvas') as HTMLCanvasElement
  if (!canvas) throw new Error('canvas not mounted')
  return { spy, changes, rerenderWithDetections, unmount, getCanvas: () => canvas }
}

describe('AZ-471 — CanvasEditor (draw / resize / multi-select / zoom / pan)', () => {
  let originalRaf: typeof globalThis.requestAnimationFrame
  let widthDescriptor: PropertyDescriptor | undefined
  let heightDescriptor: PropertyDescriptor | undefined
  let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect

  beforeEach(() => {
    // The default `renderWithProviders` mounts AuthProvider which fires
    // GET /api/admin/auth/refresh. CanvasEditor doesn't care about auth, but
    // an unhandled request triggers MSW's onUnhandledRequest:'error'. A 401
    // here keeps AuthProvider's `.catch` quiet (loading flips to false) and
    // satisfies AC-3 of AZ-456.
    server.use(http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })))

    // Force the container's clientWidth/Height (jsdom default = 0) so the
    // CanvasEditor's `useEffect(isVideo)` populates `imgSize` to 640×480.
    widthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
    heightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
    Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
      configurable: true,
      get() { return W },
    })
    Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
      configurable: true,
      get() { return H },
    })

    // Pin the canvas's bounding rect so mouse events translate cleanly:
    //   mx = e.clientX - rect.left
    //   my = e.clientY - rect.top
    originalGetBoundingClientRect = Element.prototype.getBoundingClientRect
    Element.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect {
      if (this instanceof HTMLCanvasElement) {
        return {
          x: 0, y: 0, left: 0, top: 0,
          right: W, bottom: H, width: W, height: H,
          toJSON() { return {} },
        } as DOMRect
      }
      return originalGetBoundingClientRect.call(this)
    }

    // Synchronous RAF — `draw()` runs in the same tick as the state update,
    // so strokeRect spies see the result before the assertion fires.
    originalRaf = globalThis.requestAnimationFrame
    globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
      cb(performance.now())
      return 0
    }) as typeof globalThis.requestAnimationFrame
  })

  afterEach(() => {
    globalThis.requestAnimationFrame = originalRaf
    Element.prototype.getBoundingClientRect = originalGetBoundingClientRect
    if (widthDescriptor) {
      Object.defineProperty(HTMLElement.prototype, 'clientWidth', widthDescriptor)
    } else {
      delete (HTMLElement.prototype as unknown as { clientWidth?: number }).clientWidth
    }
    if (heightDescriptor) {
      Object.defineProperty(HTMLElement.prototype, 'clientHeight', heightDescriptor)
    } else {
      delete (HTMLElement.prototype as unknown as { clientHeight?: number }).clientHeight
    }
  })

  describe('AC-1 (FT-P-39) — manual draw geometry', () => {
    it('draws a bbox from (x1,y1)→(x2,y2); detection.x,y,w,h match within ±0.5 px', async () => {
      // Arrange — empty canvas, no prior detections.
      const h = renderHarness([])
      const canvas = h.getCanvas()

      // Act — mousedown(40, 40) → mousemove(120, 100) → mouseup. Plain
      // left-click on empty area drives the production "draw" path.
      fireEvent.mouseDown(canvas, { clientX: 40, clientY: 40, button: 0 })
      fireEvent.mouseMove(canvas, { clientX: 120, clientY: 100, button: 0 })
      fireEvent.mouseUp(canvas, { clientX: 120, clientY: 100, button: 0 })

      // Assert — exactly one detection appended.
      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const finalDets = h.changes.at(-1) ?? []
      expect(finalDets).toHaveLength(1)

      // Geometry — drawn rect width=80px, height=60px on a 640×480 canvas.
      // Normalised: width=80/640=0.125, height=60/480=0.125, centerX=80/640=0.125,
      // centerY=70/480≈0.1458 (midpoint of 40..100). Tolerance: ±0.5 px ⇒
      // ±(0.5/640) on x and ±(0.5/480) on y.
      const det = finalDets[0]
      const xTol = 0.5 / W
      const yTol = 0.5 / H
      expect(det.width).toBeCloseTo(80 / W, 6)
      expect(det.height).toBeCloseTo(60 / H, 6)
      expect(Math.abs(det.centerX - 80 / W)).toBeLessThanOrEqual(xTol)
      expect(Math.abs(det.centerY - 70 / H)).toBeLessThanOrEqual(yTol)
      expect(det.classNum).toBe(0)
    })
  })

  describe('AC-2 (FT-P-40) — 8-handle resize keeps the opposite anchor invariant', () => {
    // Pre-existing bbox: centered at (0.5, 0.5), width/height 0.4 each.
    // In canvas pixels (zoom=1, pan=(0,0), 640×480):
    //   x1 = 0.3 * 640 = 192       y1 = 0.3 * 480 = 144
    //   x2 = 0.7 * 640 = 448       y2 = 0.7 * 480 = 336
    const initialBox = (): Detection[] => [makeDetection(0.5, 0.5, 0.4, 0.4)]

    function selectFirstBox(h: Harness): void {
      // Click inside the box (not Ctrl, no handle) selects index 0.
      const canvas = h.getCanvas()
      fireEvent.mouseDown(canvas, { clientX: 320, clientY: 240, button: 0 })
      fireEvent.mouseUp(canvas, { clientX: 320, clientY: 240, button: 0 })
    }

    function dragHandle(
      h: Harness,
      from: { x: number; y: number },
      to: { x: number; y: number },
    ): void {
      const canvas = h.getCanvas()
      fireEvent.mouseDown(canvas, { clientX: from.x, clientY: from.y, button: 0 })
      fireEvent.mouseMove(canvas, { clientX: to.x, clientY: to.y, button: 0 })
      fireEvent.mouseUp(canvas, { clientX: to.x, clientY: to.y, button: 0 })
    }

    function lastDet(h: Harness): Detection {
      return (h.changes.at(-1) as Detection[])[0]
    }

    it('top-left handle: bottom-right corner is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      // Drag TL handle from (192, 144) to (160, 120).
      dragHandle(h, { x: 192, y: 144 }, { x: 160, y: 120 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      // BR corner = centerX + width/2, centerY + height/2 must equal old BR (0.7, 0.7).
      expect(det.centerX + det.width / 2).toBeCloseTo(0.7, 5)
      expect(det.centerY + det.height / 2).toBeCloseTo(0.7, 5)
      // TL corner moved to normalised (160/640, 120/480) = (0.25, 0.25).
      expect(det.centerX - det.width / 2).toBeCloseTo(160 / W, 5)
      expect(det.centerY - det.height / 2).toBeCloseTo(120 / H, 5)
    })

    it('top-right handle: bottom-left corner is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      dragHandle(h, { x: 448, y: 144 }, { x: 480, y: 120 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      // BL corner stays at (0.3, 0.7).
      expect(det.centerX - det.width / 2).toBeCloseTo(0.3, 5)
      expect(det.centerY + det.height / 2).toBeCloseTo(0.7, 5)
    })

    it('bottom-left handle: top-right corner is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      dragHandle(h, { x: 192, y: 336 }, { x: 160, y: 360 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      expect(det.centerX + det.width / 2).toBeCloseTo(0.7, 5)
      expect(det.centerY - det.height / 2).toBeCloseTo(0.3, 5)
    })

    it('bottom-right handle: top-left corner is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      dragHandle(h, { x: 448, y: 336 }, { x: 500, y: 380 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      expect(det.centerX - det.width / 2).toBeCloseTo(0.3, 5)
      expect(det.centerY - det.height / 2).toBeCloseTo(0.3, 5)
    })

    it('top-edge midpoint: bottom edge y is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      // Top-edge midpoint at (320, 144). Drag y down to 100.
      dragHandle(h, { x: 320, y: 144 }, { x: 320, y: 100 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      // Bottom edge invariant.
      expect(det.centerY + det.height / 2).toBeCloseTo(0.7, 5)
      // Top edge moved to 100/480.
      expect(det.centerY - det.height / 2).toBeCloseTo(100 / H, 5)
    })

    it('right-edge midpoint: left edge x is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      // Right-edge midpoint at (448, 240). Drag x to 500.
      dragHandle(h, { x: 448, y: 240 }, { x: 500, y: 240 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      expect(det.centerX - det.width / 2).toBeCloseTo(0.3, 5)
      expect(det.centerX + det.width / 2).toBeCloseTo(500 / W, 5)
    })

    it('bottom-edge midpoint: top edge y is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      dragHandle(h, { x: 320, y: 336 }, { x: 320, y: 380 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      expect(det.centerY - det.height / 2).toBeCloseTo(0.3, 5)
      expect(det.centerY + det.height / 2).toBeCloseTo(380 / H, 5)
    })

    it('left-edge midpoint: right edge x is invariant', async () => {
      const h = renderHarness(initialBox())
      selectFirstBox(h)
      dragHandle(h, { x: 192, y: 240 }, { x: 160, y: 240 })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const det = lastDet(h)
      expect(det.centerX + det.width / 2).toBeCloseTo(0.7, 5)
      expect(det.centerX - det.width / 2).toBeCloseTo(160 / W, 5)
    })
  })

  describe('AC-3 (FT-P-41) — Ctrl+click multi-select', () => {
    // Two boxes side-by-side:
    //   A: center (0.25, 0.5), w/h 0.2  → canvas (96..224, 192..288)
    //   B: center (0.75, 0.5), w/h 0.2  → canvas (416..544, 192..288)
    const twoBoxes = (): Detection[] => [
      makeDetection(0.25, 0.5, 0.2, 0.2, 0),
      makeDetection(0.75, 0.5, 0.2, 0.2, 1),
    ]

    it.fails(
      'Ctrl+click on box B adds B to the selection (rendered as a second selected box)',
      async () => {
        // Drift: `handleMouseDown` short-circuits on `e.ctrlKey && e.button===0`
        // and enters "draw" mode before the hit-test runs. The Ctrl+click
        // multi-select branch (`if (e.ctrlKey)` inside the box-hit handler) is
        // unreachable today. Test passes once the gate order is fixed.
        const h = renderHarness(twoBoxes())
        const canvas = h.getCanvas()

        // Step 1 — plain click on A (no Ctrl) selects index 0.
        fireEvent.mouseDown(canvas, { clientX: 160, clientY: 240, button: 0 })
        fireEvent.mouseUp(canvas, { clientX: 160, clientY: 240, button: 0 })

        // Reset spy so we count only the post-Ctrl-click draws.
        h.spy.reset()

        // Step 2 — Ctrl+click on B at center (480, 240).
        fireEvent.mouseDown(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })
        fireEvent.mouseUp(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })

        // Re-render forces the spy to capture the post-state. Detections
        // didn't actually change, but a new selection forces a re-draw via
        // the useEffect on `selected`.
        h.rerenderWithDetections(twoBoxes())

        await waitFor(() => {
          const lw2Boxes = h.spy.strokeRectCalls.filter((c) => c.lineWidth === 2)
          // Each selected box renders 1 outer stroke (lineWidth=2) and 8
          // handle outlines (lineWidth keeps the value 2 in the same draw
          // pass, so the handles show up as lineWidth=2 too — the count we
          // care about is "≥ 9 strokes/selected box" * 2 selected = 18).
          expect(lw2Boxes.length).toBeGreaterThanOrEqual(18)
        }, { timeout: 1500 })
      },
    )

    it('control: production today keeps only the first-clicked box selected (drift snapshot)', async () => {
      // Pins current behaviour. After a plain click on A then a Ctrl+click on
      // B, only A reads as "selected" — exactly one box renders with the
      // selected lineWidth. If a future change starts adding B to the
      // selection, this test fails AND the AC-3 it.fails() goes green —
      // both signals visible in CI.
      const h = renderHarness(twoBoxes())
      const canvas = h.getCanvas()

      fireEvent.mouseDown(canvas, { clientX: 160, clientY: 240, button: 0 })
      fireEvent.mouseUp(canvas, { clientX: 160, clientY: 240, button: 0 })
      h.spy.reset()

      fireEvent.mouseDown(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })
      fireEvent.mouseUp(canvas, { clientX: 480, clientY: 240, button: 0, ctrlKey: true })
      h.rerenderWithDetections(twoBoxes())

      await waitFor(() => {
        // At least one stroke captured — the spy is wired.
        expect(h.spy.strokeRectCalls.length).toBeGreaterThan(0)
      })
      const selectedStrokes = h.spy.strokeRectCalls.filter((c) => c.lineWidth === 2)
      const unselectedStrokes = h.spy.strokeRectCalls.filter((c) => c.lineWidth === 1)
      // Drift snapshot: at most ONE selected box (A); B remains unselected.
      // Count by counting outer rects (the box outer stroke is the largest
      // strokeRect in either selected or unselected case). A selected box
      // produces 1 outer + 8 handle outlines = 9 strokeRects with lw=2; an
      // unselected box produces 1 strokeRect with lw=1.
      expect(selectedStrokes.length).toBe(9)
      expect(unselectedStrokes.length).toBe(1)
    })
  })

  describe('AC-4 (FT-P-42) — Ctrl+wheel zoom-around-cursor', () => {
    it.fails(
      'after Ctrl+wheel at (cx, cy), the canvas pixel under the cursor maps to the same world point (±0.5 px)',
      async () => {
        // Drift: `handleWheel` updates `zoom` only — `pan` stays at (0,0).
        // The world point under (cx, cy) shifts proportionally to the zoom
        // delta. Spec requires `new_pan = old_pan + (cursor - old_pan) * (1 - zoom_ratio)`.
        // World point under cursor BEFORE: w0 = (cx - 0) / (640 * 1) = 0.5.
        // Place the detection AT the cursor world coord so we can tell whether
        // post-zoom rendering keeps it under (cx, cy).
        const h = renderHarness([makeDetection(0.5, 0.5, 0.1, 0.1)])
        const canvas = h.getCanvas()

        const cx = 320
        const cy = 240

        // Reset spy BEFORE the wheel so we only see the post-zoom draw —
        // otherwise we'd match the pre-zoom box (width=64, centreX=320) and
        // the assertion would vacuously pass.
        h.spy.reset()

        fireEvent.wheel(canvas, { clientX: cx, clientY: cy, deltaY: -100, ctrlKey: true })
        h.rerenderWithDetections([makeDetection(0.5, 0.5, 0.1, 0.1)])

        // Find the post-zoom box (width = 0.1 * 640 * 1.1 = 70.4). Anything
        // smaller is a stale pre-zoom render that slipped through the reset.
        await waitFor(() =>
          expect(
            h.spy.strokeRectCalls.some((c) => Math.abs(c.w - 0.1 * W * 1.1) < 0.5),
          ).toBe(true),
        )
        const post = h.spy.strokeRectCalls.find((c) => Math.abs(c.w - 0.1 * W * 1.1) < 0.5)
        expect(post).toBeDefined()
        if (!post) return

        // Box centre in canvas px should equal cursor pos after zoom-around-cursor
        // (world (0.5, 0.5) → cursor (320, 240) is the invariant). Tolerance ±0.5 px.
        const centreX = post.x + post.w / 2
        const centreY = post.y + post.h / 2
        expect(Math.abs(centreX - cx)).toBeLessThanOrEqual(0.5)
        expect(Math.abs(centreY - cy)).toBeLessThanOrEqual(0.5)
      },
    )

    it('control: zoom changes the rendered box width but keeps pan = 0 (drift snapshot)', async () => {
      // Pins current behaviour: after Ctrl+wheel, the box width grows by 1.1×
      // but the box's top-left x stays at `centerX - width/2` * imgSize.w * zoom
      // (i.e. pan stays at 0). When AC-4 lands, this test fails because pan
      // becomes non-zero — the drift goes away in lockstep with the fix.
      const h = renderHarness([makeDetection(0.5, 0.5, 0.1, 0.1)])
      const canvas = h.getCanvas()
      h.spy.reset()

      fireEvent.wheel(canvas, { clientX: 320, clientY: 240, deltaY: -100, ctrlKey: true })
      h.rerenderWithDetections([makeDetection(0.5, 0.5, 0.1, 0.1)])

      await waitFor(() => expect(h.spy.strokeRectCalls.length).toBeGreaterThan(0))
      const post = h.spy.strokeRectCalls.find(
        (c) => Math.abs(c.w - 0.1 * W * 1.1) < 0.5,
      )
      expect(post).toBeDefined()
      if (!post) return
      // Drift: the box's top-left x is exactly (0.5 - 0.05) * 640 * 1.1 = 316.8;
      // pan=0, so x = 316.8. Centre would be at 316.8 + 70.4/2 = 352 — NOT 320.
      expect(post.x).toBeCloseTo(316.8, 1)
    })
  })

  describe('AC-5 (FT-P-43) — Ctrl+drag pan on empty canvas', () => {
    it.fails(
      'Ctrl+drag from (x1,y1) by (dx,dy) shifts pan by (dx, dy); detection canvas-coords stay invariant',
      async () => {
        // Drift: same Ctrl+button=0 early-return as AC-3. Ctrl+drag enters
        // draw mode → either a new bounding box is created (if the drag
        // exceeds MIN_BOX_SIZE) or nothing happens; pan never moves.
        const h = renderHarness([makeDetection(0.5, 0.5, 0.1, 0.1)])
        const canvas = h.getCanvas()
        h.spy.reset()
        const initialChangeCount = h.changes.length

        fireEvent.mouseDown(canvas, { clientX: 100, clientY: 100, button: 0, ctrlKey: true })
        fireEvent.mouseMove(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })
        fireEvent.mouseUp(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })

        h.rerenderWithDetections(h.changes.at(-1) ?? [makeDetection(0.5, 0.5, 0.1, 0.1)])

        // Spec — no detection added/modified.
        expect(h.changes.length).toBe(initialChangeCount)

        // Spec — viewport panned by (50, 30). The pre-existing box centred at
        // world (0.5, 0.5) was at canvas (320, 240); after pan it should be
        // at (370, 270).
        await waitFor(() => expect(h.spy.strokeRectCalls.length).toBeGreaterThan(0))
        const box = h.spy.strokeRectCalls.find(
          (c) => Math.abs(c.w - 0.1 * W) < 0.5,
        )
        expect(box).toBeDefined()
        if (!box) return
        const centreX = box.x + box.w / 2
        const centreY = box.y + box.h / 2
        expect(Math.abs(centreX - 370)).toBeLessThanOrEqual(0.5)
        expect(Math.abs(centreY - 270)).toBeLessThanOrEqual(0.5)
      },
    )

    it('control: Ctrl+drag today triggers draw mode and may append a detection (drift snapshot)', async () => {
      // The Ctrl+drag on an empty area today goes through the draw branch.
      // The 50×30 drag exceeds MIN_BOX_SIZE on width but not on height
      // (30 ≥ 12 ✓ — both pass). A detection IS appended. When AC-5 lands
      // and Ctrl+drag becomes pan, no detection is appended and this test
      // fails — the drift snapshot goes away with the fix.
      const h = renderHarness([])
      const canvas = h.getCanvas()
      h.spy.reset()

      fireEvent.mouseDown(canvas, { clientX: 100, clientY: 100, button: 0, ctrlKey: true })
      fireEvent.mouseMove(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })
      fireEvent.mouseUp(canvas, { clientX: 150, clientY: 130, button: 0, ctrlKey: true })

      await waitFor(() => expect(h.changes.length).toBeGreaterThanOrEqual(1))
      const last = h.changes.at(-1) as Detection[]
      expect(last).toHaveLength(1)
    })
  })
})
