import { describe, expect, it } from 'vitest'
import type { CaseState, ExhibitRelation, FolderExhibit } from './types'
import {
  MAX_BOARD_ZOOM,
  MIN_BOARD_ZOOM,
  clampBoardZoom,
  containedIds,
  discardExhibit,
  folderIsOpen,
  moveBoardPoint,
  nextOpenBoardPosition,
  nextVisibleBoardPosition,
  normalizeCase,
  panViewport,
  relationPosition,
  projectThreadTag,
  threadCurve,
  threadTagLeadIn,
  threadTagLateralLimit,
  threadTagPlacement,
  timelinePositionPercent,
  timelineRange,
  viewportCenteredOnExhibit,
  zoomFromWheel,
  zoomFromPinch,
  zoomViewportAt,
} from './boardDomain'

describe('red thread geometry', () => {
  it('turns tightness into a taut or visibly sagging bezier curve', () => {
    const taut = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 100)
    const slack = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 0)
    expect(taut.midpoint).toEqual({ x: 60, y: 30 })
    expect(slack.midpoint.y).toBeGreaterThan(taut.midpoint.y)
    expect(slack.path).toMatch(/^M 0 0 C /)
  })

  it('clamps tightness to its normalized percentage range', () => {
    expect(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 200)).toEqual(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 100))
  })

  it('places and projects tags by percentage along the curve', () => {
    const from = { x: 0, y: 0 }, to = { x: 200, y: 0 }
    expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 })
    expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 })
  })

  it('constrains tags to the thread regardless of thread tension', () => {
    expect(threadTagLateralLimit(0)).toBe(0)
    expect(threadTagLateralLimit(100)).toBe(0)
    expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100)).toMatchObject({ x: 100, y: 0, lateralOffset: 0 })
    expect(projectThreadTag({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, { x: 150, y: 80 })).toMatchObject({ positionPercent: 75, lateralOffset: 0 })
  })

  it('draws a short foreground thread segment into the eyelet from the visual left', () => {
    const leftToRight = threadTagLeadIn({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50)
    expect(leftToRight.start.x).toBeLessThan(leftToRight.end.x)
    expect(leftToRight.end.x - leftToRight.start.x).toBeCloseTo(54)
    expect(leftToRight.end).toEqual({ x: 100, y: 0 })
    expect(leftToRight.path).toMatch(/^M .* C .* 100 0$/)

    const rightToLeft = threadTagLeadIn({ x: 200, y: 0 }, { x: 0, y: 0 }, 100, 50)
    expect(rightToLeft.start.x).toBeLessThan(rightToLeft.end.x)
    expect(rightToLeft.end).toEqual({ x: 100, y: 0 })
  })
})

const folder: FolderExhibit = {
  id: 'folder-1',
  type: 'folder',
  title: 'Folder',
  content: '',
  x: 200,
  y: 300,
  width: 260,
  height: 166,
  rotation: 0,
  zIndex: 1,
  hidden: false,
  isOpen: false,
}

const state: CaseState = {
  brief: { body: '', concepts: [] },
  goals: [],
  id: 'test-level',
  title: 'Test',
  subtitle: '',
  exhibits: [folder],
  relations: [],
  connections: [],
  viewport: { x: 10, y: 20, zoom: 0.5 },
  views: [],
  revision: 0,
}

