import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { extname, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { DatabaseSync } from 'node:sqlite'

const host = '127.0.0.1'
const port = Number(process.env.ADMIN_PORT ?? 4174)
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
const adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', import.meta.url))
const iwt2BalanceOverridesPath = fileURLToPath(new URL('../src/modes/iwt2/content/balanceOverrides.ts', import.meta.url))
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
const dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', import.meta.url))

const bossImageContentTypes = {
  '.gif': 'image/gif',
  '.jpeg': 'image/jpeg',
  '.jpg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp',
}

function sendJson(response, status, body) {
  response.statusCode = status
  response.setHeader('Content-Type', 'application/json')
  response.end(JSON.stringify(body))
}

function sqlValue(value) {
  if (value === null || value === undefined) return 'NULL'
  if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL'
  return `'${String(value).replaceAll("'", "''")}'`
}

function writeAdminOverrides(database) {
  const lines = [
    '-- Generated by local admin panel. Commit this file with uploaded art changes.',
    'PRAGMA foreign_keys = ON;',
    'BEGIN TRANSACTION;',
    '',
  ]

  for (const dungeon of database.prepare(`
    SELECT id, slug, name, recommended_level AS recommendedLevel,
      content_type AS contentType, party_size AS partySize,
      experience_reward AS experienceReward, image_url AS imageUrl, description
    FROM dungeons ORDER BY id
  `).all()) {
    lines.push(`UPDATE dungeons SET slug = ${sqlValue(dungeon.slug)}, name = ${sqlValue(dungeon.name)}, recommended_level = ${sqlValue(dungeon.recommendedLevel)}, content_type = ${sqlValue(dungeon.contentType)}, party_size = ${sqlValue(dungeon.partySize)}, experience_reward = ${sqlValue(dungeon.experienceReward)}, image_url = ${sqlValue(dungeon.imageUrl)}, description = ${sqlValue(dungeon.description)} WHERE id = ${sqlValue(dungeon.id)};`)
  }

  lines.push('')
  for (const encounter of database.prepare(`
    SELECT id, slug, name, encounter_type AS encounterType,
      max_health AS maxHealth, base_damage AS baseDamage,
      tank_damage AS tankDamage, party_damage AS partyDamage,
      description, image_url AS imageUrl
    FROM encounters ORDER BY id
  `).all()) {
    lines.push(`UPDATE encounters SET slug = ${sqlValue(encounter.slug)}, name = ${sqlValue(encounter.name)}, encounter_type = ${sqlValue(encounter.encounterType)}, max_health = ${sqlValue(encounter.maxHealth)}, base_damage = ${sqlValue(encounter.baseDamage)}, tank_damage = ${sqlValue(encounter.tankDamage)}, party_damage = ${sqlValue(encounter.partyDamage)}, description = ${sqlValue(encounter.description)}, image_url = ${sqlValue(encounter.imageUrl)} WHERE id = ${sqlValue(encounter.id)};`)
  }

  lines.push('')
  for (const item of database.prepare(`
    SELECT id, slug, name, slot, rarity, item_level AS itemLevel,
      healing_power AS healingPower, max_resource_bonus AS maxResourceBonus,
      glyph, image_url AS imageUrl, description
    FROM items ORDER BY id
  `).all()) {
    lines.push(`UPDATE items SET slug = ${sqlValue(item.slug)}, name = ${sqlValue(item.name)}, slot = ${sqlValue(item.slot)}, rarity = ${sqlValue(item.rarity)}, item_level = ${sqlValue(item.itemLevel)}, healing_power = ${sqlValue(item.healingPower)}, max_resource_bonus = ${sqlValue(item.maxResourceBonus)}, glyph = ${sqlValue(item.glyph)}, image_url = ${sqlValue(item.imageUrl)}, description = ${sqlValue(item.description)} WHERE id = ${sqlValue(item.id)};`)
  }

  lines.push('', 'DELETE FROM encounter_loot;')
  for (const loot of database.prepare(`
    SELECT encounter_id AS encounterId, item_id AS itemId,
      difficulty_id AS difficultyId, drop_weight AS dropWeight, drop_chance AS dropChance
    FROM encounter_loot ORDER BY encounter_id, difficulty_id, item_id
  `).all()) {
    lines.push(`INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (${sqlValue(loot.encounterId)}, ${sqlValue(loot.itemId)}, ${sqlValue(loot.difficultyId)}, ${sqlValue(loot.dropWeight)}, ${sqlValue(loot.dropChance)});`)
  }

  lines.push('')
  for (const recipe of database.prepare(`
    SELECT id, difficulty_id AS difficultyId,
      source_dungeon_id AS sourceDungeonId, source_encounter_id AS sourceEncounterId
    FROM crafting_recipes ORDER BY id
  `).all()) {
    lines.push(`UPDATE crafting_recipes SET difficulty_id = ${sqlValue(recipe.difficultyId)}, source_dungeon_id = ${sqlValue(recipe.sourceDungeonId)}, source_encounter_id = ${sqlValue(recipe.sourceEncounterId)} WHERE id = ${sqlValue(recipe.id)};`)
  }

  lines.push('', 'DELETE FROM crafting_recipe_components;')
  for (const component of database.prepare(`
    SELECT recipe_id AS recipeId, item_id AS itemId, quantity
    FROM crafting_recipe_components ORDER BY recipe_id, item_id
  `).all()) {
    lines.push(`INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (${sqlValue(component.recipeId)}, ${sqlValue(component.itemId)}, ${sqlValue(component.quantity)});`)
  }

  lines.push('', 'DELETE FROM gear_upgrade_paths;')
  for (const path of database.prepare(`
    SELECT from_item_id AS fromItemId, to_item_id AS toItemId
    FROM gear_upgrade_paths ORDER BY from_item_id
  `).all()) {
    lines.push(`INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (${sqlValue(path.fromItemId)}, ${sqlValue(path.toItemId)});`)
  }

  lines.push('', 'COMMIT;', '')
  writeFileSync(adminOverridesPath, lines.join('\n'), { mode: 0o644 })
}

async function readJson(request, maxSize = 16 * 1024) {
  const chunks = []
  let size = 0
  for await (const chunk of request) {
    size += chunk.length
    if (size > maxSize) {
      const error = new Error('Request body is too large.')
      error.status = 413
      throw error
    }
    chunks.push(chunk)
  }
  return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}

function sendBossImage(request, response) {
  const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
  const filename = pathname.replace('/api/boss-images/', '')
  if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
    sendJson(response, 404, { error: 'Image not found.' })
    return
  }
  const imagePath = resolve(bossImageDirectory, filename)
  const insideDirectory = imagePath.startsWith(resolve(bossImageDirectory) + sep)
  const extension = extname(imagePath).toLowerCase()
  if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
    sendJson(response, 404, { error: 'Image not found.' })
    return
  }
  response.statusCode = 200
  response.setHeader('Content-Type', bossImageContentTypes[extension])
  response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
  response.setHeader('X-Content-Type-Options', 'nosniff')
  createReadStream(imagePath).pipe(response)
}

