import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CaseDocument, CaseState, ClaimExhibit, FolderExhibit, NoteExhibit } from '../src/types.js'
import type { TemplateBoardExport } from '../server/levelRepository.js'
import type { NpcDto } from '../server/narrativeRepository.js'
import type { StoryGraphDto, StoryNodeDto, UtteranceDto } from '../server/storyGraphRepository.js'

const DEV_URL = 'http://localhost:8787'
const PROD_URL = 'https://gupi.glitch.university'

type MysterySummary = { id: string; slug: string; title: string; nodes: number }

function slugify(input: string, fallback: string) {
  const value = (input || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
  return value || fallback
}
// Assign a unique key per id, deriving a readable slug and de-duplicating collisions.
function uniqueKeyer() {
  const used = new Set<string>()
  return (base: string, fallback: string) => {
    const root = slugify(base, fallback)
    let key = root
    for (let n = 2; used.has(key); n++) key = `${root}-${n}`
    used.add(key)
    return key
  }
}
// Drop undefined and empty arrays so the emitted manifest reads like a hand-written one
// (the importer tolerates sparse levels, so an absent key means "none").
function prune<T extends Record<string, unknown>>(object: T): T {
  const out: Record<string, unknown> = {}
  for (const [key, value] of Object.entries(object)) {
    if (value === undefined) continue
    if (Array.isArray(value) && value.length === 0) continue
    out[key] = value
  }
  return out as T
}

async function getJson<T>(baseUrl: string, route: string, authorization?: string): Promise<T> {
  const response = await fetch(`${baseUrl}${route}`, { headers: authorization ? { authorization } : undefined })
  if (!response.ok) throw new Error(`GET ${route} failed (${response.status}): ${await response.text()}`)
  return await response.json() as T
}

// Local dev has no login page; mint an admin cookie via the dev-only endpoint and reuse
// its token as a bearer. Production always requires an explicit OSINT_ADMIN_JWT.
async function resolveAdminJwt(baseUrl: string, isProd: boolean): Promise<string> {
  if (process.env.OSINT_ADMIN_JWT) return process.env.OSINT_ADMIN_JWT
  if (isProd) throw new Error('OSINT_ADMIN_JWT is required for --prod (mint one inside the gnommo-osint-board container)')
  const response = await fetch(`${baseUrl}/api/dev/admin-session`, { redirect: 'manual' })
  const cookie = response.headers.get('set-cookie')
  const token = cookie?.match(/auth_token=([^;]+)/)?.[1]
  if (!token) throw new Error(`Could not obtain a dev admin token from ${baseUrl}/api/dev/admin-session`)
  return token
}

// One board (frozen template version) → the importer's level shape.
function serializeLevel(board: TemplateBoardExport, assetFilenames: Map<string, string>) {
  const state: CaseState = board.state
  const documents = state.exhibits.filter((exhibit): exhibit is CaseDocument => exhibit.type === 'document')
  const folders = state.exhibits.filter((exhibit): exhibit is FolderExhibit => exhibit.type === 'folder')
  const claims = state.exhibits.filter((exhibit): exhibit is ClaimExhibit => exhibit.type === 'claim')
  const notes = state.exhibits.filter((exhibit): exhibit is NoteExhibit => exhibit.type === 'note')

  const documentKey = uniqueKeyer()
  const documentKeyById = new Map(documents.map((document, index) => [document.id, documentKey(document.title, `doc-${index + 1}`)]))
  const folderKey = uniqueKeyer()
  const timeline = state.views.find(view => view.type === 'timeline' && view.rangeMode === 'fixed')

  return prune({
    slug: board.slug,
    name: board.name,
    title: state.title,
    subtitle: state.subtitle || undefined,
    timelineRange: timeline?.range,
    brief: state.brief.body || state.brief.concepts.length || state.brief.checklist?.length
      ? prune({ body: state.brief.body, concepts: state.brief.concepts.map(concept => ({ label: concept.label, context: concept.context, expectedPartyKind: concept.expectedPartyKind || 'person' })), checklist: state.brief.checklist?.map(item => item.text) })
      : undefined,
    documents: documents.map(document => prune({
      key: documentKeyById.get(document.id)!, title: document.title, fileType: document.fileType, captureKind: document.captureKind,
      publishedAt: document.publishedAt || '', body: document.body, metadata: Object.keys(document.metadata || {}).length ? document.metadata : undefined,
      requiredFlags: document.requiredFlags, asset: document.assetId ? `assets/${assetFilenames.get(document.assetId)}` : undefined,
    })),
    folders: folders.map(folder => ({
      key: folderKey(folder.title, 'folder'), title: folder.title, content: folder.content, x: folder.x, y: folder.y, width: folder.width,
      members: state.relations.filter(relation => relation.type === 'contains' && relation.fromExhibitId === folder.id)
        .sort((a, b) => a.sortOrder - b.sortOrder).map(relation => documentKeyById.get(relation.toExhibitId)!).filter(Boolean),
    })),
    claims: claims.map(claim => ({ key: slugify(claim.statement, `claim`), statement: claim.statement, x: claim.x, y: claim.y, width: claim.width, height: claim.height })),
    notes: notes.map(note => prune({ title: note.title || undefined, content: note.content, presentation: note.presentation, x: note.x, y: note.y, width: note.width, height: note.height })),
    report: state.report ? { title: state.report.title, requiredForCompletion: state.report.requiredForCompletion } : undefined,
    goals: state.goals.map(goal => prune({ key: goal.key, title: goal.title, instructions: goal.instructions, completionMessage: goal.completionMessage, enabled: goal.enabled, requiredFlags: goal.requiredFlags })),
    evidenceMatchRules: board.matchRules.map(rule => prune({
      name: rule.name, sourceLabel: rule.sourceLabel, sourceUri: rule.sourceUri, flagKey: rule.flagKey, minimumAnchorMatches: rule.minimumAnchorMatches,
      enabled: rule.enabled, anchors: rule.anchors.map(anchor => ({ phrase: anchor.phrase, minimumSimilarity: anchor.minimumSimilarity })),
    })),
    evidenceSemanticRules: board.semanticRules.map(rule => prune({
      goalKey: rule.goalKey, name: rule.name, targetSubject: rule.targetSubject, relatedSubject: rule.relatedSubject, assertion: rule.assertion,
      successFlagKey: rule.successFlagKey, relatedFlagKey: rule.relatedFlagKey, minimumConfidence: rule.minimumConfidence, evaluatorVersion: rule.evaluatorVersion, enabled: rule.enabled,
    })),
  })
}

// A node's utterances → the importer's branching form (explicit key + parent + terminal),
// so the dialogue tree and its flag gates round-trip losslessly.
function serializeUtterances(utterances: UtteranceDto[], node: StoryNodeDto, npcKeyById: Map<string, string>) {
  const ordered = [...utterances].sort((a, b) => a.sortOrder - b.sortOrder)
  const keyById = new Map(ordered.map((utterance, index) => [utterance.id, `u${index}`]))
  const terminalKeyById = new Map(node.terminals.map(terminal => [terminal.id, terminal.terminalKey]))
  return ordered.map(utterance => prune({
    key: keyById.get(utterance.id)!,
    parent: utterance.parentUtteranceId ? keyById.get(utterance.parentUtteranceId) : undefined,
    terminal: utterance.terminalId ? terminalKeyById.get(utterance.terminalId) : undefined,
    npc: utterance.npcId ? npcKeyById.get(utterance.npcId) : undefined,
    pose: utterance.poseKey || undefined,
    text: utterance.text,
    utterer: utterance.utterer,
    awardsFlag: utterance.awardsFlag || undefined,
    requiresFlag: utterance.requiresFlag || undefined,
  }))
}

export async function exportMystery(slug: string, options: { baseUrl: string; adminJwt: string; outDir: string }) {
  const { baseUrl, adminJwt, outDir } = options
  const authorization = `Bearer ${adminJwt}`
  const mysteries = await getJson<MysterySummary[]>(baseUrl, '/api/admin/mysteries', authorization)
  const mystery = mysteries.find(candidate => candidate.slug === slug)
  if (!mystery) throw new Error(`Mystery '${slug}' not found on ${baseUrl}`)

  const [graph, npcs] = await Promise.all([
    getJson<StoryGraphDto>(baseUrl, `/api/admin/mysteries/${mystery.id}/graph`, authorization),
    getJson<NpcDto[]>(baseUrl, '/api/admin/npcs', authorization),
  ])
  const npcKeyById = new Map(npcs.map(npc => [npc.id, npc.key]))

  // Stable, readable key per node (graph nodes arrive in creation order).
  const nodeKey = uniqueKeyer()
  const nodeKeyById = new Map(graph.nodes.map(node => [node.id, nodeKey(node.label || node.nodeType, node.nodeType)]))

  // Fetch each distinct level template version once, in first-appearance order.
  const levelByVersion = new Map<string, TemplateBoardExport>()
  for (const node of graph.nodes) {
    if (node.nodeType !== 'level' || !node.levelTemplateVersionId || levelByVersion.has(node.levelTemplateVersionId)) continue
    levelByVersion.set(node.levelTemplateVersionId, await getJson<TemplateBoardExport>(baseUrl, `/api/admin/level-templates/${node.levelTemplateVersionId}/export`, authorization))
  }

  // Collect document assets across all levels; filenames come from the stored upload name.
  const assetFilenames = new Map<string, string>()
  const assetFilenameKeyer = uniqueKeyer()
  for (const board of levelByVersion.values()) {
    for (const exhibit of board.state.exhibits) {
      if (exhibit.type !== 'document' || !exhibit.assetId || assetFilenames.has(exhibit.assetId)) continue
      const suggested = exhibit.fileName || `${exhibit.assetId}.bin`
      const extension = suggested.includes('.') ? suggested.slice(suggested.lastIndexOf('.')) : ''
      const base = suggested.slice(0, suggested.length - extension.length) || exhibit.assetId
      assetFilenames.set(exhibit.assetId, `${assetFilenameKeyer(base, exhibit.assetId)}${extension}`)
    }
  }

  const levels = [...levelByVersion.values()].map(board => serializeLevel(board, assetFilenames))

  // Serialize the graph, gathering the NPCs the flow actually references as we go.
  const referencedNpcIds = new Set<string>()
  const nodes: Record<string, unknown>[] = []
  for (const node of graph.nodes) {
    const terminals = node.terminals.map(terminal => {
      if (terminal.npcId) referencedNpcIds.add(terminal.npcId)
      return prune({
        key: terminal.terminalKey, label: terminal.label,
        to: terminal.toNodeId ? nodeKeyById.get(terminal.toNodeId) : undefined,
        npc: terminal.npcId ? npcKeyById.get(terminal.npcId) : undefined,
      })
    })
    let utterances: Record<string, unknown>[] | undefined
    if (node.hasUtterances) {
      const raw = await getJson<UtteranceDto[]>(baseUrl, `/api/admin/story-nodes/${node.id}/utterances`, authorization)
      for (const utterance of raw) if (utterance.npcId) referencedNpcIds.add(utterance.npcId)
      utterances = serializeUtterances(raw, node, npcKeyById)
    }
    nodes.push(prune({
      key: nodeKeyById.get(node.id)!, type: node.nodeType, label: node.label,
      x: node.xpos, y: node.ypos, componentKey: node.componentKey || undefined, awardsFlag: node.awardsFlag || undefined,
      templateSlug: node.levelTemplateVersionId ? levelByVersion.get(node.levelTemplateVersionId)?.slug : undefined,
      terminals, utterances,
    }))
  }

  const cast = npcs.filter(npc => referencedNpcIds.has(npc.id)).map(npc => prune({
    key: npc.key, name: npc.name, role: npc.role || undefined, defaultPose: npc.defaultPose || undefined,
    phoneNumber: npc.phoneNumber || undefined, email: npc.email || undefined,
    poses: npc.poses.map(pose => ({ poseKey: pose.poseKey, assetId: pose.assetId })),
  }))

  const entry = graph.entryNodeId ? nodeKeyById.get(graph.entryNodeId) : undefined
  if (!entry) throw new Error(`Mystery '${slug}' has no entry node; author its graph entrypoint before exporting`)

  const outAbs = path.resolve(outDir)
  await mkdir(path.join(outAbs, 'assets'), { recursive: true })
  for (const [assetId, filename] of assetFilenames) {
    const response = await fetch(`${baseUrl}/api/assets/${assetId}`, { headers: { authorization } })
    if (!response.ok) throw new Error(`Download asset ${assetId} failed (${response.status})`)
    await writeFile(path.join(outAbs, 'assets', filename), Buffer.from(await response.arrayBuffer()))
  }

  const file = { slug: mystery.slug, title: mystery.title, levels, narrative: { cast, graph: { entry, nodes } } }
  await writeFile(path.join(outAbs, 'mystery.json'), JSON.stringify(file, null, 2) + '\n')
  return { slug: mystery.slug, outDir: outAbs, levels: levels.length, nodes: nodes.length, cast: cast.length, assets: assetFilenames.size }
}

const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
if (invokedPath === fileURLToPath(import.meta.url)) {
  const args = process.argv.slice(2)
  let isProd = false
  let outDir: string | undefined
  const positionals: string[] = []
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--prod') isProd = true
    else if (args[i] === '--out') outDir = args[++i]
    else positionals.push(args[i])
  }
  const slug = positionals[0]
  if (!slug) throw new Error('Usage: npm run mystery:pull -- <mystery-slug> [--prod] [--out <dir>]')
  const baseUrl = isProd ? PROD_URL : (process.env.OSINT_BOARD_URL || DEV_URL)
  const adminJwt = await resolveAdminJwt(baseUrl, isProd)
  const result = await exportMystery(slug, { baseUrl, adminJwt, outDir: outDir || path.join('mysteries', slug) })
  console.log(JSON.stringify({ ...result, source: baseUrl }, null, 2))
}