describe('board coordinate math', () => {
  it('moves board objects by screen distance divided by zoom', () => {
    expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 0.5)).toEqual({ x: 200, y: 30 })
    expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 1.25)).toEqual({ x: 140, y: 60 })
  })

  it('rejects invalid zoom during coordinate conversion', () => {
    expect(() => moveBoardPoint({ x: 0, y: 0 }, { x: 10, y: 10 }, 0)).toThrow(RangeError)
  })

  it('pans in screen coordinates without scaling the delta', () => {
    expect(panViewport(state.viewport, { x: 25, y: -10 })).toEqual({ x: 35, y: 10, zoom: 0.5 })
  })

  it('clamps toolbar, wheel, and touch pinch zoom to the same limits', () => {
    expect(clampBoardZoom(-10)).toBe(MIN_BOARD_ZOOM)
    expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM)
    expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
    expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
    expect(zoomFromWheel(1, 100)).toBeCloseTo(0.85)
    expect(zoomFromPinch(1, 100, 125)).toBeCloseTo(1.25)
    expect(zoomFromPinch(1, 100, 50)).toBeCloseTo(0.5)
    expect(zoomFromPinch(1, 0, 120)).toBe(1)
  })

  it('keeps the board point beneath the cursor fixed while zooming', () => {
    const anchor = { x: 110, y: 120 }
    const zoomed = zoomViewportAt(state.viewport, 1, anchor)
    expect(zoomed).toEqual({ x: -90, y: -80, zoom: 1 })
    expect(zoomed.x + 200 * zoomed.zoom).toBe(anchor.x)
    expect(zoomed.y + 200 * zoomed.zoom).toBe(anchor.y)
    expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport)
  })

  it('centres an exhibit in the usable mobile canvas', () => {
    const viewport = viewportCenteredOnExhibit(
      { x: 0, y: 28, zoom: .85 },
      { x: 940, y: 360, width: 330, height: 190 },
      { width: 390, height: 658 },
      { right: 64 },
    )
    expect(viewport).toEqual({ x: -776.25, y: -57.75, zoom: .85 })
    expect(viewport.x + (940 + 330 / 2) * viewport.zoom).toBe(163)
    expect(viewport.y + (360 + 190 / 2) * viewport.zoom).toBe(329)
  })

  it('rejects invalid zoom when centring an exhibit', () => {
    expect(() => viewportCenteredOnExhibit(
      { x: 0, y: 0, zoom: 0 }, folder, { width: 390, height: 658 },
    )).toThrow(RangeError)
  })

  it('places new exhibits in deterministic open slots', () => {
    const preferred = { x: 500, y: 400 }
    expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
    expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280, height: 160 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
    expect(nextOpenBoardPosition([
      { x: 500, y: 400, width: 280, height: 160 },
      { x: 826, y: 400, width: 280, height: 160 },
    ], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
  })

  it('places new evidence within a panned and zoomed mobile viewport', () => {
    const viewport = { x: -720, y: -310, zoom: .75 }
    const screen = { width: 390, height: 658 }
    const size = { width: 244, height: 294 }
    const position = nextVisibleBoardPosition([], viewport, screen, size, { top: 120, right: 68, bottom: 18, left: 16 })
    const screenLeft = viewport.x + position.x * viewport.zoom
    const screenTop = viewport.y + position.y * viewport.zoom
    expect(screenLeft).toBeGreaterThanOrEqual(16)
    expect(screenTop).toBeGreaterThanOrEqual(120)
    expect(screenLeft + size.width * viewport.zoom).toBeLessThanOrEqual(screen.width - 68)
    expect(screenTop + size.height * viewport.zoom).toBeLessThanOrEqual(screen.height - 18)
  })

  it('keeps collision avoidance inside the currently visible board area', () => {
    const viewport = { x: -400, y: -200, zoom: .5 }
    const screen = { width: 800, height: 600 }
    const size = { width: 174, height: 145 }
    const first = nextVisibleBoardPosition([], viewport, screen, size)
    const second = nextVisibleBoardPosition([{ ...first, ...size }], viewport, screen, size)
    expect(second).not.toEqual(first)
    expect(viewport.x + second.x * viewport.zoom).toBeGreaterThanOrEqual(0)
    expect(viewport.y + second.y * viewport.zoom).toBeGreaterThanOrEqual(0)
    expect(viewport.x + (second.x + size.width) * viewport.zoom).toBeLessThanOrEqual(screen.width)
    expect(viewport.y + (second.y + size.height) * viewport.zoom).toBeLessThanOrEqual(screen.height)
  })
})

