import { randomUUID } from 'node:crypto'
import type { Pool, PoolClient } from 'pg'

export type GraphSpecNode = {
  key: string; type: StoryNodeType; label?: string; x: number; y: number
  componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
  terminals?: { key: string; label?: string; to?: string | null; npc?: string }[]
  // Linear form: an ordered list (chained automatically). Branching form: give each
  // utterance a `key` and set `parent` (its predecessor) + `terminal` (its exit);
  // multiple children of one parent become player options.
  utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: Utterer; awardsFlag?: string; requiresFlag?: string }[]
}
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }

export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null }
export type StoryNodeDto = {
  id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
  xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
  awardsFlag: string | null
  terminals: TerminalDto[]
}
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
export type LevelTemplateOption = { versionId: string; slug: string; name: string; version: number }
export type Utterer = 'npc' | 'player'
export type UtteranceDto = {
  id: string; nodeId: string; utterer: Utterer; npcId: string | null; poseKey: string | null; text: string
  parentUtteranceId: string | null; terminalId: string | null; awardsFlag: string | null; requiresFlag: string | null
  xpos: number; ypos: number; sortOrder: number
}

// A sensible starter terminal set so a freshly dropped node is immediately wireable.
const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]> = {
  cutscene: [{ key: 'continue', label: 'Continue' }],
  dialogue: [{ key: 'continue', label: 'Continue' }],
  level: [{ key: 'report_back', label: 'Report back' }],
  det_gate: [{ key: 'pass', label: 'Pass' }],
  llm_gate: [{ key: 'pass', label: 'Pass' }],
  merit: [{ key: 'continue', label: 'Continue' }],
  phone: [], // a phone node's terminals are added per contact (each bound to an NPC)
}

export interface StoryGraphRepository {
  getGraph(mysteryId: string): Promise<StoryGraphDto | null>
  createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
  updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise<StoryNodeDto | null>
  deleteNode(nodeId: string): Promise<boolean>
  addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
  updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null; npcId: string | null }>): Promise<{ ok: boolean; error?: string }>
  deleteTerminal(terminalId: string): Promise<boolean>
  setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
  listLevelTemplates(): Promise<LevelTemplateOption[]>
  listUtterances(nodeId: string): Promise<UtteranceDto[]>
  createUtterance(nodeId: string, input: { utterer: Utterer; xpos: number; ypos: number; text?: string }): Promise<UtteranceDto | null>
  updateUtterance(id: string, input: Partial<{ text: string; utterer: Utterer; npcId: string | null; poseKey: string | null; xpos: number; ypos: number; parentUtteranceId: string | null; terminalId: string | null }>): Promise<{ ok: boolean; error?: string }>
  deleteUtterance(id: string): Promise<boolean>
  authorGraph(mysteryId: string, spec: GraphSpec): Promise<{ nodes: number } | null>
}

