import 'dotenv/config'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import multer from 'multer'
import pg from 'pg'
import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId, signPlayerToken } from './auth.js'
import { createUserRepository } from './userRepository.js'
import { submitCaseReport } from './caseReports.js'
import { createLevelRepository } from './levelRepository.js'
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
import { createNarrativeRepository } from './narrativeRepository.js'
import { createTextExtractorFromEnv } from './ocr.js'
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
import { createObjectStorageFromEnv } from './objectStorage.js'

const { Pool } = pg
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
  console.error('DATABASE_URL is required. Run PostgreSQL and execute npm run migrate:up first.')
  process.exit(1)
}

export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
const objectStorage = createObjectStorageFromEnv()
await objectStorage.initialize()
const textExtractor = createTextExtractorFromEnv()
const evidenceJudge = createEvidenceJudgeFromEnv()
const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge)
const narrative = createNarrativeRepository(pool, objectStorage)
const storyGraph = createStoryGraphRepository(pool)
const users = createUserRepository(pool)
const AUTH_COOKIE = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 }
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']

function wantsEdit(req: express.Request) {
  return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
}
// Identity guards for player-scoped routes.
function requireUser(req: express.Request, res: express.Response): string | null {
  const userId = resolveUserId(req)
  if (!userId) { res.status(401).json({ error: 'Sign in required' }); return null }
  return userId
}
async function ownsPlaythroughOr403(req: express.Request, res: express.Response, playthroughId: string): Promise<string | null> {
  const userId = requireUser(req, res)
  if (!userId) return null
  if (!await narrative.ownsPlaythrough(userId, playthroughId)) { res.status(403).json({ error: 'Not your playthrough' }); return null }
  return userId
}
function slug(value: unknown, fallback: string) {
  return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
}

export const app = express()
app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true }))
app.use(cookieParser())
app.use(authenticateJwt)
app.use(express.json({ limit: '2mb' }))
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: Number(process.env.MAX_DOCUMENT_BYTES || 25 * 1024 * 1024), files: 1 },
})

app.get('/api/health', async (_req, res) => {
  try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, textExtraction: textExtractor.provider,
    evidenceJudge: evidenceJudge.enabled ? evidenceJudge.provider : 'disabled', schema: 'osint', editingEnabled }) }
  catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
})
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) }))