describe('timeline projection', () => {
  it('creates at least a four-year range around a single year', () => {
    const range = timelineRange(['2021-03-12'])
    expect(range.endYear - range.startYear).toBe(4)
    expect(range.startYear).toBeLessThanOrEqual(2021)
    expect(range.endYear).toBeGreaterThanOrEqual(2021)
  })

  it('uses all supplied years when the evidence spans a wider range', () => {
    expect(timelineRange(['2012-01-01', '2024-06-01'])).toMatchObject({ startYear: 2012, endYear: 2024 })
  })

  it('projects dates proportionally and clamps dates outside the range', () => {
    const range = timelineRange(['2020-01-01', '2024-12-31'])
    expect(timelinePositionPercent('2010-01-01', range)).toBe(0)
    expect(timelinePositionPercent('2030-01-01', range)).toBe(100)
    expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
    expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
  })

  it('uses a valid configured date range without automatic year padding', () => {
    const range = timelineRange(['1987-01-08', '1987-10-24'], 2026, { start: '1987-10-01', end: '1987-10-31' })
    expect(new Date(range.start).toISOString()).toBe('1987-10-01T00:00:00.000Z')
    expect(new Date(range.end).toISOString()).toBe('1987-10-31T23:59:59.999Z')
    expect(timelinePositionPercent('1987-10-16', range)).toBeGreaterThan(48)
    expect(timelinePositionPercent('1987-10-16', range)).toBeLessThan(52)
  })
})

describe('folder domain behavior', () => {
  const relations: ExhibitRelation[] = [
    { id: 'later', fromExhibitId: folder.id, toExhibitId: 'doc-2', type: 'contains', sortOrder: 2 },
    { id: 'other', fromExhibitId: 'folder-2', toExhibitId: 'doc-x', type: 'contains', sortOrder: 0 },
    { id: 'first', fromExhibitId: folder.id, toExhibitId: 'doc-1', type: 'contains', sortOrder: 0 },
  ]

  it('orders and scopes contained documents by their normalized relations', () => {
    expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2'])
  })

  it('stores the open state explicitly on the folder exhibit', () => {
    expect(folderIsOpen(folder)).toBe(false)
    expect(folderIsOpen({ ...folder, isOpen: true })).toBe(true)
  })

  it('retains a configured expanded file position', () => {
    const document = { id: 'doc-2', type: 'document' as const, title: 'Source', x: 720, y: 415, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
    expect(relationPosition({ ...state, exhibits: [...state.exhibits, document], relations }, relations[0])).toEqual({ x: 720, y: 415 })
  })

  it('derives a deterministic position when a relation has not been moved', () => {
    expect(relationPosition({ ...state, relations }, relations[2])).toEqual({ x: 550, y: 270 })
    expect(relationPosition({ ...state, relations }, relations[0])).toEqual({ x: 960, y: 270 })
  })

  it('normalizes legacy containment without changing source ownership', () => {
    const legacy = {
      id: state.id, title: state.title, subtitle: state.subtitle, viewport: state.viewport, brief: state.brief,
      documents: [{ id: 'doc-1', title: 'Image', kind: 'IMAGE', date: '', body: [], regions: [], mimeType: 'image/png' }],
      evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }],
      relations: undefined,
    }
    const normalized = normalizeCase(legacy)
    expect(normalized.exhibits.find(exhibit => exhibit.type === 'folder')).toMatchObject({ type: 'folder', isOpen: false })
    expect(normalized.exhibits.find(exhibit => exhibit.type === 'document')).toMatchObject({ fileType: 'image', captureKind:'unclassified',metadata: {} })
    expect(normalized.relations).toHaveLength(1)
  })
})

describe('exhibit disposal', () => {
  it('removes an exhibit and every graph reference while retaining unrelated exhibits', () => {
    const note = { ...folder,id:'note-1',type:'note' as const,title:'Working note',presentation:'luggage' as const }
    const event = { ...folder, id: 'event-1', type: 'event' as const, eventDate: undefined }
    const party = { ...folder, id: 'party-1', type: 'party' as const, partyKind: 'person' as const, aliases: [] }
    const document = { id: 'doc-1', type: 'document' as const, title: 'Source', x: 20, y: 20, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false, body: [], regions: [], fileType: 'text' as const, captureKind:'unclassified' as const, metadata: {} }
    const discarded = discardExhibit({
      ...state,
      exhibits: [folder, document, note, event, party],
      relations: [
        { id: 'supports', fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 0 },
        { id: 'concerns', fromExhibitId: party.id, toExhibitId: note.id, type: 'concerns', sortOrder: 0 },
      ],
      connections: [{ id: 'thread', fromExhibitId: note.id, toExhibitId: party.id }],
      brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] },
    }, note.id)

    expect(discarded.exhibits).toContainEqual(document)
    expect(discarded.exhibits.map(exhibit => exhibit.id)).not.toContain(note.id)
    expect(discarded.relations).toEqual([])
    expect(discarded.connections).toEqual([])
    expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
  })
})
