import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Settings2, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin'
import { audio } from './audio'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLeadIn, threadTagPlacement, timelinePositionPercent, timelineRange, viewportCenteredOnExhibit, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import { CutsceneHost, type PlaythroughState, type RuntimeNode } from './narrative'

// three.js stays out of the board bundle until the player opens the inventory.
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))

const BOARD_W = 2400
const BOARD_H = 1500
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
  'image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file',
].map(value => ({ value: value as SourceFileType, label: documentWidget(value as SourceFileType).label }))

function uid(_prefix: string) { return crypto.randomUUID() }
function screenshotFile(file: File, index: number) {
  const extension = file.type === 'image/jpeg' ? 'jpg' : file.type === 'image/webp' ? 'webp' : 'png'
  const timestamp = new Date().toISOString().replace('T', ' ').replace(/:/g, '.').slice(0, 19)
  return new File([file], `Screenshot ${timestamp}${index ? ` ${index + 1}` : ''}.${extension}`, { type: file.type || 'image/png', lastModified: Date.now() })
}
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function reportAcknowledgementKey(levelId: string) { return `gupi-osint-board:report-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) {
  return [document.title, document.fileType, document.captureKind,document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
    ...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
    ...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
}
function clippingInsetRotation(id:string) {
  let hash=0
  for (const character of id) hash=(hash * 31 + character.charCodeAt(0)) >>> 0
  const degrees=((hash % 49) - 24) / 10
  return degrees === 0 ? .7 : degrees
}
function connectionPoint(item: Exhibit) {
  return exhibitWidget(item.type).connectionPorts(item)[0]
}

type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; exhibitId: string }

const placement = (x: number, y: number, width: number, height: number) => ({ x, y, width, height, rotation: 0, zIndex: 1, hidden: false })
function replaceDirectedRelations(relations: ExhibitRelation[], type: 'supports' | 'concerns', fromExhibitId: string, targets: string[]) {
  const retained = relations.filter(relation => relation.type !== type || relation.fromExhibitId !== fromExhibitId)
  return [...retained, ...targets.map((toExhibitId, sortOrder): ExhibitRelation => ({
    id: relations.find(relation => relation.type === type && relation.fromExhibitId === fromExhibitId && relation.toExhibitId === toExhibitId)?.id || uid(type),
    type, fromExhibitId, toExhibitId, sortOrder,
  }))]
}

export function App() {
  const [caseState, setCaseState] = useState<CaseState | null>(null)
  const [isAdmin, setIsAdmin] = useState(false)
  const [noLevels, setNoLevels] = useState(false)
  const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
  const [selected, setSelected] = useState<string | null>(null)
  const [linkFrom, setLinkFrom] = useState<string | null>(null)
  const [docsOpen, setDocsOpen] = useState(false)
  const [documentQuery, setDocumentQuery] = useState('')
  const [helpOpen, setHelpOpen] = useState(false)
  const [adminMenuOpen, setAdminMenuOpen] = useState(false)
  const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
  const [clock, setClock] = useState('')
  const [draggingFiles, setDraggingFiles] = useState(false)
  const [uploading, setUploading] = useState(0)
  const [documentClassificationQueue, setDocumentClassificationQueue] = useState<string[]>([])
  const [boardTool, setBoardTool] = useState<'move' | 'hand'>('move')
  const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
  const [editingFileId, setEditingFileId] = useState<string | null>(null)
  const [editingEventId, setEditingEventId] = useState<string | null>(null)
  const [newEventDraft, setNewEventDraft] = useState<EventExhibit | null>(null)
  const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
  const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
  const [briefOpen, setBriefOpen] = useState(false)
  const [reportOpen, setReportOpen] = useState(false)
  const [briefAcknowledged, setBriefAcknowledged] = useState(false)
  const [reportAcknowledged, setReportAcknowledged] = useState(false)
  const [playerName, setPlayerName] = useState('Player')
  const [editingBrief, setEditingBrief] = useState(false)
  const [editingTimeline, setEditingTimeline] = useState(false)
  const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
  const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
  const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
  const [flagsOpen, setFlagsOpen] = useState(false)
  const [inventoryOpen, setInventoryOpen] = useState(false)
  const [matchRulesOpen, setMatchRulesOpen] = useState(false)
  const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
  const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
  const [completedGoal, setCompletedGoal] = useState<LevelGoal | null>(null)
  const [narrativeOverlay, setNarrativeOverlay] = useState<RuntimeNode | null>(null)
  const [advancing, setAdvancing] = useState(false)
  const saveTimer = useRef<number | undefined>(undefined)
  const boardRef = useRef<HTMLDivElement>(null)
  const initiallyFocusedLevelRef = useRef<string | null>(null)
  const fileInputRef = useRef<HTMLInputElement>(null)
  const adminMenuRef = useRef<HTMLDivElement>(null)
  const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
  const adminRoute = window.location.pathname === '/admin'

  const loadLevelBySlug = useCallback(async (slug: string, editQuery = '') => {
    const response = await fetch(`/api/levels/${encodeURIComponent(slug)}${editQuery}`)
    if (!response.ok) throw new Error('Level unavailable')
    const data = normalizeCase(await response.json())
    setCaseState(data)
    setBriefAcknowledged(Boolean(localStorage.getItem(briefAcknowledgementKey(data.id))))
    setReportAcknowledged(Boolean(localStorage.getItem(reportAcknowledgementKey(data.id))))
    const arrivals = data.newlyVisibleDocumentIds || []
    if (arrivals.length) {
      setArrivingExhibitIds(arrivals)
      void fetch(`/api/levels/${encodeURIComponent(data.id)}/reveals/seen`, {
        method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: arrivals }),
      })
    }
    return data
  }, [])

  useEffect(() => {
    if (adminRoute) return
    const params = new URLSearchParams(window.location.search)
    fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => {
      setIsAdmin(Boolean(session?.isAdmin)); setPlayerName(String(session?.playerName || 'Player'))
    }).catch(() => setIsAdmin(false))

    const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
    const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
    const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
    const openLevel = async (slug: string) => {
      const data = await loadLevelBySlug(slug, editQuery)
      if ((data.goals.some(goal => goal.status === 'pending') || data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId))
        && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
      try {
        const playthroughResponse = await fetch('/api/playthroughs/current')
        if (playthroughResponse.ok && playthroughResponse.status !== 204) {
          const playthrough = await playthroughResponse.json() as PlaythroughState
          setActivePlaythroughId(playthrough.node?.kind === 'level' && playthrough.node.levelSlug === data.id ? playthrough.playthrough.id : null)
        } else setActivePlaythroughId(null)
      } catch { setActivePlaythroughId(null) }
      setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
    }
    const boot = async () => {
      if (deepLinkLevel) { await openLevel(deepLinkLevel); return }
      const levels = await (await fetch('/api/levels')).json() as { id: string }[]
      if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
      await openLevel(levels[0].id)
    }
    boot().catch(async () => {
      try {
        const levels = await (await fetch('/api/levels')).json() as { id: string }[]
        if (!levels[0]?.id) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
        await openLevel(levels[0].id)
      } catch {
        const cached = localStorage.getItem('gupi-osint-board:last')
        if (cached) setCaseState(normalizeCase(JSON.parse(cached)))
        setStatus(cached ? 'OFFLINE · LOCAL COPY' : 'SERVER UNAVAILABLE')
      }
    })

    const tick = () => setClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))
    tick(); const timer = window.setInterval(tick, 30000)
    return () => clearInterval(timer)
  }, [loadLevelBySlug, adminRoute])

  useEffect(() => {
    if (!adminMenuOpen) return
    const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
    document.addEventListener('pointerdown', close)
    return () => document.removeEventListener('pointerdown', close)
  }, [adminMenuOpen])

  useEffect(() => {
    if (!recentlyCreatedExhibitId) return
    const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400)
    return () => window.clearTimeout(timer)
  }, [recentlyCreatedExhibitId])

  useEffect(() => {
    if (!recentlyCreatedConnectionId) return
    const timer = window.setTimeout(() => setRecentlyCreatedConnectionId(null), 1200)
    return () => window.clearTimeout(timer)
  }, [recentlyCreatedConnectionId])

  useEffect(() => {
    if (!arrivingExhibitIds.length) return
    const timer = window.setTimeout(() => setArrivingExhibitIds([]), 1800)
    return () => window.clearTimeout(timer)
  }, [arrivingExhibitIds])

  useEffect(() => {
    if (!caseState) return
    if (briefOpen && !briefAcknowledged) {
      localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
      setBriefAcknowledged(true)
    }
    if (reportOpen && !reportAcknowledged) {
      localStorage.setItem(reportAcknowledgementKey(caseState.id), new Date().toISOString())
      setReportAcknowledged(true)
    }
  }, [briefOpen, reportOpen, briefAcknowledged, reportAcknowledged, caseState])

  useLayoutEffect(() => {
    if (!caseState || initiallyFocusedLevelRef.current === caseState.id) return
    const board = boardRef.current
    const primaryClaim = caseState.exhibits.find(exhibit => exhibit.type === 'claim' && !exhibit.hidden)
    if (!board || !primaryClaim) return
    const bounds = board.getBoundingClientRect()
    if (!bounds.width || !bounds.height) return

    // In portrait the vertical tool rail occupies part of the apparent canvas.
    // Centre the claim in the remaining usable board rather than underneath it.
    const toolRail = board.parentElement?.querySelector<HTMLElement>('.board-actions')
    const toolBounds = toolRail?.getBoundingClientRect()
    const portraitToolInset = toolBounds && toolBounds.height > toolBounds.width * 2
      ? Math.max(0, bounds.right - toolBounds.left + 8)
      : 0
    const viewport = { ...caseState.viewport, zoom: clampBoardZoom(Math.max(caseState.viewport.zoom, .85)) }
    initiallyFocusedLevelRef.current = caseState.id
    setCaseState(current => current?.id === caseState.id ? {
      ...current,
      viewport: viewportCenteredOnExhibit(viewport, primaryClaim, { width: bounds.width, height: bounds.height }, { right: portraitToolInset }),
    } : current)
  }, [caseState])

  const update = useCallback((fn: (state: CaseState) => CaseState) => {
    setCaseState(current => {
      if (!current) return current
      const next = fn(current)
      localStorage.setItem('gupi-osint-board:last', JSON.stringify(next))
      window.clearTimeout(saveTimer.current)
      saveTimer.current = window.setTimeout(() => {
        const editQuery = requestedEditMode && next.editingAllowed ? '?edit=1' : ''
        fetch(`/api/levels/${encodeURIComponent(next.id)}${editQuery}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(next) })
          .then(r => { if (!r.ok) throw new Error(); setStatus('SAVED TO CASE ARCHIVE') })
          .catch(() => setStatus('OFFLINE · SAVED LOCALLY'))
      }, 450)
      return next
    })
  }, [requestedEditMode])

  const focusEvidence = (id: string) => {
    if (!caseState) return
    const ev = caseState.exhibits.find(e => e.id === id)
    if (!ev) return
    setSelected(id)
    const bounds = boardRef.current?.getBoundingClientRect()
    update(s => ({ ...s, viewport: bounds
      ? viewportCenteredOnExhibit(s.viewport, ev, { width: bounds.width, height: bounds.height })
      : s.viewport }))
  }

  const focusDocument = (id: string) => {
    if (!caseState) return
    const document = caseState.exhibits.find((exhibit): exhibit is CaseDocument => exhibit.id === id && exhibit.type === 'document')
    if (!document) return
    const membership = caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === document.id)
    const folder = membership ? caseState.exhibits.find((exhibit): exhibit is FolderExhibit => exhibit.id === membership.fromExhibitId && exhibit.type === 'folder') : undefined
    const visibleTarget = folder && !folder.isOpen ? folder : document
    const bounds = boardRef.current?.getBoundingClientRect()
    setSelected(document.id)
    update(state => {
      if (!bounds) return state
      const focusViewport = { ...state.viewport, zoom: Math.max(state.viewport.zoom, .9) }
      return { ...state, viewport: viewportCenteredOnExhibit(focusViewport, visibleTarget, { width: bounds.width, height: bounds.height }) }
    })
    setStatus(`LOCATED EXHIBIT ${document.displayNumber || ''}`.trim())
  }

  const extract = (doc: CaseDocument, regionId: string) => {
    if (!caseState) return
    const region = doc.regions.find(r => r.id === regionId)!
    const existingSource = caseState.relations.find(relation => relation.type === 'source' && relation.toExhibitId === doc.id && relation.sourceRegionId === regionId)
    if (existingSource) { setOpenDoc(null); focusEvidence(existingSource.fromExhibitId); return }
    const ev: FolderExhibit = {
      id: uid('folder'), type: 'folder', title: `${documentWidget(doc.fileType).label.toUpperCase()} EVIDENCE`, content: region.excerpt, isOpen: false,
      ...placement(850 + Math.random() * 220, 390 + Math.random() * 250, 260, 166),
    }
    update(s => ({ ...s, exhibits: [...s.exhibits, ev], relations: [...s.relations,
      { id: uid('contains'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'contains', sortOrder: 0 },
      { id: uid('source'), fromExhibitId: ev.id, toExhibitId: doc.id, type: 'source', sortOrder: 0, sourceRegionId: region.id },
    ] }))
    setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
  }

  const addNote = (preset?: string, presentation:NotePresentation = 'luggage') => {
    const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
    if (!content || !caseState) return
    const { viewport } = caseState
    const size = presentation === 'lined_sheet' ? { width:220,height:270 } : { width:108,height:154 }
    const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width:size.width })
    const note: Evidence = { id: uid('note'), type:'note', title:presentation === 'lined_sheet' ? 'FIELD NOTE' : 'WORKING NOTE', content,presentation,
      ...placement(position.x,position.y,size.width,size.height) }
    update(s => ({ ...s, exhibits: [...s.exhibits, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
  }

  const addEvent = () => {
    if (!caseState) return
    const { viewport } = caseState
    const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
    const event: EventExhibit = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
      ...placement(position.x, position.y, 270, 174) }
    setNewEventDraft(event)
  }

  const addParty = () => {
    if (!caseState) return
    const { viewport } = caseState
    const position = nextOpenBoardPosition(caseState.exhibits, {
      x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
    }, { width: 280 })
    setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], ...placement(position.x, position.y, 280, 190) })
  }

  const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
    if (!caseState) return
    const concept = caseState.brief.concepts.find(item => item.id === conceptId)
    if (!concept) return
    const existingId = concept.resolvedPartyExhibitId
    const partyId = existingId || uid('party')
    const { viewport } = caseState
    const existingParty = caseState.exhibits.find((item): item is PartyExhibit => item.id === existingId && item.type === 'party')
    const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.exhibits, {
      x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
    }, { width: 280 })
    const party: PartyExhibit = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
      title: concept.label, content: concept.context, aliases: [],
      ...placement(position.x, position.y, 280, 190) }
    update(state => ({ ...state,
      exhibits: existingId ? state.exhibits.map(item => item.id === existingId && item.type === 'party' ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.exhibits, party],
      brief: { ...state.brief, concepts: state.brief.concepts.map(item => item.id === conceptId ? { ...item, resolvedPartyExhibitId: partyId } : item) },
    }))
    setSelected(partyId)
    setRecentlyCreatedExhibitId(partyId)
    setStatus(`${partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`)
  }

  const closeBrief = () => {
    if (caseState) {
      localStorage.setItem(briefAcknowledgementKey(caseState.id), new Date().toISOString())
      setBriefAcknowledged(true)
    }
    setBriefOpen(false)
  }

  const handleCardClick = (id: string) => {
    if (!linkFrom) { setSelected(current => current === id ? null : id); return }
    completeThread(id)
  }

  const completeThread = (targetId: string) => {
    if (!linkFrom || !caseState || linkFrom === targetId) return
    const existing = caseState.connections.find(connection => (connection.fromExhibitId === linkFrom && connection.toExhibitId === targetId) || (connection.fromExhibitId === targetId && connection.toExhibitId === linkFrom))
    if (existing) {
      setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
      return
    }
    const source = caseState.exhibits.find(exhibit => exhibit.id === linkFrom)
    const target = caseState.exhibits.find(exhibit => exhibit.id === targetId)
    setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId,
      label:source && target ? defaultConnectionLabel(source,target) : 'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
    setLinkFrom(null)
    if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
  }

  const saveThread = (connection: Connection) => {
    if (!caseState) return
    const exists = caseState.connections.some(item => item.id === connection.id)
    update(state => ({ ...state, connections: exists ? state.connections.map(item => item.id === connection.id ? connection : item) : [...state.connections, connection] }))
    if (!exists) setRecentlyCreatedConnectionId(connection.id)
    setThreadDraft(null)
    setStatus(connection.label ? 'RED THREAD TAGGED' : 'RED THREAD TIGHTENED')
  }

  const removeThread = (id: string) => {
    update(state => ({ ...state, connections: state.connections.filter(connection => connection.id !== id) }))
    setThreadDraft(null)
    setStatus('RED THREAD REMOVED')
  }

  const removeExhibit = (id: string) => {
    update(state => discardExhibit(state, id))
    setSelected(current => current === id ? null : current)
    setLinkFrom(current => current === id ? null : current)
    setEditingFolderId(current => current === id ? null : current)
    setEditingEventId(current => current === id ? null : current)
    setEditingPartyId(current => current === id ? null : current)
    setRecentlyCreatedExhibitId(current => current === id ? null : current)
    setStatus('EXHIBIT DISCARDED · RELATIONS REMOVED')
  }

  const toggleThreadTool = () => {
    if (linkFrom) { setLinkFrom(null); setStatus('RED THREAD CANCELLED') }
    else if (selected) { setLinkFrom(selected); setStatus('RED THREAD READY · SELECT TARGET') }
    setBoardTool('move')
  }

  const reset = async () => {
    if (!caseState?.sourceTemplateVersionId || !window.confirm('Reset this investigation to its original template version?')) return
    const response = await fetch(`/api/levels/${encodeURIComponent(caseState!.id)}/reset`, { method: 'POST' })
    if (response.ok) { const data = await response.json(); setCaseState(normalizeCase(data)); localStorage.removeItem('gupi-osint-board:last'); setSelected(null); setStatus('CASE RESET') }
  }

  const saveAsTemplate = async () => {
    if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
    const name = window.prompt('Template name:', caseState.title)?.trim()
    if (!name) return
    window.clearTimeout(saveTimer.current)
    setStatus('FREEZING TEMPLATE VERSION…')
    const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}?edit=1`, {
      method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(caseState),
    })
    if (!saved.ok) { setStatus('LEVEL SAVE FAILED'); return }
    const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/templates?edit=1`, {
      method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }),
    })
    if (!response.ok) { setStatus('TEMPLATE SAVE FAILED'); return }
    const template: { slug: string; currentVersion: number } = await response.json()
    setStatus(`TEMPLATE ${template.slug.toUpperCase()} · VERSION ${template.currentVersion}`)
  }

  const instantiateTemplate = async () => {
    if (!requestedEditMode || !caseState?.editingAllowed) return
    const templatesResponse = await fetch('/api/templates')
    if (!templatesResponse.ok) { setStatus('TEMPLATE ARCHIVE UNAVAILABLE'); return }
    const templates: { slug: string; name: string; currentVersion: number }[] = await templatesResponse.json()
    if (!templates.length) { setStatus('NO TEMPLATES SAVED'); return }
    const templateSlug = window.prompt(`Template slug:\n${templates.map(item => `${item.slug} (v${item.currentVersion})`).join('\n')}`, templates[0].slug)?.trim()
    if (!templateSlug) return
    const title = window.prompt('Name the new investigation:', templates.find(item => item.slug === templateSlug)?.name || 'New Investigation')?.trim()
    if (!title) return
    setStatus('CLONING TEMPLATE…')
    const response = await fetch(`/api/templates/${encodeURIComponent(templateSlug)}/levels?edit=1`, {
      method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }),
    })
    if (!response.ok) { setStatus('TEMPLATE CLONE FAILED'); return }
    const level: CaseState = await response.json()
    window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`)
  }

  const enterLevelEditor = () => {
    const params = new URLSearchParams(window.location.search)
    params.set('edit', '1')
    window.location.assign(`${window.location.pathname}?${params.toString()}`)
  }

  const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
    if (!caseState) return
    const queue = Array.from(files)
    const boardScreen = boardRef.current?.getBoundingClientRect()
    const screen = { width: boardScreen?.width || window.innerWidth, height: boardScreen?.height || window.innerHeight }
    const portraitMobile = screen.width <= 900 && screen.height > screen.width
    const insets = {
      top: Math.min(140, screen.height * .28),
      right: portraitMobile ? 68 : 18,
      bottom: portraitMobile ? 18 : 68,
      left: 18,
    }
    const reserved = [...caseState.exhibits]
    setUploading(queue.length)
    setDraggingFiles(false)
    for (const file of queue) {
      // Reserve for the largest image presentation so choosing Clip, Image, or
      // Mugshot in the following dialog cannot make it grow beyond the viewport.
      const reservedSize = { width: 244, height: 294 }
      const position = nextVisibleBoardPosition(reserved, caseState.viewport, screen, reservedSize, insets, { width: BOARD_W, height: BOARD_H })
      const form = new FormData()
      form.append('file', file)
      form.append('x', String(position.x))
      form.append('y', String(position.y))
      try {
        setStatus(source === 'clipboard' ? 'PASTING SCREENSHOT · READING SOURCE…' : `IMPORTING ${file.name.toUpperCase()} · READING SOURCE…`)
        const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents`, { method: 'POST', body: form })
        if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
        const uploaded: UploadedCaseDocument = await response.json()
        const { analysis, ...document } = uploaded
        reserved.push({ ...document, ...position, ...reservedSize })
        update(s => {
          return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
        })
        setSelected(document.id)
        setArrivingExhibitIds(current => [...new Set([...current, document.id])])
        void fetch(`/api/levels/${encodeURIComponent(caseState.id)}/reveals/seen`, {
          method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
        })
        const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
        if (deterministicCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(deterministicCompletion)
        if (analysis.awardedFlags.length) {
          window.clearTimeout(saveTimer.current)
          const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
          await loadLevelBySlug(caseState.id, editQuery)
          setSelected(document.id)
          setStatus(deterministicCompletion ? caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
        } else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
        else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
          setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
          const judgeResponse = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents/${encodeURIComponent(document.id)}/judge`, { method: 'POST' })
          if (judgeResponse.ok) {
            const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
            const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
            if (semanticCompletion || semantic.awardedFlags.length) {
              if (semanticCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(semanticCompletion)
              window.clearTimeout(saveTimer.current)
              const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
              await loadLevelBySlug(caseState.id, editQuery)
              setSelected(document.id)
            }
            if (semanticCompletion) setStatus(caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE')
            else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
            else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
            else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
            else setStatus('SCREENSHOT SAVED · SOURCE DOES NOT YET PROVE THE OBJECTIVE')
          } else if (source === 'clipboard') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
          else setStatus(`IMPORTED · ${file.name.toUpperCase()} · TEXT ANALYZED`)
        } else if (source === 'clipboard' && analysis.extractionStatus === 'succeeded') setStatus('SCREENSHOT PASTED · TEXT ANALYZED')
        else if (source === 'clipboard' && analysis.extractionStatus === 'failed') setStatus('SCREENSHOT SAVED · TEXT ANALYSIS UNAVAILABLE')
        else setStatus(source === 'clipboard' ? 'SCREENSHOT PASTED · NEW IMAGE DOCUMENT' : `IMPORTED · ${file.name.toUpperCase()}`)
        if (document.fileType === 'image') setDocumentClassificationQueue(current => [...new Set([...current,document.id])])
      } catch (error) {
        setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
      } finally { setUploading(count => count - 1) }
    }
  }, [caseState, loadLevelBySlug, requestedEditMode, update])

  const classifyDocument = (documentId:string,captureKind:DocumentCaptureKind) => {
    const presentation=documentCapture(captureKind)
    const initialClipRotation=Math.round((Math.random() * 20 - 10) * 10) / 10
    const classify = (document:CaseDocument):CaseDocument => ({ ...document,captureKind,width:presentation.defaultSize.width,height:presentation.defaultSize.height,
      title:captureKind === 'clipping' && document.captureKind === 'unclassified' && document.title === document.fileName ? '' : document.title,
      rotation:captureKind === 'clipping' && document.captureKind === 'unclassified' ? initialClipRotation : document.rotation })
    update(state => ({ ...state,exhibits:state.exhibits.map(exhibit => exhibit.id === documentId && exhibit.type === 'document'
      ? classify(exhibit)
      : exhibit) }))
    setOpenDoc(current => current?.id === documentId ? classify(current) : current)
    setDocumentClassificationQueue(current => current.filter(id => id !== documentId))
    setStatus(captureKind === 'unclassified' ? 'EVIDENCE SAVED · CLASSIFY IT LATER IN METADATA' : `${presentation.label.toUpperCase()} CLASSIFICATION SAVED`)
  }

  const continueAfterGoal = async () => {
    if (!activePlaythroughId) { setCompletedGoal(null); return }
    setAdvancing(true)
    try {
      const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
        method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
      })
      if (!response.ok) { setStatus('OBJECTIVE COMPLETE · STORY ADVANCE UNAVAILABLE'); return }
      const next = await response.json() as PlaythroughState
      if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
      else if (next.node?.kind === 'cutscene') {
        setReportOpen(false)
        setCompletedGoal(null)
        setNarrativeOverlay(next.node)
      } else window.location.assign('/?resume=1') // resume straight into the next node, not the splash
    } finally { setAdvancing(false) }
  }

  const continueAfterNarrativeOverlay = async () => {
    if (!activePlaythroughId) return
    setAdvancing(true)
    try {
      const response = await fetch(`/api/playthroughs/${encodeURIComponent(activePlaythroughId)}/advance`, {
        method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
      })
      if (!response.ok) { setStatus('STORY ADVANCE UNAVAILABLE'); return }
      const next = await response.json() as PlaythroughState
      if (next.node?.kind === 'level' && next.node.levelSlug) window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`)
      else window.location.assign('/?resume=1')
    } finally { setAdvancing(false) }
  }

  const openCaseReport = async () => {
    if (!caseState?.report) return
    window.clearTimeout(saveTimer.current)
    const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
    setStatus('COMPILING CASE REPORT…')
    const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}${editQuery}`, {
      method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(caseState),
    })
    if (saved.ok) await loadLevelBySlug(caseState.id,editQuery)
    setReportOpen(true)
    setStatus(saved.ok ? 'CASE REPORT COMPILED FROM BOARD' : 'CASE REPORT OPEN · LATEST SERVER COPY')
  }

  const submitReport = async (input:CaseReportSubmissionInput) => {
    if (!caseState?.report) throw new Error('Case report unavailable')
    setStatus('SUBMITTING CASE REPORT…')
    const response=await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/report/submissions`,{
      method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(input),
    })
    if (!response.ok) { const body=await response.json().catch(() => ({})); throw new Error(body.error || 'Report submission failed') }
    const report=await response.json() as CaseReport
    const reportEvidence=report.claims.flatMap(claim => claim.evidence)
    const byDocument=new Map(reportEvidence.map(item => [item.documentExhibitId,item]))
    const byConnection=new Map(reportEvidence.map(item => [item.connectionId,item]))
    setCaseState(current => current ? {
      ...current,report,
      exhibits:current.exhibits.map(exhibit => exhibit.type === 'document' && byDocument.has(exhibit.id)
        ? { ...exhibit,publishedAt:byDocument.get(exhibit.id)!.publishedAt,sourceCitation:byDocument.get(exhibit.id)!.sourceCitation,sourceUri:byDocument.get(exhibit.id)!.sourceUri,displayNumber:byDocument.get(exhibit.id)!.displayNumber }
        : exhibit),
      connections:current.connections.map(connection => byConnection.has(connection.id)
        ? { ...connection,label:byConnection.get(connection.id)!.relationText || undefined } : connection),
    } : current)
    setStatus(report.status === 'accepted' ? 'CASE REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED FOR REVISION' : 'REPORT NEEDS SUPPORTING EVIDENCE')
    return report
  }

  useEffect(() => {
    if (!caseState) return
    const handlePaste = (event: ClipboardEvent) => {
      const images = Array.from(event.clipboardData?.items || []).flatMap(item => {
        const file = item.kind === 'file' && item.type.startsWith('image/') ? item.getAsFile() : null
        return file ? [file] : []
      })
      if (!images.length) return
      event.preventDefault()
      void uploadFiles(images.map(screenshotFile), 'clipboard')
    }
    window.addEventListener('paste', handlePaste)
    return () => window.removeEventListener('paste', handlePaste)
  }, [caseState, uploadFiles])

  if (adminRoute) return <AdminPanel />
  if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
  if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>

  const documents = documentExhibits(caseState.exhibits)
  const evidence = evidenceExhibits(caseState.exhibits)
  const documentById = new Map(documents.map(document => [document.id, document]))
  const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
  const filteredDocuments = normalizedDocumentQuery ? documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : documents
  const classificationDocument = documentClassificationQueue.length ? documents.find(document => document.id === documentClassificationQueue[0]) || null : null
  const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
  const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
  const briefAttentionCount = !briefAcknowledged ? unresolvedConceptCount + pendingGoalCount : 0
  const reportAttentionCount = !reportAcknowledged && caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
  const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
  const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => {
    const widget=exhibitWidget(exhibit.type)
    if (!widget) throw new Error(`No widget is registered for exhibit type “${String(exhibit.type)}”`)
    return widget.temporalFacts(exhibit).map(fact => {
    const membership = exhibit.type === 'document' ? caseState.relations.find(relation => relation.type === 'contains' && relation.toExhibitId === exhibit.id) : undefined
    const folder = membership ? caseState.exhibits.find(candidate => candidate.id === membership.fromExhibitId && candidate.type === 'folder') as FolderExhibit | undefined : undefined
    const sourceTemporalId = folder && !folder.isOpen ? `widget:${folder.id}` : `widget:${exhibit.id}`
    return { id: fact.id, sourceTemporalId, date: fact.start, label: fact.label, kind: exhibit.type === 'document' ? 'document' as const : 'widget' as const, exhibitId: exhibit.id }
    })
  }).sort((a, b) => dateValue(a.date) - dateValue(b.date))
  const storyEvents = evidence.filter((item): item is EventExhibit => item.type === 'event').sort((a, b) => {
    if (!a.eventDate) return b.eventDate ? 1 : 0
    if (!b.eventDate) return -1
    return dateValue(a.eventDate) - dateValue(b.eventDate)
  })
  const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
  return <main className="desktop">
    {inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
      <Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
        onTearToBoard={text => { addNote(text,'lined_sheet'); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
    </Suspense>}
    <header className="menubar">
      <div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
      <nav>
        <button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
        <button className={briefOpen ? 'active' : ''} aria-label="Brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
        {caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
        {activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>}
        <button onClick={() => setHelpOpen(true)}>HELP</button>
        {isAdmin && <div className="admin-menu" ref={adminMenuRef}>
          <button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
          {adminMenuOpen && <div className="admin-menu-items" role="menu">
            <button role="menuitem" onClick={() => { setFlagsOpen(true); setAdminMenuOpen(false) }}>LEVEL FLAGS</button>
            {!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
              <button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF &amp; CONCEPTS</button>
              <button role="menuitem" onClick={() => { setMatchRulesOpen(true); setAdminMenuOpen(false) }}>EVIDENCE MATCHING</button>
              <button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
              <button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
              <button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
            </>}
          </div>}
        </div>}
      </nav>
      <div className="terminal-status"><i /> {status}<span>{clock}</span></div>
    </header>

    <section className="workspace">
      <aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
        <div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${documents.length}` : documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
        <label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
        <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'ADD DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) void uploadFiles(e.target.files); e.target.value = '' }} /></>
        <div className="doc-list">
          {filteredDocuments.map((doc, index) => <div className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id}>
            <button className="doc-row-main" type="button" title="Click to highlight on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
              <div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
              <div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div>
            </button>
            <button className="doc-row-locate" type="button" aria-label={`Locate ${doc.title || `Exhibit ${doc.displayNumber || index + 1}`} on board`} title="Zoom to document" onClick={() => focusDocument(doc.id)}><ChevronRight size={16}/></button>
          </div>)}
          {normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
        </div>
      </aside>

      <div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
        <div className="case-heading"><h1>{caseState.title}</h1></div>
        <Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
        {briefOpen && <BriefPanel
          levelId={caseState.id}
          brief={caseState.brief}
          goals={caseState.goals}
          parties={evidence.filter((item): item is PartyExhibit => item.type === 'party')}
          recentlyCreatedExhibitId={recentlyCreatedExhibitId}
          canEdit={canAuthor}
          onClose={closeBrief}
          onEdit={() => setEditingBrief(true)}
          onClassify={classifyConcept}
          onNewParty={addParty}
          onLocate={focusEvidence}
          onEditParty={setEditingPartyId}
        />}
        {storyEvents.length > 0 && <aside className="story-strip"><small>RECONSTRUCTED STORY</small>{storyEvents.map((event, index) => <button key={event.id} className={selected === event.id ? 'selected' : ''} onClick={() => focusEvidence(event.id)}><time>{event.eventDate?.slice(0, 10) || 'UNDATED'}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
        <div className="board-actions">
          <button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
          <button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
          <span />
          <button onClick={() => addNote()}><NotebookPen size={17}/> NEW NOTE</button>
          <button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
          <button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
          <button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
          <span />
          <button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
          <b>{Math.round(caseState.viewport.zoom * 100)}%</b>
          <button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
          <button aria-label="Reset" title={caseState.sourceTemplateVersionId ? 'Reset to source template' : 'This level has no source template'} disabled={!caseState.sourceTemplateVersionId} onClick={reset}><RotateCcw size={17}/></button>
        </div>
        {draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
      </div>
    </section>

    <DocumentLocatorBeam documentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.exhibits.map(item => `${item.id}:${item.x}:${item.y}:${item.type === 'folder' ? item.isOpen : ''}`).join('|')}`} />
    <TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.exhibits.map(e => `${e.id}:${e.x}:${e.y}:${e.type === 'folder' ? e.isOpen : ''}`).join('|')}`}/>
    {timelineView?.visible !== false && <Timeline items={temporalItems} range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { const exhibit = caseState.exhibits.find(candidate => candidate.id === item.exhibitId); if (exhibit?.type === 'document') setOpenDoc(exhibit); else focusEvidence(item.exhibitId) }}/>
    }
    {classificationDocument && <DocumentClassificationPanel document={classificationDocument}
      onChoose={captureKind => classifyDocument(classificationDocument.id,captureKind)}/>}
    {openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onInfo={() => setEditingFileId(openDoc.id)}
      onDelete={() => { if (window.confirm(`Delete “${openDoc.title}” from this board? Its connections and folder membership will also be removed.`)) { removeExhibit(openDoc.id); setOpenDoc(null) } }}
      onType={captureKind => classifyDocument(openDoc.id,captureKind)} onExtract={id => extract(openDoc, id)} extracted={caseState.relations.flatMap(relation => relation.type === 'source' && relation.toExhibitId === openDoc.id ? [relation.sourceRegionId] : [])} />}
    {editingFolderId && <FolderEditor
      key={editingFolderId}
      folder={caseState.exhibits.find((widget): widget is FolderExhibit => widget.id === editingFolderId && widget.type === 'folder')!}
      memberIds={containedIds(caseState, editingFolderId)}
      documents={documents}
      canManageContents={canAuthor}
      onClose={() => setEditingFolderId(null)}
      onSave={(folder, members) => {
        update(state => ({
          ...state,
          exhibits: state.exhibits.map(widget => widget.id === folder.id ? folder : widget),
          relations: [
            ...state.relations.filter(relation => relation.type !== 'contains' || relation.fromExhibitId !== folder.id),
            ...members.map((documentId, index): ExhibitRelation => ({ id: state.relations.find(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id && relation.toExhibitId === documentId)?.id || uid('contains'), fromExhibitId: folder.id, toExhibitId: documentId, type: 'contains', sortOrder: index })),
          ],
        }))
        setEditingFolderId(null)
        setStatus('FOLDER UPDATED')
      }}
    />}
    {editingFileId && <FileEditor key={editingFileId} document={documents.find(document => document.id === editingFileId)!} canEditGates={canAuthor} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, exhibits: state.exhibits.map(candidate => candidate.id === document.id ? document : candidate) })); setOpenDoc(current => current?.id === document.id ? document : current); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>
    }
    {editingEventId && <EventEditor
      key={editingEventId}
      event={caseState.exhibits.find((item): item is EventExhibit => item.id === editingEventId && item.type === 'event')!}
      exhibits={caseState.exhibits}
      relations={caseState.relations}
      onClose={() => setEditingEventId(null)}
      onSave={(event, supports) => { update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === event.id ? event : item), relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
    />}
    {newEventDraft && <EventEditor
      key={newEventDraft.id}
      event={newEventDraft}
      exhibits={caseState.exhibits}
      relations={caseState.relations}
      onClose={() => setNewEventDraft(null)}
      onSave={(event, supports) => {
        update(state => ({ ...state, exhibits: [...state.exhibits, event], relations: replaceDirectedRelations(state.relations, 'supports', event.id, supports) }))
        setNewEventDraft(null)
        setSelected(event.id)
        setRecentlyCreatedExhibitId(event.id)
        setStatus(event.eventDate ? 'DATED EVENT ADDED' : 'UNDATED EVENT ADDED')
      }}
    />}
    {editingPartyId && <PartyEditor
      key={editingPartyId}
      party={caseState.exhibits.find((item): item is PartyExhibit => item.id === editingPartyId && item.type === 'party')!}
      exhibits={caseState.exhibits}
      relations={caseState.relations}
      onClose={() => setEditingPartyId(null)}
      onSave={(party, related) => {
        update(state => ({ ...state, exhibits: state.exhibits.map(item => item.id === party.id ? party : item), relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
        setEditingPartyId(null)
        setStatus('PARTY DOSSIER UPDATED')
      }}
    />}
    {newPartyDraft && <PartyEditor
      key={newPartyDraft.id}
      party={newPartyDraft}
      exhibits={caseState.exhibits}
      relations={caseState.relations}
      creating
      onClose={() => setNewPartyDraft(null)}
      onSave={(party, related) => {
        update(state => ({ ...state, exhibits: [...state.exhibits, party], relations: replaceDirectedRelations(state.relations, 'concerns', party.id, related) }))
        setNewPartyDraft(null)
        setSelected(party.id)
        setRecentlyCreatedExhibitId(party.id)
        setStatus(`${party.partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`)
      }}
    />}
    {editingBrief && <BriefEditor
      brief={caseState.brief}
      onClose={() => setEditingBrief(false)}
      onSave={brief => {
        update(state => ({ ...state, brief }))
        setEditingBrief(false)
        setStatus('LEVEL BRIEF UPDATED')
      }}
    />}
    {editingTimeline && <TimelineRangeEditor
      range={timelineView?.rangeMode === 'fixed' ? timelineView.range : undefined}
      dates={temporalItems.map(item => item.date)}
      onClose={() => setEditingTimeline(false)}
      onSave={timelineRange => {
        update(state => ({ ...state, views: state.views.map(view => view.type === 'timeline' ? { ...view, rangeMode: timelineRange ? 'fixed' : 'auto', range: timelineRange || undefined } : view) }))
        setEditingTimeline(false)
        setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
      }}
    />}
    {threadDraft && <ThreadEditor
      key={threadDraft.id}
      connection={threadDraft}
      sourceName={caseState.exhibits.find(item => item.id === threadDraft.fromExhibitId)?.title || 'Exhibit'}
      targetName={caseState.exhibits.find(item => item.id === threadDraft.toExhibitId)?.title || 'Exhibit'}
      isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
      onClose={() => setThreadDraft(null)}
      onSave={saveThread}
      onRemove={() => removeThread(threadDraft.id)}
    />}
    {reportOpen && caseState.report && <CaseReportPanel key={`${caseState.id}:${caseState.report.status}:${caseState.report.claims.flatMap(claim => claim.evidence).length}`}
      report={caseState.report} defaultInvestigator={caseState.report.investigatorName || playerName} hasNext={Boolean(activePlaythroughId)}
      onClose={() => setReportOpen(false)} onSubmit={submitReport}
      onContinue={() => activePlaythroughId ? void continueAfterGoal() : setReportOpen(false)}/>}
    {helpOpen && <Help onClose={() => setHelpOpen(false)}/>} 
    {flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
    {matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
    {completedGoal && <GoalComplete goal={completedGoal} hasNext={Boolean(activePlaythroughId)} busy={advancing} onContinue={() => void continueAfterGoal()}/>}
    {narrativeOverlay?.kind === 'cutscene' && <CutsceneHost componentKey={narrativeOverlay.componentKey} label={narrativeOverlay.label}
      presentation={narrativeOverlay.presentation} onComplete={() => { if (!advancing) void continueAfterNarrativeOverlay() }}/>}
  </main>
}

function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (level: CaseState) => void }) {
  const [creating, setCreating] = useState(false)
  const createLevel = async () => {
    const title = window.prompt('Name this investigation level:', 'Untitled Investigation')?.trim()
    if (!title) return
    setCreating(true)
    try {
      const response = await fetch('/api/levels', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }) })
      if (!response.ok) throw new Error('Could not create level')
      onCreated(await response.json())
    } finally { setCreating(false) }
  }
  return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
}

function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedExhibitId, arrivingExhibitIds, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; locatorDocumentId: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; arrivingExhibitIds: string[]; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
  const drag = useRef<{ kind: 'pan' | 'widget' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
  const suppressClick = useRef(false)
  const touchPoints = useRef(new Map<number, { x: number; y: number }>())
  const pinchDistance = useRef<number | null>(null)
  const folderLongPress = useRef<{ pointerId: number; id: string; startX: number; startY: number; timer: number } | null>(null)
  const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
  const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
  const [draggingWidget, setDraggingWidget] = useState(false)
  const [trashActive, setTrashActive] = useState(false)
  const trashRef = useRef<HTMLDivElement>(null)
  const trashTarget = useRef(false)
  const byId = useMemo(() => new Map(state.exhibits.map(exhibit => [exhibit.id, exhibit])), [state.exhibits])
  const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
  const pointForId = (id: string) => {
    const exhibit = byId.get(id)
    if (!exhibit) return undefined
    const membership = exhibit.type === 'document' ? containmentRelations.find(item => item.toExhibitId === id) : undefined
    const folder = membership ? byId.get(membership.fromExhibitId) : undefined
    return folder?.type === 'folder' && !folder.isOpen ? connectionPoint(folder) : connectionPoint(exhibit)
  }
  useEffect(() => {
    const board = boardRef.current
    if (!board) return
    const handleWheelZoom = (event: WheelEvent) => {
      event.preventDefault()
      event.stopPropagation()
      if (event.ctrlKey || event.metaKey || event.deltaY === 0) return
      const bounds = board.getBoundingClientRect()
      const anchor = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }
      update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromWheel(s.viewport.zoom, event.deltaY), anchor) }))
    }
    board.addEventListener('wheel', handleWheelZoom, { passive: false })
    return () => board.removeEventListener('wheel', handleWheelZoom)
  }, [boardRef, update])
  const pointerDown = (event: React.PointerEvent, target?: { kind: 'widget'; id: string }) => {
    if ((event.target as HTMLElement).closest('button')) return
    if (event.pointerType === 'touch') {
      touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
      if (touchPoints.current.size >= 2) {
        if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
        folderLongPress.current = null
        const points = [...touchPoints.current.values()]
        pinchDistance.current = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
        drag.current = null
        event.preventDefault()
        return
      }
    }
    const widget = target?.kind === 'widget' ? byId.get(target.id) : undefined
    drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? state.viewport.x, originY: widget?.y ?? state.viewport.y }
    setDraggingWidget(target?.kind === 'widget')
    try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
  }
  const threadTagPointerDown = (event: React.PointerEvent, id: string) => {
    event.stopPropagation()
    if (event.button !== 0) return
    event.preventDefault()
    setDraggingThreadTagId(id)
    drag.current = { kind: 'thread-tag', id, startX: event.clientX, startY: event.clientY, originX: 0, originY: 0 }
    try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
  }
  const trackThreadPointer = (event: React.PointerEvent) => {
    if (linkFrom && boardRef.current) {
      const bounds = boardRef.current.getBoundingClientRect()
      setThreadPointer({ x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom })
    }
  }
  const pointerMove = (event: React.PointerEvent) => {
    trackThreadPointer(event)
    const pendingFolderPress = folderLongPress.current
    if (pendingFolderPress?.pointerId === event.pointerId && Math.hypot(event.clientX - pendingFolderPress.startX, event.clientY - pendingFolderPress.startY) > 8) {
      window.clearTimeout(pendingFolderPress.timer)
      folderLongPress.current = null
    }
    if (event.pointerType === 'touch' && touchPoints.current.has(event.pointerId)) {
      touchPoints.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
      if (touchPoints.current.size >= 2) {
        const points = [...touchPoints.current.values()]
        const distance = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y)
        const previous = pinchDistance.current
        if (previous && boardRef.current) {
          const bounds = boardRef.current.getBoundingClientRect()
          const anchor = { x: (points[0].x + points[1].x) / 2 - bounds.left, y: (points[0].y + points[1].y) / 2 - bounds.top }
          update(s => ({ ...s, viewport: zoomViewportAt(s.viewport, zoomFromPinch(s.viewport.zoom, previous, distance), anchor) }))
        }
        pinchDistance.current = distance
        suppressClick.current = true
        event.preventDefault()
        return
      }
    }
    if (!drag.current) return
    // Snapshot the drag ref: the `update` closures below run inside a React state
    // updater (deferred/possibly re-invoked), by which point `drag.current` may be null.
    const active = drag.current
    const dx = event.clientX - active.startX, dy = event.clientY - active.startY
    if (Math.abs(dx) + Math.abs(dy) > 4) active.moved = true
    if (active.kind === 'widget') {
      const bounds = trashRef.current?.getBoundingClientRect()
      const overTrash = Boolean(bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom)
      trashTarget.current = overTrash
      setTrashActive(overTrash)
    }
    if (active.kind === 'thread-tag' && boardRef.current) {
      const connection = state.connections.find(item => item.id === active.id)
      const from = connection ? pointForId(connection.fromExhibitId) : undefined
      const to = connection ? pointForId(connection.toExhibitId) : undefined
      if (connection && from && to) {
        const bounds = boardRef.current.getBoundingClientRect()
        const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
        const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
        update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: 0 } : item) }))
      }
    } else if (active.kind === 'widget') update(s => { const next = moveBoardPoint({ x: active.originX, y: active.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === active.id ? { ...exhibit, ...next } : exhibit) } })
    else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: active.originX, y: active.originY }, { x: dx, y: dy }) }))
  }
  const finishDrag = (event: React.PointerEvent) => {
    if (folderLongPress.current?.pointerId === event.pointerId) {
      window.clearTimeout(folderLongPress.current.timer)
      folderLongPress.current = null
    }
    if (event.pointerType === 'touch') {
      touchPoints.current.delete(event.pointerId)
      if (touchPoints.current.size < 2) pinchDistance.current = null
    }
    const completedDrag = drag.current
    if (completedDrag) suppressClick.current = Boolean(completedDrag.moved)
    drag.current = null
    setDraggingThreadTagId(null)
    setDraggingWidget(false)
    setTrashActive(false)
    if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) {
      const exhibit=byId.get(completedDrag.id)
      if (exhibit && exhibitWidget(exhibit.type).capabilities.discardable) onDiscardExhibit(completedDrag.id)
    }
    trashTarget.current = false
  }
  const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
  const startFolderLongPress = (event: React.PointerEvent, id: string) => {
    if (event.pointerType !== 'touch' || tool !== 'move' || linkFrom || (event.target as HTMLElement).closest('button')) return
    if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
    const pointerId = event.pointerId
    const timer = window.setTimeout(() => {
      if (touchPoints.current.size !== 1 || folderLongPress.current?.pointerId !== pointerId) return
      folderLongPress.current = null
      drag.current = null
      trashTarget.current = false
      setDraggingWidget(false)
      setTrashActive(false)
      suppressClick.current = true
      toggleFolder(id)
    }, 520)
    folderLongPress.current = { pointerId, id, startX: event.clientX, startY: event.clientY, timer }
  }
  useEffect(() => () => {
    if (folderLongPress.current) window.clearTimeout(folderLongPress.current.timer)
  }, [])
  const widgetContext: ExhibitWidgetContext = { exhibits: state.exhibits, relations: state.relations, dispatch: (command: WidgetCommand) => {
    if (command.type === 'open-document') onOpenSource(command.documentId)
    else if (command.type === 'edit-folder') onEditFolder(command.folderId)
    else if (command.type === 'edit-event') onEditEvent(command.eventId)
    else if (command.type === 'edit-party') onEditParty(command.partyId)
    else if (command.type === 'edit-document') onEditFile(command.documentId)
    else if (command.type === 'update-memory-cue') onUpdateDocumentCue(command.documentId, command.cue)
  } }
  const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
  return <div className={`board-viewport tool-${tool} ${linkFrom ? 'threading' : ''}`} ref={boardRef}
    onPointerDown={e => {
      const target = e.target as HTMLElement
      const emptyBoardDrag = e.button === 0 && !target.closest('.evidence-card, .source-file-widget, .thread-tag, button')
      if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1 || emptyBoardDrag) { e.preventDefault(); pointerDown(e) }
    }}
    onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
    <div ref={trashRef} className={`board-trash ${draggingWidget ? 'drag-ready' : ''} ${trashActive ? 'active' : ''}`} aria-hidden="true"><Trash2 size={23}/><span><b>DISCARD</b><small>DRAG EXHIBIT HERE</small></span></div>
    <div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
      <div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
      <svg className="connections" width={BOARD_W} height={BOARD_H}>
        {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
        {previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
      </svg>
      {state.connections.map(connection => { const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; const tagPlacement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, 0); const compact = connection.tagStyle === 'compact'; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${dragging ? 'dragging' : ''}`} style={{ left: tagPlacement.x, top: tagPlacement.y }} title={connection.label ? dragging ? `Position ${Math.round(tagPlacement.positionPercent)}%` : 'Drag along thread · click to edit' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } onEditConnection(connection) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(tagPlacement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b></span>)}</button> })}
      <svg className="thread-lead-ins" width={BOARD_W} height={BOARD_H} aria-hidden="true">
        {state.connections.map(connection => { if (!connection.label || connection.tagStyle === 'compact') return null; const p1 = pointForId(connection.fromExhibitId), p2 = pointForId(connection.toExhibitId); if (!p1 || !p2) return null; return <path key={`lead-in:${connection.id}`} d={threadTagLeadIn(p1, p2, connection.tightness, connection.tagPosition).path}/> })}
      </svg>
      <svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
        {state.relations.filter(relation => relation.type === 'supports').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
      </svg>
      <svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
        {state.relations.filter(relation => relation.type === 'concerns').map(relation => { const origin = pointForId(relation.fromExhibitId), target = pointForId(relation.toExhibitId); return origin && target ? <line key={relation.id} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/> : null })}
      </svg>
      <svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
        {containmentRelations.map(relation => { const folder = byId.get(relation.fromExhibitId), document = byId.get(relation.toExhibitId); if (folder?.type !== 'folder' || document?.type !== 'document') return null; const origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={folder.isOpen ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={folder.isOpen ? document.x + document.width / 2 : origin.x} y2={folder.isOpen ? document.y + document.height / 2 : origin.y}/> })}
      </svg>
      {evidenceExhibits(state.exhibits).filter(exhibit => !exhibit.hidden).map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = byId.get(id); return document?.type === 'document' ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !ev.isOpen && containedDocuments.some(document => document.id === locatorDocumentId) ? locatorDocumentId : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; const containsArrival = ev.type === 'folder' && containedDocuments.some(document => arrivingExhibitIds.includes(document.id)); const notePresentation=ev.type === 'note' ? ev.presentation === 'lined_sheet' ? 'lined-sheet' : 'luggage-tag' : ''; return <article key={ev.id} tabIndex={ev.type === 'folder' ? 0 : undefined} aria-expanded={ev.type === 'folder' ? ev.isOpen : undefined} title={ev.type === 'folder' ? 'Double-click or hold to open or close this folder' : undefined} data-document-locator-target={locatedDocumentId} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${notePresentation} ${selected === ev.id ? 'selected' : ''} ${locatedDocumentId ? 'document-located' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id || arrivingExhibitIds.includes(ev.id) || containsArrival ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, height: ev.height, rotate: `${ev.rotation + (i % 3 - 1) * .45}deg`, zIndex: ev.zIndex }}
        onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; if (ev.type === 'folder') startFolderLongPress(e, ev.id); if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }}
        onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }}
        onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (ev.type === 'folder' && e.detail > 1) return; if (tool === 'move') onCardClick(ev.id) }} onDoubleClick={e => { e.stopPropagation(); if (ev.type === 'folder' && tool === 'move' && !linkFrom && !(e.target as HTMLElement).closest('button')) toggleFolder(ev.id) }} onKeyDown={e => { if (ev.type === 'folder' && e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); toggleFolder(ev.id) } }}>
        <header><span>{definition.heading(ev, widgetContext)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
        <Widget exhibit={ev} context={widgetContext}/>
      </article>})}
      {documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left,top,width:document.width,height:document.height,rotate:`${document.rotation}deg`,zIndex:document.zIndex,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
        onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
        onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
        {document.captureKind === 'clipping' && <span className="clip-stamped-pin" aria-hidden="true"/>}
        <header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
        <div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
        {document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
        <div className="source-file-footer">{document.captureKind === 'clipping' && <span className={`clip-provenance ${missingProvenance.length ? 'incomplete' : 'complete'}`}>{missingProvenance.length ? `ADD ${missingProvenance.join(' + ')}` : 'SOURCE RECORDED'}</span>}<time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
          <div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div></div>
      </article> })}
    </div>
  </div>
}