// Player accounts (path A: GUPI issues the token). register/login set the auth_token
// cookie; every game write then binds to this user via resolveUserId.
app.post('/api/auth/register', async (req, res, next) => {
  try {
    const body = req.body || {}
    const result = await users.registerUser({ handle: String(body.handle || ''), password: String(body.password || ''), displayName: String(body.displayName || '') })
    if (result.error || !result.user) return res.status(result.error === 'That handle is taken' ? 409 : 400).json({ error: result.error || 'Registration failed' })
    res.cookie('auth_token', signPlayerToken(result.user), AUTH_COOKIE)
    res.status(201).json({ user: result.user })
  } catch (error) { next(error) }
})
app.post('/api/auth/login', async (req, res, next) => {
  try {
    const body = req.body || {}
    const user = await users.authenticateUser(String(body.handle || ''), String(body.password || ''))
    if (!user) return res.status(401).json({ error: 'Invalid handle or password' })
    res.cookie('auth_token', signPlayerToken(user), AUTH_COOKIE)
    res.json({ user })
  } catch (error) { next(error) }
})
app.post('/api/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); res.json({ ok: true }) })
app.get('/api/auth/me', async (req, res, next) => {
  try {
    const sub = req.authClaims?.sub
    const user = typeof sub === 'string' ? await users.getUser(sub) : null
    user ? res.json({ user }) : res.status(204).end()
  } catch (error) { next(error) }
})
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
  const requestedReturn = String(req.query.returnTo || '/')
  const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
  res.cookie('auth_token', createDevelopmentAdminToken(), { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
  res.redirect(returnTo)
})
app.get('/api/levels', async (_req, res, next) => {
  try { res.json(await levels.listLevels()) }
  catch (error) { next(error) }
})
app.get('/api/templates', async (_req, res, next) => {
  try { res.json(await levels.listTemplates()) }
  catch (error) { next(error) }
})
app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => {
  try {
    if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
    const title = String(req.body?.title || '').trim() || undefined
    const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`)
    const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version })
    level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
  } catch (error) { next(error) }
})
app.post('/api/levels', requireAdmin, async (req, res, next) => {
  try {
    if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
    const title = String(req.body?.title || 'Untitled Investigation').trim()
    const id = slug(req.body?.id, `level-${Date.now()}`)
    res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => {
  try {
    if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
    const name = String(req.body?.name || 'Untitled Template').trim()
    const templateSlug = slug(req.body?.slug, name)
    const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name })
    template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.get('/api/assets/:id', async (req, res, next) => {
  try {
    const asset = await levels.getAsset(req.params.id)
    if (!asset) return res.status(404).json({ error: 'Asset not found' })
    const inline = asset.mimeType === 'application/pdf' || asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('text/')
    res.setHeader('Content-Type', asset.mimeType || 'application/octet-stream')
    res.setHeader('Content-Length', asset.byteSize)
    res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.originalName)}`)
    res.setHeader('X-Content-Type-Options', 'nosniff')
    asset.stream.on('error', next)
    asset.stream.pipe(res)
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
  try {
    if (!req.file) return res.status(400).json({ error: 'A file is required' })
    const extraction = await textExtractor.extract(req.file)
    const x = Number(req.body?.x); const y = Number(req.body?.y)
    const placement = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
    const document = await levels.uploadDocument(String(req.params.id), req.file, extraction, placement)
    document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/documents/:documentId/judge', async (req, res, next) => {
  try {
    const levelUser = resolveUserId(req)
    if (!hasAdminClaim(req) && (!levelUser || !await narrative.ownsActiveLevel(levelUser, String(req.params.id)))) {
      return res.status(403).json({ error: 'This level is not active for the current player' })
    }
    const result = await levels.judgeDocument(String(req.params.id), String(req.params.documentId))
    result ? res.json(result) : res.status(404).json({ error: 'Level or document not found' })
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/reveals/seen', async (req, res, next) => {
  try {
    const ids = Array.isArray(req.body?.documentIds) ? req.body.documentIds.map(String) : []
    const acknowledged = await levels.acknowledgeRevealedDocuments(String(req.params.id), ids)
    acknowledged === null ? res.status(404).json({ error: 'Level not found' }) : res.json({ acknowledged })
  } catch (error) { next(error) }
})
app.get('/api/levels/:id/flags', requireAdmin, async (req, res, next) => {
  try {
    const flags = await levels.listFlags(String(req.params.id))
    flags ? res.json(flags) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.put('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
  try {
    const updated = await levels.setFlag(String(req.params.id), String(req.params.key), true)
    updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.delete('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
  try {
    const updated = await levels.setFlag(String(req.params.id), String(req.params.key), false)
    updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.get('/api/levels/:id', async (req, res, next) => {
  try {
    const level = await levels.getLevel(req.params.id, wantsEdit(req))
    level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.put('/api/levels/:id', async (req, res, next) => {
  const state = req.body as CaseState
  if (!state || state.id !== req.params.id || !Array.isArray(state.exhibits) || !Array.isArray(state.views) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
  try {
    const authorMode = wantsEdit(req)
    await levels.saveLevel(state, authorMode)
    res.json({ ok: true, mode: authorMode ? 'author' : 'play' })
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/reset', async (req, res, next) => {
  try {
    const level = await levels.resetLevel(req.params.id)
    level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/report/submissions', async (req, res, next) => {
  try {
    const report = await submitCaseReport(pool,String(req.params.id),{
      investigatorName:String(req.body?.investigatorName || resolvePlayerName(req)),
    })
    report ? res.status(201).json(report) : res.status(404).json({ error:'Level not found' })
  } catch (error) { next(error) }
})

// Admin authoring panel: NPC template library and mystery listing. Reads require an
// admin claim; writes additionally require editing to be enabled on this deployment.
function requireEditing(res: express.Response) {
  if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
  return true
}
app.get('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
  try {
    const rules = await levels.listEvidenceMatchRules(String(req.params.id))
    rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const rule = await levels.createEvidenceMatchRule(String(req.params.id), req.body)
    rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.put('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const rule = await levels.updateEvidenceMatchRule(String(req.params.id), String(req.params.ruleId), req.body)
    rule ? res.json(rule) : res.status(404).json({ error: 'Level or evidence match rule not found' })
  } catch (error) { next(error) }
})
app.delete('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const removed = await levels.deleteEvidenceMatchRule(String(req.params.id), String(req.params.ruleId))
    if (removed === null) return res.status(404).json({ error: 'Level not found' })
    removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Evidence match rule not found' })
  } catch (error) { next(error) }
})
app.get('/api/levels/:id/goals', requireAdmin, async (req, res, next) => {
  try {
    const goals = await levels.listGoals(String(req.params.id))
    goals ? res.json(goals) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/goals', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const goal = await levels.createGoal(String(req.params.id), req.body)
    goal ? res.status(201).json(goal) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.put('/api/levels/:id/goals/:goalId', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const goal = await levels.updateGoal(String(req.params.id), String(req.params.goalId), req.body)
    goal ? res.json(goal) : res.status(404).json({ error: 'Level or goal not found' })
  } catch (error) { next(error) }
})
app.delete('/api/levels/:id/goals/:goalId', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const removed = await levels.deleteGoal(String(req.params.id), String(req.params.goalId))
    if (removed === null) return res.status(404).json({ error: 'Level not found' })
    removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Goal not found' })
  } catch (error) { next(error) }
})
app.get('/api/levels/:id/evidence-semantic-rules', requireAdmin, async (req, res, next) => {
  try {
    const rules = await levels.listEvidenceSemanticRules(String(req.params.id))
    rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.post('/api/levels/:id/evidence-semantic-rules', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const rule = await levels.createEvidenceSemanticRule(String(req.params.id), req.body)
    rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' })
  } catch (error) { next(error) }
})
app.put('/api/levels/:id/evidence-semantic-rules/:ruleId', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const rule = await levels.updateEvidenceSemanticRule(String(req.params.id), String(req.params.ruleId), req.body)
    rule ? res.json(rule) : res.status(404).json({ error: 'Level or semantic rule not found' })
  } catch (error) { next(error) }
})
app.delete('/api/levels/:id/evidence-semantic-rules/:ruleId', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const removed = await levels.deleteEvidenceSemanticRule(String(req.params.id), String(req.params.ruleId))
    if (removed === null) return res.status(404).json({ error: 'Level not found' })
    removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Semantic rule not found' })
  } catch (error) { next(error) }
})
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
  try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
})
app.delete('/api/admin/mysteries/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const ok = await narrative.deleteMystery(String(req.params.id))
    ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Mystery not found' })
  } catch (error) { next(error) }
})
// Shared asset library (images, audio, PDFs) — reuses the immutable, deduplicated
// osint.assets store; bytes served via GET /api/assets/:id.
app.get('/api/admin/assets', requireAdmin, async (_req, res, next) => {
  try { res.json(await narrative.listAssets()) } catch (error) { next(error) }
})
app.post('/api/admin/assets', requireAdmin, upload.single('file'), async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    if (!req.file) return res.status(400).json({ error: 'A file is required' })
    res.status(201).json(await narrative.uploadAsset(req.file))
  } catch (error) { next(error) }
})
app.delete('/api/admin/assets/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const outcome = await narrative.deleteAsset(String(req.params.id))
    if (outcome === 'deleted') return res.json({ ok: true })
    res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'Asset is in use' : 'Asset not found' })
  } catch (error) { next(error) }
})
app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => {
  try { res.json(await narrative.listNpcs()) } catch (error) { next(error) }
})
app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
    res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null, phoneNumber: req.body.phoneNumber ?? null, email: req.body.email ?? null }))
  } catch (error) { next(error) }
})
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose, phoneNumber: req.body?.phoneNumber, email: req.body?.email })
    npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
  } catch (error) { next(error) }
})
app.delete('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const outcome = await narrative.deleteNpc(String(req.params.id))
    if (outcome === 'deleted') return res.json({ ok: true })
    res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'NPC is used by a cutscene' : 'NPC not found' })
  } catch (error) { next(error) }
})
app.post('/api/admin/npcs/:id/poses', requireAdmin, upload.single('file'), async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    if (!req.file) return res.status(400).json({ error: 'An image file is required' })
    if (!req.body?.poseKey) return res.status(400).json({ error: 'A pose key is required' })
    const npc = await narrative.addPose(String(req.params.id), String(req.body.poseKey), req.file)
    npc ? res.status(201).json(npc) : res.status(404).json({ error: 'NPC not found' })
  } catch (error) { next(error) }
})
app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const npc = await narrative.deletePose(String(req.params.id), String(req.params.poseKey))
    npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
  } catch (error) { next(error) }
})

