import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from './msw/server'
import { jsonResponse } from './msw/helpers'
import {
  renderWithProviders,
  screen,
  fireEvent,
  waitFor,
} from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { FlightProvider } from '../src/components/FlightContext'
import DatasetPage from '../src/features/dataset/DatasetPage'
import {
  AnnotationSource,
  AnnotationStatus,
  Affiliation,
  CombatReadiness,
  MediaType,
} from '../src/types'
import type { AnnotationListItem, DatasetItem } from '../src/types'

// AZ-474 — tile-split + YOLO parser + auto-zoom + indicator + malformed.
//
// Production reality: the split UI is QUARANTINED today (per
// `_docs/04_refactoring/01-testability-refactoring/deferred_to_refactor.md`
// row D11; traceability matrix marks AC-39 / FT-P-51 [Q]).
//
//   - There is no Split-tile button on `<DatasetPage>` and no
//     `POST /api/annotations/dataset/{id}/split` callsite anywhere in
//     `src/`.
//   - There is no YOLO label parser module and no `<TileViewer>` /
//     auto-zoom viewport / tile-zoom indicator.
//   - `DatasetItem.isSplit: boolean` is on the type and surfaces from
//     `GET /api/annotations/dataset`, but `DatasetPage` does not read it
//     (it reads `isSeed` for the red-ring affordance instead).
//
// Every AC is therefore a documented drift today: the AC tests use
// `it.fails()` (and one `test.fail()` for the e2e) and each is paired
// with a control PASS that pins the *current* behaviour, so a regression
// that *removes* the placeholder (e.g., `DatasetPage` starts crashing on
// `isSplit: true`) is caught immediately, and the contract tests flip
// green automatically once the split surface lands in Phase B.

// ---------------------------------------------------------------------------
// Shared MSW seeds: a happy-path split annotation, a malformed-label one,
// and a non-split. The dataset-list shape mirrors `DatasetItem` exactly.
// ---------------------------------------------------------------------------

const happySplitAnnotation: AnnotationListItem = {
  id: 'ann-split-happy',
  mediaId: 'media-tile',
  time: null,
  createdDate: '2026-05-11T10:00:00Z',
  userId: 'user-alice',
  source: AnnotationSource.AI,
  status: AnnotationStatus.Edited,
  isSplit: true,
  splitTile: '3 0.5 0.5 0.2 0.2',
  detections: [
    {
      id: 'det-tile-1',
      classNum: 3,
      label: 'class-3',
      confidence: 0.91,
      affiliation: Affiliation.Hostile,
      combatReadiness: CombatReadiness.Ready,
      centerX: 0.5,
      centerY: 0.5,
      width: 0.2,
      height: 0.2,
    },
  ],
}

const malformedSplitAnnotation: AnnotationListItem = {
  id: 'ann-split-malformed',
  mediaId: 'media-tile',
  time: null,
  createdDate: '2026-05-11T10:01:00Z',
  userId: 'user-alice',
  source: AnnotationSource.AI,
  status: AnnotationStatus.Edited,
  isSplit: true,
  splitTile: 'garbage',
  detections: [],
}

const nonSplitAnnotation: AnnotationListItem = {
  id: 'ann-not-split',
  mediaId: 'media-tile',
  time: null,
  createdDate: '2026-05-11T10:02:00Z',
  userId: 'user-alice',
  source: AnnotationSource.Manual,
  status: AnnotationStatus.Created,
  isSplit: false,
  splitTile: null,
  detections: [],
}

function datasetRowFromAnnotation(a: AnnotationListItem): DatasetItem {
  return {
    annotationId: a.id,
    imageName: `image-${a.mediaId}-${a.id}.jpg`,
    thumbnailPath: `/thumbs/${a.mediaId}.jpg`,
    status: a.status,
    createdDate: a.createdDate,
    createdEmail: 'op_alice@test.local',
    flightName: 'Flight 1',
    source: a.source,
    isSeed: false,
    isSplit: a.isSplit,
  }
}

beforeEach(() => {
  seedBearer()
  server.use(
    http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
    // FlightProvider mounts a user-settings fetch when authenticated. The
    // dataset surface does not depend on it; we satisfy MSW's unhandled-
    // request gate with a 404 so the noise does not pollute the report.
    http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
    http.get('/api/annotations/dataset', () =>
      jsonResponse({
        items: [
          datasetRowFromAnnotation(happySplitAnnotation),
          datasetRowFromAnnotation(malformedSplitAnnotation),
          datasetRowFromAnnotation(nonSplitAnnotation),
        ],
        totalCount: 3,
      }),
    ),
  )
})

afterEach(() => {
  clearBearer()
  vi.restoreAllMocks()
})

// ---------------------------------------------------------------------------
// AC-1 — tile-split endpoint contract (FT-P-51, [Q]).
// ---------------------------------------------------------------------------