function MugshotCaption({ name }:{ name:string }) {
  const [visibleName,setVisibleName]=useState(name)
  const [writing,setWriting]=useState(false)
  const previousName=useRef(name)
  useEffect(() => {
    if (name === previousName.current) return
    previousName.current=name
    if (!name) { setVisibleName('');setWriting(false);return }
    const letterDelay=Math.max(48,Math.min(82,900 / name.length))
    const duration=Math.round(letterDelay * name.length)
    let character=0
    setVisibleName('');setWriting(true)
    const stopSound=audio.sharpie(duration + 80)
    const timer=window.setInterval(() => {
      character+=1
      setVisibleName(name.slice(0,character))
      if (character >= name.length) { window.clearInterval(timer);setWriting(false) }
    },letterDelay)
    return () => { window.clearInterval(timer);stopSound?.() }
  },[name])
  return <strong className={`mugshot-caption ${writing ? 'writing' : ''}`} aria-label={name ? `Identified as ${name}` : 'Unlabelled mugshot'}><span>{visibleName}</span></strong>
}

function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) {
  const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null)
  useLayoutEffect(() => {
    if (!documentId) { setBeam(null); return }
    let frame = 0
    let animateUntil = Date.now() + 500
    const matchingElement = (attribute: 'documentRowId' | 'documentLocatorTarget') => Array.from(document.querySelectorAll<HTMLElement>(attribute === 'documentRowId' ? '[data-document-row-id]' : '[data-document-locator-target]')).find(element => element.dataset[attribute] === documentId)
    const measure = () => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(function measureFrame() {
        const source = matchingElement('documentRowId')
        const target = matchingElement('documentLocatorTarget')
        const viewport = document.querySelector<HTMLElement>('.board-viewport')
        if (!source || !target || !viewport) { setBeam(null); return }
        const from = source.getBoundingClientRect(), to = target.getBoundingClientRect(), bounds = viewport.getBoundingClientRect()
        const x1 = Math.min(from.right - 3, bounds.left - 3)
        const y1 = from.top + from.height / 2
        const x2 = Math.max(bounds.left + 12, Math.min(bounds.right - 12, to.left + to.width / 2))
        const y2 = Math.max(bounds.top + 12, Math.min(bounds.bottom - 12, to.top + to.height / 2))
        const bend = Math.max(70, Math.abs(x2 - x1) * .32)
        setBeam({ path: `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`, x: x2, y: y2 })
        if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame)
      })
    }
    measure()
    const observer = new ResizeObserver(measure)
    const observed = [matchingElement('documentRowId'), matchingElement('documentLocatorTarget'), document.querySelector<HTMLElement>('.board-viewport')].filter((element): element is HTMLElement => Boolean(element))
    observed.forEach(element => observer.observe(element))
    const handleLayoutChange = () => { animateUntil = Date.now() + 500; measure() }
    window.addEventListener('resize', handleLayoutChange)
    document.querySelector('.doc-list')?.addEventListener('scroll', handleLayoutChange, { passive: true })
    return () => {
      cancelAnimationFrame(frame)
      observer.disconnect()
      window.removeEventListener('resize', handleLayoutChange)
      document.querySelector('.doc-list')?.removeEventListener('scroll', handleLayoutChange)
    }
  }, [documentId, layoutKey])
  if (!beam) return null
  return <svg className="document-locator-beam" aria-hidden="true">
    <defs><filter id="document-locator-glow" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="4" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
    <path className="document-locator-halo" d={beam.path}/><path className="document-locator-ray" d={beam.path}/><circle cx={beam.x} cy={beam.y} r="8"/><circle className="document-locator-pulse" cx={beam.x} cy={beam.y} r="15"/>
  </svg>
}

