import { createHash, randomUUID } from 'node:crypto'
import { Readable } from 'node:stream'
import type { Pool, PoolClient } from 'pg'
import type { BoardView, BriefConcept, CaseDocument, CaseState, DocumentCaptureKind, DocumentSemanticAnalysis, Evidence, EvidenceMatchRuleDefinition, EvidenceSemanticRuleDefinition, Exhibit, ExhibitRelation, LevelFlag, LevelGoal, OrganizationKind, PartyKind, SourceFileType, UploadedCaseDocument } from '../src/types.js'
import { isClaimExhibit, isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js'
import { loadCaseReport } from './caseReports.js'
import { evaluateEvidenceRules, type EvidenceMatchRule } from './evidenceMatching.js'
import { EvidenceJudgeError, type EvidenceJudge } from './evidenceJudge.js'
import { filterLevelVisibility, mergePlayerStateForPersistence } from './levelVisibility.js'
import type { ObjectStorage } from './objectStorage.js'
import type { TextExtractionResult } from './ocr.js'

export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
// A frozen template version's board, read back for round-trip export. `state.goals`
// carries the goals (in author mode); match/semantic rules are read alongside.
export type TemplateBoardExport = { slug: string; name: string; version: number; state: CaseState; matchRules: EvidenceMatchRuleDefinition[]; semanticRules: EvidenceSemanticRuleDefinition[] }
export type EvidenceMatchRuleInput = {
  name: string
  sourceLabel?: string
  sourceUri?: string
  flagKey: string
  minimumAnchorMatches?: number
  enabled?: boolean
  anchors: { phrase: string; minimumSimilarity?: number }[]
}
export type LevelGoalInput = {
  key: string
  title: string
  instructions?: string
  completionMessage?: string
  enabled?: boolean
  requiredFlags: string[]
}
export type EvidenceSemanticRuleInput = {
  goalId: string
  name: string
  targetSubject: string
  relatedSubject?: string
  assertion: string
  successFlagKey: string
  relatedFlagKey?: string
  minimumConfidence?: number
  evaluatorVersion?: string
  enabled?: boolean
}

export interface LevelRepository {
  listLevels(): Promise<unknown[]>
  createLevel(input: { id: string; title: string; subtitle: string }): Promise<CaseState>
  listTemplates(): Promise<TemplateSummary[]>
  instantiateTemplate(templateSlug: string, input: { id: string; title?: string; version?: number }): Promise<CaseState | null>
  saveLevelAsTemplate(levelId: string, input: { slug: string; name: string }): Promise<TemplateSummary | null>
  getTemplateBoard(versionId: string): Promise<TemplateBoardExport | null>
  getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
  saveLevel(state: CaseState, authorMode: boolean): Promise<void>
  resetLevel(levelId: string): Promise<CaseState | null>
  getAsset(assetId: string): Promise<AssetResponse | null>
  uploadDocument(levelId: string, file: UploadedDocument, extraction: TextExtractionResult, placement?: { x: number; y: number }): Promise<UploadedCaseDocument | null>
  listFlags(levelId: string): Promise<LevelFlag[] | null>
  setFlag(levelId: string, key: string, earned: boolean): Promise<boolean>
  acknowledgeRevealedDocuments(levelId: string, documentIds: string[]): Promise<number | null>
  listEvidenceMatchRules(levelId: string): Promise<EvidenceMatchRuleDefinition[] | null>
  createEvidenceMatchRule(levelId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
  updateEvidenceMatchRule(levelId: string, ruleId: string, input: EvidenceMatchRuleInput): Promise<EvidenceMatchRuleDefinition | null>
  deleteEvidenceMatchRule(levelId: string, ruleId: string): Promise<boolean | null>
  listGoals(levelId: string): Promise<LevelGoal[] | null>
  createGoal(levelId: string, input: LevelGoalInput): Promise<LevelGoal | null>
  updateGoal(levelId: string, goalId: string, input: LevelGoalInput): Promise<LevelGoal | null>
  deleteGoal(levelId: string, goalId: string): Promise<boolean | null>
  listEvidenceSemanticRules(levelId: string): Promise<EvidenceSemanticRuleDefinition[] | null>
  createEvidenceSemanticRule(levelId: string, input: EvidenceSemanticRuleInput): Promise<EvidenceSemanticRuleDefinition | null>
  updateEvidenceSemanticRule(levelId: string, ruleId: string, input: EvidenceSemanticRuleInput): Promise<EvidenceSemanticRuleDefinition | null>
  deleteEvidenceSemanticRule(levelId: string, ruleId: string): Promise<boolean | null>
  judgeDocument(levelId: string, documentId: string): Promise<DocumentSemanticAnalysis | null>
}

type LevelRow = {
  id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
  viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date; revision: string
  source_template_version_id: string | null
}
type ExhibitRow = {
  id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
  title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
  capture_kind_id: DocumentCaptureKind | null
  asset_id: string | null; published_at: Date | null; occurred_at: Date | null
  captured_at: Date | null; source_uri: string | null; citation_text: string | null; display_number: number | null; statement: string | null
  original_name: string | null; mime_type: string | null; byte_size: string | null
  source_document_id: string | null; source_region_key: string | null
  note_presentation_kind: 'luggage' | 'lined_sheet' | null
  party_kind: PartyKind | null; organization_kind: OrganizationKind | null
}

const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const flagPattern = /^[a-z][a-z0-9_.-]{0,63}$/
function requireUuid(value: string, label: string) {
  if (!uuidPattern.test(value)) throw new Error(`${label} must be a UUID`)
  return value
}
function timestamp(value: string | undefined) {
  if (!value) return null
  const date = new Date(value)
  return Number.isFinite(date.getTime()) ? date.toISOString() : null
}
function requireFlagKey(value: string) {
  if (!flagPattern.test(value)) throw new Error('Flag keys must start with a lowercase letter and contain only lowercase letters, numbers, dots, dashes, or underscores')
  return value
}
function requireRuleInput(input: EvidenceMatchRuleInput) {
  const name = String(input.name || '').trim()
  if (!name || name.length > 160) throw new Error('Evidence match rule names must be between 1 and 160 characters')
  const flagKey = requireFlagKey(String(input.flagKey || '').trim())
  const sourceLabel = String(input.sourceLabel || '').trim()
  const sourceUri = String(input.sourceUri || '').trim()
  if (sourceLabel.length > 300) throw new Error('Evidence source labels cannot exceed 300 characters')
  if (sourceUri.length > 2_000) throw new Error('Evidence source URIs cannot exceed 2000 characters')
  if (sourceUri) { try { new URL(sourceUri) } catch { throw new Error('Evidence source URI must be an absolute URL') } }
  if (!Array.isArray(input.anchors) || !input.anchors.length || input.anchors.length > 20) throw new Error('Evidence match rules require between 1 and 20 anchors')
  const anchors = input.anchors.map(anchor => {
    const phrase = String(anchor.phrase || '').trim()
    if (phrase.length < 12 || phrase.length > 1_000) throw new Error('Evidence match anchors must be between 12 and 1000 characters')
    const minimumSimilarity = anchor.minimumSimilarity === undefined ? 0.72 : Number(anchor.minimumSimilarity)
    if (!Number.isFinite(minimumSimilarity) || minimumSimilarity < 0.5 || minimumSimilarity > 1) throw new Error('Anchor similarity must be between 0.5 and 1')
    return { phrase, minimumSimilarity }
  })
  const minimumAnchorMatches = input.minimumAnchorMatches === undefined ? 1 : Number(input.minimumAnchorMatches)
  if (!Number.isInteger(minimumAnchorMatches) || minimumAnchorMatches < 1 || minimumAnchorMatches > anchors.length) throw new Error('Required anchor matches must be between 1 and the number of anchors')
  return { name, sourceLabel:sourceLabel || undefined, sourceUri:sourceUri || undefined, flagKey, minimumAnchorMatches, enabled: input.enabled !== false, anchors }
}
function requireGoalInput(input: LevelGoalInput) {
  const key = requireFlagKey(String(input.key || '').trim())
  const title = String(input.title || '').trim()
  const instructions = String(input.instructions || '').trim()
  const completionMessage = String(input.completionMessage || '').trim()
  if (!title || title.length > 200) throw new Error('Level goal titles must be between 1 and 200 characters')
  if (instructions.length > 10_000) throw new Error('Level goal instructions cannot exceed 10000 characters')
  if (completionMessage.length > 2_000) throw new Error('Level goal completion messages cannot exceed 2000 characters')
  if (!Array.isArray(input.requiredFlags)) throw new Error('Level goal requiredFlags must be an array')
  const requiredFlags = [...new Set(input.requiredFlags.map(value => requireFlagKey(String(value || '').trim())))]
  if (!requiredFlags.length || requiredFlags.length > 20) throw new Error('Level goals require between 1 and 20 flags')
  return { key, title, instructions, completionMessage, enabled: input.enabled !== false, requiredFlags }
}
function requireSemanticRuleInput(input: EvidenceSemanticRuleInput) {
  const goalId = requireUuid(String(input.goalId || '').trim(), 'Goal id')
  const name = String(input.name || '').trim()
  const targetSubject = String(input.targetSubject || '').trim()
  const relatedSubject = String(input.relatedSubject || '').trim()
  const assertion = String(input.assertion || '').trim()
  const successFlagKey = requireFlagKey(String(input.successFlagKey || '').trim())
  const relatedFlagKey = relatedSubject && input.relatedFlagKey ? requireFlagKey(String(input.relatedFlagKey).trim()) : undefined
  const minimumConfidence = input.minimumConfidence === undefined ? .85 : Number(input.minimumConfidence)
  const evaluatorVersion = String(input.evaluatorVersion || 'evidence_claim_v1').trim()
  if (!name || name.length > 160) throw new Error('Semantic rule names must be between 1 and 160 characters')
  if (!targetSubject || targetSubject.length > 300) throw new Error('Target subjects must be between 1 and 300 characters')
  if (relatedSubject.length > 300) throw new Error('Related subjects cannot exceed 300 characters')
  if (!assertion || assertion.length > 2_000) throw new Error('Assertions must be between 1 and 2000 characters')
  if (!Number.isFinite(minimumConfidence) || minimumConfidence < .5 || minimumConfidence > 1) throw new Error('Semantic confidence must be between 0.5 and 1')
  if (!evaluatorVersion || evaluatorVersion.length > 100) throw new Error('Evaluator versions must be between 1 and 100 characters')
  return { goalId,name,targetSubject,relatedSubject:relatedSubject || undefined,assertion,successFlagKey,relatedFlagKey,
    minimumConfidence,evaluatorVersion,enabled:input.enabled !== false }
}
function documentType(document: CaseDocument): SourceFileType {
  const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
  return allowed.includes(document.fileType) ? document.fileType : 'file'
}
function documentCaptureKind(value: unknown): DocumentCaptureKind {
  const allowed: DocumentCaptureKind[] = ['unclassified','photo','scene','clipping','full_page']
  return allowed.includes(value as DocumentCaptureKind) ? value as DocumentCaptureKind : 'unclassified'
}
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage, evidenceJudge: EvidenceJudge): LevelRepository {
  async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
    const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
      l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
      FROM osint.levels l JOIN osint.boards b ON b.id=l.board_id WHERE l.slug = $1${lock ? ' FOR UPDATE OF l,b' : ''}`, [slug])
    return result.rows[0] || null
  }

  async function createDefaultBoardViews(client: PoolClient, boardId: string) {
    const viewId = randomUUID()
    await client.query(`INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height)
      VALUES ($1,$2,'timeline','docked','bottom',112)`, [viewId, boardId])
    await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
  }

  async function evidenceMatchRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise<EvidenceMatchRuleDefinition[]> {
    const result = await client.query<{
      rule_id: string; name: string; source_label:string | null; source_uri:string | null; flag_key: string; matcher_version: 'char_trigram_v1'; minimum_anchor_matches: number; enabled: boolean
      anchor_id: string; phrase_text: string; minimum_similarity: string; sort_order: number
    }>(`SELECT r.id AS rule_id,r.name,r.source_label,r.source_uri,r.flag_key,r.matcher_version,r.minimum_anchor_matches,r.enabled,
        a.id AS anchor_id,a.phrase_text,a.minimum_similarity::text,a.sort_order
      FROM osint.evidence_match_rules r
      JOIN osint.evidence_match_anchors a ON a.rule_id=r.id
      WHERE r.board_id=$1 ${includeDisabled ? '' : 'AND r.enabled'}
      ORDER BY r.created_at,r.id,a.sort_order,a.id`, [boardId])
    const rules = new Map<string, EvidenceMatchRuleDefinition>()
    for (const row of result.rows) {
      const rule = rules.get(row.rule_id) || { id: row.rule_id, name: row.name, sourceLabel:row.source_label || undefined,sourceUri:row.source_uri || undefined,flagKey: row.flag_key,
        matcherVersion: row.matcher_version, minimumAnchorMatches: row.minimum_anchor_matches, enabled: row.enabled, anchors: [] }
      rule.anchors.push({ id: row.anchor_id, phrase: row.phrase_text, minimumSimilarity: Number(row.minimum_similarity), sortOrder: row.sort_order })
      rules.set(row.rule_id, rule)
    }
    return [...rules.values()]
  }

  async function writeEvidenceMatchRule(client: PoolClient, level: LevelRow, ruleId: string, rawInput: EvidenceMatchRuleInput, update: boolean) {
    const input = requireRuleInput(rawInput)
    if (update) {
      const changed = await client.query(`UPDATE osint.evidence_match_rules SET name=$3,source_label=$4,source_uri=$5,flag_key=$6,minimum_anchor_matches=$7,enabled=$8,updated_at=NOW()
        WHERE id=$1 AND board_id=$2`, [ruleId,level.board_id,input.name,input.sourceLabel || null,input.sourceUri || null,input.flagKey,input.minimumAnchorMatches,input.enabled])
      if (!changed.rowCount) return null
      await client.query('DELETE FROM osint.evidence_match_anchors WHERE rule_id=$1', [ruleId])
    } else {
      await client.query(`INSERT INTO osint.evidence_match_rules (id,board_id,name,source_label,source_uri,flag_key,minimum_anchor_matches,enabled)
        VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [ruleId,level.board_id,input.name,input.sourceLabel || null,input.sourceUri || null,input.flagKey,input.minimumAnchorMatches,input.enabled])
    }
    for (const [sortOrder, anchor] of input.anchors.entries()) await client.query(`INSERT INTO osint.evidence_match_anchors
      (id,rule_id,phrase_text,minimum_similarity,sort_order) VALUES ($1,$2,$3,$4,$5)`,
    [randomUUID(), ruleId, anchor.phrase, anchor.minimumSimilarity, sortOrder])
    const rule = (await evidenceMatchRules(client, level.board_id, true)).find(candidate => candidate.id === ruleId) || null
    if (rule) await evaluateRuleForExistingDocuments(client, level, rule)
    await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
    return rule
  }

  async function evaluateRuleForExistingDocuments(client: PoolClient, level: LevelRow, rule: EvidenceMatchRuleDefinition) {
    if (!rule.enabled) return
    const documents = await client.query<{ document_exhibit_id:string;extraction_id:string;extracted_text:string }>(`
      SELECT DISTINCT ON (document.exhibit_id) document.exhibit_id AS document_exhibit_id,
        extraction.id AS extraction_id,extraction.extracted_text
      FROM osint.document_exhibits document
      JOIN osint.exhibits exhibit ON exhibit.id=document.exhibit_id AND exhibit.board_id=$1
      JOIN osint.asset_text_extractions extraction ON extraction.asset_id=document.asset_id
        AND extraction.status='succeeded' AND BTRIM(extraction.extracted_text)<>''
      ORDER BY document.exhibit_id,extraction.updated_at DESC,extraction.id DESC`, [level.board_id])
    for (const document of documents.rows) {
      const evaluation = evaluateEvidenceRules(document.extracted_text, [rule as EvidenceMatchRule])[0]
      const persisted = await client.query<{ id:string }>(`INSERT INTO osint.evidence_match_evaluations
        (id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,matched,matched_anchor_count,score)
        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
        ON CONFLICT (level_id,document_exhibit_id,rule_id) DO UPDATE SET
          extraction_id=EXCLUDED.extraction_id,matched=EXCLUDED.matched,
          matched_anchor_count=EXCLUDED.matched_anchor_count,score=EXCLUDED.score,evaluated_at=NOW()
        RETURNING id`, [randomUUID(),level.id,level.board_id,document.document_exhibit_id,document.extraction_id,
        rule.id,evaluation.matched,evaluation.matchedAnchorCount,evaluation.score])
      const evaluationId = persisted.rows[0].id
      await client.query('DELETE FROM osint.evidence_match_anchor_evaluations WHERE evaluation_id=$1', [evaluationId])
      for (const anchor of evaluation.anchors) await client.query(`INSERT INTO osint.evidence_match_anchor_evaluations
        (evaluation_id,anchor_id,similarity,matched,matched_text) VALUES ($1,$2,$3,$4,$5)`,
      [evaluationId,anchor.anchorId,anchor.similarity,anchor.matched,anchor.matchedText])
      if (evaluation.matched) await client.query(`INSERT INTO osint.level_flags
        (level_id,board_id,flag_key,awarded_by_evidence_match_id) VALUES ($1,$2,$3,$4)
        ON CONFLICT (level_id,flag_key) DO NOTHING`, [level.id,level.board_id,evaluation.flagKey,evaluationId])
    }
  }

  async function levelGoalStates(
    client: Pool | PoolClient,
    level: Pick<LevelRow, 'id' | 'board_id'>,
    authorMode = false,
    newlyCompletedKeys: ReadonlySet<string> = new Set(),
  ): Promise<LevelGoal[]> {
    const result = await client.query<{
      id: string; goal_key: string; title: string; instructions: string; completion_message: string; enabled: boolean
      required_flags: string[]; requirement_count: number; earned_count: number; completed_at: Date | null
    }>(`SELECT goal.id,goal.goal_key,goal.title,goal.instructions,goal.completion_message,goal.enabled,
        COALESCE(array_agg(requirement.flag_key ORDER BY requirement.flag_key)
          FILTER (WHERE requirement.flag_key IS NOT NULL),'{}'::text[]) AS required_flags,
        COUNT(requirement.flag_key)::int AS requirement_count,
        COUNT(flag.flag_key)::int AS earned_count,
        MAX(flag.earned_at) AS completed_at
      FROM osint.level_goals goal
      LEFT JOIN osint.level_goal_flag_requirements requirement ON requirement.goal_id=goal.id
      LEFT JOIN osint.level_flags flag ON flag.level_id=$1 AND flag.flag_key=requirement.flag_key
      WHERE goal.board_id=$2 ${authorMode ? '' : 'AND goal.enabled'}
      GROUP BY goal.id
      ORDER BY goal.created_at,goal.id`, [level.id, level.board_id])
    return result.rows.map(row => {
      const complete = row.requirement_count > 0 && row.earned_count === row.requirement_count
      return {
        ...(authorMode ? { id: row.id, enabled: row.enabled, requiredFlags: row.required_flags } : {}),
        key: row.goal_key,
        title: row.title,
        instructions: row.instructions,
        completionMessage: row.completion_message,
        status: complete ? 'complete' as const : 'pending' as const,
        ...(complete && row.completed_at ? { completedAt: row.completed_at.toISOString() } : {}),
        newlyCompleted: complete && newlyCompletedKeys.has(row.goal_key),
      }
    })
  }

  async function writeGoal(client: PoolClient, level: LevelRow, goalId: string, rawInput: LevelGoalInput, update: boolean) {
    const input = requireGoalInput(rawInput)
    if (update) {
      const changed = await client.query(`UPDATE osint.level_goals SET
        goal_key=$3,title=$4,instructions=$5,completion_message=$6,enabled=$7,updated_at=NOW()
        WHERE id=$1 AND board_id=$2`, [goalId,level.board_id,input.key,input.title,input.instructions,input.completionMessage,input.enabled])
      if (!changed.rowCount) return null
      await client.query('DELETE FROM osint.level_goal_flag_requirements WHERE goal_id=$1', [goalId])
    } else await client.query(`INSERT INTO osint.level_goals
      (id,board_id,goal_key,title,instructions,completion_message,enabled) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
    [goalId,level.board_id,input.key,input.title,input.instructions,input.completionMessage,input.enabled])
    for (const flag of input.requiredFlags) await client.query(
      'INSERT INTO osint.level_goal_flag_requirements (board_id,goal_id,flag_key) VALUES ($1,$2,$3)',
      [level.board_id,goalId,flag])
    await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
    await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
    return (await levelGoalStates(client, level, true)).find(goal => goal.id === goalId) || null
  }

  async function evidenceSemanticRules(client: Pool | PoolClient, boardId: string, includeDisabled = false): Promise<EvidenceSemanticRuleDefinition[]> {
    const result = await client.query<{
      id:string; goal_id:string; goal_key:string; name:string; target_subject:string; related_subject:string | null; assertion_text:string
      success_flag_key:string; related_flag_key:string | null; minimum_confidence:string; evaluator_version:string; enabled:boolean
    }>(`SELECT rule.id,rule.goal_id,goal.goal_key,rule.name,rule.target_subject,rule.related_subject,rule.assertion_text,
        rule.success_flag_key,rule.related_flag_key,rule.minimum_confidence::text,rule.evaluator_version,rule.enabled
      FROM osint.evidence_semantic_rules rule JOIN osint.level_goals goal ON goal.id=rule.goal_id
      WHERE rule.board_id=$1 ${includeDisabled ? '' : 'AND rule.enabled AND goal.enabled'}
      ORDER BY rule.created_at,rule.id`, [boardId])
    return result.rows.map(row => ({
      id:row.id,goalId:row.goal_id,goalKey:row.goal_key,name:row.name,targetSubject:row.target_subject,
      relatedSubject:row.related_subject || undefined,assertion:row.assertion_text,successFlagKey:row.success_flag_key,
      relatedFlagKey:row.related_flag_key || undefined,minimumConfidence:Number(row.minimum_confidence),
      evaluatorVersion:row.evaluator_version,enabled:row.enabled,
    }))
  }

  async function writeEvidenceSemanticRule(client: PoolClient, level: LevelRow, ruleId: string, rawInput: EvidenceSemanticRuleInput, update: boolean) {
    const input = requireSemanticRuleInput(rawInput)
    if (update) {
      const changed = await client.query(`UPDATE osint.evidence_semantic_rules SET goal_id=$3,name=$4,target_subject=$5,
        related_subject=$6,assertion_text=$7,success_flag_key=$8,related_flag_key=$9,minimum_confidence=$10,
        evaluator_version=$11,enabled=$12,updated_at=NOW() WHERE id=$1 AND board_id=$2`,
      [ruleId,level.board_id,input.goalId,input.name,input.targetSubject,input.relatedSubject || null,input.assertion,input.successFlagKey,
        input.relatedFlagKey || null,input.minimumConfidence,input.evaluatorVersion,input.enabled])
      if (!changed.rowCount) return null
    } else await client.query(`INSERT INTO osint.evidence_semantic_rules
      (id,board_id,goal_id,name,target_subject,related_subject,assertion_text,success_flag_key,related_flag_key,
        minimum_confidence,evaluator_version,enabled) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
    [ruleId,level.board_id,input.goalId,input.name,input.targetSubject,input.relatedSubject || null,input.assertion,input.successFlagKey,
      input.relatedFlagKey || null,input.minimumConfidence,input.evaluatorVersion,input.enabled])
    await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
    await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
    return (await evidenceSemanticRules(client, level.board_id, true)).find(rule => rule.id === ruleId) || null
  }

  async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
    const level = await findLevel(pool, slug)
    if (!level) return null
    return assembleBoard(level, authorMode)
  }

  // Assemble a board's CaseState from a level row (id + board_id). Split out from
  // assembleLevel so a frozen template version's board can be read the same way (its
  // board_id backs the same tables); player-state reads (flags/seen) return empty for
  // a template board that no player has instantiated.
  async function assembleBoard(level: LevelRow, authorMode = false): Promise<CaseState> {
    const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
      aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult, requirementsResult, flagsResult, seenResult, checklistResult] = await Promise.all([
      pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
        COALESCE(f.title, d.title, n.title, ev.title, p.display_name, claim.statement, '') AS title,
        COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
        f.is_open, d.document_type_id, d.capture_kind_id, d.asset_id, d.published_at, d.captured_at, d.source_uri,d.citation_text,citation.display_number,
        n.presentation_kind AS note_presentation_kind,
        ev.occurred_at,claim.statement,
        p.party_kind, op.organization_kind,
        a.original_name, a.mime_type, a.byte_size,
        s.source_document_exhibit_id AS source_document_id, sr.region_key AS source_region_key
        FROM osint.exhibits e
        LEFT JOIN osint.folder_exhibits f ON f.exhibit_id = e.id
        LEFT JOIN osint.document_exhibits d ON d.exhibit_id = e.id
        LEFT JOIN osint.note_exhibits n ON n.exhibit_id = e.id
        LEFT JOIN osint.event_exhibits ev ON ev.exhibit_id = e.id
        LEFT JOIN osint.party_exhibits p ON p.exhibit_id = e.id
        LEFT JOIN osint.organization_parties op ON op.exhibit_id = e.id
        LEFT JOIN osint.claim_exhibits claim ON claim.exhibit_id = e.id
        LEFT JOIN osint.exhibit_citations citation ON citation.board_id=e.board_id AND citation.exhibit_id=e.id
        LEFT JOIN osint.assets a ON a.id = d.asset_id
        LEFT JOIN osint.exhibit_sources s ON s.exhibit_id = e.id
        LEFT JOIN osint.document_regions sr ON sr.id = s.source_region_id
        WHERE e.board_id = $1 ORDER BY e.z_index, e.created_at, e.id`, [level.board_id]),
      pool.query<{ document_exhibit_id: string; content: string }>(
        `SELECT b.document_exhibit_id, b.content FROM osint.document_content_blocks b
         JOIN osint.exhibits e ON e.id = b.document_exhibit_id WHERE e.board_id = $1 ORDER BY b.document_exhibit_id, b.sort_order`, [level.board_id]),
      pool.query<{ document_exhibit_id: string; region_key: string; label: string; excerpt: string; occurred_at: Date | null }>(
        `SELECT r.document_exhibit_id, r.region_key, r.label, r.excerpt, r.occurred_at FROM osint.document_regions r
         JOIN osint.exhibits e ON e.id = r.document_exhibit_id WHERE e.board_id = $1 ORDER BY r.document_exhibit_id, r.sort_order`, [level.board_id]),
      pool.query<{ folder_exhibit_id: string; child_exhibit_id: string; sort_order: number; xpos: number; ypos: number }>(
        `SELECT m.folder_exhibit_id, m.child_exhibit_id, m.sort_order, child.xpos, child.ypos
         FROM osint.folder_memberships m JOIN osint.exhibits child ON child.id = m.child_exhibit_id
         WHERE m.board_id = $1 ORDER BY m.sort_order, m.child_exhibit_id`, [level.board_id]),
      pool.query<{ id: string; from_exhibit_id: string; to_exhibit_id: string; label: string | null; tightness: number; tag_style: 'luggage' | 'compact'; tag_position_percent: number; tag_lateral_offset: number }>(
        `SELECT id, from_exhibit_id, to_exhibit_id, label, tightness, tag_style, tag_position_percent, tag_lateral_offset FROM osint.exhibit_connections WHERE board_id = $1 ORDER BY created_at, id`, [level.board_id]),
      pool.query<{ exhibit_id: string; field_key: string; value: string }>(
        `SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
         JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
      pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
        `SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1
         ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
      pool.query<{ party_exhibit_id: string; alias: string }>(
        `SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
         WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]),
      pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
        `SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1
         ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
      pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]),
      pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>(
        `SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts
         WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
      pool.query<{ id: string; view_type_id: 'timeline'; placement_mode: 'docked' | 'canvas' | 'window'; dock_edge: 'top' | 'right' | 'bottom' | 'left' | null; xpos: number | null; ypos: number | null; width: number | null; height: number; z_index: number; visible: boolean; range_mode: 'auto' | 'fixed'; range_start: string | null; range_end: string | null }>(
        `SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
          t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
          JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
      pool.query<{ document_exhibit_id: string; flag_key: string }>(
        'SELECT document_exhibit_id,flag_key FROM osint.document_flag_requirements WHERE board_id=$1 ORDER BY document_exhibit_id,flag_key', [level.board_id]),
      pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.level_flags WHERE level_id=$1 ORDER BY flag_key', [level.id]),
      pool.query<{ document_exhibit_id: string }>('SELECT document_exhibit_id FROM osint.level_seen_documents WHERE level_id=$1', [level.id]),
      pool.query<{ id: string; text: string }>('SELECT id,text FROM osint.level_brief_checklist_items WHERE board_id=$1 ORDER BY sort_order,id', [level.board_id]),
    ])

    const blocks = new Map<string, string[]>()
    for (const row of blocksResult.rows) blocks.set(row.document_exhibit_id, [...(blocks.get(row.document_exhibit_id) || []), row.content])
    const regions = new Map<string, CaseDocument['regions']>()
    for (const row of regionsResult.rows) regions.set(row.document_exhibit_id, [...(regions.get(row.document_exhibit_id) || []), {
      id: row.region_key, label: row.label, excerpt: row.excerpt, date: row.occurred_at?.toISOString(),
    }])
    const metadata = new Map<string, Record<string, string>>()
    for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
    const aliases = new Map<string, string[]>()
    for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
    const requirements = new Map<string, string[]>()
    for (const row of requirementsResult.rows) requirements.set(row.document_exhibit_id, [...(requirements.get(row.document_exhibit_id) || []), row.flag_key])
    const relations: ExhibitRelation[] = [
      ...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
        toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
      ...eventEvidenceResult.rows.map(row => ({ id: `supports:${row.event_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.event_exhibit_id,
        toExhibitId: row.evidence_exhibit_id, type: 'supports' as const, sortOrder: row.sort_order, note: row.note || undefined })),
      ...partyEvidenceResult.rows.map(row => ({ id: `concerns:${row.party_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.party_exhibit_id,
        toExhibitId: row.evidence_exhibit_id, type: 'concerns' as const, sortOrder: row.sort_order, note: row.note || undefined })),
      ...exhibitsResult.rows.flatMap(row => row.source_document_id ? [{ id: `source:${row.id}`, fromExhibitId: row.id, toExhibitId: row.source_document_id,
        type: 'source' as const, sourceRegionId: row.source_region_key || undefined, sortOrder: 0 }] : []),
    ]
    const base = (row: ExhibitRow) => ({ id: row.id, title: row.title, x: row.xpos, y: row.ypos, width: row.width, height: row.height,
      rotation: row.rotation, zIndex: row.z_index, hidden: row.hidden })
    const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
      const type = row.document_type_id || 'file'
      const publishedAt = row.published_at?.toISOString()
      return { ...base(row), type: 'document', publishedAt, capturedAt:row.captured_at?.toISOString(),sourceUri:row.source_uri || undefined,
        sourceCitation:row.citation_text || undefined,displayNumber:row.display_number || undefined,requiredFlags: requirements.get(row.id) || [],
        body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
        fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
        fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type,
        captureKind: documentCaptureKind(row.capture_kind_id), metadata: metadata.get(row.id) || {} }
    })
    const evidence: Evidence[] = []
    for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document')) {
      const common = { ...base(row), title: row.title, content: row.content }
      if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
      else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
      else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
      else if (row.exhibit_type_id === 'claim') evidence.push({ ...base(row), type:'claim', title:row.title, statement:row.statement || row.title })
      else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note', presentation:row.note_presentation_kind || 'luggage' })
      else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
    }
    const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index,
      placement: row.placement_mode === 'docked'
        ? { mode: 'docked', dockEdge: row.dock_edge || 'bottom', size: row.height }
        : { mode: row.placement_mode, x: row.xpos || 0, y: row.ypos || 0, width: row.width || 900, height: row.height },
      rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
    const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
      ...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
    const goals = await levelGoalStates(pool, level, authorMode)
    const report = await loadCaseReport(pool,level)
    const fullState: CaseState = { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
      connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
        label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
        tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
      viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
      views, revision: Number(level.revision),
      brief: { body: briefResult.rows[0]?.body || '', concepts, checklist: checklistResult.rows.map(row => ({ id: row.id, text: row.text })) }, goals, report, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
      sourceTemplateVersionId: level.source_template_version_id || undefined }
    return filterLevelVisibility(fullState, flagsResult.rows.map(row => row.flag_key), seenResult.rows.map(row => row.document_exhibit_id), authorMode)
  }

  async function templateSummary(slug: string): Promise<TemplateSummary | null> {
    const result = await pool.query<{
      id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
    }>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
        FROM osint.level_templates t
        JOIN osint.level_template_versions current ON current.id=t.current_version_id
        JOIN osint.level_template_versions v ON v.template_id=t.id
        WHERE t.slug=$1 GROUP BY t.id,current.version`, [slug])
    const row = result.rows[0]
    return row ? { id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
      versionCount: row.version_count, updatedAt: row.updated_at.toISOString() } : null
  }

  async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
    if (!Array.isArray(state.exhibits) || !Array.isArray(state.views)) throw new Error('Level state must contain exhibits and views')
    const documents = state.exhibits.filter(isDocumentExhibit)
    const evidence = state.exhibits.filter((exhibit): exhibit is Evidence => !isDocumentExhibit(exhibit))
    const documentIds = new Set(documents.map(document => requireUuid(document.id, 'Document id')))
    const evidenceIds = new Set(evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
    const allIds = state.exhibits.map(exhibit => exhibit.id)
    if (new Set(allIds).size !== allIds.length) throw new Error('Exhibit ids must be unique within a board')
    const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>(
      'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind]))

    await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
      updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
    await client.query('UPDATE osint.boards SET revision=revision+1, updated_at=NOW() WHERE id=$1', [level.board_id])
    await client.query('DELETE FROM osint.exhibit_connections WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.folder_memberships WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.level_brief_checklist_items WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
    await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [level.board_id])
    await client.query('DELETE FROM osint.document_flag_requirements WHERE board_id=$1',[level.board_id])
    await client.query('DELETE FROM osint.document_content_blocks WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
    await client.query('DELETE FROM osint.document_regions WHERE document_exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
    for (const table of ['folder_exhibits', 'image_documents', 'note_exhibits', 'event_exhibits', 'person_parties', 'organization_parties', 'party_exhibits', 'claim_exhibits']) {
      await client.query(`DELETE FROM osint.${table} WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)`, [level.board_id])
    }
    if (documentIds.size) {
      await client.query('DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1) AND NOT (exhibit_id=ANY($2::uuid[]))',[level.board_id,[...documentIds]])
      await client.query('DELETE FROM osint.exhibit_citations WHERE board_id=$1 AND NOT (exhibit_id=ANY($2::uuid[]))',[level.board_id,[...documentIds]])
    } else {
      await client.query('DELETE FROM osint.document_exhibits WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)',[level.board_id])
      await client.query('DELETE FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])
    }
    if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
    else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])

    for (const exhibit of state.exhibits) {
      const clipping=exhibit.type === 'document' && documentCaptureKind(exhibit.captureKind) === 'clipping'
      await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
        ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
      [exhibit.id,level.board_id,exhibit.type,exhibit.x,exhibit.y,clipping ? 230 : exhibit.width,clipping ? 290 : exhibit.height,exhibit.rotation,exhibit.zIndex,exhibit.hidden])
    }
    let nextCitation = Number((await client.query<{ maximum:number }>('SELECT COALESCE(MAX(display_number),0)::int AS maximum FROM osint.exhibit_citations WHERE board_id=$1',[level.board_id])).rows[0].maximum)
    for (const document of documents) {
      const clippingTitle=document.captureKind === 'clipping' && document.sourceCitation?.trim()
        && (!document.title.trim() || document.title === document.fileName) ? document.sourceCitation.trim() : document.title
      await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,capture_kind_id,asset_id,title,published_at,captured_at,source_uri,citation_text)
        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (exhibit_id) DO UPDATE SET
          document_type_id=EXCLUDED.document_type_id,asset_id=EXCLUDED.asset_id,title=EXCLUDED.title,published_at=EXCLUDED.published_at,
          capture_kind_id=EXCLUDED.capture_kind_id,captured_at=EXCLUDED.captured_at,source_uri=EXCLUDED.source_uri,citation_text=EXCLUDED.citation_text`,
      [document.id, documentType(document), documentCaptureKind(document.captureKind), document.assetId || null, clippingTitle,
        timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null,document.sourceCitation || ''])
      const existingCitation = await client.query<{ display_number:number }>('SELECT display_number FROM osint.exhibit_citations WHERE board_id=$1 AND exhibit_id=$2',[level.board_id,document.id])
      if (!existingCitation.rows[0]) {
        const requested = Number(document.displayNumber)
        const displayNumber = Number.isInteger(requested) && requested > 0
          && !(await client.query('SELECT 1 FROM osint.exhibit_citations WHERE board_id=$1 AND display_number=$2',[level.board_id,requested])).rowCount
          ? requested : ++nextCitation
        await client.query('INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number) VALUES ($1,$2,$3)',[level.board_id,document.id,displayNumber])
        nextCitation=Math.max(nextCitation,displayNumber)
      }
      if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
      for (const flag of new Set((document.requiredFlags || []).map(value => value.trim()).filter(Boolean).map(requireFlagKey))) await client.query(
        'INSERT INTO osint.document_flag_requirements (board_id,document_exhibit_id,flag_key) VALUES ($1,$2,$3)', [level.board_id, document.id, flag])
      for (const [sortOrder, content] of document.body.entries()) await client.query(
        'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
      for (const [sortOrder, region] of document.regions.entries()) await client.query(
        `INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
         VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
    }
    for (const exhibit of evidence) {
      if (isFolderExhibit(exhibit)) await client.query(
        'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
        [exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
      if (exhibit.type === 'note') await client.query(
        'INSERT INTO osint.note_exhibits (exhibit_id,title,note_text,presentation_kind) VALUES ($1,$2,$3,$4)',
        [exhibit.id,exhibit.title,exhibit.content,exhibit.presentation === 'lined_sheet' ? 'lined_sheet' : 'luggage'])
      if (isEventExhibit(exhibit)) await client.query(
        'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
        [exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
      if (isPartyExhibit(exhibit)) {
        const partyKind = exhibit.partyKind
        await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
          [exhibit.id, partyKind, exhibit.title, exhibit.content])
        if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
        else await client.query('INSERT INTO osint.organization_parties (exhibit_id,organization_kind) VALUES ($1,$2)',
          [exhibit.id, exhibit.organizationKind || 'business'])
        for (const [sortOrder, alias] of (exhibit.aliases || []).filter(Boolean).entries()) await client.query(
          'INSERT INTO osint.party_aliases (id,party_exhibit_id,alias,sort_order) VALUES ($1,$2,$3,$4)', [randomUUID(), exhibit.id, alias, sortOrder])
      }
      if (isClaimExhibit(exhibit)) await client.query(
        'INSERT INTO osint.claim_exhibits (exhibit_id,statement) VALUES ($1,$2)', [exhibit.id,exhibit.statement.trim()])
    }

    for (const relation of state.relations) {
      if (!allIds.includes(relation.fromExhibitId) || !allIds.includes(relation.toExhibitId) || relation.fromExhibitId === relation.toExhibitId) throw new Error('Relation references an unknown or identical exhibit')
      if (relation.type === 'contains') await client.query(`INSERT INTO osint.folder_memberships
        (board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
      [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder])
      if (relation.type === 'supports') await client.query(`INSERT INTO osint.event_evidence
        (board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
      [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
      if (relation.type === 'concerns') await client.query(`INSERT INTO osint.party_evidence
        (board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
      [level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
      if (relation.type === 'source') {
        if (!documentIds.has(relation.toExhibitId)) throw new Error('Exhibit source must reference a document')
        let regionId: string | null = null
        if (relation.sourceRegionId) {
          const region = await client.query<{ id: string }>(
            'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [relation.toExhibitId, relation.sourceRegionId])
          regionId = region.rows[0]?.id || null
        }
        await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
          [relation.fromExhibitId, relation.toExhibitId, regionId])
      }
    }
    for (const connection of state.connections) {
      requireUuid(connection.id, 'Connection id')
      if (!allIds.includes(connection.fromExhibitId) || !allIds.includes(connection.toExhibitId) || connection.fromExhibitId === connection.toExhibitId) throw new Error('Connection references an unknown or identical exhibit')
      const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
      const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
      const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
      const tagOffset = 0
      await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset)
        VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
    }
    for (const view of state.views) {
      requireUuid(view.id, 'Board view id')
      const placement = view.placement
      const docked = placement.mode === 'docked'
      await client.query(`INSERT INTO osint.board_views
        (id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [view.id, level.board_id, view.type, placement.mode,
        placement.mode === 'docked' ? placement.dockEdge : null, placement.mode === 'docked' ? null : placement.x, placement.mode === 'docked' ? null : placement.y,
        placement.mode === 'docked' ? null : placement.width, placement.mode === 'docked' ? placement.size : placement.height, view.zIndex, view.visible])
      if (view.type === 'timeline') {
        if (view.rangeMode === 'fixed' && (!view.range || !timestamp(view.range.start) || !timestamp(view.range.end) || Date.parse(view.range.end) <= Date.parse(view.range.start))) throw new Error('Timeline end must be after timeline start')
        await client.query(`INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)`,
          [view.id, view.rangeMode, view.rangeMode === 'fixed' ? view.range!.start : null, view.rangeMode === 'fixed' ? view.range!.end : null])
      }
    }
    const fields = new Map<string, string>()
    for (const document of documents) for (const key of Object.keys(document.metadata || {})) {
      if (!fields.has(key)) {
        const fieldId = randomUUID(); fields.set(key, fieldId)
        await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
      }
      await client.query('INSERT INTO osint.exhibit_metadata_text_values (exhibit_id,field_id,value) VALUES ($1,$2,$3)',
        [document.id, fields.get(key), document.metadata[key]])
    }
    const brief = state.brief || { body: '', concepts: [] }
    await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
    for (const [sortOrder, text] of (brief.checklist || []).map(item => item.text.trim()).filter(Boolean).entries())
      await client.query('INSERT INTO osint.level_brief_checklist_items (id,board_id,sort_order,text) VALUES ($1,$2,$3,$4)', [randomUUID(), level.board_id, sortOrder, text])
    for (const [sortOrder, concept] of brief.concepts.entries()) {
      requireUuid(concept.id, 'Brief concept id')
      if (concept.resolvedPartyExhibitId && !evidenceIds.has(concept.resolvedPartyExhibitId)) throw new Error('Concept resolution references an unknown party')
      const expected = concept.expectedPartyKind || expectedConceptKinds.get(concept.id) || null
      await client.query(`INSERT INTO osint.brief_concepts
        (id,board_id,label,context_text,sort_order,expected_party_kind,resolved_party_exhibit_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
      [concept.id, level.board_id, concept.label, concept.context, sortOrder, expected, concept.resolvedPartyExhibitId || null])
    }
    if (state.report) await client.query(`INSERT INTO osint.case_reports (board_id,title,required_for_completion)
      VALUES ($1,$2,$3) ON CONFLICT (board_id) DO UPDATE SET title=EXCLUDED.title,
      required_for_completion=EXCLUDED.required_for_completion,updated_at=NOW()`,
    [level.board_id,state.report.title,state.report.requiredForCompletion])
  }

  return {
    async listLevels() {
      const result = await pool.query(`SELECT slug AS id, title, subtitle, status, updated_at AS "updatedAt"
        FROM osint.levels ORDER BY updated_at DESC`)
      return result.rows
    },
    async createLevel(input) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const boardId = randomUUID(); const levelId = randomUUID()
        await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
        await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`,
          [levelId, input.id, boardId, input.title, input.subtitle])
        await createDefaultBoardViews(client, boardId)
        await client.query('COMMIT')
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
      return (await assembleLevel(input.id))!
    },
    async listTemplates() {
      const result = await pool.query<{
        id: string; slug: string; name: string; current_version: number; version_count: number; updated_at: Date
      }>(`SELECT t.id,t.slug,t.name,current.version AS current_version,COUNT(v.id)::int AS version_count,t.updated_at
          FROM osint.level_templates t
          JOIN osint.level_template_versions current ON current.id=t.current_version_id
          JOIN osint.level_template_versions v ON v.template_id=t.id
          GROUP BY t.id,current.version ORDER BY t.updated_at DESC,t.slug`)
      return result.rows.map(row => ({ id: row.id, slug: row.slug, name: row.name, currentVersion: row.current_version,
        versionCount: row.version_count, updatedAt: row.updated_at.toISOString() }))
    },
    async instantiateTemplate(templateSlug, input) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const version = await client.query<{ id: string; board_id: string; title: string; subtitle: string }>(
          `SELECT v.id,v.board_id,v.title,v.subtitle FROM osint.level_templates t
           JOIN osint.level_template_versions v ON v.template_id=t.id
           WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)
           FOR SHARE OF t,v`, [templateSlug, input.version ?? null])
        const source = version.rows[0]
        if (!source) { await client.query('ROLLBACK'); return null }
        const boardId = randomUUID(); const levelId = randomUUID()
        await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
        await client.query(`INSERT INTO osint.levels
          (id,slug,board_id,source_template_version_id,title,subtitle) VALUES ($1,$2,$3,$4,$5,$6)`,
        [levelId, input.id, boardId, source.id, input.title || source.title, source.subtitle])
        await cloneBoard(client, source.board_id, boardId)
        await client.query('COMMIT')
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
      return assembleLevel(input.id)
    },
    async saveLevelAsTemplate(levelId, input) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [input.slug])
        let template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE slug=$1 FOR UPDATE', [input.slug])
        let templateId = template.rows[0]?.id
        if (!templateId) {
          templateId = randomUUID()
          await client.query('INSERT INTO osint.level_templates (id,slug,name) VALUES ($1,$2,$3)', [templateId, input.slug, input.name])
          template = await client.query<{ id: string }>('SELECT id FROM osint.level_templates WHERE id=$1 FOR UPDATE', [templateId])
        }
        const versionResult = await client.query<{ version: number }>(
          'SELECT COALESCE(MAX(version),0)::int+1 AS version FROM osint.level_template_versions WHERE template_id=$1', [templateId])
        const version = versionResult.rows[0].version
        const boardId = randomUUID(); const versionId = randomUUID()
        await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'template_version')`, [boardId])
        await client.query(`INSERT INTO osint.level_template_versions
          (id,template_id,version,board_id,title,subtitle,created_from_level_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
        [versionId, templateId, version, boardId, level.title, level.subtitle, level.id])
        await cloneBoard(client, level.board_id, boardId)
        await client.query('UPDATE osint.level_templates SET name=$2,current_version_id=$3,updated_at=NOW() WHERE id=$1',
          [templateId, input.name, versionId])
        await client.query('COMMIT')
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
      return templateSummary(input.slug)
    },
    async getTemplateBoard(versionId) {
      const version = (await pool.query<{ board_id: string; title: string; subtitle: string; version: number; slug: string; name: string }>(
        `SELECT v.board_id,v.title,v.subtitle,v.version,t.slug,t.name FROM osint.level_template_versions v
         JOIN osint.level_templates t ON t.id=v.template_id WHERE v.id=$1`, [versionId])).rows[0]
      if (!version) return null
      // A synthetic level row over the version's board: id has no player rows (flags/seen
      // resolve empty); viewport/revision/status are placeholders the exporter discards.
      const level: LevelRow = {
        id: versionId, slug: version.slug, board_id: version.board_id, title: version.title, subtitle: version.subtitle,
        status: 'template', viewport_x: 0, viewport_y: 28, viewport_zoom: 0.7, updated_at: new Date(), revision: '0',
        source_template_version_id: null,
      }
      const [state, matchRules, semanticRules] = await Promise.all([
        assembleBoard(level, true), evidenceMatchRules(pool, version.board_id, true), evidenceSemanticRules(pool, version.board_id, true),
      ])
      return { slug: version.slug, name: version.name, version: version.version, state, matchRules, semanticRules }
    },
    getLevel(levelId, authorMode = false) { return assembleLevel(levelId, authorMode) },
    async saveLevel(state, authorMode) {
      let persistedState = state
      if (!authorMode) {
        const [full, visible] = await Promise.all([assembleLevel(state.id, true), assembleLevel(state.id, false)])
        if (!full || !visible) throw new Error('Level not found')
        persistedState = mergePlayerStateForPersistence(full, visible, state)
      }
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, state.id, true)
        if (!level) throw new Error('Level not found')
        await replaceBoard(client, level, persistedState)
        await client.query('COMMIT')
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async resetLevel(levelId) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        if (level.source_template_version_id) {
          const version = await client.query<{ board_id: string; title: string; subtitle: string }>(
            'SELECT board_id,title,subtitle FROM osint.level_template_versions WHERE id=$1 FOR SHARE', [level.source_template_version_id])
          const source = version.rows[0]
          if (!source) throw new Error('Source template version not found')
          await client.query('DELETE FROM osint.level_flags WHERE level_id=$1', [level.id])
          await client.query('DELETE FROM osint.level_seen_documents WHERE level_id=$1', [level.id])
          await clearBoard(client, level.board_id)
          await cloneBoard(client, source.board_id, level.board_id)
          await client.query(`UPDATE osint.levels SET title=$2,subtitle=$3,viewport_x=0,viewport_y=28,viewport_zoom=0.7,updated_at=NOW()
            WHERE id=$1`, [level.id, source.title, source.subtitle])
        }
        await client.query('COMMIT')
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
      return assembleLevel(levelId)
    },
    async getAsset(assetId) {
      if (!uuidPattern.test(assetId)) return null
      const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content,storage_provider,object_key FROM osint.assets WHERE id=$1', [assetId])
      const asset = result.rows[0]
      if (!asset) return null
      if (asset.storage_provider === 'postgres') {
        if (!asset.content) throw new Error(`PostgreSQL asset ${assetId} has no content`)
        return { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: Readable.from(asset.content) }
      }
      if (!asset.object_key) throw new Error(`Object asset ${assetId} has no object key`)
      const object = await objectStorage.getObject(asset.object_key)
      return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null
    },
    async uploadDocument(levelId, file, extraction, placement) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const goalsBeforeUpload = await levelGoalStates(client, level)
        const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
        const checksum = createHash('sha256').update(file.buffer).digest('hex')
        let assetId = (await client.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2 FOR SHARE', [checksum,file.size])).rows[0]?.id
        if (!assetId) {
          const objectKey = `assets/${checksum.slice(0,2)}/${checksum}`
          const stored = await objectStorage.putObject(objectKey,file.buffer,file.mimetype || 'application/octet-stream')
          const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
            (id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
            VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
            ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
          [candidateAssetId,file.originalname,file.mimetype || 'application/octet-stream',file.size,checksum,objectStorage.bucket,objectKey,stored.etag || null])
          assetId = asset.rows[0].id
        }
        const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
        const xpos = Number.isFinite(placement?.x) ? Math.max(0, Math.min(10_000, Number(placement?.x))) : 100
        const ypos = Number.isFinite(placement?.y) ? Math.max(0, Math.min(10_000, Number(placement?.y))) : 100
        await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
          VALUES ($1,$2,'document',$3,$4,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id, xpos, ypos])
        await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
          [exhibitId, fileType, assetId, file.originalname])
        const citation = await client.query<{ display_number:number }>(`INSERT INTO osint.exhibit_citations (board_id,exhibit_id,display_number)
          SELECT $1,$2,COALESCE(MAX(display_number),0)+1 FROM osint.exhibit_citations WHERE board_id=$1 RETURNING display_number`,
        [level.board_id,exhibitId])
        if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
        if (extraction.status === 'succeeded' && extraction.text.trim()) await client.query(
          'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,0,$3)', [randomUUID(), exhibitId, extraction.text.trim()])

        const extractionResult = await client.query<{ id: string }>(`INSERT INTO osint.asset_text_extractions
          (id,asset_id,extractor,extractor_version,language,status,extracted_text,error_message)
          VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
          ON CONFLICT (asset_id,extractor,extractor_version,language) DO UPDATE SET
            status=EXCLUDED.status,extracted_text=EXCLUDED.extracted_text,error_message=EXCLUDED.error_message,updated_at=NOW()
          RETURNING id`, [randomUUID(), assetId, extraction.extractor, extraction.extractorVersion, extraction.language,
          extraction.status, extraction.text, extraction.error?.slice(0, 2_000) || null])
        const extractionId = extractionResult.rows[0].id
        const ruleDefinitions = extraction.status === 'succeeded' && extraction.text.trim()
          ? await evidenceMatchRules(client, level.board_id)
          : []
        const evaluations = evaluateEvidenceRules(extraction.text, ruleDefinitions as EvidenceMatchRule[])
        const matchedFlags: string[] = []
        const awardedFlags: string[] = []
        for (const evaluation of evaluations) {
          const evaluationId = randomUUID()
          await client.query(`INSERT INTO osint.evidence_match_evaluations
            (id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,matched,matched_anchor_count,score)
            VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [evaluationId, level.id, level.board_id, exhibitId, extractionId,
            evaluation.ruleId, evaluation.matched, evaluation.matchedAnchorCount, evaluation.score])
          for (const anchor of evaluation.anchors) await client.query(`INSERT INTO osint.evidence_match_anchor_evaluations
            (evaluation_id,anchor_id,similarity,matched,matched_text) VALUES ($1,$2,$3,$4,$5)`,
          [evaluationId, anchor.anchorId, anchor.similarity, anchor.matched, anchor.matchedText])
          if (!evaluation.matched) continue
          matchedFlags.push(evaluation.flagKey)
          const awarded = await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key,awarded_by_evidence_match_id)
            VALUES ($1,$2,$3,$4) ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key`,
          [level.id, level.board_id, evaluation.flagKey, evaluationId])
          if (awarded.rowCount) awardedFlags.push(evaluation.flagKey)
        }
        const priorGoalStatus = new Map(goalsBeforeUpload.map(goal => [goal.key, goal.status]))
        const goals = (await levelGoalStates(client, level)).map(goal => ({
          ...goal,
          newlyCompleted: goal.status === 'complete' && priorGoalStatus.get(goal.key) !== 'complete',
        }))
        await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
        await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
        await client.query('COMMIT')
        return { id: exhibitId,type:'document',title:file.originalname,x:xpos,y:ypos,width:174,height:145,rotation:0,zIndex:0,hidden:false,
          displayNumber:citation.rows[0].display_number,
          fileType,captureKind:'unclassified',metadata:{},body:extraction.status === 'succeeded' && extraction.text.trim() ? [extraction.text.trim()] : [],regions:[],assetId,
          fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size,
          analysis:{ extractionStatus:extraction.status, matchedFlags:[...new Set(matchedFlags)], awardedFlags:[...new Set(awardedFlags)], goals } }
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async listFlags(levelId) {
      const level = await findLevel(pool, levelId)
      if (!level) return null
      const result = await pool.query<{ flag_key: string; earned_at: Date | null; gated_document_count: number }>(`
        WITH keys AS (
          SELECT flag_key FROM osint.level_flags WHERE level_id=$1
          UNION
          SELECT flag_key FROM osint.document_flag_requirements WHERE board_id=$2
          UNION
          SELECT flag_key FROM osint.evidence_match_rules WHERE board_id=$2
          UNION
          SELECT flag_key FROM osint.level_goal_flag_requirements WHERE board_id=$2
        )
        SELECT keys.flag_key,flags.earned_at,COUNT(requirements.document_exhibit_id)::int AS gated_document_count
        FROM keys
        LEFT JOIN osint.level_flags flags ON flags.level_id=$1 AND flags.flag_key=keys.flag_key
        LEFT JOIN osint.document_flag_requirements requirements ON requirements.board_id=$2 AND requirements.flag_key=keys.flag_key
        GROUP BY keys.flag_key,flags.earned_at ORDER BY keys.flag_key`, [level.id, level.board_id])
      return result.rows.map(row => ({ key: row.flag_key, earnedAt: row.earned_at?.toISOString(), gatedDocumentCount: row.gated_document_count }))
    },
    async setFlag(levelId, rawKey, earned) {
      const key = requireFlagKey(rawKey.trim())
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return false }
        if (earned) await client.query(`INSERT INTO osint.level_flags (level_id,board_id,flag_key) VALUES ($1,$2,$3)
          ON CONFLICT (level_id,flag_key) DO NOTHING`, [level.id, level.board_id, key])
        else {
          await client.query('DELETE FROM osint.level_flags WHERE level_id=$1 AND flag_key=$2', [level.id, key])
          await client.query(`DELETE FROM osint.level_seen_documents seen USING osint.document_flag_requirements requirement
            WHERE seen.level_id=$1 AND seen.document_exhibit_id=requirement.document_exhibit_id AND requirement.board_id=$2 AND requirement.flag_key=$3`,
          [level.id, level.board_id, key])
        }
        await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
        await client.query('COMMIT')
        return true
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async listEvidenceMatchRules(levelId) {
      const level = await findLevel(pool, levelId)
      return level ? evidenceMatchRules(pool, level.board_id, true) : null
    },
    async createEvidenceMatchRule(levelId, input) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const rule = await writeEvidenceMatchRule(client, level, randomUUID(), input, false)
        await client.query('COMMIT')
        return rule
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async updateEvidenceMatchRule(levelId, ruleId, input) {
      if (!uuidPattern.test(ruleId)) return null
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const rule = await writeEvidenceMatchRule(client, level, ruleId, input, true)
        await client.query('COMMIT')
        return rule
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async deleteEvidenceMatchRule(levelId, ruleId) {
      if (!uuidPattern.test(ruleId)) return false
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const removed = await client.query('DELETE FROM osint.evidence_match_rules WHERE id=$1 AND board_id=$2', [ruleId, level.board_id])
        if (removed.rowCount) await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
        await client.query('COMMIT')
        return Boolean(removed.rowCount)
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async listGoals(levelId) {
      const level = await findLevel(pool, levelId)
      return level ? levelGoalStates(pool, level, true) : null
    },
    async createGoal(levelId, input) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const goal = await writeGoal(client, level, randomUUID(), input, false)
        await client.query('COMMIT')
        return goal
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async updateGoal(levelId, goalId, input) {
      if (!uuidPattern.test(goalId)) return null
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const goal = await writeGoal(client, level, goalId, input, true)
        await client.query('COMMIT')
        return goal
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async deleteGoal(levelId, goalId) {
      if (!uuidPattern.test(goalId)) return false
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const removed = await client.query('DELETE FROM osint.level_goals WHERE id=$1 AND board_id=$2', [goalId,level.board_id])
        if (removed.rowCount) {
          await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
          await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
        }
        await client.query('COMMIT')
        return Boolean(removed.rowCount)
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async listEvidenceSemanticRules(levelId) {
      const level = await findLevel(pool, levelId)
      return level ? evidenceSemanticRules(pool, level.board_id, true) : null
    },
    async createEvidenceSemanticRule(levelId, input) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const rule = await writeEvidenceSemanticRule(client, level, randomUUID(), input, false)
        await client.query('COMMIT')
        return rule
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async updateEvidenceSemanticRule(levelId, ruleId, input) {
      if (!uuidPattern.test(ruleId)) return null
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const rule = await writeEvidenceSemanticRule(client, level, ruleId, input, true)
        await client.query('COMMIT')
        return rule
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async deleteEvidenceSemanticRule(levelId, ruleId) {
      if (!uuidPattern.test(ruleId)) return false
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const level = await findLevel(client, levelId, true)
        if (!level) { await client.query('ROLLBACK'); return null }
        const removed = await client.query('DELETE FROM osint.evidence_semantic_rules WHERE id=$1 AND board_id=$2', [ruleId,level.board_id])
        if (removed.rowCount) {
          await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
          await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
        }
        await client.query('COMMIT')
        return Boolean(removed.rowCount)
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
    async judgeDocument(levelId, documentId) {
      if (!uuidPattern.test(documentId)) return null
      const level = await findLevel(pool, levelId)
      if (!level) return null
      const document = (await pool.query<{ extraction_id:string; extracted_text:string }>(`SELECT extraction.id AS extraction_id,extraction.extracted_text
        FROM osint.document_exhibits document
        JOIN osint.exhibits exhibit ON exhibit.id=document.exhibit_id
        JOIN osint.asset_text_extractions extraction ON extraction.asset_id=document.asset_id AND extraction.status='succeeded'
        WHERE document.exhibit_id=$1 AND exhibit.board_id=$2
        ORDER BY extraction.updated_at DESC LIMIT 1`, [documentId,level.board_id])).rows[0]
      const goalsBefore = await levelGoalStates(pool, level)
      if (!document?.extracted_text.trim()) return { status:'failed',retryable:false,awardedFlags:[],goals:goalsBefore }
      const pendingGoalKeys = new Set(goalsBefore.filter(goal => goal.status === 'pending').map(goal => goal.key))
      const rules = (await evidenceSemanticRules(pool, level.board_id)).filter(rule => pendingGoalKeys.has(rule.goalKey)).slice(0, 5)
      if (!rules.length) return { status:'not_needed',retryable:false,awardedFlags:[],goals:goalsBefore }
      if (!evidenceJudge.enabled) return { status:'unavailable',retryable:true,awardedFlags:[],goals:goalsBefore }

      type EvaluationRow = { id:string; status:'pending'|'succeeded'|'failed'; subject:'target'|'related'|'ambiguous'|'neither'|null
        supports_claim:boolean|null; evidence_excerpt:string; confidence:string|null }
      const awardedFlags: string[] = []
      let resultStatus: DocumentSemanticAnalysis['status'] = 'failed'
      let lastVerdict: Pick<DocumentSemanticAnalysis,'subject'|'supportsClaim'|'evidenceExcerpt'|'confidence'> = {}
      for (const rule of rules) {
        const evaluatorVersion = `${rule.evaluatorVersion}:${evidenceJudge.evaluatorVersion}`.slice(0, 100)
        const evaluationId = randomUUID()
        const claimed = await pool.query<{ id:string }>(`INSERT INTO osint.evidence_semantic_evaluations
          (id,level_id,board_id,document_exhibit_id,extraction_id,rule_id,evaluator_version,provider,model,status)
          VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending')
          ON CONFLICT (level_id,document_exhibit_id,rule_id,evaluator_version) DO UPDATE SET
            provider=EXCLUDED.provider,model=EXCLUDED.model,status='pending',subject=NULL,supports_claim=NULL,
            evidence_excerpt='',confidence=NULL,failure_code=NULL,attempt_count=osint.evidence_semantic_evaluations.attempt_count+1,updated_at=NOW()
          WHERE osint.evidence_semantic_evaluations.status='failed'
             OR (osint.evidence_semantic_evaluations.status='pending' AND osint.evidence_semantic_evaluations.updated_at < NOW()-INTERVAL '2 minutes')
          RETURNING id`, [evaluationId,level.id,level.board_id,documentId,document.extraction_id,rule.id,evaluatorVersion,evidenceJudge.provider,evidenceJudge.model])
        const activeId = claimed.rows[0]?.id
        if (!activeId) {
          const existing = (await pool.query<EvaluationRow>(`SELECT id,status,subject,supports_claim,evidence_excerpt,confidence::text
            FROM osint.evidence_semantic_evaluations WHERE level_id=$1 AND document_exhibit_id=$2 AND rule_id=$3 AND evaluator_version=$4`,
          [level.id,documentId,rule.id,evaluatorVersion])).rows[0]
          if (existing?.status === 'pending') { if (resultStatus !== 'succeeded') resultStatus = 'pending'; continue }
          if (existing?.status === 'succeeded') {
            resultStatus = 'succeeded'
            lastVerdict = { subject:existing.subject || undefined,supportsClaim:existing.supports_claim ?? undefined,
              evidenceExcerpt:existing.evidence_excerpt,confidence:existing.confidence === null ? undefined : Number(existing.confidence) }
            continue
          }
          continue
        }
        try {
          const verdict = await evidenceJudge.judge({ targetSubject:rule.targetSubject,relatedSubject:rule.relatedSubject,
            assertion:rule.assertion,evidenceText:document.extracted_text })
          const flagKey = verdict.supportsClaim && verdict.confidence >= rule.minimumConfidence
            ? verdict.subject === 'target' ? rule.successFlagKey : verdict.subject === 'related' ? rule.relatedFlagKey : undefined
            : undefined
          const client = await pool.connect()
          try {
            await client.query('BEGIN')
            await client.query(`UPDATE osint.evidence_semantic_evaluations SET status='succeeded',subject=$2,supports_claim=$3,
              evidence_excerpt=$4,confidence=$5,failure_code=NULL,evaluated_at=NOW(),updated_at=NOW() WHERE id=$1`,
            [activeId,verdict.subject,verdict.supportsClaim,verdict.evidenceExcerpt,verdict.confidence])
            if (flagKey) {
              const awarded = await client.query(`INSERT INTO osint.level_flags
                (level_id,board_id,flag_key,awarded_by_semantic_evaluation_id) VALUES ($1,$2,$3,$4)
                ON CONFLICT (level_id,flag_key) DO NOTHING RETURNING flag_key`, [level.id,level.board_id,flagKey,activeId])
              if (awarded.rowCount) awardedFlags.push(flagKey)
            }
            await client.query('COMMIT')
          } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
          resultStatus = 'succeeded'
          lastVerdict = { subject:verdict.subject,supportsClaim:verdict.supportsClaim,evidenceExcerpt:verdict.evidenceExcerpt,confidence:verdict.confidence }
          if (flagKey === rule.successFlagKey) break
        } catch (error) {
          const code = (error instanceof EvidenceJudgeError ? error.code : 'provider_error').slice(0, 100)
          await pool.query(`UPDATE osint.evidence_semantic_evaluations SET status='failed',failure_code=$2,
            evaluated_at=NOW(),updated_at=NOW() WHERE id=$1`, [activeId,code])
        }
      }
      const priorGoalStatus = new Map(goalsBefore.map(goal => [goal.key,goal.status]))
      const goals = (await levelGoalStates(pool, level)).map(goal => ({ ...goal,
        newlyCompleted:goal.status === 'complete' && priorGoalStatus.get(goal.key) !== 'complete' }))
      return { status:resultStatus,retryable:resultStatus === 'failed' || resultStatus === 'pending',awardedFlags,goals,...lastVerdict }
    },
    async acknowledgeRevealedDocuments(levelId, rawDocumentIds) {
      const documentIds = [...new Set(rawDocumentIds.filter(id => uuidPattern.test(id)))]
      const level = await findLevel(pool, levelId)
      if (!level) return null
      if (!documentIds.length) return 0
      const result = await pool.query(`INSERT INTO osint.level_seen_documents (level_id,board_id,document_exhibit_id)
        SELECT $1,$2,e.id FROM osint.exhibits e
        WHERE e.board_id=$2 AND e.id=ANY($3::uuid[]) AND e.exhibit_type_id='document' AND NOT e.hidden
          AND NOT EXISTS (
            SELECT 1 FROM osint.document_flag_requirements requirement
            WHERE requirement.document_exhibit_id=e.id AND NOT EXISTS (
              SELECT 1 FROM osint.level_flags flag WHERE flag.level_id=$1 AND flag.flag_key=requirement.flag_key
            )
          )
        ON CONFLICT (level_id,document_exhibit_id) DO NOTHING`, [level.id, level.board_id, documentIds])
      return result.rowCount || 0
    },
  }
}