describe('AZ-474 — AC-1 (FT-P-51 [Q]): tile-split endpoint contract', () => {
  it.fails(
    'splitting a tile sends POST /api/annotations/dataset/<id>/split',
    async () => {
      // Arrange — capture POSTs to the split endpoint. Production has no
      // such callsite today, so this MSW handler will never fire and the
      // assertion fails. Once the SPA wires a "Split tile" affordance,
      // this test flips green.
      const splitPosts: { url: string; body: unknown }[] = []
      server.use(
        http.post('/api/annotations/dataset/:id/split', async ({ request, params }) => {
          splitPosts.push({ url: request.url, body: await request.json().catch(() => null) })
          return jsonResponse({ id: params.id, ok: true }, { status: 200 })
        }),
      )

      renderWithProviders(
        <FlightProvider>
          <DatasetPage />
        </FlightProvider>,
      )

      // Act — wait for the dataset to render, then look for the Split-tile
      // affordance. The locator is intentionally generic (any button or
      // role with "split" in its accessible name) so the test passes for
      // any reasonable implementation choice in Phase B.
      await waitFor(
        () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
        { timeout: 3000 },
      )
      const splitBtn = await screen.findByRole(
        'button',
        { name: /split/i },
        { timeout: 1000 },
      )
      fireEvent.click(splitBtn)

      // Assert — exactly one POST to /api/annotations/dataset/<id>/split.
      await waitFor(() => expect(splitPosts.length).toBe(1), { timeout: 1000 })
      expect(splitPosts[0].url).toMatch(
        /\/api\/annotations\/dataset\/[^/]+\/split$/,
      )
    },
  )

  it('control: today no Split-tile affordance is rendered (drift snapshot)', async () => {
    renderWithProviders(
      <FlightProvider>
        <DatasetPage />
      </FlightProvider>,
    )
    await waitFor(
      () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
      { timeout: 3000 },
    )

    expect(screen.queryByRole('button', { name: /split/i })).toBeNull()
  })
})

// ---------------------------------------------------------------------------
// AC-2 — YOLO label parser happy path (FT-P-52).
// ---------------------------------------------------------------------------

describe('AZ-474 — AC-2 (FT-P-52): YOLO label parser happy path', () => {
  it.fails(
    'a parser module parses "3 0.5 0.5 0.2 0.2" into the canonical 5-tuple',
    async () => {
      // Black-box discipline note: the spec's "parser module" does not
      // exist yet. The right way to test this once it ships is via the
      // public surface (rendered tile rect, downstream save body, etc.),
      // not via a direct import of the parser. For now the test fails
      // because there IS no public surface that consumes splitTile.
      //
      // Production behaviour today: <DatasetPage> double-click loads
      // /api/annotations/dataset/<id> (the full AnnotationListItem with
      // splitTile) but the editor never reads splitTile. So the parser
      // is not exercised by ANY user-visible action, and there is no
      // observable to assert against.
      //
      // We render the full DatasetPage, double-click the happy-split
      // annotation row, and look for the parsed tile rect being applied
      // to a TileViewer. Today no TileViewer mounts and no rect is
      // produced — the test fails as drift.
      server.use(
        http.get(
          `/api/annotations/dataset/${happySplitAnnotation.id}`,
          () => jsonResponse(happySplitAnnotation),
        ),
      )

      renderWithProviders(
        <FlightProvider>
          <DatasetPage />
        </FlightProvider>,
      )

      await waitFor(
        () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
        { timeout: 3000 },
      )

      const happyImg = screen.getByAltText(
        new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`),
      )
      fireEvent.doubleClick(happyImg.closest('div')!)

      // The parsed tile rect should be exposed via a `data-tile-rect`
      // attribute on the TileViewer mount, e.g. "0.4,0.4,0.6,0.6"
      // (cx-w/2, cy-h/2, cx+w/2, cy+h/2) for input "3 0.5 0.5 0.2 0.2".
      // No such element exists today.
      const rect = await screen.findByTestId('tile-rect', {}, { timeout: 1500 })
      expect(rect.getAttribute('data-tile-rect')).toBe('0.4,0.4,0.6,0.6')
    },
  )

  it('control: today the editor mounts without parsing splitTile', async () => {
    server.use(
      http.get(
        `/api/annotations/dataset/${happySplitAnnotation.id}`,
        () => jsonResponse(happySplitAnnotation),
      ),
    )

    renderWithProviders(
      <FlightProvider>
        <DatasetPage />
      </FlightProvider>,
    )
    await waitFor(
      () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
      { timeout: 3000 },
    )

    const happyImg = screen.getByAltText(
      new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`),
    )
    fireEvent.doubleClick(happyImg.closest('div')!)

    // Pin: no tile-rect testid is present today.
    expect(screen.queryByTestId('tile-rect')).toBeNull()
  })
})