// Story flow graph authoring (Phase 1): nodes, terminals, wiring, entrypoint.
app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => {
  try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) }
})
// Read a frozen template version's board (exhibits, goals, rules) for the mystery:pull
// exporter — read-only, so no editing gate.
app.get('/api/admin/level-templates/:versionId/export', requireAdmin, async (req, res, next) => {
  try {
    const board = await levels.getTemplateBoard(String(req.params.versionId))
    board ? res.json(board) : res.status(404).json({ error: 'Template version not found' })
  } catch (error) { next(error) }
})
app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
  try {
    const graph = await storyGraph.getGraph(String(req.params.id))
    graph ? res.json(graph) : res.status(404).json({ error: 'Mystery not found' })
  } catch (error) { next(error) }
})
app.post('/api/admin/mysteries/:id/nodes', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const nodeType = String(req.body?.nodeType) as StoryNodeType
    if (!STORY_NODE_TYPES.includes(nodeType)) return res.status(400).json({ error: 'Unknown node type' })
    const node = await storyGraph.createNode(String(req.params.id), { nodeType, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, label: req.body?.label })
    node ? res.status(201).json(node) : res.status(404).json({ error: 'Mystery not found' })
  } catch (error) { next(error) }
})
app.patch('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const node = await storyGraph.updateNode(String(req.params.id), req.body || {})
    node ? res.json(node) : res.status(404).json({ error: 'Node not found' })
  } catch (error) { next(error) }
})
app.delete('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const ok = await storyGraph.deleteNode(String(req.params.id))
    ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Node not found' })
  } catch (error) { next(error) }
})
app.post('/api/admin/story-nodes/:id/terminals', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    if (!req.body?.terminalKey) return res.status(400).json({ error: 'A terminal key is required' })
    const node = await storyGraph.addTerminal(String(req.params.id), { terminalKey: String(req.body.terminalKey), label: req.body?.label })
    node ? res.status(201).json(node) : res.status(404).json({ error: 'Node not found' })
  } catch (error) { next(error) }
})
app.patch('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const result = await storyGraph.updateTerminal(String(req.params.id), req.body || {})
    result.ok ? res.json({ ok: true }) : res.status(result.error === 'Terminal not found' ? 404 : 400).json({ error: result.error })
  } catch (error) { next(error) }
})
app.delete('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const ok = await storyGraph.deleteTerminal(String(req.params.id))
    ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Terminal not found' })
  } catch (error) { next(error) }
})
app.put('/api/admin/mysteries/:id/entry', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const result = await storyGraph.setEntryNode(String(req.params.id), req.body?.nodeId ?? null)
    result.ok ? res.json({ ok: true }) : res.status(400).json({ error: result.error })
  } catch (error) { next(error) }
})
app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    if (!req.body?.entry || !Array.isArray(req.body?.nodes)) return res.status(400).json({ error: 'A graph spec needs entry and nodes' })
    const result = await storyGraph.authorGraph(String(req.params.id), req.body)
    result ? res.status(201).json(result) : res.status(404).json({ error: 'Mystery not found' })
  } catch (error) { next(error) }
})