function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) {
  const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([])
  const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|')
  useLayoutEffect(() => {
    let frame = 0
    let animateUntil = Date.now() + 500
    const measure = () => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(function measureFrame() {
        const sources = new Map(Array.from(document.querySelectorAll<HTMLElement>('[data-temporal-id]')).map(element => [element.dataset.temporalId, element]))
        const markers = new Map(Array.from(document.querySelectorAll<HTMLElement>('[data-marker-id]')).map(element => [element.dataset.markerId, element]))
        setLines(items.flatMap(item => {
          const source = sources.get(item.sourceTemporalId), marker = markers.get(item.id)
          if (!source || !marker) return []
          const from = source.getBoundingClientRect(), to = marker.getBoundingClientRect()
          const clip = source.closest<HTMLElement>('.board-viewport, .doc-list')?.getBoundingClientRect()
          const visibleLeft = Math.max(from.left, clip?.left ?? 0), visibleRight = Math.min(from.right, clip?.right ?? window.innerWidth)
          const visibleTop = Math.max(from.top, clip?.top ?? 0), visibleBottom = Math.min(from.bottom, clip?.bottom ?? window.innerHeight)
          if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) return []
          return [{ id: item.id, x1: visibleLeft + (visibleRight - visibleLeft) / 2, y1: visibleBottom, x2: to.left + to.width / 2, y2: to.top + to.height / 2 }]
        }))
        if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame)
      })
    }
    measure()
    const observer = new ResizeObserver(measure)
    document.querySelectorAll<HTMLElement>('.workspace, .timeline, [data-temporal-id], [data-marker-id]').forEach(element => observer.observe(element))
    const handleResize = () => { animateUntil = Date.now() + 500; measure() }
    window.addEventListener('resize', handleResize)
    return () => { cancelAnimationFrame(frame); observer.disconnect(); window.removeEventListener('resize', handleResize) }
  }, [itemsKey, layoutKey])
  return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
}