// ---------------------------------------------------------------------------
// AC-3 — DatasetItem.isSplit honored on the dataset list (FT-P-53).
// ---------------------------------------------------------------------------

describe('AZ-474 — AC-3 (FT-P-53): DatasetItem.isSplit honored on dataset list', () => {
  it.fails(
    'items with isSplit: true render a split affordance distinct from non-split items',
    async () => {
      renderWithProviders(
        <FlightProvider>
          <DatasetPage />
        </FlightProvider>,
      )
      await waitFor(
        () => expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(3),
        { timeout: 3000 },
      )

      // Spec: the rendered card for an isSplit annotation MUST carry a
      // visible affordance that the non-split card does NOT carry. The
      // simplest acceptable shape is `data-is-split="true"` on the card
      // root, but a localized badge / icon would also satisfy this.
      const happyCard = screen
        .getByAltText(
          new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`),
        )
        .closest('div')
      const nonSplitCard = screen
        .getByAltText(
          new RegExp(`image-${nonSplitAnnotation.mediaId}-${nonSplitAnnotation.id}`),
        )
        .closest('div')

      // Drift today: isSplit is read from the network shape but never
      // consumed by the renderer.
      expect(happyCard?.getAttribute('data-is-split')).toBe('true')
      expect(nonSplitCard?.getAttribute('data-is-split')).not.toBe('true')
    },
  )

  it('control: dataset list mounts and renders all rows even with mixed isSplit values', async () => {
    renderWithProviders(
      <FlightProvider>
        <DatasetPage />
      </FlightProvider>,
    )

    // Pin: page renders both split and non-split items without crash.
    await waitFor(
      () =>
        expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(3),
      { timeout: 3000 },
    )
  })
})

// ---------------------------------------------------------------------------
// AC-4 — auto-zoom viewport matches tile rect (FT-P-54).
// ---------------------------------------------------------------------------

describe('AZ-474 — AC-4 (FT-P-54): tile auto-zoom viewport matches tile rect', () => {
  it.fails('opening a split annotation auto-zooms the viewport to the tile rect', async () => {
    server.use(
      http.get(
        `/api/annotations/dataset/${happySplitAnnotation.id}`,
        () => jsonResponse(happySplitAnnotation),
      ),
    )

    renderWithProviders(
      <FlightProvider>
        <DatasetPage />
      </FlightProvider>,
    )
    await waitFor(
      () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
      { timeout: 3000 },
    )

    const happyImg = screen.getByAltText(
      new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`),
    )
    fireEvent.doubleClick(happyImg.closest('div')!)

    // Spec: the viewport rect (in normalized canvas coords) should match
    // the parsed tile rect — for "3 0.5 0.5 0.2 0.2" → [0.4, 0.4, 0.6, 0.6]
    // ±0.5 px after rendering. We expose this via a `data-viewport-rect`
    // attribute on the canvas mount.
    const viewport = await screen.findByTestId(
      'tile-viewport',
      {},
      { timeout: 1500 },
    )
    const rect = viewport.getAttribute('data-viewport-rect') ?? ''
    const [x1, y1, x2, y2] = rect.split(',').map(Number)
    expect(Math.abs(x1 - 0.4)).toBeLessThan(0.01)
    expect(Math.abs(y1 - 0.4)).toBeLessThan(0.01)
    expect(Math.abs(x2 - 0.6)).toBeLessThan(0.01)
    expect(Math.abs(y2 - 0.6)).toBeLessThan(0.01)
  })

  it('control: today no tile-viewport testid is exposed', () => {
    renderWithProviders(
      <FlightProvider>
        <DatasetPage />
      </FlightProvider>,
    )
    expect(screen.queryByTestId('tile-viewport')).toBeNull()
  })
})

// ---------------------------------------------------------------------------
// AC-5 — zoom indicator visible while active (FT-P-55).
// ---------------------------------------------------------------------------

describe('AZ-474 — AC-5 (FT-P-55): tile-zoom indicator visible while active', () => {
  it.fails(
    'while zoomed into a tile, the indicator carries an accessible name',
    async () => {
      server.use(
        http.get(
          `/api/annotations/dataset/${happySplitAnnotation.id}`,
          () => jsonResponse(happySplitAnnotation),
        ),
      )

      renderWithProviders(
        <FlightProvider>
          <DatasetPage />
        </FlightProvider>,
      )
      await waitFor(
        () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
        { timeout: 3000 },
      )

      const happyImg = screen.getByAltText(
        new RegExp(`image-${happySplitAnnotation.mediaId}-${happySplitAnnotation.id}`),
      )
      fireEvent.doubleClick(happyImg.closest('div')!)

      // Spec: an indicator with role="status" and an accessible name
      // matching the i18n-keyed "Tile zoom" text (or equivalent) is in
      // the DOM while the zoom is active.
      const indicator = await screen.findByRole(
        'status',
        { name: /tile|zoom/i },
        { timeout: 1500 },
      )
      expect(indicator).toBeInTheDocument()
    },
  )

  it('control: today no role="status" + name=/tile|zoom/ indicator is mounted', () => {
    renderWithProviders(
      <FlightProvider>
        <DatasetPage />
      </FlightProvider>,
    )
    // Pin: there may be other role=status nodes (spinners), but none with
    // a tile/zoom accessible name.
    expect(screen.queryByRole('status', { name: /tile|zoom/i })).toBeNull()
  })
})

