import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'

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 briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
function documentSearchText(document: CaseDocument) {
  return [document.title, document.kind, document.date, document.publishedAt, 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 connectionPoint(item: Evidence) {
  return exhibitWidget(item.type).connectionPoint(item)
}

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

export function App() {
  const [caseState, setCaseState] = useState<CaseState | null>(null)
  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 [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
  const [clock, setClock] = useState('')
  const [draggingFiles, setDraggingFiles] = useState(false)
  const [uploading, setUploading] = useState(0)
  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 [editingPartyId, setEditingPartyId] = useState<string | null>(null)
  const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null)
  const [briefOpen, setBriefOpen] = useState(false)
  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 saveTimer = useRef<number | undefined>(undefined)
  const boardRef = useRef<HTMLDivElement>(null)
  const fileInputRef = useRef<HTMLInputElement>(null)
  const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'

  useEffect(() => {
    const params = new URLSearchParams(window.location.search)
    fetch('/api/levels').then(r => {
      if (!r.ok) throw new Error('Server unavailable')
      return r.json()
    }).then(async (levels: { id: string }[]) => {
      const levelId = params.get('level') || levels[0]?.id
      if (!levelId) { setNoLevels(true); setStatus('NO LEVELS IN ARCHIVE'); return }
      const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
      const response = await fetch(`/api/levels/${encodeURIComponent(levelId)}${editQuery}`)
      if (!response.ok) throw new Error('Level unavailable')
      const data = normalizeCase(await response.json())
      setCaseState(data)
      if (data.brief.concepts.some(concept => !concept.resolvedPartyExhibitId) && !localStorage.getItem(briefAcknowledgementKey(data.id))) setBriefOpen(true)
      setStatus('EVIDENCE INTEGRITY: PROBABLY OK')
    })
      .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)
  }, [])

  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])

  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.evidence.find(e => e.id === id)
    if (!ev) return
    setSelected(id)
    update(s => ({ ...s, viewport: { ...s.viewport, x: 500 - ev.x * s.viewport.zoom, y: 260 - ev.y * s.viewport.zoom } }))
  }

  const extract = (doc: CaseDocument, regionId: string) => {
    if (!caseState) return
    const region = doc.regions.find(r => r.id === regionId)!
    const existing = caseState.evidence.find(e => e.sourceDocumentId === doc.id && e.sourceRegionId === regionId)
    if (existing) { setOpenDoc(null); focusEvidence(existing.id); return }
    const ev: Evidence = {
      id: uid('folder'), type: 'folder', title: `${doc.kind} EVIDENCE`, content: region.excerpt, config: { open: false },
      sourceDocumentId: doc.id, sourceRegionId: region.id, containedDocumentIds: [doc.id],
      x: 850 + Math.random() * 220, y: 390 + Math.random() * 250, width: 260,
    }
    update(s => ({ ...s, evidence: [...s.evidence, ev], relations: [...s.relations, { id: `contains:${ev.id}:${doc.id}`, fromWidgetId: ev.id, toWidgetId: doc.id, type: 'contains', sortOrder: 0 }] }))
    setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
  }

  const addNote = () => {
    const content = window.prompt('What do you think this evidence means?')?.trim()
    if (!content || !caseState) return
    const { viewport } = caseState
    const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
    const note: Evidence = { id: uid('note'), type: 'note', title: 'WORKING NOTE', content, ...position, width: 108 }
    update(s => ({ ...s, evidence: [...s.evidence, note] })); setSelected(note.id); setRecentlyCreatedExhibitId(note.id)
  }

  const addEvent = () => {
    if (!caseState) return
    const { viewport } = caseState
    const position = nextOpenBoardPosition(caseState.evidence, { x: Math.max(100, (620 - viewport.x) / viewport.zoom), y: Math.max(100, (290 - viewport.y) / viewport.zoom) }, { width: 270 })
    const event: Evidence = { id: uid('event'), type: 'event', title: 'UNTITLED EVENT', content: 'Describe what happened.',
      eventDate: new Date().toISOString(), supportingEvidenceIds: [], ...position, width: 270 }
    update(state => ({ ...state, evidence: [...state.evidence, event] }))
    setSelected(event.id); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id)
  }

  const addParty = () => {
    if (!caseState) return
    const { viewport } = caseState
    const position = nextOpenBoardPosition(caseState.evidence, {
      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: [], relatedEvidenceIds: [], ...position, width: 280 })
  }

  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.evidence.find(item => item.id === existingId)
    const position = existingParty ? { x: existingParty.x, y: existingParty.y } : nextOpenBoardPosition(caseState.evidence, {
      x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
    }, { width: 280 })
    const party: Evidence = { id: partyId, type: 'party', partyKind, organizationKind: partyKind === 'organization' ? 'business' : undefined,
      title: concept.label, content: concept.context, aliases: [], relatedEvidenceIds: [],
      ...position, width: 280 }
    update(state => ({ ...state,
      evidence: existingId ? state.evidence.map(item => item.id === existingId ? { ...item, partyKind, organizationKind: partyKind === 'organization' ? item.organizationKind || 'business' : undefined } : item) : [...state.evidence, 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())
    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.fromEvidenceId === linkFrom && connection.toEvidenceId === targetId) || (connection.fromEvidenceId === targetId && connection.toEvidenceId === linkFrom))
    if (existing) {
      setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
      return
    }
    setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
    setLinkFrom(null)
    if (caseState.evidence.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 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 uploadFiles = async (files: FileList | File[]) => {
    if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
    const queue = Array.from(files)
    setUploading(queue.length)
    setDraggingFiles(false)
    for (const file of queue) {
      const form = new FormData()
      form.append('file', file)
      try {
        const response = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/documents?edit=1`, { method: 'POST', body: form })
        if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
        const document: CaseDocument = await response.json()
        update(s => ({ ...s, documents: [...s.documents, document] }))
        setStatus(`IMPORTED · ${file.name.toUpperCase()}`)
      } catch (error) {
        setStatus(error instanceof Error ? error.message.toUpperCase() : 'UPLOAD FAILED')
      } finally { setUploading(count => count - 1) }
    }
  }

  if (noLevels) return <EmptyArchive canEdit={requestedEditMode} onCreated={level => { setCaseState(level); setNoLevels(false); window.history.replaceState({}, '', `?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 documentById = new Map(caseState.documents.map(document => [document.id, document]))
  const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
  const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents
  const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
  const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
  const temporalItems: TemporalItem[] = [
    ...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
      const document = documentById.get(documentId)
      const date = document?.publishedAt || document?.date
      const relation = caseState.relations.find(candidate => candidate.type === 'contains' && candidate.fromWidgetId === folder.id && candidate.toWidgetId === documentId)
      return document && date && relation ? [{ id: `folder:${folder.id}:document:${document.id}`, sourceTemporalId: folderIsOpen(folder) ? `file:${relation.id}` : `widget:${folder.id}`, date, label: document.title, kind: 'document' as const, evidenceId: folder.id, documentId: document.id }] : []
    })),
    ...caseState.documents.filter(document => !containedDocumentIds.has(document.id) && (document.publishedAt || document.date)).map(document => ({ id: `document:${document.id}`, sourceTemporalId: `document:${document.id}`, date: document.publishedAt || document.date, label: document.title, kind: 'document' as const, documentId: document.id })),
    ...caseState.evidence.filter(widget => widget.type === 'event' && widget.eventDate).map(widget => ({ id: `widget:${widget.id}`, sourceTemporalId: `widget:${widget.id}`, date: widget.eventDate!, label: widget.content, kind: 'widget' as const, evidenceId: widget.id })),
  ].sort((a, b) => dateValue(a.date) - dateValue(b.date))
  const storyEvents = caseState.evidence.filter(item => item.type === 'event' && item.eventDate).sort((a, b) => dateValue(a.eventDate!) - dateValue(b.eventDate!))
  return <main className="desktop">
    <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 onClick={() => setDocsOpen(v => !v)}>FILE</button><button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => setEditingTimeline(true)}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
      </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}/${caseState.documents.length}` : caseState.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>
        {requestedEditMode && caseState.editingAllowed && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
        <div className="doc-list">
          {filteredDocuments.map((doc, index) => <button className="doc-row" data-temporal-id={`document:${doc.id}`} key={doc.id} onDoubleClick={() => setOpenDoc(doc)} onClick={() => setOpenDoc(doc)}>
            <div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
            <div><strong>{doc.title}</strong><span>{doc.kind} · {doc.date}</span></div><ChevronRight size={16}/>
          </button>)}
          {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>
        <div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
      </aside>

      <div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { 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) uploadFiles(e.dataTransfer.files) }}>
        <div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
        <Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
        {briefOpen && <BriefPanel
          brief={caseState.brief}
          parties={caseState.evidence.filter(item => item.type === 'party')}
          recentlyCreatedExhibitId={recentlyCreatedExhibitId}
          canEdit={requestedEditMode && Boolean(caseState.editingAllowed)}
          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)}</time><b>{index + 1}. {event.title}</b><span>{event.content}</span></button>)}</aside>}
        {!docsOpen && <button className="open-files" onClick={() => setDocsOpen(true)}><FolderOpen size={18}/> CASE MATERIALS <b>{caseState.documents.length}</b></button>}
        <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>

    <TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
    <Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
    {openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
    {editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>} 
    {editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>} 
    {editingEventId && <EventEditor
      key={editingEventId}
      event={caseState.evidence.find(item => item.id === editingEventId)!}
      evidence={caseState.evidence}
      documents={caseState.documents}
      onClose={() => setEditingEventId(null)}
      onSave={event => { update(state => ({ ...state, evidence: state.evidence.map(item => item.id === event.id ? event : item) })); setEditingEventId(null); setStatus('EVENT NARRATIVE UPDATED') }}
    />}
    {editingPartyId && <PartyEditor
      key={editingPartyId}
      party={caseState.evidence.find(item => item.id === editingPartyId)!}
      evidence={caseState.evidence}
      documents={caseState.documents}
      onClose={() => setEditingPartyId(null)}
      onSave={party => {
        update(state => ({ ...state, evidence: state.evidence.map(item => item.id === party.id ? party : item) }))
        setEditingPartyId(null)
        setStatus('PARTY DOSSIER UPDATED')
      }}
    />}
    {newPartyDraft && <PartyEditor
      key={newPartyDraft.id}
      party={newPartyDraft}
      evidence={caseState.evidence}
      documents={caseState.documents}
      creating
      onClose={() => setNewPartyDraft(null)}
      onSave={party => {
        update(state => ({ ...state, evidence: [...state.evidence, party] }))
        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={caseState.timelineRange}
      dates={temporalItems.map(item => item.date)}
      onClose={() => setEditingTimeline(false)}
      onSave={timelineRange => {
        update(state => ({ ...state, timelineRange }))
        setEditingTimeline(false)
        setStatus(timelineRange ? 'TIMELINE RANGE UPDATED' : 'TIMELINE RANGE AUTOMATIC')
      }}
    />}
    {threadDraft && <ThreadEditor
      key={threadDraft.id}
      connection={threadDraft}
      sourceName={caseState.evidence.find(item => item.id === threadDraft.fromEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.fromEvidenceId)?.title || 'Exhibit'}
      targetName={caseState.evidence.find(item => item.id === threadDraft.toEvidenceId)?.title || caseState.documents.find(item => item.id === threadDraft.toEvidenceId)?.title || 'Exhibit'}
      isNew={!caseState.connections.some(item => item.id === threadDraft.id)}
      onClose={() => setThreadDraft(null)}
      onSave={saveThread}
      onRemove={() => removeThread(threadDraft.id)}
    />}
    {helpOpen && <Help onClose={() => setHelpOpen(false)}/>} 
  </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, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; 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; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
  const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | '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 [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
  const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
  const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
  const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
  const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
  const pointForId = (id: string) => {
    const evidence = byId.get(id)
    if (evidence) return connectionPoint(evidence)
    const relation = containmentRelations.find(item => item.toWidgetId === id)
    const folder = relation ? byId.get(relation.fromWidgetId) : undefined
    if (!relation || !folder) return undefined
    const position = relationPosition(state, relation)
    return folderIsOpen(folder) ? { x: position.x + 87, y: position.y + 72 } : connectionPoint(folder)
  }
  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' | 'relation'; 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) {
        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
    const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
    const position = relation ? relationPosition(state, relation) : undefined
    drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
    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)
    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
    const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
    if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
    if (drag.current.kind === 'thread-tag' && boardRef.current) {
      if (drag.current.moved) setExpandedThreadTagId(null)
      const connection = state.connections.find(item => item.id === drag.current!.id)
      const from = connection ? pointForId(connection.fromEvidenceId) : undefined
      const to = connection ? pointForId(connection.toEvidenceId) : 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: placement.lateralOffset } : item) }))
      }
    } else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
    else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
    else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
  }
  const finishDrag = (event: React.PointerEvent) => {
    if (event.pointerType === 'touch') {
      touchPoints.current.delete(event.pointerId)
      if (touchPoints.current.size < 2) pinchDistance.current = null
    }
    if (drag.current) suppressClick.current = Boolean(drag.current.moved)
    drag.current = null
    setDraggingThreadTagId(null)
  }
  const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
  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
      if (!target.closest('.thread-tag')) setExpandedThreadTagId(null)
      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 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.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); 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.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: placement.x, top: placement.y }} title={connection.label ? dragging ? `Position ${Math.round(placement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : '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 } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(placement.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>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
      <svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
        {state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
          const evidence = byId.get(evidenceId)
          let target = evidence ? connectionPoint(evidence) : undefined
          if (!target) {
            const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
            const folder = relation ? byId.get(relation.fromWidgetId) : undefined
            if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
          }
          if (!target) return []
          const origin = connectionPoint(event)
          return [<line key={`${event.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
        }))}
      </svg>
      <svg className="party-association-lines" width={BOARD_W} height={BOARD_H}>
        {state.evidence.filter(party => party.type === 'party').flatMap(party => (party.relatedEvidenceIds || []).flatMap(evidenceId => {
          const evidence = byId.get(evidenceId)
          let target = evidence ? connectionPoint(evidence) : undefined
          if (!target) {
            const relation = containmentRelations.find(item => item.toWidgetId === evidenceId)
            const folder = relation ? byId.get(relation.fromWidgetId) : undefined
            if (relation && folder) target = folderIsOpen(folder) ? { x: relationPosition(state, relation).x + 87, y: relationPosition(state, relation).y + 72 } : connectionPoint(folder)
          }
          if (!target) return []
          const origin = connectionPoint(party)
          return [<line key={`${party.id}:${evidenceId}`} x1={origin.x} y1={origin.y} x2={target.x} y2={target.y}/>]
        }))}
      </svg>
      <svg className="folder-bands" width={BOARD_W} height={BOARD_H}>
        {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return <line className={open ? 'open' : 'closed'} key={relation.id} x1={origin.x} y1={origin.y} x2={open ? position.x + 87 : origin.x} y2={open ? position.y + 72 : origin.y}/> })}
      </svg>
      {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return <article key={ev.id} data-temporal-id={`widget:${ev.id}`} className={`evidence-card ${definition.visualType} ${ev.type === 'note' ? 'luggage-tag' : ''} ${selected === ev.id ? 'selected' : ''} ${linkFrom === ev.id ? 'linking' : ''} ${linkFrom && linkFrom !== ev.id ? 'thread-target' : ''} ${recentlyCreatedExhibitId === ev.id ? 'arriving' : ''}`} style={{ left: ev.x, top: ev.y, width: ev.width, rotate: `${(i % 3 - 1) * .45}deg` }}
        onPointerDown={e => { e.stopPropagation(); if (linkFrom && e.button === 0) return; 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 (tool === 'move') onCardClick(ev.id) }}>
        <header><span>{definition.heading(ev, containedDocuments)}</span><i>{String(i + 1).padStart(3, '0')}</i></header>
        <Widget exhibit={ev} documents={containedDocuments} onOpenSource={onOpenSource} onToggleFolder={toggleFolder} onEditFolder={onEditFolder} onEditEvent={onEditEvent} onEditParty={onEditParty}/>
      </article>})}
      {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={relation.id} data-temporal-id={`file:${relation.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''}`} style={{ left, top }}
        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: 'relation', id: relation.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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
        <header><span>{definition.label.toUpperCase()}</span><i>{String((relation.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
        <div className="source-file-preview"><Preview document={document} source={source}/></div>
        <strong>{document.title}</strong><time>{(document.publishedAt || document.date)?.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>
      </article> })}
    </div>
  </div>
}

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"><small>TEMPORAL INDEX</small><b>TIMELINE</b><button onClick={onEdit}>{range ? `${range.start} — ${range.end}` : `${items.length} DATED ITEMS · AUTO`}</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.evidenceId === selected || item.documentId === 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 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 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) => {
    const lateralLimit = threadTagLateralLimit(tightness)
    onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition,
      tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.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 mean? Add a short tag if the thread represents a specific claim.</p>
      <label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" 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>Expressive · rotates to read</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 for along-thread position and tension-limited lateral play.</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({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; 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 partyById = new Map(parties.map(party => [party.id, party]))
  const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
  return <aside className="brief-panel"><header><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>
    <p>{brief.body || 'No brief has been authored yet.'}</p>
    <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}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
  </aside>
}

function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: () => void; onSave: (brief: LevelBrief) => void }) {
  const [body, setBody] = useState(brief.body)
  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({ body: body.trim(), 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={5} value={body} onChange={event => setBody(event.target.value)}/></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, evidence, documents, creating = false, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; creating?: boolean; onClose: () => void; onSave: (party: Evidence) => void }) {
  const [name, setName] = useState(party.title)
  const [summary, setSummary] = useState(party.content)
  const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind || 'person')
  const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
  const [aliases, setAliases] = useState((party.aliases || []).join('\n'))
  const [related, setRelated] = useState(party.relatedEvidenceIds || [])
  const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.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), relatedEvidenceIds: 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: Evidence; memberIds: string[]; documents: CaseDocument[]; canManageContents: boolean; onClose: () => void; onSave: (folder: Evidence, 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(), containedDocumentIds: members }, 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 || document.date)?.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, evidence, documents, onClose, onSave }: { event: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (event: Evidence) => void }) {
  const [title, setTitle] = useState(event.title)
  const [narrative, setNarrative] = useState(event.content)
  const [occurredAt, setOccurredAt] = useState(localDateTime(event.eventDate))
  const [supports, setSupports] = useState(event.supportingEvidenceIds || [])
  const candidates = [
    ...evidence.filter(item => item.id !== event.id && item.type !== 'event').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })),
    ...documents.map(document => ({ id: document.id, title: document.title, kind: document.fileType.replaceAll('_', ' ').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() : new Date().toISOString()
    onSave({ ...event, title: title.trim() || 'UNTITLED EVENT', content: narrative.trim() || 'Something happened.', eventDate, supportingEvidenceIds: 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"><span><CalendarClock size={13}/> OCCURRED AT · LOCAL</span><input aria-label="Occurred at" type="datetime-local" required value={occurredAt} onChange={change => setOccurredAt(change.target.value)}/></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. Its occurrence time is separate from the publication time of the documents supporting it.</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 FileEditor({ document, onClose, onSave }: { document: CaseDocument; onClose: () => void; onSave: (document: CaseDocument) => void }) {
  const [title, setTitle] = useState(document.title)
  const [fileType, setFileType] = useState<SourceFileType>(document.fileType)
  const [publishedTime, setPublishedTime] = useState(localDateTime(document.publishedAt || document.date))
  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 = publishedTime ? new Date(publishedTime).toISOString() : undefined
    onSave({ ...document, title: title.trim() || document.fileName || 'UNTITLED FILE', fileType, publishedAt, date: publishedAt?.slice(0, 10) || '', 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</span><input value={title} 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><CalendarClock size={13}/> PUBLISHED TIME · LOCAL</span><input type="datetime-local" value={publishedTime} onChange={event => setPublishedTime(event.target.value)}/></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>
      <p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
      <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 DocumentWindow({ doc, onClose, onExtract, extracted }: { doc: CaseDocument; onClose: () => 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 drag = useRef<{ x: number; y: number; px: number; py: number } | null>(null)
  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>FILE&nbsp;&nbsp; EDIT&nbsp;&nbsp; EVIDENCE&nbsp;&nbsp; VIEW</nav>
    <div className={`paper ${doc.assetId ? 'asset-paper' : ''}`}><div className="paper-meta"><span>GLITCH UNIVERSITY ARCHIVE</span><b>{doc.kind}</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.date}</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> }