function Timeline({ items, range, selected, onSelect, onEdit }: { items: TemporalItem[]; range?: TimelineRange | null; selected: string | null; onSelect: (item: TemporalItem) => void; onEdit: () => void }) {
  const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date), undefined, range || undefined)
  const position = (date: string) => timelinePositionPercent(date, { start, end })
  const ticks = range
    ? Array.from({ length: 5 }, (_, index) => { const value = start + (end - start) * index / 4; return { value, label: new Date(value).toISOString().slice(5, 10) } })
    : Array.from({ length: endYear - startYear + 1 }, (_, index) => { const year = startYear + index; return { value: Date.parse(`${year}-01-01T00:00:00.000Z`), label: String(year) } })
  return <footer className="timeline"><div className="timeline-label"><b>TIMELINE</b><span className="timeline-range">{range ? `${range.start} — ${range.end}` : `${items.length} DATED ITEMS · AUTO`}</span><button className="timeline-settings" type="button" aria-label="Timeline settings" title="Adjust timeline range" onClick={onEdit}><Settings2 size={15}/></button></div><div className="timeline-track"><div className="axis"/>{ticks.map((tick, index) => <span className="year" key={`${tick.value}:${index}`} style={{ left: `${timelinePositionPercent(new Date(tick.value).toISOString(), { start, end })}%` }}>{tick.label}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.exhibitId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)} — ${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
}