// Utterance sub-graph (dialogue crafter).
app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
  try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) }
})
// Resolved runtime dialogue tree for the editor's live preview (same resolver as play).
app.get('/api/admin/story-nodes/:id/dialogue', requireAdmin, async (req, res, next) => {
  try { res.json(await narrative.resolveDialogue(String(req.params.id))) } catch (error) { next(error) }
})
app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const utterer = req.body?.utterer === 'player' ? 'player' : 'npc'
    const utterance = await storyGraph.createUtterance(String(req.params.id), { utterer, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, text: req.body?.text })
    utterance ? res.status(201).json(utterance) : res.status(404).json({ error: 'Node not found' })
  } catch (error) { next(error) }
})
app.patch('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const result = await storyGraph.updateUtterance(String(req.params.id), req.body || {})
    result.ok ? res.json({ ok: true }) : res.status(result.error === 'Utterance not found' ? 404 : 400).json({ error: result.error })
  } catch (error) { next(error) }
})
app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
  try {
    if (!requireEditing(res)) return
    const ok = await storyGraph.deleteUtterance(String(req.params.id))
    ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Utterance not found' })
  } catch (error) { next(error) }
})

// Play mode: the launchable case list for the splash picker (entrypoint required).
app.get('/api/mysteries', async (_req, res, next) => {
  try { res.json(await narrative.listPlayableMysteries()) }
  catch (error) { next(error) }
})

// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
// dialogue, levels) lives in the story graph, seeded separately.
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
  try {
    if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
    const body = req.body || {}
    if (!body.slug || !body.title) return res.status(400).json({ error: 'A mystery requires slug and title' })
    const created = await narrative.authorMystery({ slug: slug(body.slug, body.slug), title: String(body.title), cast: Array.isArray(body.cast) ? body.cast : [] })
    res.status(201).json(created)
  } catch (error) { next(error) }
})

// New Game creates a playthrough bound to the caller's identity.
app.post('/api/playthroughs', async (req, res, next) => {
  try {
    const userId = requireUser(req, res); if (!userId) return
    const result = await narrative.createPlaythrough(userId, req.body?.mystery ? slug(req.body.mystery, req.body.mystery) : undefined)
    result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' })
  } catch (error) { next(error) }
})
app.get('/api/playthroughs/current', async (req, res, next) => {
  try {
    const userId = resolveUserId(req)
    const result = userId ? await narrative.getCurrentPlaythrough(userId) : null
    result ? res.json(result) : res.status(204).end()
  } catch (error) { next(error) }
})
// Advance the playthrough through the story graph (follows a terminal; auto-skips
// gates; instantiates the board when entering a level node).
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
  try {
    const userId = requireUser(req, res); if (!userId) return
    const result = await narrative.advancePlaythrough(userId, String(req.params.id), req.body?.terminalKey)
    result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : result.errorCode ? 409 : 400)
      .json({ error: result.error, errorCode: result.errorCode, pendingGoals: result.pendingGoals })
  } catch (error) { next(error) }
})

// The playthrough case-state (achievements). Read is open; granting is a dev-only
// stand-in until the server-side achievement rule engine drives awards from play.
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
  try {
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    const flags = await narrative.listAchievements(String(req.params.id))
    flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
  } catch (error) { next(error) }
})
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
  try {
    if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' })
    const result = await narrative.awardAchievement(String(req.params.id), String(req.body.flagKey), req.body.nodeId ? String(req.body.nodeId) : null)
    result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
  } catch (error) { next(error) }
})
// A dialogue line was reached in play — grant its authored achievement (validated
// server-side against the player's current node, so players can't forge flags).
app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => {
  try {
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid))
    result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
  } catch (error) { next(error) }
})
// Field notebook: capture NPC lines during play, list them, and remove (on tear/discard).
app.get('/api/playthroughs/:id/notebook', async (req, res, next) => {
  try {
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    res.json(await narrative.notebookPages(String(req.params.id)))
  } catch (error) { next(error) }
})
app.post('/api/playthroughs/:id/notebook', async (req, res, next) => {
  try {
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    const page = await narrative.addNotebookPage(String(req.params.id), String(req.body?.text || ''), req.body?.utteranceId ? String(req.body.utteranceId) : null)
    page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' })
  } catch (error) { next(error) }
})
app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => {
  try {
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId))
    ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' })
  } catch (error) { next(error) }
})
// The phone tool: the directory available on the current node, and dialing a number.
app.get('/api/playthroughs/:id/phone', async (req, res, next) => {
  try {
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    res.json(await narrative.phoneDirectory(String(req.params.id)))
  } catch (error) { next(error) }
})
app.post('/api/playthroughs/:id/dial', async (req, res, next) => {
  try {
    if (!await ownsPlaythroughOr403(req, res, String(req.params.id))) return
    res.json(await narrative.dial(String(req.params.id), String(req.body?.number || '')))
  } catch (error) { next(error) }
})
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
  try {
    if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' })
    const userId = requireUser(req, res); if (!userId) return
    if (!req.body?.nodeId) return res.status(400).json({ error: 'A nodeId is required' })
    const result = await narrative.gotoNode(userId, String(req.params.id), String(req.body.nodeId))
    result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' || result.error === 'Node not found' ? 404 : 400).json({ error: result.error })
  } catch (error) { next(error) }
})

app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  if (error instanceof multer.MulterError) {
    return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
  }
  console.error(error); res.status(500).json({ error: 'Internal server error' })
})
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
const port = Number(process.env.PORT || 8787)
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') {
  process.on('SIGTERM', shutdown)
  process.on('SIGINT', shutdown)
}
