import { createServer } from 'node:net'
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 { PlaythroughState } from './narrativeRepository.js'
import { runMigrations } from './migrations.js'

const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_narrative_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 = ''

function authFetch(url: string, authorization?: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers)
  if (authorization) headers.set('authorization', authorization)
  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))
    })
  })
}

suite('narrative graph runtime', () => {
  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-narrative-jwt-secret'
    process.env.ASSET_STORAGE_DRIVER = 'memory'
    process.env.PORT = String(port)
    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 (appPool) await appPool.end()
    if (!adminClient) return
    await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
    await adminClient.end()
  })

  it('New Game walks the seeded graph: cutscene → dialogue → level → finished', async () => {
    const json = { 'content-type': 'application/json' }
    // A frozen level template to back the level node.
    await authFetch(`${baseUrl}/api/levels`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ id: 'gr-src', title: 'Runtime Source' }) })
    await authFetch(`${baseUrl}/api/levels/gr-src/templates?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ name: 'Runtime Chapter', slug: 'gr-mystery-chapter' }) })
    // Mystery + cast, then seed a linear graph.
    await authFetch(`${baseUrl}/api/mysteries?edit=1`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({ slug: 'gr-mystery', title: 'Runtime Mystery', cast: [{ key: 'prof', name: 'Prof. Test', role: 'GU' }] }) })
    const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id: string; slug: string }[]
    const mysteryId = mysteries.find(m => m.slug === 'gr-mystery')!.id
    const seed = await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method: 'POST', headers: json, body: JSON.stringify({
      entry: 'intro',
      nodes: [
        { key: 'intro', type: 'cutscene', label: 'Title', componentKey: 'runtime-title', x: 0, y: 0, terminals: [{ key: 'continue', to: 'brief' }] },
        { key: 'brief', type: 'dialogue', label: 'Briefing', x: 200, y: 0, terminals: [{ key: 'continue', to: 'level' }], utterances: [{ npc: 'prof', text: 'Welcome.' }, { npc: 'prof', text: 'Investigate.' }] },
        { key: 'level', type: 'level', label: 'Board', templateSlug: 'gr-mystery-chapter', x: 400, y: 0, terminals: [{ key: 'report_back', to: 'debrief' }] },
        { key: 'debrief', type: 'dialogue', label: 'Debrief', x: 600, y: 0, terminals: [{ key: 'continue', to: null }], utterances: [{ npc: 'prof', text: 'Case closed.' }] },
      ],
    }) })
    expect(seed.status).toBe(201)

    // New Game lands on the entry cutscene.
    const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method: 'POST', headers: json, body: '{}' })
    expect(created.status).toBe(201)
    const start = await created.json() as PlaythroughState
    expect(start.node?.kind).toBe('cutscene')
    expect(start.node?.componentKey).toBe('runtime-title')
    const id = start.playthrough.id

    // Advance into the briefing dialogue (NPC utterances become steps).
    const brief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
    expect(brief.node?.kind).toBe('dialogue')
    expect(brief.node?.utterances).toHaveLength(2)
    const root = brief.node?.utterances?.find(u => u.id === brief.node?.rootId)
    expect(root).toMatchObject({ text: 'Welcome.', speaker: { name: 'Prof. Test' } })
    expect(root?.childIds).toHaveLength(1) // linear parent-chain

    // Advance into the level (a board is instantiated and loadable).
    const level = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
    expect(level.node?.kind).toBe('level')
    expect(level.node?.levelSlug).toMatch(/^gr-mystery-play-/)
    expect((await fetch(`${baseUrl}/api/levels/${level.node!.levelSlug}`)).status).toBe(200)

    // Report back → debrief dialogue.
    const debrief = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
    expect(debrief.node?.kind).toBe('dialogue')

    // Final advance → finished; current returns nothing active.
    const done = await (await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, undefined, { method: 'POST', headers: json, body: '{}' })).json() as PlaythroughState
    expect(done.playthrough.status).toBe('finished')
    expect(done.node).toBeNull()
    expect((await authFetch(`${baseUrl}/api/playthroughs/current`, undefined)).status).toBe(204)

    // Identity scoping: another user has no playthrough and cannot advance this one.
    const playerTwo = `Bearer ${jwt.sign({ sub: 'player-two' }, process.env.JWT_SECRET!)}`
    expect((await authFetch(`${baseUrl}/api/playthroughs/current`, playerTwo)).status).toBe(204)
    expect((await authFetch(`${baseUrl}/api/playthroughs/${id}/advance`, playerTwo, { method: 'POST', headers: json, body: '{}' })).status).toBe(404)
  })

  it('blocks a level terminal until authored goals complete, then promotes their flags', async () => {
    const json = { 'content-type':'application/json' }
    await authFetch(`${baseUrl}/api/levels`, adminAuthorization, { method:'POST',headers:json,
      body:JSON.stringify({ id:'goal-src',title:'Goal Source' }) })
    const goalResponse = await authFetch(`${baseUrl}/api/levels/goal-src/goals`, adminAuthorization, { method:'POST',headers:json,
      body:JSON.stringify({ key:'demo.prove-inventor',title:'Prove the inventor claim',instructions:'Paste the source.',
        completionMessage:'Source verified.',requiredFlags:['demo.inventor-proved'] }) })
    expect(goalResponse.status).toBe(201)
    expect((await authFetch(`${baseUrl}/api/levels/goal-src/evidence-match-rules`, adminAuthorization, { method:'POST',headers:json,
      body:JSON.stringify({ name:'Known patent text',flagKey:'demo.inventor-proved',minimumAnchorMatches:1,
        anchors:[{ phrase:'Nils Aall Barricelli improved chest of drawers',minimumSimilarity:.72 }] }) })).status).toBe(201)
    expect((await authFetch(`${baseUrl}/api/levels/goal-src/templates?edit=1`, adminAuthorization, { method:'POST',headers:json,
      body:JSON.stringify({ name:'Goal Chapter',slug:'goal-chapter' }) })).status).toBe(201)
    expect((await authFetch(`${baseUrl}/api/mysteries?edit=1`, adminAuthorization, { method:'POST',headers:json,
      body:JSON.stringify({ slug:'goal-mystery',title:'Goal Mystery',cast:[] }) })).status).toBe(201)
    const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id:string;slug:string }[]
    const mysteryId = mysteries.find(mystery => mystery.slug === 'goal-mystery')!.id
    expect((await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method:'POST',headers:json,
      body:JSON.stringify({ entry:'level',nodes:[
        { key:'level',type:'level',label:'Prove it',templateSlug:'goal-chapter',x:0,y:0,
          terminals:[{ key:'continue',label:'Continue',to:'merit' }] },
        { key:'merit',type:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit',x:200,y:0,
          terminals:[{ key:'continue',label:'Accept',to:null }] },
      ] }) })).status).toBe(201)

    const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method:'POST',headers:json,body:JSON.stringify({ mystery:'goal-mystery' }) })
    expect(created.status).toBe(201)
    const atLevel = await created.json() as PlaythroughState
    expect(atLevel.node?.kind).toBe('level')
    const playthroughId = atLevel.playthrough.id
    const levelSlug = atLevel.node!.levelSlug!

    const tooEarly = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
    expect(tooEarly.status).toBe(409)
    expect(await tooEarly.json()).toMatchObject({ errorCode:'goals_incomplete',pendingGoals:[{ key:'demo.prove-inventor' }] })

    const upload = new FormData()
    upload.append('file',new Blob(['Patent record: Nils Aall Barricelli improved chest of drawers.'],{ type:'text/plain' }),'patent.txt')
    const uploadResponse = await fetch(`${baseUrl}/api/levels/${levelSlug}/documents`, { method:'POST',body:upload })
    expect(uploadResponse.status).toBe(201)
    expect(await uploadResponse.json()).toMatchObject({ analysis:{ awardedFlags:['demo.inventor-proved'],
      goals:[expect.objectContaining({ key:'demo.prove-inventor',status:'complete',newlyCompleted:true })] } })

    const completed = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
    expect(completed.status).toBe(200)
    expect(await completed.json()).toMatchObject({ playthrough:{ status:'active' },node:{ kind:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit' } })
    expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json())
      .toEqual(expect.arrayContaining(['demo.inventor-proved','demo.inventor-merit']))
    const achievement = await appPool.query<{ awarded_by_node_id:string | null }>(
      'SELECT awarded_by_node_id FROM osint.achievements WHERE playthrough_id=$1 AND flag_key=$2', [playthroughId,'demo.inventor-proved'])
    expect(achievement.rows[0].awarded_by_node_id).toBe(atLevel.node!.id)

    const finished = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' })
    expect(finished.status).toBe(200)
    expect((await finished.json() as PlaythroughState).playthrough.status).toBe('finished')
  })
})