function localDateTime(value?: string) {
  if (!value) return ''
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00`
  const date = new Date(value)
  if (!Number.isFinite(date.getTime())) return ''
  const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
  return local.toISOString().slice(0, 16)
}

function publishedDateParts(value?: string) {
  if (!value) return { date:'',time:'' }
  const parsed=new Date(value)
  if (!Number.isFinite(parsed.getTime())) return { date:'',time:'' }
  const iso=parsed.toISOString()
  const time=iso.slice(11,16)
  return { date:iso.slice(0,10),time:time === '00:00' ? '' : time }
}

function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: TimelineRange | null; dates: string[]; onClose: () => void; onSave: (range: TimelineRange | null) => void }) {
  const dated = dates.map(date => date.slice(0, 10)).filter(Boolean).sort()
  const [start, setStart] = useState(range?.start || dated[0] || '')
  const [end, setEnd] = useState(range?.end || dated[dated.length - 1] || '')
  const valid = Boolean(start && end && end > start)
  return <div className="modal-shade"><form className="window timeline-editor" onSubmit={event => { event.preventDefault(); if (valid) onSave({ start, end }) }}>
    <header><CalendarClock size={16}/><b>Adjust timeline range</b><span/><button type="button" aria-label="Close timeline editor" onClick={onClose}><X size={14}/></button></header>
    <div><small>TEMPORAL VIEWPORT · BOARD SETTING</small><p>Choose the interval shown across the full timeline. Dated evidence outside it is pinned to the nearest edge.</p>
      <div className="timeline-range-fields"><label className="field"><span>START DATE</span><input aria-label="Timeline start date" type="date" required value={start} onChange={event => setStart(event.target.value)}/></label><label className="field"><span>END DATE</span><input aria-label="Timeline end date" type="date" required value={end} min={start} onChange={event => setEnd(event.target.value)}/></label></div>
      <div className="folder-editor-actions"><button type="button" onClick={() => onSave(null)}>USE AUTOMATIC RANGE</button><button className="primary" type="submit" disabled={!valid}>APPLY RANGE</button></div>
    </div>
  </form></div>
}

function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubmit, onContinue }: {
  report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
  onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
}) {
  const investigatorName=report.investigatorName || defaultInvestigator
  const unfinishedStatement=(value:string) => !value.trim() || /^proof that(?:\s*(?:…|\.\.\.))?$/i.test(value.trim())
  const missingItems=report.claims.flatMap((claim,claimIndex) => claim.evidence.length
    ? claim.evidence.flatMap(item => [
      !item.publishedAt ? `CLAIM ${claimIndex + 1} / EXHIBIT ${item.displayNumber} DATE` : '',
      !item.sourceCitation?.trim() ? `CLAIM ${claimIndex + 1} / EXHIBIT ${item.displayNumber} SOURCE` : '',
      unfinishedStatement(item.relationText) ? `CLAIM ${claimIndex + 1} / EXHIBIT ${item.displayNumber} STATEMENT` : '',
    ].filter(Boolean))
    : [`CLAIM ${claimIndex + 1} SUPPORTING EVIDENCE`])
  const reportStatus=report.status === 'accepted' ? 'ACCEPTED'
    : report.status === 'evidence_insufficient' ? 'RETURNED — EVIDENCE NOT ESTABLISHED'
      : report.status === 'evidence_accepted_report_incomplete' ? `RETURNED${missingItems.length ? ` — ${missingItems.length} ITEM${missingItems.length === 1 ? '' : 'S'} MISSING` : ''}`
        : missingItems.length ? `INCOMPLETE — ${missingItems.length} ITEM${missingItems.length === 1 ? '' : 'S'} MISSING` : 'READY TO FILE'
  const [busy,setBusy]=useState(false)
  const [error,setError]=useState('')
  const submit=async (event:React.FormEvent) => {
    event.preventDefault();setBusy(true);setError('')
    try { await onSubmit({ investigatorName }) }
    catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
    finally { setBusy(false) }
  }
  return <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
    <header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
    <div className="case-report-paper"><div className="case-report-letterhead"><h2>CASE REPORT</h2><p>{report.title}</p></div>
      <div className="report-summary"><p>INVESTIGATOR: <span>{investigatorName}</span></p><p>STATUS: <strong className={report.status === 'accepted' ? 'complete' : missingItems.length || report.status !== 'draft' ? 'incomplete' : ''}>{reportStatus}</strong></p></div>
      <div className="report-rule" aria-hidden="true"/>
      {report.claims.map((claim,claimIndex) => <section className="report-claim" key={claim.claimExhibitId}>
        <h3>CLAIM {String(claimIndex + 1).padStart(2,'0')}</h3><blockquote>{claim.statement}</blockquote>
        <h4>EVIDENCE</h4>
        {claim.evidence.length === 0 ? <div className="report-empty-evidence">[ SUPPORTING EVIDENCE MISSING ]</div>
          : claim.evidence.map(item => { const rejected=report.status !== 'draft' && !item.evidenceAccepted; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : rejected ? 'rejected' : ''}`} key={item.connectionId}>
            <h5>EXHIBIT {item.displayNumber} — {item.fileType.replaceAll('_',' ').toUpperCase()}</h5>
            <p className="report-document-title">{item.documentTitle}</p>
            <p className="report-evidence-sentence">Exhibit {item.displayNumber}, dated {item.publishedAt?.slice(0,10) || <strong className="report-missing">[ DATE MISSING ]</strong>}, from {item.sourceCitation || <strong className="report-missing">[ SOURCE MISSING ]</strong>}:</p>
            <blockquote>{unfinishedStatement(item.relationText) ? <strong className="report-missing">[ CONNECTION STATEMENT MISSING ]</strong> : item.relationText}</blockquote>
            <p className="report-source-line">SOURCE LINK: {item.sourceUri ? <a href={item.sourceUri} target="_blank" rel="noreferrer">{item.sourceUri}</a> : <span>[ NOT RECORDED · OPTIONAL ]</span>}</p>
            {rejected && <p className="report-evidence-diagnostic">NOT VERIFIED: {item.verification.detail}</p>}
          </article> })}
      </section>)}
      <div className="report-rule" aria-hidden="true"/>
      {report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><h3>{report.status === 'accepted' ? 'REPORT ACCEPTED' : 'REPORT RETURNED'}</h3><p>{report.feedback}</p></section>}
      {error && <p className="flag-error">{error}</p>}
      <div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
        ? <button className="primary" type="button" onClick={onContinue}>{hasNext ? 'CONTINUE' : 'CLOSE CASE'} <span>▸</span></button>
        : <button className="primary" type="submit" disabled={busy}>{busy ? 'FILING…' : 'SUBMIT CASE REPORT'}</button>}</div>
    </div>
  </form></div>
}