function sendItemImage(request, response) {
  const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
  const filename = pathname.replace('/api/item-images/', '')
  if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
    sendJson(response, 404, { error: 'Image not found.' })
    return
  }
  const imagePath = resolve(itemImageDirectory, filename)
  const insideDirectory = imagePath.startsWith(resolve(itemImageDirectory) + sep)
  const extension = extname(imagePath).toLowerCase()
  if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
    sendJson(response, 404, { error: 'Image not found.' })
    return
  }
  response.statusCode = 200
  response.setHeader('Content-Type', bossImageContentTypes[extension])
  response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
  response.setHeader('X-Content-Type-Options', 'nosniff')
  createReadStream(imagePath).pipe(response)
}

function sendDungeonImage(request, response) {
  const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
  const filename = pathname.replace('/api/dungeon-images/', '')
  if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
    sendJson(response, 404, { error: 'Image not found.' })
    return
  }
  const imagePath = resolve(dungeonImageDirectory, filename)
  const insideDirectory = imagePath.startsWith(resolve(dungeonImageDirectory) + sep)
  const extension = extname(imagePath).toLowerCase()
  if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
    sendJson(response, 404, { error: 'Image not found.' })
    return
  }
  response.statusCode = 200
  response.setHeader('Content-Type', bossImageContentTypes[extension])
  response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
  response.setHeader('X-Content-Type-Options', 'nosniff')
  createReadStream(imagePath).pipe(response)
}

