import { createServer } from 'node:net'
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js'
import { runMigrations } from './migrations.js'
import type { StoryGraphDto } from './storyGraphRepository.js'

const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_api_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
let adminAuthorization = ''
let judgeServer: HttpServer
let judgeVerdict = { subject:'target',supports_claim:true,evidence_excerpt:'Ada Example patented a pocket telescope',confidence:.96 }
let judgeHttpStatus = 200

function adminFetch(url: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers)
  headers.set('authorization', adminAuthorization)
  return fetch(url, { ...init, headers })
}

async function availablePort() {
  return new Promise<number>((resolve, reject) => {
    const probe = createServer()
    probe.once('error', reject)
    probe.listen(0, '127.0.0.1', () => {
      const address = probe.address()
      const port = typeof address === 'object' && address ? address.port : 0
      probe.close(error => error ? reject(error) : resolve(port))
    })
  })
}

const placed = (x: number, y: number, width: number, height: number, zIndex = 1) => ({ x, y, width, height, rotation: 0, zIndex, hidden: false })

suite('normalized level persistence API', () => {
  beforeAll(async () => {
    const adminUrl = new URL(baseDatabaseUrl!)
    adminUrl.pathname = '/postgres'
    adminClient = new Client({ connectionString: adminUrl.toString() })
    await adminClient.connect()
    await adminClient.query(`CREATE DATABASE "${databaseName}"`)
    const testUrl = new URL(baseDatabaseUrl!)
    testUrl.pathname = `/${databaseName}`
    const databaseUrl = testUrl.toString()
    await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)

    const port = await availablePort()
    process.env.DATABASE_URL = databaseUrl
    process.env.LEVEL_EDITING_ENABLED = 'true'
    process.env.JWT_SECRET = 'osint-integration-jwt-secret'
    process.env.ASSET_STORAGE_DRIVER = 'memory'
    process.env.PORT = String(port)
    judgeServer = createHttpServer((_req, res) => {
      res.statusCode = judgeHttpStatus
      res.setHeader('content-type', 'application/json')
      res.end(JSON.stringify({ content: [{ type:'tool_use',name:'record_evidence_verdict',input:judgeVerdict }] }))
    })
    await new Promise<void>((resolve, reject) => judgeServer.listen(0, '127.0.0.1', resolve).once('error', reject))
    const judgeAddress = judgeServer.address()
    process.env.EVIDENCE_JUDGE_PROVIDER = 'anthropic'
    process.env.EVIDENCE_JUDGE_MODEL = 'integration-haiku'
    process.env.ANTHROPIC_API_KEY = 'integration-key'
    process.env.ANTHROPIC_API_URL = `http://127.0.0.1:${typeof judgeAddress === 'object' && judgeAddress ? judgeAddress.port : 0}`
    const serverModule = await import('./index.js')
    appServer = serverModule.server
    appPool = serverModule.pool
    baseUrl = `http://127.0.0.1:${port}`
    adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
  })

  afterAll(async () => {
    if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
    if (judgeServer) await new Promise<void>((resolve, reject) => judgeServer.close(error => error ? reject(error) : resolve()))
    if (appPool) await appPool.end()
    if (!adminClient) return
    await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
    await adminClient.end()
  })

  it('round-trips exhibits, relations, board views, private objects, and template clones', async () => {
    expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false, playerName:'Player' })
    const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
      method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
    })
    expect(createResponse.status).toBe(201)
    const state = await createResponse.json() as CaseState
    const timeline = state.views.find((view): view is TimelineView => view.type === 'timeline')!
    timeline.rangeMode = 'fixed'
    timeline.range = { start: '2021-04-01', end: '2021-04-30' }
    state.viewport = { x: 91, y: -42, zoom: 0.85 }

    const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', captureKind:'full_page',metadata: {}, ...placed(1051, 417, 205, 282, 2) }
    const gatedDocument: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Later tip', body: [], regions: [], fileType: 'image', captureKind:'clipping',metadata: {}, requiredFlags: ['tip.received'], ...placed(1260, 417, 210, 178, 3) }
    const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
    const note: NoteExhibit = { id:randomUUID(),type:'note',title:'Extract',content:'Date matters',presentation:'lined_sheet',...placed(420,300,220,270) }
    const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
    const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
    state.exhibits = [document, gatedDocument, folder, note, event, party]
    state.relations = [
      { id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
      { id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 },
      { id: randomUUID(), fromExhibitId: event.id, toExhibitId: document.id, type: 'supports', sortOrder: 0 },
      { id: randomUUID(), fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 1 },
      { id: randomUUID(), fromExhibitId: party.id, toExhibitId: document.id, type: 'concerns', sortOrder: 0 },
    ]
    state.connections = [{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
    state.brief = { body: 'Identify Ada Lovelace.', concepts: [{ id: randomUUID(), label: 'Ada Lovelace', context: 'Named in evidence.', expectedPartyKind: 'person', resolvedPartyExhibitId: party.id }] }

    const save = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state) })
    expect(await save.json()).toEqual({ ok: true, mode: 'author' })
    const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
    expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
    expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
    expect(loaded.exhibits.find(item => item.id === note.id)).toMatchObject({ presentation:'lined_sheet',width:220,height:270 })
    expect(loaded.exhibits.find(item => item.id === document.id)).toMatchObject({ captureKind:'full_page',width:205,height:282 })
    expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
    expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
    expect(loaded.exhibits.find(item => item.id === gatedDocument.id)).toMatchObject({ requiredFlags: ['tip.received'] })

    const beforeFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
    expect(beforeFlag.exhibits.map(item => item.id)).not.toContain(gatedDocument.id)
    expect(beforeFlag.newlyVisibleDocumentIds).toContain(document.id)
    expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/flags`)).json()).toEqual([
      { key: 'tip.received', gatedDocumentCount: 1 },
    ])
    expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'PUT' })).status).toBe(200)
    const afterFlag = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
    expect(afterFlag.exhibits.map(item => item.id)).toContain(gatedDocument.id)
    expect(afterFlag.newlyVisibleDocumentIds).toContain(gatedDocument.id)
    expect((await fetch(`${baseUrl}/api/levels/${state.id}/reveals/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: afterFlag.newlyVisibleDocumentIds }) })).status).toBe(200)
    expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).newlyVisibleDocumentIds).toEqual([])

    expect((await adminFetch(`${baseUrl}/api/levels/${state.id}/flags/tip.received`, { method: 'DELETE' })).status).toBe(200)
    const matchRuleResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`, {
      method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
        name: 'Smoke source passage', sourceLabel: 'Archive smoke test', sourceUri: 'https://example.test/archive/smoke',
        flagKey: 'tip.received', minimumAnchorMatches: 1,
        anchors: [{ phrase: 'OSINT smoke evidence from the archive', minimumSimilarity: 0.72 }],
      }),
    })
    expect(matchRuleResponse.status).toBe(201)
    expect(await matchRuleResponse.json()).toMatchObject({ name: 'Smoke source passage', sourceLabel: 'Archive smoke test',
      sourceUri: 'https://example.test/archive/smoke', flagKey: 'tip.received', anchors: [{ phrase: 'OSINT smoke evidence from the archive' }] })
    expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-match-rules`)).json()).toHaveLength(1)
    const goalResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/goals`, {
      method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
        key: 'smoke.prove-source', title: 'Prove the source', instructions: 'Paste a matching archival source.',
        completionMessage: 'Source verified.', requiredFlags: ['tip.received'],
      }),
    })
    expect(goalResponse.status).toBe(201)
    expect(await goalResponse.json()).toMatchObject({ key: 'smoke.prove-source', status: 'pending', requiredFlags: ['tip.received'] })
    const playGoalBefore = (await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).goals[0]
    expect(playGoalBefore).toMatchObject({ key: 'smoke.prove-source', status: 'pending', newlyCompleted: false })
    expect(playGoalBefore).not.toHaveProperty('id')
    expect(playGoalBefore).not.toHaveProperty('requiredFlags')

    const semanticGoalResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/goals`, {
      method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ key:'smoke.semantic-proof',title:'Prove the semantic claim',
        instructions:'Upload another credible source.',completionMessage:'Claim verified.',requiredFlags:['semantic.proved'] }),
    })
    expect(semanticGoalResponse.status).toBe(201)
    const semanticGoal = await semanticGoalResponse.json() as { id:string }
    const semanticRuleResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/evidence-semantic-rules`, {
      method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ goalId:semanticGoal.id,name:'Ada inventor claim',
        targetSubject:'Ada Example',relatedSubject:'Ada Example Senior',assertion:'Ada Example was an inventor',successFlagKey:'semantic.proved',
        relatedFlagKey:'semantic.father',minimumConfidence:.85 }),
    })
    expect(semanticRuleResponse.status).toBe(201)

    const upload = new FormData()
    upload.append('file', new Blob(['OSINT smoke evidence from the archlve'], { type: 'text/plain' }), 'smoke-evidence.txt')
    upload.append('x', '812')
    upload.append('y', '438')
    const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: upload })
    expect(uploadResponse.status).toBe(201)
    const uploaded = await uploadResponse.json() as DocumentExhibit & { analysis: { extractionStatus: string; matchedFlags: string[]; awardedFlags: string[]; goals: CaseState['goals'] } }
    expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text', x: 812, y: 438,
      body: ['OSINT smoke evidence from the archlve'], analysis: { extractionStatus: 'succeeded', matchedFlags: ['tip.received'], awardedFlags: ['tip.received'],
        goals: expect.arrayContaining([expect.objectContaining({ key: 'smoke.prove-source', status: 'complete', newlyCompleted: true })]) } })
    expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence from the archlve')
    const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
    expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
    const automaticallyRevealed = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
    expect(automaticallyRevealed.exhibits.map(item => item.id)).toContain(gatedDocument.id)
    expect(automaticallyRevealed.goals).toEqual(expect.arrayContaining([
      expect.objectContaining({ key: 'smoke.prove-source', status: 'complete', newlyCompleted: false }),
    ]))
    const evaluationRows = await appPool.query<{ matched: boolean; matched_anchor_count: number }>(
      'SELECT matched,matched_anchor_count FROM osint.evidence_match_evaluations WHERE document_exhibit_id=$1', [uploaded.id])
    expect(evaluationRows.rows).toEqual([{ matched: true, matched_anchor_count: 1 }])

    const semanticUpload = new FormData()
    semanticUpload.append('file', new Blob(['Archive entry: Ada Example patented a pocket telescope in 1948.'], { type:'text/plain' }), 'semantic-evidence.txt')
    const semanticDocumentResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method:'POST',body:semanticUpload })
    expect(semanticDocumentResponse.status).toBe(201)
    const semanticDocument = await semanticDocumentResponse.json() as DocumentExhibit & { analysis:{ goals:CaseState['goals'] } }
    expect(semanticDocument.analysis.goals.find(goal => goal.key === 'smoke.semantic-proof')?.status).toBe('pending')
    const judgedResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents/${semanticDocument.id}/judge`, { method:'POST' })
    expect(judgedResponse.status).toBe(200)
    expect(await judgedResponse.json()).toMatchObject({ status:'succeeded',subject:'target',supportsClaim:true,confidence:.96,
      awardedFlags:['semantic.proved'],goals:expect.arrayContaining([expect.objectContaining({ key:'smoke.semantic-proof',status:'complete',newlyCompleted:true })]) })
    const semanticProvenance = await appPool.query<{ flag_key:string; awarded_by_semantic_evaluation_id:string | null }>(
      'SELECT flag_key,awarded_by_semantic_evaluation_id FROM osint.level_flags WHERE level_id=(SELECT id FROM osint.levels WHERE slug=$1) AND flag_key=$2',
      [state.id,'semantic.proved'])
    expect(semanticProvenance.rows[0]).toMatchObject({ flag_key:'semantic.proved',awarded_by_semantic_evaluation_id:expect.any(String) })
    expect(await (await adminFetch(`${baseUrl}/api/levels/${state.id}/documents/${semanticDocument.id}/judge`, { method:'POST' })).json())
      .toMatchObject({ status:'not_needed',awardedFlags:[] })

    const screenshot = new FormData()
    screenshot.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), 'Screenshot 2026-08-22.png')
    const screenshotResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents`, { method: 'POST', body: screenshot })
    expect(screenshotResponse.status).toBe(201)
    expect(await screenshotResponse.json()).toMatchObject({ type: 'document', fileType: 'image',captureKind:'unclassified',fileName: 'Screenshot 2026-08-22.png' })

    const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
    expect(templateResponse.status).toBe(201)
    const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }) })
    expect(cloneResponse.status).toBe(201)
    const clone = await cloneResponse.json() as CaseState
    expect(clone.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
    expect(clone.exhibits.map(item => item.id)).not.toContain(folder.id)
    expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
    expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
    expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
    expect(clone.exhibits.find(item => item.type === 'note')).toMatchObject({ presentation:'lined_sheet',width:220,height:270 })
    expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
    const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
    expect(authoredClone.exhibits.find(item => item.type === 'document' && item.title === 'Later tip')).toMatchObject({ captureKind:'clipping',requiredFlags: ['tip.received'] })
    expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-match-rules`)).json()).toEqual([
      expect.objectContaining({ name: 'Smoke source passage', sourceLabel: 'Archive smoke test', sourceUri: 'https://example.test/archive/smoke',
        flagKey: 'tip.received', anchors: [expect.objectContaining({ phrase: 'OSINT smoke evidence from the archive' })] }),
    ])
    expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/goals`)).json()).toEqual(expect.arrayContaining([
      expect.objectContaining({ key: 'smoke.prove-source', status: 'pending', requiredFlags: ['tip.received'] }),
      expect.objectContaining({ key: 'smoke.semantic-proof', status: 'pending', requiredFlags: ['semantic.proved'] }),
    ]))
    expect(await (await adminFetch(`${baseUrl}/api/levels/${clone.id}/evidence-semantic-rules`)).json()).toEqual([
      expect.objectContaining({ name:'Ada inventor claim',goalKey:'smoke.semantic-proof',successFlagKey:'semantic.proved' }),
    ])
  })

  it('evaluates existing documents when an evidence fingerprint is added later', async () => {
    const created = await adminFetch(`${baseUrl}/api/levels`, {
      method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ id:'late-evidence-rule',title:'Late evidence rule' }),
    })
    expect(created.status).toBe(201)
    const level = await created.json() as CaseState
    const upload = new FormData()
    upload.append('file',new Blob(['Archive patent 93585 names Niels Aall Baricelli and describes a Koffert-kommode.'],{ type:'text/plain' }),'patent.txt')
    const uploaded = await (await fetch(`${baseUrl}/api/levels/${level.id}/documents`,{ method:'POST',body:upload })).json() as DocumentExhibit & {
      analysis:{ matchedFlags:string[] }
    }
    expect(uploaded.analysis.matchedFlags).toEqual([])

    const rule = await adminFetch(`${baseUrl}/api/levels/${level.id}/evidence-match-rules`,{
      method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
        name:'Late National Library fingerprint',flagKey:'late.patent-recognized',minimumAnchorMatches:2,
        anchors:[
          { phrase:'Archive patent 93585 names Niels Aall Baricelli',minimumSimilarity:.7 },
          { phrase:'describes a Koffert-kommode',minimumSimilarity:.7 },
        ],
      }),
    })
    expect(rule.status).toBe(201)
    expect(await (await adminFetch(`${baseUrl}/api/levels/${level.id}/flags`)).json()).toEqual([
      expect.objectContaining({ key:'late.patent-recognized',earnedAt:expect.any(String) }),
    ])
    const evaluation = await appPool.query<{ matched:boolean;matched_anchor_count:number }>(
      'SELECT matched,matched_anchor_count FROM osint.evidence_match_evaluations WHERE document_exhibit_id=$1',[uploaded.id])
    expect(evaluation.rows).toEqual([{ matched:true,matched_anchor_count:2 }])
  })

  it('imports the data-defined Scene 7 template with its private recognition rules', async () => {
    const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js')
    const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json')
    const imported = await importMysteryTemplate(manifestPath, baseUrl, adminAuthorization.replace(/^Bearer /, ''))
    expect(imported.mystery).toEqual({ slug:'barricelli-inventor-proof' })
    expect(imported.playableLevel).toMatchObject({ title:'The Barricelli Files',exhibits:[expect.objectContaining({ type:'claim',statement:'Nils Aall Barricelli was an inventor.' })],report:expect.objectContaining({
      title:'Barricelli Inventor Finding',requiredForCompletion:true,status:'draft',
    }),goals:[expect.objectContaining({
      key:'barricelli.inventor-proof',status:'pending',newlyCompleted:false,
    })] })
    const sceneSevenRules=await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/evidence-match-rules`)).json()
    expect(sceneSevenRules).toHaveLength(2)
    expect(sceneSevenRules).toEqual(expect.arrayContaining([
      expect.objectContaining({ name:'Google Patents · GB695913A',sourceUri:'https://patents.google.com/patent/GB695913A/en',
        flagKey:'scene7.nils_inventor_proved',minimumAnchorMatches:2 }),
      expect.objectContaining({ name:'Nasjonalbiblioteket · Patent 93585',
        sourceUri:'https://www.nb.no/items/921545b51acef0b05054cfc1f4666975?page=9&searchText=baricelli',
        flagKey:'scene7.nils_inventor_proved',minimumAnchorMatches:2 }),
    ]))
    expect(await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/evidence-semantic-rules`)).json()).toEqual([
      expect.objectContaining({ goalKey:'barricelli.inventor-proof',targetSubject:'Nils Aall Barricelli',
        relatedFlagKey:'scene7.father_inventor_discovered' }),
    ])
    const mysteries = await (await adminFetch(`${baseUrl}/api/admin/mysteries`)).json() as { id:string;slug:string }[]
    const mysteryId = mysteries.find(mystery => mystery.slug === 'barricelli-inventor-proof')!.id
    const graph = await (await adminFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
    expect(graph.nodes).toHaveLength(3)
    expect(graph.nodes.find(node => node.nodeType === 'level')).toMatchObject({ label:'Demonstrate OSINT skill',levelTemplateVersionId:expect.any(String) })
    expect(graph.nodes.find(node => node.nodeType === 'cutscene')).toMatchObject({ label:'Case adjudication',componentKey:'case-adjudication' })
    expect(graph.nodes.find(node => node.nodeType === 'merit')).toMatchObject({ label:'The Barricelli Luggage',awardsFlag:'barricelli_luggage' })

    const fixtureDir = path.join(path.dirname(manifestPath), 'fixtures')
    const fatherUpload = new FormData()
    fatherUpload.append('file', new Blob([readFileSync(path.join(fixtureDir, 'father-only-negative-ocr.txt'))], { type:'text/plain' }), 'father-source.txt')
    const fatherDocument = await (await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents`, { method:'POST',body:fatherUpload })).json() as DocumentExhibit & { analysis:{ goals:CaseState['goals'] } }
    expect(fatherDocument.analysis.goals[0].status).toBe('pending')
    judgeVerdict = { subject:'related',supports_claim:true,evidence_excerpt:'den italienske maler og opfinder Barricelli',confidence:.96 }
    judgeHttpStatus = 429
    expect(await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents/${fatherDocument.id}/judge`, { method:'POST' })).json())
      .toMatchObject({ status:'failed',retryable:true,awardedFlags:[],goals:[expect.objectContaining({ status:'pending' })] })
    judgeHttpStatus = 200
    const fatherJudgment = await (await adminFetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents/${fatherDocument.id}/judge`, { method:'POST' })).json()
    expect(fatherJudgment).toMatchObject({ status:'succeeded',subject:'related',awardedFlags:['scene7.father_inventor_discovered'],
      goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'pending' })] })
    const relatedState=await (await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}`)).json() as CaseState
    const relatedClaim=relatedState.exhibits.find(exhibit => exhibit.type === 'claim')!
    const relatedConnectionId=randomUUID()
    relatedState.connections.push({ id:relatedConnectionId,fromExhibitId:relatedClaim.id,toExhibitId:fatherDocument.id,label:'Proof that the Barricelli family included an inventor.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
    expect((await fetch(`${baseUrl}/api/levels/${relatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(relatedState) })).status).toBe(200)
    const relatedSubmission=await (await fetch(`${baseUrl}/api/levels/${relatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
      investigatorName:'Test Player',
    }) })).json()
    expect(relatedSubmission).toMatchObject({ status:'evidence_insufficient',issues:expect.arrayContaining(['missing_accepted_evidence','connected_evidence_unverified']),
      feedback:expect.stringContaining('related person rather than the claim subject'),claims:[expect.objectContaining({ evidence:[expect.objectContaining({
        documentExhibitId:fatherDocument.id,evidenceAccepted:false,verification:expect.objectContaining({ status:'semantic_rejected' }),
      })] })] })

    const targetUpload = new FormData()
    targetUpload.append('file', new Blob([readFileSync(path.join(fixtureDir, 'google-patents-target-ocr.txt'))], { type:'text/plain' }), 'google-patents-source.txt')
    const targetResponse = await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}/documents`, { method:'POST',body:targetUpload })
    expect(targetResponse.status).toBe(201)
    const targetDocument=await targetResponse.json() as DocumentExhibit & { analysis:{ awardedFlags:string[];goals:CaseState['goals'] } }
    expect(targetDocument).toMatchObject({ displayNumber:expect.any(Number),analysis:{ awardedFlags:['scene7.nils_inventor_proved'],
      goals:[expect.objectContaining({ key:'barricelli.inventor-proof',status:'complete',newlyCompleted:true })] } })
    const reportState=await (await fetch(`${baseUrl}/api/levels/${imported.playableLevel.id}`)).json() as CaseState
    const claim=reportState.exhibits.find(exhibit => exhibit.type === 'claim')!
    const connectionId=randomUUID()
    reportState.connections.push({ id:connectionId,fromExhibitId:claim.id,toExhibitId:targetDocument.id,label:'Proof that…',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
    expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
    const incomplete=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
      investigatorName:'Test Player',
      // Legacy/forged report fields must not mutate board-owned provenance.
      evidence:[{ connectionId,documentExhibitId:targetDocument.id,relationText:'Forged complete statement',publishedAt:'1953-08-19',sourceCitation:'Forged source' }],
    }) })
    expect(incomplete.status).toBe(201)
    expect(await incomplete.json()).toMatchObject({ status:'evidence_accepted_report_incomplete',issues:expect.arrayContaining(['unfinished_relation','missing_date','missing_source']),
      feedback:"The evidence is good enough, but the report itself won't hold up in court. Add the date, cite the source, and provide the link if you can. Then we can accept it." })
    const targetOnBoard=reportState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === targetDocument.id)
    if (!targetOnBoard || targetOnBoard.type !== 'document') throw new Error('Target document missing from board')
    targetOnBoard.publishedAt='1953-08-19T00:00:00.000Z'
    targetOnBoard.sourceCitation='Google Patents · GB695913A'
    targetOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en'
    const targetConnection=reportState.connections.find(connection => connection.id === connectionId)
    if (!targetConnection) throw new Error('Target connection missing from board')
    targetConnection.label='Proof that Barricelli is named as the inventor on patent GB695913A.'
    expect((await fetch(`${baseUrl}/api/levels/${reportState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(reportState) })).status).toBe(200)
    const accepted=await fetch(`${baseUrl}/api/levels/${reportState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
      investigatorName:'Test Player',
    }) })
    expect(accepted.status).toBe(201)
    const acceptedBody=await accepted.json()
    expect(acceptedBody).toMatchObject({ status:'accepted',investigatorName:'Test Player',issues:[] })
    expect(acceptedBody.claims.flatMap((item:{ evidence:unknown[] }) => item.evidence)).toEqual(expect.arrayContaining([expect.objectContaining({
      displayNumber:targetDocument.displayNumber,evidenceAccepted:true,sourceCitation:'Google Patents · GB695913A',publishedAt:'1953-08-19T00:00:00.000Z',
      verification:expect.objectContaining({ status:'accepted' }),
    })]))

    // A later copy of the same correct source must be accepted on its own evaluation,
    // even though the first copy already owns the one-time level-flag provenance.
    const repeatedUpload=new FormData()
    repeatedUpload.append('file',new Blob([readFileSync(path.join(fixtureDir,'google-patents-target-ocr.txt'))],{ type:'text/plain' }),'google-patents-second-copy.txt')
    const repeatedDocument=await (await fetch(`${baseUrl}/api/levels/${reportState.id}/documents`,{ method:'POST',body:repeatedUpload })).json() as DocumentExhibit & { analysis:{ matchedFlags:string[];awardedFlags:string[] } }
    expect(repeatedDocument.analysis).toMatchObject({ matchedFlags:['scene7.nils_inventor_proved'],awardedFlags:[] })
    const repeatedState=await (await fetch(`${baseUrl}/api/levels/${reportState.id}`)).json() as CaseState
    const repeatedConnectionId=randomUUID()
    repeatedState.connections.push({ id:repeatedConnectionId,fromExhibitId:claim.id,toExhibitId:repeatedDocument.id,label:'Proof that Barricelli is named as the inventor on patent GB695913A.',tightness:65,tagStyle:'luggage',tagPosition:50,tagOffset:0 })
    const repeatedOnBoard=repeatedState.exhibits.find(exhibit => exhibit.type === 'document' && exhibit.id === repeatedDocument.id)
    if (!repeatedOnBoard || repeatedOnBoard.type !== 'document') throw new Error('Repeated document missing from board')
    repeatedOnBoard.publishedAt='1953-08-19T00:00:00.000Z'
    repeatedOnBoard.sourceCitation='Google Patents · GB695913A'
    repeatedOnBoard.sourceUri='https://patents.google.com/patent/GB695913A/en'
    expect((await fetch(`${baseUrl}/api/levels/${repeatedState.id}`,{ method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(repeatedState) })).status).toBe(200)
    const repeatedReport=await (await fetch(`${baseUrl}/api/levels/${repeatedState.id}/report/submissions`,{ method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
      investigatorName:'Test Player',
    }) })).json()
    expect(repeatedReport).toMatchObject({ status:'accepted',claims:[expect.objectContaining({ evidence:expect.arrayContaining([expect.objectContaining({
      documentExhibitId:repeatedDocument.id,evidenceAccepted:true,verification:expect.objectContaining({ status:'accepted' }),
    })]) })] })
    judgeVerdict = { subject:'target',supports_claim:true,evidence_excerpt:'Ada Example patented a pocket telescope',confidence:.96 }
    judgeHttpStatus = 200
  })
})