function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) {
  const [label, setLabel] = useState(connection.label || '')
  const [tightness, setTightness] = useState(connection.tightness ?? 65)
  const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage')
  const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50)
  const save = (tag = label) => {
    onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition, tagOffset: 0 })
  }
  return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
    <header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
    <div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
      <p>What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.</p>
      <label className="field"><span>EVIDENTIARY STATEMENT · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="Proof that…" value={label} onChange={event => setLabel(event.target.value)}/></label>
      <fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Physical · hangs from the thread</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
      <label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board to slide it along the red thread.</small></label>
      <label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
      <div className="folder-editor-actions">{!isNew && <button className="danger" type="button" onClick={onRemove}>REMOVE THREAD</button>}<span/>{isNew && <button type="button" onClick={() => save('')}>SKIP TAG</button>}<button className="primary" type="submit">{isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD'}</button></div>
    </div>
  </form></div>
}

function BriefPanel({ levelId, brief, goals, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { levelId: string; brief: LevelBrief; goals: LevelGoal[]; parties: PartyExhibit[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
  const [minimized, setMinimized] = useState(false)
  const partyById = new Map(parties.map(party => [party.id, party]))
  // Player-side checklist ticks: guidance only, so persist locally per level rather than
  // on the shared board.
  const checkedKey = `gupi-osint-board:checklist:${levelId}`
  const [checked, setChecked] = useState<Set<string>>(() => { try { return new Set<string>(JSON.parse(localStorage.getItem(checkedKey) || '[]')) } catch { return new Set() } })
  const toggleChecked = (item: string) => setChecked(previous => { const next = new Set(previous); next.has(item) ? next.delete(item) : next.add(item); localStorage.setItem(checkedKey, JSON.stringify([...next])); return next })
  const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
  const pending = goals.filter(goal => goal.status === 'pending').length
  const heading = goals.length ? brief.concepts.length ? 'CASE OBJECTIVES' : 'ASSIGNMENT' : 'CONCEPT CLASSIFICATION'
  const authoredBody = brief.body.trim()
  const [firstLine = '', ...remainingLines] = authoredBody.split(/\r?\n/)
  const startsWithDisplayHeading = remainingLines.some(line => line.trim()) && firstLine.length < 70 && firstLine === firstLine.toLocaleUpperCase()
  const assignment = (startsWithDisplayHeading ? remainingLines.join('\n').trim() : authoredBody)
    || goals.map(goal => [goal.title, goal.instructions].filter(Boolean).join('\n')).join('\n\n')
    || 'No brief has been authored yet.'
  const checklist = brief.checklist || []
  return <aside className={`brief-panel ${minimized ? 'minimized' : ''}`}><header onDoubleClick={() => setMinimized(value => !value)}><div><small>LEVEL BRIEF · {pending ? `${pending} OBJECTIVE${pending === 1 ? '' : 'S'} OPEN` : unresolved ? `${unresolved} UNRESOLVED` : 'READY'}</small><b>{heading}</b></div><span/><button type="button" aria-label={minimized ? 'Restore brief' : 'Minimize brief'} title={minimized ? 'Restore' : 'Minimize'} onDoubleClick={event => event.stopPropagation()} onClick={() => setMinimized(value => !value)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close brief" title="Close" onDoubleClick={event => event.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
    <p>{assignment}</p>
    {checklist.length > 0 && <section className="brief-checklist"><h3><ClipboardCheck size={16}/> CHECKLIST</h3><ol>{checklist.map(item => { const done = checked.has(item.id); return <li key={item.id}><button type="button" className={done ? 'done' : ''} aria-pressed={done} onClick={() => toggleChecked(item.id)}><i>{done && <Check size={12} strokeWidth={3}/>}</i><span>{item.text}</span></button></li> })}</ol></section>}
    {goals.length > 0 && <div className="brief-goals">{goals.map((goal, index) => <section className={goal.status} key={goal.key}><i>{goal.status === 'complete' ? '✓' : index + 1}</i><div><b>{goal.title}</b><span>{goal.instructions}</span></div><em>{goal.status === 'complete' ? 'VERIFIED' : 'OPEN'}</em></section>)}</div>}
    {brief.concepts.length > 0 && <><div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
    <button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button></>}
    {canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
    <button className="dismiss-brief" onClick={onClose}>{pending || unresolved ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
  </aside>
}

function GoalComplete({ goal, hasNext, busy, onContinue }: { goal: LevelGoal; hasNext: boolean; busy: boolean; onContinue: () => void }) {
  return <div className="goal-complete-shade" role="dialog" aria-modal="true" aria-labelledby="goal-complete-title">
    <section className="goal-complete-card">
      <div className="goal-complete-signal"><i/><span>GU-NET SOURCE AUTHENTICATION</span><i/></div>
      <small>OBJECTIVE COMPLETE</small>
      <h2 id="goal-complete-title">{goal.title}</h2>
      <p>{goal.completionMessage || 'The submitted evidence satisfies this objective.'}</p>
      <div className="goal-complete-stamp">SOURCE<br/><b>VERIFIED</b></div>
      <button type="button" disabled={busy} onClick={onContinue}>{busy ? 'OPENING NEXT FILE…' : hasNext ? 'CONTINUE' : 'RETURN TO BOARD'} <span>▸</span></button>
    </section>
  </div>
}

function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
  const [body, setBody] = useState(brief.body)
  const [checklist, setChecklist] = useState((brief.checklist || []).map(item => item.text).join('\n'))
  const [concepts, setConcepts] = useState<BriefConcept[]>(brief.concepts)
  const addConcept = () => setConcepts(current => [...current, { id: uid('concept'), label: '', context: '', expectedPartyKind: 'person' }])
  return <div className="modal-shade"><form className="window folder-editor brief-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...brief, body: body.trim(), checklist: checklist.split('\n').map(item => item.trim()).filter(Boolean).map(text => ({ id: uid('check'), text })), concepts: concepts.filter(item => item.label.trim()).map(item => ({ ...item, label: item.label.trim(), context: item.context.trim() })) }) }}>

    <header><BookOpen size={16}/><b>Edit level brief</b><span/><button type="button" aria-label="Close brief editor" onClick={onClose}><X size={14}/></button></header>
    <div className="folder-editor-body"><small>AUTHORING · PLAYER CONCEPTS</small>
      <label className="field"><span>BRIEF</span><textarea aria-label="Level brief" rows={4} value={body} onChange={event => setBody(event.target.value)}/></label>
      <label className="field"><span>CHECKLIST · ONE STEP PER LINE</span><textarea aria-label="Level checklist" rows={5} value={checklist} onChange={event => setChecklist(event.target.value)} placeholder={'Find a reliable source online\nPaste the screenshot onto the board\nConnect it to the claim with red thread\nSubmit the Case Report'}/></label>
      <div className="metadata-heading"><div><b>CONCEPTS TO CLASSIFY</b><small>EXPECTED TYPE IS HIDDEN FROM PLAYERS</small></div><button type="button" onClick={addConcept}><Plus size={13}/> ADD CONCEPT</button></div>
      <div className="concept-editor-list">{concepts.map(concept => <div className="concept-editor-row" key={concept.id}><input aria-label="Concept name" placeholder="REAL NAME" value={concept.label} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, label: event.target.value } : item))}/><input aria-label="Concept context" placeholder="CONTEXT IN THE BRIEF" value={concept.context} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, context: event.target.value } : item))}/><select aria-label="Expected party type" value={concept.expectedPartyKind || 'person'} onChange={event => setConcepts(items => items.map(item => item.id === concept.id ? { ...item, expectedPartyKind: event.target.value as PartyKind } : item))}><option value="person">Person</option><option value="organization">Organization</option></select><button type="button" aria-label="Remove concept" onClick={() => setConcepts(items => items.filter(item => item.id !== concept.id))}><Trash2 size={13}/></button></div>)}</div>
      <p className="folder-editor-note">Concepts are names in the brief, not board exhibits. A player turns each concept into a Party exhibit by classifying it.</p>
      <div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE BRIEF</button></div>
    </div>
  </form></div>
}

function PartyEditor({ party, exhibits, relations, creating = false, onClose, onSave }: { party: PartyExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; creating?: boolean; onClose: () => void; onSave: (party: PartyExhibit, related: string[]) => void }) {
  const [name, setName] = useState(party.title)
  const [summary, setSummary] = useState(party.content)
  const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind)
  const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
  const [aliases, setAliases] = useState(party.aliases.join('\n'))
  const [related, setRelated] = useState(relations.filter(relation => relation.type === 'concerns' && relation.fromExhibitId === party.id).map(relation => relation.toExhibitId))
  const candidates = exhibits.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.toUpperCase() : item.type.toUpperCase() }))
  const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
  return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean) }, related) }}>
    <header>{partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
    <div className="folder-editor-body"><small>PARTY EXHIBIT · {partyKind.toUpperCase()}</small>
      {creating && <label className="field"><span>PARTY TYPE</span><select aria-label="Party type" value={partyKind} onChange={event => setPartyKind(event.target.value as PartyKind)}><option value="person">Person</option><option value="organization">Organization</option></select></label>}
      <label className="field"><span>DISPLAY NAME</span><input aria-label="Party name" required value={name} onChange={event => setName(event.target.value)}/></label>
      <label className="field"><span>DOSSIER SUMMARY</span><textarea aria-label="Party summary" rows={3} value={summary} onChange={event => setSummary(event.target.value)}/></label>
      {partyKind === 'organization' && <label className="field"><span>ORGANIZATION TYPE</span><select aria-label="Organization type" value={organizationKind} onChange={event => setOrganizationKind(event.target.value as OrganizationKind)}><option value="business">Business</option><option value="public_body">Public body</option><option value="association">Association</option><option value="informal_group">Informal group</option><option value="other">Other</option></select></label>}
      <label className="field"><span>ALIASES · ONE PER LINE</span><textarea aria-label="Party aliases" rows={2} value={aliases} onChange={event => setAliases(event.target.value)}/></label>
      <div className="folder-members-heading"><div><b>ASSOCIATED EVIDENCE</b><small>{related.length} LINKED</small></div><span>PARTY DOSSIER</span></div>
      <div className="folder-members">{candidates.map(candidate => { const included = related.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'ASSOCIATED' : 'NOT LINKED'}</span></div> })}</div>
      <div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE DOSSIER</button></div>
    </div>
  </form></div>
}