function saveBossImage(database, encounterId, payload) {
  const encounter = database.prepare(`
    SELECT id, slug, encounter_type AS encounterType
    FROM encounters WHERE id = ?
  `).get(encounterId)
  if (!encounter) throw new Error('Encounter not found.')
  const dataUrl = String(payload.imageData ?? '')
  const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
  if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
  const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
  const bytes = Buffer.from(match[2], 'base64')
  if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
    throw new Error('Boss image must be 1 byte to 4 MB.')
  }
  mkdirSync(bossImageDirectory, { recursive: true })
  const filename = `${encounter.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
  writeFileSync(resolve(bossImageDirectory, filename), bytes, { mode: 0o644 })
  const imageUrl = `/api/boss-images/${filename}`
  database.prepare(`UPDATE encounters SET image_url = ? WHERE id = ?`).run(imageUrl, encounterId)
  return imageUrl
}

function saveItemImage(database, itemId, payload) {
  const item = database.prepare(`SELECT id, slug, slot FROM items WHERE id = ?`).get(itemId)
  if (!item || item.slot === 'component') throw new Error('Equipment item not found.')
  const dataUrl = String(payload.imageData ?? '')
  const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
  if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
  const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
  const bytes = Buffer.from(match[2], 'base64')
  if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
    throw new Error('Equipment image must be 1 byte to 4 MB.')
  }
  mkdirSync(itemImageDirectory, { recursive: true })
  const filename = `${item.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
  writeFileSync(resolve(itemImageDirectory, filename), bytes, { mode: 0o644 })
  const imageUrl = `/api/item-images/${filename}`
  database.prepare(`UPDATE items SET image_url = ? WHERE id = ?`).run(imageUrl, itemId)
  return imageUrl
}