export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
  async function mysteryOfNode(nodeId: string): Promise<string | null> {
    const result = await pool.query<{ mystery_id: string }>('SELECT mystery_id FROM osint.story_nodes WHERE id=$1', [nodeId])
    return result.rows[0]?.mystery_id ?? null
  }

  async function loadGraph(mysteryId: string): Promise<StoryGraphDto | null> {
    const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
    if (!mystery.rows[0]) return null
    const [nodes, terminals] = await Promise.all([
      pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>(
        'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
      pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number; npc_id: string | null }>(
        `SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order,t.npc_id FROM osint.story_node_terminals t
         JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
    ])
    const byNode = new Map<string, TerminalDto[]>()
    for (const row of terminals.rows) {
      const list = byNode.get(row.parent_node_id) || []
      list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order, npcId: row.npc_id })
      byNode.set(row.parent_node_id, list)
    }
    return {
      mysteryId, entryNodeId: mystery.rows[0].entry_node_id,
      nodes: nodes.rows.map(row => ({
        id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
        xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
        awardsFlag: row.awards_flag,
        terminals: byNode.get(row.id) || [],
      })),
    }
  }

  return {
    getGraph: loadGraph,

    async createNode(mysteryId, input) {
      const client = await pool.connect()
      try {
        await client.query('BEGIN')
        const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
        if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
        const nodeId = randomUUID()
        const label = input.label?.trim() || input.nodeType
        await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances) VALUES ($1,$2,$3,$4,$5,$6,$7)',
          [nodeId, mysteryId, input.nodeType, label, input.xpos, input.ypos, input.nodeType === 'dialogue' || input.nodeType === 'cutscene'])
        for (const [index, terminal] of DEFAULT_TERMINALS[input.nodeType].entries())
          await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',
            [randomUUID(), nodeId, terminal.key, terminal.label, index])
        await client.query('COMMIT')
        const graph = await loadGraph(mysteryId)
        return graph?.nodes.find(node => node.id === nodeId) ?? null
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },

    async updateNode(nodeId, input) {
      const mysteryId = await mysteryOfNode(nodeId)
      if (!mysteryId) return null
      const sets: string[] = []
      const values: unknown[] = [nodeId]
      const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
      if (input.label !== undefined) set('label', input.label.trim())
      if (input.xpos !== undefined) set('xpos', input.xpos)
      if (input.ypos !== undefined) set('ypos', input.ypos)
      if (input.hasUtterances !== undefined) set('has_utterances', input.hasUtterances)
      if (input.componentKey !== undefined) set('component_key', input.componentKey || null)
      if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
      if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
      if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
      if (input.awardsFlag !== undefined) set('awards_flag', input.awardsFlag?.trim() || null)
      if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
      const graph = await loadGraph(mysteryId)
      return graph?.nodes.find(node => node.id === nodeId) ?? null
    },

    async deleteNode(nodeId) {
      const result = await pool.query('DELETE FROM osint.story_nodes WHERE id=$1', [nodeId])
      return (result.rowCount ?? 0) > 0
    },

    async addTerminal(nodeId, input) {
      const mysteryId = await mysteryOfNode(nodeId)
      if (!mysteryId) return null
      const key = input.terminalKey.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'out'
      const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId])
      await pool.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (parent_node_id,terminal_key) DO NOTHING',
        [randomUUID(), nodeId, key, input.label?.trim() || key, order.rows[0].next])
      const graph = await loadGraph(mysteryId)
      return graph?.nodes.find(node => node.id === nodeId) ?? null
    },

    async updateTerminal(terminalId, input) {
      const owner = await pool.query<{ parent_node_id: string; mystery_id: string }>(
        `SELECT t.parent_node_id, n.mystery_id FROM osint.story_node_terminals t JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE t.id=$1`, [terminalId])
      if (!owner.rows[0]) return { ok: false, error: 'Terminal not found' }
      if (input.toNodeId !== undefined && input.toNodeId !== null) {
        const target = await mysteryOfNode(input.toNodeId)
        if (target !== owner.rows[0].mystery_id) return { ok: false, error: 'A wire must stay within the same mystery' }
      }
      const sets: string[] = []
      const values: unknown[] = [terminalId]
      const set = (column: string, value: unknown) => { values.push(value); sets.push(`${column}=$${values.length}`) }
      if (input.label !== undefined) set('label', input.label.trim())
      if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
      if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
      if (input.npcId !== undefined) set('npc_id', input.npcId || null)
      if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
      return { ok: true }
    },

    async deleteTerminal(terminalId) {
      const result = await pool.query('DELETE FROM osint.story_node_terminals WHERE id=$1', [terminalId])
      return (result.rowCount ?? 0) > 0
    },

    async setEntryNode(mysteryId, nodeId) {
      if (nodeId !== null) {
        const target = await mysteryOfNode(nodeId)
        if (target !== mysteryId) return { ok: false, error: 'Entry node must belong to the mystery' }
      }
      const result = await pool.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, nodeId])
      return (result.rowCount ?? 0) > 0 ? { ok: true } : { ok: false, error: 'Mystery not found' }
    },

    async listLevelTemplates() {
      const result = await pool.query<{ version_id: string; slug: string; name: string; version: number }>(
        `SELECT v.id AS version_id,t.slug,t.name,v.version FROM osint.level_templates t
         JOIN osint.level_template_versions v ON v.id=t.current_version_id ORDER BY t.name`)
      return result.rows.map(row => ({ versionId: row.version_id, slug: row.slug, name: row.name, version: row.version }))
    },

    async listUtterances(nodeId) {
      const result = await pool.query<UtteranceRow>(
        `SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,awards_flag,requires_flag,xpos,ypos,sort_order
         FROM osint.utterances WHERE node_id=$1 ORDER BY sort_order,id`, [nodeId])
      return result.rows.map(mapUtterance)
    },

    async createUtterance(nodeId, input) {
      const node = await pool.query('SELECT 1 FROM osint.story_nodes WHERE id=$1', [nodeId])
      if (!node.rows[0]) return null
      const id = randomUUID()
      const order = await pool.query<{ next: number }>('SELECT COALESCE(MAX(sort_order),-1)+1 AS next FROM osint.utterances WHERE node_id=$1', [nodeId])
      await pool.query('INSERT INTO osint.utterances (id,node_id,utterer,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7)',
        [id, nodeId, input.utterer, input.text || '', input.xpos, input.ypos, order.rows[0].next])
      const created = await pool.query<UtteranceRow>(
        `SELECT id,node_id,utterer,npc_id,pose_key,text,parent_utterance_id,terminal_id,awards_flag,requires_flag,xpos,ypos,sort_order FROM osint.utterances WHERE id=$1`, [id])
      return mapUtterance(created.rows[0])
    },

    async updateUtterance(id, input) {
      const owner = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [id])
      if (!owner.rows[0]) return { ok: false, error: 'Utterance not found' }
      const nodeId = owner.rows[0].node_id
      // Same-node integrity for the three links.
      for (const link of ['parentUtteranceId'] as const) {
        const value = input[link]
        if (value) {
          const target = await pool.query<{ node_id: string }>('SELECT node_id FROM osint.utterances WHERE id=$1', [value])
          if (target.rows[0]?.node_id !== nodeId) return { ok: false, error: 'Linked utterance must be in the same node' }
        }
      }
      if (input.terminalId) {
        const terminal = await pool.query<{ parent_node_id: string }>('SELECT parent_node_id FROM osint.story_node_terminals WHERE id=$1', [input.terminalId])
        if (terminal.rows[0]?.parent_node_id !== nodeId) return { ok: false, error: 'Terminal must belong to this node' }
      }
      const columns: Record<string, string> = {
        text: 'text', utterer: 'utterer', npcId: 'npc_id', poseKey: 'pose_key', xpos: 'xpos', ypos: 'ypos',
        parentUtteranceId: 'parent_utterance_id', terminalId: 'terminal_id',
      }
      const sets: string[] = []
      const values: unknown[] = [id]
      for (const [key, column] of Object.entries(columns)) {
        if ((input as Record<string, unknown>)[key] !== undefined) { values.push((input as Record<string, unknown>)[key]); sets.push(`${column}=$${values.length}`) }
      }
      if (sets.length) await pool.query(`UPDATE osint.utterances SET ${sets.join(',')} WHERE id=$1`, values)
      return { ok: true }
    },

    async deleteUtterance(id) {
      const result = await pool.query('DELETE FROM osint.utterances WHERE id=$1', [id])
      return (result.rowCount ?? 0) > 0
    },

    // Seed/replace a mystery's whole graph from a spec (used by the manifest importer),
    // so a default flow is authored content that survives re-imports.
    async authorGraph(mysteryId, spec) {
      const client: PoolClient = await pool.connect()
      try {
        await client.query('BEGIN')
        const mystery = await client.query('SELECT 1 FROM osint.mysteries WHERE id=$1', [mysteryId])
        if (!mystery.rows[0]) { await client.query('ROLLBACK'); return null }
        await client.query('UPDATE osint.mysteries SET entry_node_id=NULL WHERE id=$1', [mysteryId])
        await client.query('DELETE FROM osint.story_nodes WHERE mystery_id=$1', [mysteryId])

        const nodeIds = new Map<string, string>()
        const terminalIds = new Map<string, string>() // `${nodeKey}:${terminalKey}` -> id
        for (const node of spec.nodes) {
          const id = randomUUID(); nodeIds.set(node.key, id)
          let versionId: string | null = null
          if (node.type === 'level' && node.templateSlug) {
            const version = await client.query<{ id: string }>(
              `SELECT v.id FROM osint.level_templates t JOIN osint.level_template_versions v ON v.template_id=t.id
               WHERE t.slug=$1 AND (($2::int IS NULL AND v.id=t.current_version_id) OR v.version=$2)`, [node.templateSlug, node.version ?? null])
            versionId = version.rows[0]?.id ?? null
            if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
          }
          await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key,awards_flag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
            [id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null, node.awardsFlag?.trim()|| null])
          for (const [index, terminal] of (node.terminals || []).entries()) {
            const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
            let npcId: string | null = null
            if (terminal.npc) { // phone-node terminal bound to an NPC (the callee)
              const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [terminal.npc])
              npcId = npc.rows[0]?.id ?? null
              if (!npcId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} references unknown NPC ${terminal.npc}`)
            }
            await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order,npc_id) VALUES ($1,$2,$3,$4,$5,$6)',
              [terminalId, id, terminal.key, terminal.label || terminal.key, index, npcId])
          }
        }
        // Wire terminals now that all nodes exist.
        for (const node of spec.nodes) for (const terminal of node.terminals || []) {
          if (!terminal.to) continue
          const toId = nodeIds.get(terminal.to)
          if (!toId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} points at unknown node ${terminal.to}`)
          await client.query('UPDATE osint.story_node_terminals SET to_node_id=$2 WHERE id=$1', [terminalIds.get(`${node.key}:${terminal.key}`), toId])
        }
        // Utterances (linear seed): create, then chain them and exit the last one via
        // the node's first terminal, so the crafter shows a connected flow.
        for (const node of spec.nodes) {
          const spec2 = node.utterances || []
          const created: string[] = []
          const uttKeyToId = new Map<string, string>()
          for (const [index, utterance] of spec2.entries()) {
            let npcId: string | null = null
            if (utterance.npc) {
              const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc])
              npcId = npc.rows[0]?.id ?? null
            }
            const utteranceId = randomUUID(); created.push(utteranceId)
            if (utterance.key) uttKeyToId.set(utterance.key, utteranceId)
            await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,awards_flag,requires_flag,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)',
              [utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, utterance.awardsFlag || null, utterance.requiresFlag || null, 60, 60 + index * 120, index])
          }
          const branching = spec2.some(utterance => utterance.key)
          if (branching) {
            // Explicit tree: wire each utterance's parent + exit terminal by key.
            for (const utterance of spec2) {
              const id = utterance.key ? uttKeyToId.get(utterance.key) : undefined
              if (!id) continue
              if (utterance.parent) await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [id, uttKeyToId.get(utterance.parent) ?? null])
              if (utterance.terminal) await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [id, terminalIds.get(`${node.key}:${utterance.terminal}`) ?? null])
            }
          } else {
            // Linear: each line follows the previous; the last exits via the first terminal.
            for (let i = 1; i < created.length; i++)
              await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]])
            const firstTerminalKey = node.terminals?.[0]?.key
            const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined
            if (created.length && exitTerminalId)
              await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId])
          }
        }
        const entryId = nodeIds.get(spec.entry)
        if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
        await client.query('UPDATE osint.mysteries SET entry_node_id=$2 WHERE id=$1', [mysteryId, entryId])
        await client.query('COMMIT')
        return { nodes: spec.nodes.length }
      } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
    },
  }
}

type UtteranceRow = {
  id: string; node_id: string; utterer: Utterer; npc_id: string | null; pose_key: string | null; text: string
  parent_utterance_id: string | null; terminal_id: string | null; awards_flag: string | null; requires_flag: string | null
  xpos: number; ypos: number; sort_order: number
}
function mapUtterance(row: UtteranceRow): UtteranceDto {
  return {
    id: row.id, nodeId: row.node_id, utterer: row.utterer, npcId: row.npc_id, poseKey: row.pose_key, text: row.text,
    parentUtteranceId: row.parent_utterance_id, terminalId: row.terminal_id,
    awardsFlag: row.awards_flag, requiresFlag: row.requires_flag,
    xpos: row.xpos, ypos: row.ypos, sortOrder: row.sort_order,
  }
}