function FolderEditor({ folder, memberIds, documents, canManageContents, onClose, onSave }: { folder: FolderExhibit; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: FolderExhibit, members: string[]) => void }) {
  const [title, setTitle] = useState(folder.title)
  const [content, setContent] = useState(folder.content)
  const [members, setMembers] = useState(memberIds)
  const toggleMember = (documentId: string) => setMembers(current => current.includes(documentId) ? current.filter(id => id !== documentId) : [...current, documentId])
  const submit = (event: React.FormEvent) => {
    event.preventDefault()
    onSave({ ...folder, title: title.trim() || 'UNTITLED EVIDENCE FOLDER', content: content.trim() }, members)
  }
  return <div className="modal-shade"><form className="window folder-editor" onSubmit={submit}>
    <header><FolderOpen size={16}/><b>Edit evidence folder</b><span/><button type="button" aria-label="Close folder editor" onClick={onClose}><X size={14}/></button></header>
    <div className="folder-editor-body">
      <small>FOLDER WIDGET</small>
      <label className="field"><span>TITLE</span><input value={title} onChange={event => setTitle(event.target.value)}/></label>
      <label className="field"><span>ANNOTATION</span><textarea rows={3} value={content} onChange={event => setContent(event.target.value)}/></label>
      <div className="folder-members-heading"><div><b>CONTAINED SOURCE FILES</b><small>{members.length} SELECTED</small></div><span>{canManageContents ? 'LEVEL CONTENT' : 'FIXED CONTENT'}</span></div>
      <div className="folder-members">
        {documents.map(document => { const included = members.includes(document.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={document.id}>
          <label><input type="checkbox" disabled={!canManageContents} checked={included} onChange={() => toggleMember(document.id)}/><FileText size={16}/><span><b>{document.title}</b><small>{document.fileType.replaceAll('_', ' ').toUpperCase()}</small></span></label>
          <span className="folder-member-date">{document.publishedAt?.slice(0, 10) || 'UNDATED'}</span>
        </div> })}
      </div>
      <p className="folder-editor-note">The folder owns this text and its containment relationships. Publication time and other metadata belong to the individual files.</p>
      <div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE FOLDER</button></div>
    </div>
  </form></div>
}

function EventEditor({ event, exhibits, relations, onClose, onSave }: { event: EventExhibit; exhibits: Exhibit[]; relations: ExhibitRelation[]; onClose: () => void; onSave: (event: EventExhibit, supports: string[]) => void }) {
  const [title, setTitle] = useState(event.title)
  const [narrative, setNarrative] = useState(event.content)
  const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
  const [supports, setSupports] = useState(relations.filter(relation => relation.type === 'supports' && relation.fromExhibitId === event.id).map(relation => relation.toExhibitId))
  const candidates = exhibits.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type === 'document' ? item.fileType.replaceAll('_', ' ').toUpperCase() : item.type.toUpperCase() }))
  const toggle = (id: string) => setSupports(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
  const submit = (submitEvent: React.FormEvent) => {
    submitEvent.preventDefault()
    const eventDate = occurredAt ? new Date(occurredAt).toISOString() : undefined
    onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate }, supports)
  }
  return <div className="modal-shade"><form className="window folder-editor event-editor" onSubmit={submit}>
    <header><CalendarClock size={16}/><b>Edit reconstructed event</b><span/><button type="button" aria-label="Close event editor" onClick={onClose}><X size={14}/></button></header>
    <div className="folder-editor-body">
      <small>EVENT EXHIBIT · THIS HAPPENED</small>
      <label className="field"><span>TITLE</span><input aria-label="Event title" value={title} onChange={change => setTitle(change.target.value)}/></label>
      <label className="field"><span>NARRATIVE</span><textarea aria-label="Event narrative" rows={4} value={narrative} onChange={change => setNarrative(change.target.value)}/></label>
      <label className="field event-date-field"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL · OPTIONAL</span><span><input aria-label="Occurred at" type="datetime-local" value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/><button type="button" disabled={!occurredAt} onClick={() => setOccurredAt('')}>CLEAR DATE</button></span></label>
      <div className="folder-members-heading"><div><b>SUPPORTING EXHIBITS</b><small>{supports.length} CITED</small></div><span>NORMALIZED EVENT EVIDENCE</span></div>
      <div className="folder-members event-support-list">
        {candidates.length === 0 && <p>ADD SOURCE MATERIAL OR NOTES BEFORE CITING EVIDENCE.</p>}
        {candidates.map(candidate => { const included = supports.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'SUPPORTS EVENT' : 'NOT CITED'}</span></div> })}
      </div>
      <p className="folder-editor-note">An event is an investigator assertion. Leave occurrence time blank when it is unknown; creation time is never used for timeline placement.</p>
      <div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE EVENT</button></div>
    </div>
  </form></div>
}

function CaptureKindIcon({ kind }:{ kind:Exclude<DocumentCaptureKind,'unclassified'> }) {
  if (kind === 'photo') return <Camera size={24}/>
  if (kind === 'scene') return <Images size={24}/>
  if (kind === 'clipping') return <Newspaper size={24}/>
  return <FileText size={24}/>
}

function DocumentClassificationPanel({ document,onChoose }:{ document:CaseDocument;onChoose:(kind:DocumentCaptureKind)=>void }) {
  const choices=(Object.keys(documentCaptureRegistry) as DocumentCaptureKind[]).filter((kind):kind is Exclude<DocumentCaptureKind,'unclassified'> => kind !== 'unclassified')
  const source=document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''
  return <div className="modal-shade evidence-classification-shade"><section className="evidence-classification" role="dialog" aria-modal="true" aria-labelledby="evidence-classification-title">
    <header><ClipboardCheck size={16}/><b>Classify new evidence</b><span/><button type="button" aria-label="Classify later" onClick={() => onChoose('unclassified')}><X size={14}/></button></header>
    <div className="evidence-classification-body">
      <div className="classification-source">{source ? <img src={source} alt=""/> : <ImageIcon size={36}/>}<div><small>EXHIBIT {document.displayNumber || '—'}</small><strong>{document.title}</strong></div></div>
      <div className="classification-question"><small>NEW SOURCE DOCUMENT</small><h2 id="evidence-classification-title">What kind of evidence is this?</h2><p>This changes how it appears on the board. It does not alter the original file, OCR, or provenance.</p></div>
      <div className="classification-options">{choices.map(kind => { const definition=documentCapture(kind); return <button type="button" key={kind} aria-label={`Classify as ${definition.label}`} onClick={() => onChoose(kind)}>
        <CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>
      </button> })}</div>
      <button type="button" className="classify-later" onClick={() => onChoose('unclassified')}>NOT SURE · CLASSIFY LATER</button>
    </div>
  </section></div>
}

function FileEditor({ document, canEditGates, onClose, onSave }: { document: CaseDocument; canEditGates: boolean; onClose: () => void; onSave: (document: CaseDocument) => void }) {
  const [title, setTitle] = useState(document.title)
  const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
  const [captureKind, setCaptureKind] = useState<DocumentCaptureKind>(document.captureKind)
  const [publishedDate,setPublishedDate]=useState(() => publishedDateParts(document.publishedAt).date)
  const [publishedTime,setPublishedTime]=useState(() => publishedDateParts(document.publishedAt).time)
  const [sourceCitation,setSourceCitation]=useState(document.sourceCitation || '')
  const [sourceUri,setSourceUri]=useState(document.sourceUri || '')
  const [requiredFlags, setRequiredFlags] = useState((document.requiredFlags || []).join(', '))
  const [metadata, setMetadata] = useState(() => Object.entries(document.metadata).map(([key, value]) => ({ id: uid('metadata'), key, value })))
  const submit = (event: React.FormEvent) => {
    event.preventDefault()
    const publishedAt=publishedDate ? `${publishedDate}T${publishedTime || '00:00'}:00.000Z` : undefined
    const presentation=documentCapture(captureKind)
    const resolvedTitle=title.trim() || (captureKind === 'clipping' ? sourceCitation.trim() : document.fileName || 'UNTITLED FILE')
    onSave({ ...document,title:resolvedTitle,fileType,captureKind,publishedAt,
      sourceCitation:sourceCitation.trim() || undefined,sourceUri:sourceUri.trim() || undefined,
      ...(captureKind !== document.captureKind ? presentation.defaultSize : {}),
      requiredFlags: canEditGates ? [...new Set(requiredFlags.split(',').map(value => value.trim().toLowerCase()).filter(Boolean))] : document.requiredFlags,
      metadata: Object.fromEntries(metadata.filter(row => row.key.trim()).map(row => [row.key.trim(), row.value])) })
  }
  return <div className="modal-shade"><form className="window file-editor" onSubmit={submit}>
    <header><ImageIcon size={16}/><b>Edit source-file metadata</b><span/><button type="button" aria-label="Close file editor" onClick={onClose}><X size={14}/></button></header>
    <div className="file-editor-body">
      <small>SOURCE FILE WIDGET</small>
      <div className="file-editor-grid">
        <label className="field"><span>TITLE · DEFAULTS TO SOURCE FOR CLIPS</span><input aria-label="Document title" value={title} placeholder={captureKind === 'clipping' ? 'Uses Source / Publication when blank' : ''} onChange={event => setTitle(event.target.value)}/></label>
        <label className="field"><span>FILE TYPE</span><select value={fileType} onChange={event => setFileType(event.target.value as SourceFileType)}>{SOURCE_FILE_TYPES.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
      </div>
      <label className="field"><span>BOARD PRESENTATION</span><select aria-label="Board presentation" value={captureKind} onChange={event => setCaptureKind(event.target.value as DocumentCaptureKind)}>{Object.entries(documentCaptureRegistry).map(([kind,definition]) => <option key={kind} value={kind}>{definition.label} — {definition.description}</option>)}</select></label>
      <div className="file-editor-grid published-fields">
        <label className="field"><span><CalendarClock size={13}/> PUBLISHED DATE</span><input aria-label="Published date" type="date" value={publishedDate} onChange={event => { setPublishedDate(event.target.value); if (!event.target.value) setPublishedTime('') }}/></label>
        <label className="field"><span>TIME · OPTIONAL · UTC</span><input aria-label="Published time" type="time" step="60" disabled={!publishedDate} value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></label>
      </div>
      <div className="file-editor-grid">
        <label className="field"><span>SOURCE / PUBLICATION</span><input aria-label="Source publication" value={sourceCitation} maxLength={1000} placeholder="e.g. Google Patents · GB695913A" onChange={event => setSourceCitation(event.target.value)}/></label>
        <label className="field"><span>SOURCE URL</span><input aria-label="Source URL" type="url" value={sourceUri} maxLength={2000} placeholder="https://…" onChange={event => setSourceUri(event.target.value)}/></label>
      </div>
      {canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
      <div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
      <div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
    
      <div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
    </div>
  </form></div>
}

function LevelFlagsEditor({ levelId, onClose, onChanged }: { levelId: string; onClose: () => void; onChanged: () => void | Promise<void> }) {
  const [flags, setFlags] = useState<LevelFlag[]>([])
  const [newKey, setNewKey] = useState('')
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState('')
  const load = useCallback(async () => {
    const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags`)
    if (!response.ok) throw new Error('Could not load level flags')
    setFlags(await response.json())
  }, [levelId])
  useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load flags')) }, [load])
  const setEarned = async (key: string, earned: boolean) => {
    setBusy(true); setError('')
    try {
      const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/flags/${encodeURIComponent(key)}`, { method: earned ? 'PUT' : 'DELETE' })
      if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not update flag') }
      await Promise.all([load(), onChanged()])
      setNewKey('')
    } catch (error) { setError(error instanceof Error ? error.message : 'Could not update flag') } finally { setBusy(false) }
  }
  const submit = (event: React.FormEvent) => {
    event.preventDefault()
    const key = newKey.trim().toLowerCase()
    if (key) void setEarned(key, true)
  }
  return <div className="modal-shade"><section className="window flags-editor">
    <header><Network size={16}/><b>Level flags</b><span/><button type="button" aria-label="Close level flags" onClick={onClose}><X size={14}/></button></header>
    <div className="flags-editor-body"><small>ACHIEVEMENTS / DOCUMENT REVEALS</small>
      <p>Documents remain server-hidden until every flag assigned in their metadata has been earned.</p>
      <div className="flag-list">{flags.length === 0 && <div className="flag-empty">NO FLAGS OR DOCUMENT GATES IN THIS LEVEL</div>}{flags.map(flag => <div className={`flag-row ${flag.earnedAt ? 'earned' : ''}`} key={flag.key}><div><b>{flag.key}</b><small>{flag.gatedDocumentCount} GATED DOCUMENT{flag.gatedDocumentCount === 1 ? '' : 'S'}</small></div><button disabled={busy} onClick={() => void setEarned(flag.key, !flag.earnedAt)}>{flag.earnedAt ? 'REVOKE' : 'AWARD'}</button></div>)}</div>
      <form className="flag-add" onSubmit={submit}><input aria-label="New flag key" value={newKey} placeholder="tip.received" pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setNewKey(event.target.value.toLowerCase())}/><button disabled={busy || !newKey.trim()} type="submit">AWARD FLAG</button></form>
      {error && <p className="flag-error">{error}</p>}
    </div>
  </section></div>
}

type MatchRuleDraft = {
  id?: string
  name: string
  sourceLabel: string
  sourceUri: string
  flagKey: string
  minimumAnchorMatches: number
  enabled: boolean
  anchors: { id: string; phrase: string; minimumSimilarity: number }[]
}
const emptyMatchRule = (): MatchRuleDraft => ({ name: '', sourceLabel:'', sourceUri:'', flagKey: '', minimumAnchorMatches: 1, enabled: true,
  anchors: [{ id: uid('anchor'), phrase: '', minimumSimilarity: 0.72 }] })