function saveDungeonImage(database, dungeonId, payload) {
  const dungeon = database.prepare(`SELECT id, slug FROM dungeons WHERE id = ?`).get(dungeonId)
  if (!dungeon) throw new Error('Dungeon not found.')
  const dataUrl = String(payload.imageData ?? '')
  const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
  if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
  const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
  const bytes = Buffer.from(match[2], 'base64')
  if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
    throw new Error('Dungeon image must be 1 byte to 4 MB.')
  }
  mkdirSync(dungeonImageDirectory, { recursive: true })
  const filename = `${dungeon.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
  writeFileSync(resolve(dungeonImageDirectory, filename), bytes, { mode: 0o644 })
  const imageUrl = `/api/dungeon-images/${filename}`
  database.prepare(`UPDATE dungeons SET image_url = ? WHERE id = ?`).run(imageUrl, dungeonId)
  return imageUrl
}

function readIwt2BalanceOverrides() {
  if (!existsSync(iwt2BalanceOverridesPath)) return { bosses: {} }
  const source = readFileSync(iwt2BalanceOverridesPath, 'utf8')
  const exportIndex = source.indexOf('IWT2_BALANCE_OVERRIDES')
  const objectStart = source.indexOf('{', exportIndex)
  const objectEnd = source.lastIndexOf('}')
  if (exportIndex < 0 || objectStart < 0 || objectEnd <= objectStart) return { bosses: {} }
  try {
    const parsed = JSON.parse(source.slice(objectStart, objectEnd + 1))
    return sanitizeIwt2BalanceOverrides(parsed)
  } catch {
    return { bosses: {} }
  }
}

function sanitizeIwt2BalanceOverrides(value) {
  const overrides = { bosses: {} }
  const bosses = value && typeof value === 'object' && value.bosses && typeof value.bosses === 'object'
    ? value.bosses
    : {}
  for (const bossId of ['bulldrome', 'yian-kut-ku']) {
    const boss = bosses[bossId]
    if (!boss || typeof boss !== 'object') continue
    const nextBoss = {}
    if (Number.isFinite(Number(boss.maxHealth))) nextBoss.maxHealth = Math.max(1, Math.round(Number(boss.maxHealth)))
    if (bossId === 'yian-kut-ku' && Number.isFinite(Number(boss.birdHealth))) {
      nextBoss.birdHealth = Math.max(1, Math.round(Number(boss.birdHealth)))
    }
    if (Object.keys(nextBoss).length > 0) overrides.bosses[bossId] = nextBoss
  }
  return overrides
}

function writeIwt2BalanceOverrides(overrides) {
  const source = [
    "import type { Iwt2BalanceOverrides } from './bosses'",
    '',
    '// Generated by local admin panel. Commit this file with intended IWT2 balance changes.',
    `export const IWT2_BALANCE_OVERRIDES: Iwt2BalanceOverrides = ${JSON.stringify(sanitizeIwt2BalanceOverrides(overrides), null, 2)}`,
    '',
  ].join('\n')
  writeFileSync(iwt2BalanceOverridesPath, source, { mode: 0o644 })
}

function updateIwt2EntityHp(entityId, payload) {
  const maxHealth = Number(payload.maxHealth)
  if (!Number.isFinite(maxHealth) || maxHealth <= 0) throw new Error('HP must be greater than zero.')
  const roundedHealth = Math.round(maxHealth)
  const overrides = readIwt2BalanceOverrides()
  const bosses = { ...(overrides.bosses ?? {}) }
  if (entityId === 'bulldrome' || entityId === 'yian-kut-ku') {
    bosses[entityId] = {
      ...(bosses[entityId] ?? {}),
      maxHealth: roundedHealth,
    }
  } else if (entityId === 'yian-kut-ku:yian-bird' || entityId === 'yian-bird') {
    bosses['yian-kut-ku'] = {
      ...(bosses['yian-kut-ku'] ?? {}),
      birdHealth: roundedHealth,
    }
  } else {
    throw new Error('IWT2 entity not found.')
  }
  const nextOverrides = sanitizeIwt2BalanceOverrides({ bosses })
  writeIwt2BalanceOverrides(nextOverrides)
  return nextOverrides
}

function listIwt2BossAssets() {
  if (!existsSync(bossImageDirectory)) return []
  return readdirSync(bossImageDirectory, { withFileTypes: true })
    .filter((entry) => entry.isFile() && bossImageContentTypes[extname(entry.name).toLowerCase()])
    .map((entry) => {
      const imagePath = resolve(bossImageDirectory, entry.name)
      return {
        filename: entry.name,
        size: statSync(imagePath).size,
        url: `/api/boss-images/${entry.name}`,
      }
    })
    .sort((a, b) => a.filename.localeCompare(b.filename))
}

function sendFile(response, filePath) {
  const contentTypes = {
    '.css': 'text/css; charset=utf-8',
    '.html': 'text/html; charset=utf-8',
    '.ico': 'image/x-icon',
    '.jpeg': 'image/jpeg',
    '.jpg': 'image/jpeg',
    '.js': 'text/javascript; charset=utf-8',
    '.json': 'application/json; charset=utf-8',
    '.png': 'image/png',
    '.svg': 'image/svg+xml',
    '.webp': 'image/webp',
  }
  response.statusCode = 200
  response.setHeader('Content-Type', contentTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream')
  response.setHeader('X-Content-Type-Options', 'nosniff')
  response.setHeader('Referrer-Policy', 'same-origin')
  response.setHeader('X-Frame-Options', 'DENY')
  createReadStream(filePath).pipe(response)
}

function serveStatic(request, response) {
  const requestPath = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
  const candidate = resolve(distPath, `.${requestPath}`)
  const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`)
  if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) {
    sendFile(response, candidate)
    return
  }
  const adminIndexPath = resolve(distPath, 'admin.html')
  if (!existsSync(adminIndexPath)) {
    response.statusCode = 503
    response.end('Admin build missing. Run npm run build.')
    return
  }
  sendFile(response, adminIndexPath)
}