// ---------------------------------------------------------------------------
// AC-6 — malformed YOLO label surfaces a user-visible error (FT-N-10).
// ---------------------------------------------------------------------------

describe('AZ-474 — AC-6 (FT-N-10): malformed YOLO label produces user-visible error', () => {
  it.fails(
    'opening an annotation with splitTile="garbage" renders an in-DOM error and no NaN bbox',
    async () => {
      server.use(
        http.get(
          `/api/annotations/dataset/${malformedSplitAnnotation.id}`,
          () => jsonResponse(malformedSplitAnnotation),
        ),
      )

      const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined)

      try {
        renderWithProviders(
          <FlightProvider>
            <DatasetPage />
          </FlightProvider>,
        )
        await waitFor(
          () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
          { timeout: 3000 },
        )

        const malformedImg = screen.getByAltText(
          new RegExp(
            `image-${malformedSplitAnnotation.mediaId}-${malformedSplitAnnotation.id}`,
          ),
        )
        fireEvent.doubleClick(malformedImg.closest('div')!)

        // Spec: user-visible error surfaces — a role="alert" region or a
        // localized toast — and NO bbox is rendered for the malformed label.
        // alert() is forbidden by NFT-SEC-07; the assertion below pins that.
        const alertEl = await screen.findByRole('alert', {}, { timeout: 1500 })
        expect(alertEl).toBeInTheDocument()
        expect(alertSpy).not.toHaveBeenCalled()

        // No NaN-rendered box: every rendered bbox stroke produces finite
        // getBoundingClientRect values. We check via canvas geometry —
        // CanvasEditor draws into a 2D context, so any NaN coords would
        // have made the canvas blank or thrown — neither is observable
        // post-fix because the page should refuse to render the tile and
        // surface the alert instead.
        const canvases = document.querySelectorAll('canvas')
        for (const c of canvases) {
          const rect = c.getBoundingClientRect()
          expect(Number.isFinite(rect.width)).toBe(true)
          expect(Number.isFinite(rect.height)).toBe(true)
        }
      } finally {
        alertSpy.mockRestore()
      }
    },
  )

  it('control: today the page does NOT crash on a malformed splitTile (silent swallow)', async () => {
    server.use(
      http.get(
        `/api/annotations/dataset/${malformedSplitAnnotation.id}`,
        () => jsonResponse(malformedSplitAnnotation),
      ),
    )

    renderWithProviders(
      <FlightProvider>
        <DatasetPage />
      </FlightProvider>,
    )
    await waitFor(
      () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
      { timeout: 3000 },
    )

    const malformedImg = screen.getByAltText(
      new RegExp(
        `image-${malformedSplitAnnotation.mediaId}-${malformedSplitAnnotation.id}`,
      ),
    )
    fireEvent.doubleClick(malformedImg.closest('div')!)

    // Pin: page stays mounted; no role="alert" is rendered today.
    expect(screen.queryByRole('alert')).toBeNull()
  })

  it('control: alert() is never called from the dataset double-click path', async () => {
    server.use(
      http.get(
        `/api/annotations/dataset/${malformedSplitAnnotation.id}`,
        () => jsonResponse(malformedSplitAnnotation),
      ),
    )
    const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined)

    try {
      renderWithProviders(
        <FlightProvider>
          <DatasetPage />
        </FlightProvider>,
      )
      await waitFor(
        () => expect(screen.getAllByRole('img').length).toBeGreaterThan(0),
        { timeout: 3000 },
      )

      const malformedImg = screen.getByAltText(
        new RegExp(
          `image-${malformedSplitAnnotation.mediaId}-${malformedSplitAnnotation.id}`,
        ),
      )
      fireEvent.doubleClick(malformedImg.closest('div')!)

      // Defence in depth (NFT-SEC-07): alert() is banned outside the
      // seeded allow-list. This control passes today (no alert) AND
      // continues to pass after the fix lands (which uses an in-DOM alert).
      expect(alertSpy).not.toHaveBeenCalled()
    } finally {
      alertSpy.mockRestore()
    }
  })
})