function EvidenceMatchRulesEditor({ levelId, onClose }: { levelId: string; onClose: () => void }) {
  const [rules, setRules] = useState<EvidenceMatchRuleDefinition[]>([])
  const [draft, setDraft] = useState<MatchRuleDraft>(emptyMatchRule)
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState('')
  const load = useCallback(async () => {
    const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules`)
    if (!response.ok) throw new Error('Could not load evidence match rules')
    setRules(await response.json())
  }, [levelId])
  useEffect(() => { void load().catch(error => setError(error instanceof Error ? error.message : 'Could not load rules')) }, [load])
  const edit = (rule: EvidenceMatchRuleDefinition) => setDraft({ id:rule.id,name:rule.name,sourceLabel:rule.sourceLabel || '',sourceUri:rule.sourceUri || '',flagKey:rule.flagKey,
    minimumAnchorMatches:rule.minimumAnchorMatches,enabled:rule.enabled,
    anchors:rule.anchors.map(anchor => ({ id:anchor.id,phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) })
  const submit = async (event: React.FormEvent) => {
    event.preventDefault(); setBusy(true); setError('')
    try {
      const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules${draft.id ? `/${encodeURIComponent(draft.id)}` : ''}`, {
        method: draft.id ? 'PUT' : 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ name:draft.name,sourceLabel:draft.sourceLabel,sourceUri:draft.sourceUri,flagKey:draft.flagKey.toLowerCase(),minimumAnchorMatches:draft.minimumAnchorMatches,
          enabled:draft.enabled,anchors:draft.anchors.map(anchor => ({ phrase:anchor.phrase,minimumSimilarity:anchor.minimumSimilarity })) }),
      })
      if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || 'Could not save evidence rule') }
      await load(); setDraft(emptyMatchRule())
    } catch (error) { setError(error instanceof Error ? error.message : 'Could not save evidence rule') } finally { setBusy(false) }
  }
  const remove = async (rule: EvidenceMatchRuleDefinition) => {
    if (!window.confirm(`Delete evidence match rule “${rule.name}”?`)) return
    setBusy(true); setError('')
    try {
      const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}/evidence-match-rules/${encodeURIComponent(rule.id)}`, { method:'DELETE' })
      if (!response.ok) throw new Error('Could not delete evidence rule')
      await load(); if (draft.id === rule.id) setDraft(emptyMatchRule())
    } catch (error) { setError(error instanceof Error ? error.message : 'Could not delete evidence rule') } finally { setBusy(false) }
  }
  const validAnchors = draft.anchors.filter(anchor => anchor.phrase.trim().length >= 12)
  const canSave = draft.name.trim() && /^[a-z][a-z0-9_.-]{0,63}$/.test(draft.flagKey) && validAnchors.length === draft.anchors.length
    && draft.minimumAnchorMatches >= 1 && draft.minimumAnchorMatches <= draft.anchors.length
  return <div className="modal-shade"><section className="window match-rules-editor">
    <header><Search size={16}/><b>Evidence text matching</b><span/><button type="button" aria-label="Close evidence matching" onClick={onClose}><X size={14}/></button></header>
    <div className="match-rules-body"><small>OCR / FUZZY PASSAGE RULES</small>
      <p>When OCR from a player-uploaded source matches enough distinctive passages, the configured flag is awarded. Matching ignores case, punctuation, accents, and ordinary OCR noise.</p>
      <div className="match-rule-layout"><div className="match-rule-list">
        {rules.length === 0 && <div className="flag-empty">NO AUTOMATIC EVIDENCE RULES</div>}
        {rules.map(rule => <div className={`match-rule-row ${rule.enabled ? '' : 'disabled'}`} key={rule.id}><div><b>{rule.name}</b><small>{rule.flagKey} · {rule.minimumAnchorMatches}/{rule.anchors.length} ANCHORS</small></div><button type="button" onClick={() => edit(rule)}>EDIT</button><button type="button" disabled={busy} onClick={() => void remove(rule)}><Trash2 size={12}/></button></div>)}
      </div>
      <form className="match-rule-form" onSubmit={event => void submit(event)}>
        <div className="match-rule-form-heading"><b>{draft.id ? 'EDIT RULE' : 'NEW RULE'}</b>{draft.id && <button type="button" onClick={() => setDraft(emptyMatchRule())}>NEW</button>}</div>
        <label className="field"><span>RULE NAME</span><input value={draft.name} maxLength={160} onChange={event => setDraft(value => ({ ...value,name:event.target.value }))} placeholder="Contemporary fire report"/></label>
        <div className="match-rule-fields"><label className="field"><span>SOURCE LABEL · OPTIONAL</span><input value={draft.sourceLabel} maxLength={300} onChange={event => setDraft(value => ({ ...value,sourceLabel:event.target.value }))} placeholder="Google Patents · GB695913A"/></label>
          <label className="field"><span>CANONICAL URL · OPTIONAL</span><input type="url" value={draft.sourceUri} maxLength={2000} onChange={event => setDraft(value => ({ ...value,sourceUri:event.target.value }))} placeholder="https://patents.google.com/…"/></label></div>
        <div className="match-rule-fields"><label className="field"><span>AWARD FLAG</span><input value={draft.flagKey} pattern="[a-z][a-z0-9_.-]{0,63}" onChange={event => setDraft(value => ({ ...value,flagKey:event.target.value.toLowerCase() }))} placeholder="source.fire-report"/></label>
          <label className="field"><span>REQUIRED HITS</span><input type="number" min="1" max={draft.anchors.length} value={draft.minimumAnchorMatches} onChange={event => setDraft(value => ({ ...value,minimumAnchorMatches:Number(event.target.value) }))}/></label></div>
        <label className="match-rule-enabled"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(value => ({ ...value,enabled:event.target.checked }))}/> ENABLE THIS RULE</label>
        <div className="anchor-heading"><b>REFERENCE PASSAGES</b><button type="button" onClick={() => setDraft(value => ({ ...value,anchors:[...value.anchors,{ id:uid('anchor'),phrase:'',minimumSimilarity:.72 }] }))}><Plus size={12}/> ADD PASSAGE</button></div>
        <div className="anchor-list">{draft.anchors.map((anchor,index) => <div className="anchor-row" key={anchor.id}><div><small>ANCHOR {index + 1}</small><textarea value={anchor.phrase} rows={3} placeholder="Paste a distinctive passage of at least 12 characters…" onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,phrase:event.target.value } : item) }))}/></div><label><span>SIMILARITY</span><input type="number" min="0.5" max="1" step="0.01" value={anchor.minimumSimilarity} onChange={event => setDraft(value => ({ ...value,anchors:value.anchors.map(item => item.id === anchor.id ? { ...item,minimumSimilarity:Number(event.target.value) } : item) }))}/></label><button type="button" aria-label="Remove reference passage" disabled={draft.anchors.length === 1} onClick={() => setDraft(value => ({ ...value,minimumAnchorMatches:Math.min(value.minimumAnchorMatches,value.anchors.length - 1),anchors:value.anchors.filter(item => item.id !== anchor.id) }))}><Trash2 size={13}/></button></div>)}</div>
        {error && <p className="flag-error">{error}</p>}
        <div className="folder-editor-actions"><button type="button" onClick={onClose}>CLOSE</button><button className="primary" type="submit" disabled={busy || !canSave}>{busy ? 'SAVING…' : 'SAVE RULE'}</button></div>
      </form></div>
    </div>
  </section></div>
}

const DOCUMENT_TYPE_MENU:Exclude<DocumentCaptureKind,'unclassified'>[] = ['full_page','scene','photo','clipping']

function DocumentWindow({ doc, onClose, onInfo, onDelete, onType, onExtract, extracted }: {
  doc: CaseDocument
  onClose: () => void
  onInfo: () => void
  onDelete: () => void
  onType: (captureKind:Exclude<DocumentCaptureKind,'unclassified'>) => void
  onExtract: (id: string) => void
  extracted: (string | undefined)[]
}) {
  const [pos, setPos] = useState({ x: Math.max(280, window.innerWidth * .34), y: 118 })
  const [minimized, setMinimized] = useState(false)
  const [menu, setMenu] = useState<'file'|'type'|null>(null)
  const drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
  const menuRef = useRef<HTMLElement>(null)
  useEffect(() => {
    if (!menu) return
    const closeMenu = (event:PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setMenu(null) }
    document.addEventListener('pointerdown',closeMenu)
    return () => document.removeEventListener('pointerdown',closeMenu)
  },[menu])
  const startDrag = (e: React.PointerEvent<HTMLElement>) => {
    if ((e.target as HTMLElement).closest('button')) return
    drag.current = { x: e.clientX, y: e.clientY, px: pos.x, py: pos.y }
    e.currentTarget.setPointerCapture(e.pointerId)
  }
  return <section className={`window document-window ${minimized ? 'minimized' : ''}`} style={{ left: pos.x, top: pos.y }}>
    <header onPointerDown={startDrag} onPointerMove={e => drag.current && setPos({ x: drag.current.px + e.clientX - drag.current.x, y: drag.current.py + e.clientY - drag.current.y })} onPointerUp={() => { drag.current = null }} onDoubleClick={() => setMinimized(v => !v)}><FileText size={15}/><b>{doc.title}</b><span/><button type="button" aria-label={minimized ? 'Restore document' : 'Minimize document'} title={minimized ? 'Restore' : 'Minimize'} onPointerDown={e => e.stopPropagation()} onClick={() => setMinimized(v => !v)}>{minimized ? <Plus size={14}/> : <Minus size={14}/>}</button><button type="button" aria-label="Close document" title="Close" onPointerDown={e => e.stopPropagation()} onClick={onClose}><X size={14}/></button></header>
    {!minimized && <><nav ref={menuRef} className="document-menu-bar" aria-label="Document actions">
      <div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'file'} onClick={() => setMenu(current => current === 'file' ? null : 'file')}>FILE</button>
        {menu === 'file' && <div className="document-menu-items" role="menu">
          <button type="button" role="menuitem" onClick={() => { setMenu(null);onInfo() }}><Info size={13}/><span><b>INFO</b><small>Metadata and provenance</small></span></button>
          <button type="button" role="menuitem" className="danger" onClick={() => { setMenu(null);onDelete() }}><Trash2 size={13}/><span><b>DELETE</b><small>Remove from this board</small></span></button>
        </div>}
      </div>
      <div className="document-menu"><button type="button" aria-haspopup="menu" aria-expanded={menu === 'type'} onClick={() => setMenu(current => current === 'type' ? null : 'type')}>TYPE</button>
        {menu === 'type' && <div className="document-menu-items type-menu" role="menu">{DOCUMENT_TYPE_MENU.map(kind => { const definition=documentCapture(kind);return <button type="button" role="menuitemradio" aria-checked={doc.captureKind === kind} key={kind} onClick={() => { onType(kind);setMenu(null) }}>
          <CaptureKindIcon kind={kind}/><span><b>{definition.label}</b><small>{definition.description}</small></span>{doc.captureKind === kind && <Check className="menu-check" size={13}/>}</button> })}</div>}
      </div>
    </nav>
    <div className={`paper ${doc.assetId ? 'asset-paper' : ''}`} onPointerDown={() => setMenu(null)}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.captureKind === 'unclassified' ? documentWidget(doc.fileType).label : documentCapture(doc.captureKind).label}</b></div>{doc.assetId ? <DocumentAsset doc={doc}/> : doc.body.map((line, i) => <p key={i}>{line}</p>)}{doc.regions.length > 0 && <div className="extracts">{doc.regions.map(r => <button key={r.id} className={extracted.includes(r.id) ? 'done' : ''} onClick={() => onExtract(r.id)}><Network size={15}/>{extracted.includes(r.id) ? 'LOCATE ON BOARD' : r.label}</button>)}</div>}</div>
    <footer><span>ARCHIVE ITEM · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span><span>PROVENANCE LOCKED</span></footer></>}
  </section>
}

function DocumentAsset({ doc }: { doc: CaseDocument }) {
  const source = `/api/assets/${encodeURIComponent(doc.assetId!)}`
  const Asset = documentWidget(doc.fileType).Asset
  return <Asset document={doc} source={source}/>
}

function Help({ onClose }: { onClose: () => void }) { return <div className="modal-shade"><section className="window help"><header><CircleHelp size={16}/><b>Field Manual</b><span/><button onClick={onClose}><X size={14}/></button></header><div><small>GU-NET QUICK START</small><h2>Reconstruct what happened.</h2><ol><li>Open a case document.</li><li>Extract the highlighted clue.</li><li>Drag evidence into meaningful groups.</li><li>Select an exhibit, choose the red-thread tool, then select its target.</li><li>Tag the thread with the claim it represents.</li><li>Use dated markers to move through the case.</li></ol><p>The system will not announce your conclusion. Make it visible.</p><button className="primary" onClick={onClose}>BEGIN INVESTIGATION</button></div></section></div> }