const server = createServer(async (request, response) => {
  try {
    if (!request.url?.startsWith('/api/')) {
      serveStatic(request, response)
      return
    }

    if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
      sendBossImage(request, response)
      return
    }

    if (request.url.startsWith('/api/item-images/') && request.method === 'GET') {
      sendItemImage(request, response)
      return
    }

    if (request.url.startsWith('/api/dungeon-images/') && request.method === 'GET') {
      sendDungeonImage(request, response)
      return
    }

    if (request.url === '/api/admin/iwt2/data' && request.method === 'GET') {
      sendJson(response, 200, {
        balanceOverrides: readIwt2BalanceOverrides(),
        bossAssets: listIwt2BossAssets(),
      })
      return
    }

    const iwt2HpMatch = request.url.match(/^\/api\/admin\/iwt2\/entities\/([^/]+)\/hp$/)
    if (iwt2HpMatch && request.method === 'PUT') {
      const payload = await readJson(request)
      const balanceOverrides = updateIwt2EntityHp(decodeURIComponent(iwt2HpMatch[1]), payload)
      sendJson(response, 200, { ok: true, balanceOverrides })
      return
    }

    if (!existsSync(databasePath)) {
      sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
      return
    }

    const database = new DatabaseSync(databasePath)
    database.exec('PRAGMA foreign_keys = ON')
    database.exec('PRAGMA journal_mode = WAL')
    database.exec('PRAGMA busy_timeout = 5000')

    try {
      if (request.url === '/api/admin/data' && request.method === 'GET') {
        const items = database.prepare(`
          SELECT id, slug, name, slot, rarity, item_level AS itemLevel,
            healing_power AS healingPower, max_resource_bonus AS maxResourceBonus,
            glyph, image_url AS imageUrl, description
          FROM items ORDER BY slot, item_level, id
        `).all()
        const encounters = database.prepare(`
          SELECT id, dungeon_id AS dungeonId, sequence, slug, name AS enemyName,
            encounter_type AS encounterType, max_health AS maxHealth, base_damage AS baseDamage,
            tank_damage AS tankDamage, party_damage AS partyDamage,
            description, image_url AS imageUrl
          FROM encounters ORDER BY dungeon_id, sequence
        `).all()
        const difficulties = database.prepare(`
          SELECT id, slug, name, dropped_item_level AS droppedItemLevel
          FROM difficulties ORDER BY id
        `).all()
        const encounterLoot = database.prepare(`
          SELECT encounter_id AS encounterId, item_id AS itemId,
            difficulty_id AS difficultyId, drop_weight AS dropWeight, drop_chance AS dropChance
          FROM encounter_loot ORDER BY encounter_id, difficulty_id, item_id
        `).all()
        const craftingRecipes = database.prepare(`
          SELECT cr.id, cr.item_id AS itemId, cr.difficulty_id AS difficultyId,
            cr.source_dungeon_id AS sourceDungeonId,
            cr.source_encounter_id AS sourceEncounterId,
            crc.item_id AS componentId, crc.quantity
          FROM crafting_recipes cr
          LEFT JOIN crafting_recipe_components crc ON crc.recipe_id = cr.id
          ORDER BY cr.id, crc.item_id
        `).all()
        const recipes = new Map()
        for (const row of craftingRecipes) {
          if (!recipes.has(row.id)) {
            recipes.set(row.id, {
              id: row.id, itemId: row.itemId, difficultyId: row.difficultyId,
              sourceDungeonId: row.sourceDungeonId, sourceEncounterId: row.sourceEncounterId,
              components: [],
            })
          }
          if (row.componentId) {
            recipes.get(row.id).components.push({ itemId: row.componentId, quantity: row.quantity })
          }
        }
        const dungeons = database.prepare(`
          SELECT id, slug, name, recommended_level AS recommendedLevel,
            content_type AS contentType, party_size AS partySize,
            experience_reward AS experienceReward, image_url AS imageUrl,
            description
          FROM dungeons ORDER BY id
        `).all()
        const gearUpgradePaths = database.prepare(`
          SELECT from_item_id AS fromItemId, to_item_id AS toItemId
          FROM gear_upgrade_paths ORDER BY from_item_id
        `).all()
        const classes = database.prepare(`
          SELECT id, slug, name, resource_name AS resourceName,
            max_resource AS maxResource, theme_color AS themeColor, description
          FROM classes ORDER BY id
        `).all()
        const abilities = database.prepare(`
          SELECT id, class_id AS classId, slug, name, spell_type AS spellType,
            resource_cost AS cost, cooldown_seconds AS cooldown, power,
            unlock_level AS unlockLevel, glyph, description
          FROM spells ORDER BY class_id, unlock_level, id
        `).all()
        const talents = database.prepare(`
          SELECT talents.id, talents.class_id AS classId, talents.slug, talents.name,
            talents.max_rank AS maxRank, talents.tier, talents.branch,
            talents.prerequisite_talent_id AS prerequisiteTalentId,
            talents.prerequisite_rank AS prerequisiteRank,
            prerequisite.name AS prerequisiteName,
            talents.effect_type AS effectType,
            talents.effect_value_per_rank AS effectValuePerRank,
            talents.glyph, talents.description
          FROM talents
          LEFT JOIN talents AS prerequisite
            ON prerequisite.id = talents.prerequisite_talent_id
          ORDER BY talents.class_id, talents.tier, talents.branch
        `).all()
        sendJson(response, 200, {
          items,
          encounters,
          difficulties,
          encounterLoot,
          craftingRecipes: [...recipes.values()],
          dungeons,
          gearUpgradePaths,
          classes: classes.map((gameClass) => ({
            ...gameClass,
            abilities: abilities.filter((ability) => ability.classId === gameClass.id),
            talents: talents.filter((talent) => talent.classId === gameClass.id),
          })),
        })
        return
      }

      const bossImageMatch = request.url.match(/^\/api\/admin\/encounters\/(\d+)\/image$/)
      if (bossImageMatch && request.method === 'PUT') {
        const payload = await readJson(request, 6 * 1024 * 1024)
        const imageUrl = saveBossImage(database, Number(bossImageMatch[1]), payload)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true, imageUrl })
        return
      }

      const itemImageMatch = request.url.match(/^\/api\/admin\/items\/(\d+)\/image$/)
      if (itemImageMatch && request.method === 'PUT') {
        const payload = await readJson(request, 6 * 1024 * 1024)
        const imageUrl = saveItemImage(database, Number(itemImageMatch[1]), payload)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true, imageUrl })
        return
      }

      const dungeonImageMatch = request.url.match(/^\/api\/admin\/dungeons\/(\d+)\/image$/)
      if (dungeonImageMatch && request.method === 'PUT') {
        const payload = await readJson(request, 6 * 1024 * 1024)
        const imageUrl = saveDungeonImage(database, Number(dungeonImageMatch[1]), payload)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true, imageUrl })
        return
      }

      const itemMatch = request.url.match(/^\/api\/admin\/items\/(\d+)$/)
      if (itemMatch && request.method === 'PUT') {
        const payload = await readJson(request)
        const itemId = Number(itemMatch[1])
        const fields = []
        const values = []
        for (const key of ['name', 'slug', 'slot', 'rarity', 'glyph', 'description']) {
          if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) }
        }
        for (const key of ['item_level', 'healing_power', 'max_resource_bonus']) {
          if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(Number(payload[key])) }
        }
        if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
        values.push(itemId)
        database.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...values)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      const dungeonMatch = request.url.match(/^\/api\/admin\/dungeons\/(\d+)$/)
      if (dungeonMatch && request.method === 'PUT') {
        const payload = await readJson(request)
        const dungeonId = Number(dungeonMatch[1])
        const fields = []
        const values = []
        for (const key of ['name', 'slug', 'content_type', 'description']) {
          if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) }
        }
        for (const key of ['recommended_level', 'party_size', 'experience_reward']) {
          if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(Number(payload[key])) }
        }
        if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
        values.push(dungeonId)
        database.prepare(`UPDATE dungeons SET ${fields.join(', ')} WHERE id = ?`).run(...values)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      const encounterMatch = request.url.match(/^\/api\/admin\/encounters\/(\d+)$/)
      if (encounterMatch && request.method === 'PUT') {
        const payload = await readJson(request)
        const encounterId = Number(encounterMatch[1])
        const fields = []
        const values = []
        for (const key of ['name', 'slug', 'encounter_type', 'description']) {
          if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) }
        }
        for (const key of ['max_health', 'base_damage', 'tank_damage', 'party_damage']) {
          if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(Number(payload[key])) }
        }
        if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
        values.push(encounterId)
        database.prepare(`UPDATE encounters SET ${fields.join(', ')} WHERE id = ?`).run(...values)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      if (request.url === '/api/admin/encounter-loot' && request.method === 'POST') {
        const payload = await readJson(request)
        database.prepare(`
          INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
          VALUES (?, ?, ?, ?, ?)
          ON CONFLICT(encounter_id, difficulty_id, item_id)
          DO UPDATE SET drop_weight = excluded.drop_weight, drop_chance = excluded.drop_chance
        `).run(payload.encounterId, payload.itemId, payload.difficultyId, payload.dropWeight ?? 100, payload.dropChance ?? 0.65)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      const lootDelete = request.url.match(/^\/api\/admin\/encounter-loot\/(\d+)\/(\d+)\/(\d+)$/)
      if (lootDelete && request.method === 'DELETE') {
        database.prepare(`DELETE FROM encounter_loot WHERE encounter_id = ? AND difficulty_id = ? AND item_id = ?`)
          .run(Number(lootDelete[1]), Number(lootDelete[2]), Number(lootDelete[3]))
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/)
      if (recipeComponents && request.method === 'POST') {
        const payload = await readJson(request)
        const quantity = Number(payload.quantity)
        if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Component quantity must be at least 1.')
        database.prepare(`
          INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
          VALUES (?, ?, ?)
          ON CONFLICT(recipe_id, item_id)
          DO UPDATE SET quantity = excluded.quantity
        `).run(Number(recipeComponents[1]), payload.itemId, quantity)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      const recipeMatch = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)$/)
      if (recipeMatch && request.method === 'PUT') {
        const payload = await readJson(request)
        database.prepare(`
          UPDATE crafting_recipes
          SET difficulty_id = ?, source_dungeon_id = ?, source_encounter_id = ?
          WHERE id = ?
        `).run(
          payload.difficultyId ? Number(payload.difficultyId) : null,
          payload.sourceDungeonId ? Number(payload.sourceDungeonId) : null,
          payload.sourceEncounterId ? Number(payload.sourceEncounterId) : null,
          Number(recipeMatch[1]),
        )
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      const recipeComponentDelete = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components\/(\d+)$/)
      if (recipeComponentDelete && request.method === 'DELETE') {
        database.prepare(`DELETE FROM crafting_recipe_components WHERE recipe_id = ? AND item_id = ?`)
          .run(Number(recipeComponentDelete[1]), Number(recipeComponentDelete[2]))
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      if (request.url === '/api/admin/gear-upgrade-paths' && request.method === 'POST') {
        const payload = await readJson(request)
        const fromItemId = Number(payload.fromItemId)
        const toItemId = Number(payload.toItemId)
        if (!fromItemId || !toItemId || fromItemId === toItemId) throw new Error('Choose two different equipment items.')
        database.prepare(`
          INSERT INTO gear_upgrade_paths (from_item_id, to_item_id)
          VALUES (?, ?)
          ON CONFLICT(from_item_id) DO UPDATE SET to_item_id = excluded.to_item_id
        `).run(fromItemId, toItemId)
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      const upgradePathDelete = request.url.match(/^\/api\/admin\/gear-upgrade-paths\/(\d+)$/)
      if (upgradePathDelete && request.method === 'DELETE') {
        database.prepare(`DELETE FROM gear_upgrade_paths WHERE from_item_id = ?`).run(Number(upgradePathDelete[1]))
        writeAdminOverrides(database)
        sendJson(response, 200, { ok: true })
        return
      }

      sendJson(response, 404, { error: 'Admin API route not found.' })
    } catch (error) {
      const status = Number(error?.status) || 400
      sendJson(response, status, { error: error instanceof Error ? error.message : 'Unable to process request.' })
    } finally {
      database.close()
    }
  } catch (error) {
    sendJson(response, 500, { error: error instanceof Error ? error.message : 'Server error.' })
  }
})

server.listen(port, host, () => {
  console.log(`Admin panel listening on http://${host}:${port}`)
})
