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 { StoryGraphDto, StoryNodeDto, LevelTemplateOption, UtteranceDto } from './storyGraphRepository.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_storygraph_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 auth = ''

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 json = { 'content-type': 'application/json' }
function admin(url: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers); headers.set('authorization', auth)
  return fetch(url, { ...init, headers })
}

suite('story graph authoring 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-storygraph-jwt'
    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}`
    auth = `Bearer ${jwt.sign({ sub: 'sg-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()
  })

  async function makeMystery(slug: string) {
    await admin(`${baseUrl}/api/levels`, { method: 'POST', headers: json, body: JSON.stringify({ id: `${slug}-src`, title: 'SG Source' }) })
    await admin(`${baseUrl}/api/levels/${slug}-src/templates?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ name: `${slug} chapter` }) })
    await admin(`${baseUrl}/api/mysteries?edit=1`, { method: 'POST', headers: json, body: JSON.stringify({ slug, title: slug, chapters: [{ templateSlug: `${slug}-chapter` }], cast: [], cutscenes: [] }) })
    const list = await (await admin(`${baseUrl}/api/admin/mysteries`)).json() as { id: string; slug: string }[]
    return list.find(m => m.slug === slug)!.id
  }

  it('builds a node graph: create, wire, configure, set entry', async () => {
    const mysteryId = await makeMystery('sg-mystery')
    const templates = await (await admin(`${baseUrl}/api/admin/level-templates`)).json() as LevelTemplateOption[]
    const chapter = templates.find(t => t.slug === 'sg-mystery-chapter')!

    const cutscene = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 40, ypos: 40 }) })).json() as StoryNodeDto
    const level = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 320, ypos: 40 }) })).json() as StoryNodeDto
    expect(cutscene.terminals).toHaveLength(1)
    expect(cutscene.terminals[0].terminalKey).toBe('continue')
    expect(level.terminals[0].terminalKey).toBe('report_back')

    // Wire cutscene → level; configure level template; set entrypoint.
    expect((await admin(`${baseUrl}/api/admin/story-terminals/${cutscene.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: level.id }) })).status).toBe(200)
    await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ levelTemplateVersionId: chapter.versionId }) })
    await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/entry`, { method: 'PUT', headers: json, body: JSON.stringify({ nodeId: cutscene.id }) })

    const graph = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
    expect(graph.nodes).toHaveLength(2)
    expect(graph.entryNodeId).toBe(cutscene.id)
    expect(graph.nodes.find(n => n.id === cutscene.id)!.terminals[0].toNodeId).toBe(level.id)
    expect(graph.nodes.find(n => n.id === level.id)!.levelTemplateVersionId).toBe(chapter.versionId)

    // Deleting the target node unwires (SET NULL) rather than deleting the source's port.
    await admin(`${baseUrl}/api/admin/story-nodes/${level.id}`, { method: 'DELETE' })
    const after = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto
    expect(after.nodes).toHaveLength(1)
    expect(after.nodes[0].terminals[0].toNodeId).toBeNull()
  })

  it('rejects wiring a terminal across mysteries', async () => {
    const mysteryA = await makeMystery('sg-a')
    const mysteryB = await makeMystery('sg-b')
    const nodeA = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryA}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
    const nodeB = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryB}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
    const cross = await admin(`${baseUrl}/api/admin/story-terminals/${nodeA.terminals[0].id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ toNodeId: nodeB.id }) })
    expect(cross.status).toBe(400)
  })

  it('crafts utterances: NPC prompt, player option, wiring and same-node validation', async () => {
    const mysteryId = await makeMystery('sg-utt')
    const dialogue = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'dialogue', xpos: 0, ypos: 0 }) })).json() as StoryNodeDto
    const terminalId = dialogue.terminals[0].id
    const otherNode = await (await admin(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'level', xpos: 400, ypos: 0 }) })).json() as StoryNodeDto

    const prompt = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'npc', xpos: 40, ypos: 40, text: 'Are you ready?' }) })).json() as UtteranceDto
    const yes = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`, { method: 'POST', headers: json, body: JSON.stringify({ utterer: 'player', xpos: 300, ypos: 40, text: 'Yes' }) })).json() as UtteranceDto

    // 'Yes' hangs under the prompt (parent); and exits via the node's terminal.
    expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ parentUtteranceId: prompt.id }) })).status).toBe(200)
    expect((await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId }) })).status).toBe(200)

    const list = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
    const savedYes = list.find(u => u.id === yes.id)!
    expect(savedYes.parentUtteranceId).toBe(prompt.id)
    expect(savedYes.terminalId).toBe(terminalId)

    // A terminal from another node cannot be used as this utterance's exit.
    const foreign = await admin(`${baseUrl}/api/admin/utterances/${yes.id}`, { method: 'PATCH', headers: json, body: JSON.stringify({ terminalId: otherNode.terminals[0].id }) })
    expect(foreign.status).toBe(400)

    // Deleting the NPC prompt cascades its player options.
    await admin(`${baseUrl}/api/admin/utterances/${prompt.id}`, { method: 'DELETE' })
    const after = await (await admin(`${baseUrl}/api/admin/story-nodes/${dialogue.id}/utterances`)).json() as UtteranceDto[]
    expect(after).toHaveLength(0)
  })

  it('refuses graph writes without an admin claim', async () => {
    const mysteryId = await makeMystery('sg-guard')
    expect((await fetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/nodes`, { method: 'POST', headers: json, body: JSON.stringify({ nodeType: 'cutscene', xpos: 0, ypos: 0 }) })).status).toBe(403)
  })
})
