import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import {
  createHash,
  randomBytes,
  scryptSync,
  timingSafeEqual,
} from 'node:crypto'
import { isIP } from 'node:net'
import { extname, resolve, sep } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { catalogPayload } from './catalog.mjs'
import {
  catchUpExperienceReward as catchUpExperienceRewardForTarget,
  coinDropQuantity,
  roguelikeCoinItemLevel,
} from '../src/shared/rewardRules.mjs'

const databasePath = fileURLToPath(new URL('../data/game.db', 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 bossImageContentTypes = {
  '.gif': 'image/gif',
  '.jpeg': 'image/jpeg',
  '.jpg': 'image/jpeg',
  '.png': 'image/png',
  '.webp': 'image/webp',
}
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
const componentSlot = 'component'
const directCraftItemLevels = new Set([1, 10, 20, 25])
const sessionCookieName = 'chronicle_session'
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
const rateLimitBuckets = new Map()
const pvpQueue = new Map()
const pvpMatches = new Map()
const pvpQueueTtlMs = 15 * 1000
const pvpMatchTtlMs = 60 * 60 * 1000

function addColumnIfMissing(database, table, column, definition) {
  const columns = database.prepare(`PRAGMA table_info(${table})`).all()
  if (!columns.some((candidate) => candidate.name === column)) {
    database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
  }
}

function ensureRuntimeMigrations(database) {
  addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT')
  database.prepare(`
    UPDATE accounts
    SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP)
    WHERE last_saved_at IS NULL OR last_saved_at = ''
  `).run()
}

function sqliteDateFromMs(value) {
  const timestamp = Number(value)
  if (!Number.isFinite(timestamp) || timestamp <= 0) {
    return new Date().toISOString()
  }
  return new Date(timestamp).toISOString()
}

function epochMsFromSqliteDate(value) {
  const timestamp = Date.parse(String(value ?? ''))
  return Number.isFinite(timestamp) ? timestamp : 0
}

function sendJson(response, status, body, headers = {}) {
  response.statusCode = status
  response.setHeader('Content-Type', 'application/json')
  for (const [name, value] of Object.entries(headers)) response.setHeader(name, value)
  response.end(JSON.stringify(body))
}

function touchAccountSave(database, accountId) {
  database.prepare(`
    UPDATE accounts
    SET last_saved_at = CURRENT_TIMESTAMP
    WHERE id = ?
  `).run(accountId)
}

function sendMutatingResult(response, database, accountId, body) {
  touchAccountSave(database, accountId)
  sendJson(response, 200, body)
}

function configuredCorsOrigins() {
  return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
    .split(',')
    .map((origin) => origin.trim())
    .filter(Boolean)
}

function setCorsHeaders(response, request) {
  const origin = request.headers.origin
  if (typeof origin !== 'string') return
  const allowedOrigins = configuredCorsOrigins()
  if (!allowedOrigins.includes('*') && !allowedOrigins.includes(origin)) return
  response.setHeader('Access-Control-Allow-Origin', origin)
  response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,OPTIONS')
  response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
  response.setHeader('Access-Control-Max-Age', '86400')
  response.setHeader('Vary', 'Origin')
}

function sendCorsPreflight(request, response) {
  setCorsHeaders(response, request)
  response.statusCode = 204
  response.end()
}

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 insideImageDirectory = imagePath.startsWith(resolve(bossImageDirectory) + sep)
  const extension = extname(imagePath).toLowerCase()
  if (
    !insideImageDirectory
    || !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 insideImageDirectory = imagePath.startsWith(resolve(itemImageDirectory) + sep)
  const extension = extname(imagePath).toLowerCase()
  if (
    !insideImageDirectory
    || !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 || encounter.encounterType !== 'boss') {
    throw new Error('Boss 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 === componentSlot) {
    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 normalizeIp(value) {
  const address = String(value ?? '').trim()
  if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) {
    return address.slice(7)
  }
  return address
}

function requestIp(request) {
  if (process.env.TRUST_PROXY === '1') {
    const forwarded = request.headers['x-forwarded-for']
    if (typeof forwarded === 'string') {
      return normalizeIp(forwarded.split(',')[0])
    }
  }
  return normalizeIp(request.socket.remoteAddress ?? 'unknown')
}

function consumeRateLimit(key, limit, windowMs) {
  const now = Date.now()
  const bucket = rateLimitBuckets.get(key)
  if (!bucket || now >= bucket.resetAt) {
    rateLimitBuckets.set(key, { count: 1, resetAt: now + windowMs })
    return
  }
  bucket.count += 1
  if (bucket.count > limit) {
    const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
    const error = new Error(`Too many requests. Try again in ${retryAfter} seconds.`)
    error.status = 429
    error.retryAfter = retryAfter
    throw error
  }
}

function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
  const targetLevel = database.prepare(`
    SELECT COALESCE(MAX(level), 0) AS level
    FROM characters
    WHERE account_id = ?
      AND id != ?
  `).get(accountId, characterId).level
  if (targetLevel <= currentLevel) return baseReward
  const targetExperience = database.prepare(`
    SELECT experience_required AS experienceRequired
    FROM level_progression
    WHERE level = ?
  `).get(targetLevel)?.experienceRequired ?? currentExperience
  return catchUpExperienceRewardForTarget(baseReward, currentExperience, targetExperience)
}

function normalizeUsername(value) {
  const username = String(value ?? '').trim()
  if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
    throw new Error('Username must be 3-20 letters, numbers, or underscores.')
  }
  return username
}

function normalizeCharacterName(value, fallback) {
  const name = String(value ?? fallback).trim()
  if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
    throw new Error('Character name must be 2-20 characters and start with a letter.')
  }
  return name
}

function validatePassword(value) {
  const password = String(value ?? '')
  if (password.length < 10 || password.length > 128) {
    throw new Error('Password must be 10-128 characters.')
  }
  return password
}

function passwordDigest(password, salt) {
  return scryptSync(password, salt, 64).toString('hex')
}

function verifyPassword(password, account) {
  const actual = Buffer.from(passwordDigest(password, account.passwordSalt), 'hex')
  const expected = Buffer.from(account.passwordHash, 'hex')
  return actual.length === expected.length && timingSafeEqual(actual, expected)
}

function tokenHash(token) {
  return createHash('sha256').update(token).digest('hex')
}

function parseCookies(request) {
  return Object.fromEntries(
    String(request.headers.cookie ?? '')
      .split(';')
      .map((part) => part.trim())
      .filter(Boolean)
      .map((part) => {
        const separator = part.indexOf('=')
        return separator < 0
          ? [part, '']
          : [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))]
      }),
  )
}

function bearerToken(request) {
  const authorization = request.headers.authorization
  if (typeof authorization !== 'string') return ''
  const match = authorization.match(/^Bearer\s+(.+)$/i)
  return match ? match[1].trim() : ''
}

function requestSessionToken(request) {
  return bearerToken(request) || parseCookies(request)[sessionCookieName] || ''
}

function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
  const secure = request.headers['x-forwarded-proto'] === 'https'
    || Boolean(request.socket.encrypted)
    || process.env.COOKIE_SECURE === '1'
  return [
    `${sessionCookieName}=${encodeURIComponent(token)}`,
    'HttpOnly',
    'Path=/',
    'SameSite=Lax',
    `Max-Age=${maxAge}`,
    secure ? 'Secure' : '',
  ].filter(Boolean).join('; ')
}

function createSession(database, accountId, ip, activeCharacterId) {
  const token = randomBytes(32).toString('base64url')
  database.prepare(`
    INSERT INTO sessions (account_id, token_hash, active_character_id, expires_at, created_ip)
    VALUES (?, ?, ?, datetime('now', '+30 days'), ?)
  `).run(accountId, tokenHash(token), activeCharacterId ?? null, ip)
  return token
}

function currentSession(database, request) {
  const token = requestSessionToken(request)
  if (!token) return null
  return database.prepare(`
    SELECT
      sessions.id AS sessionId,
      accounts.id AS accountId,
      accounts.username,
      characters.id AS characterId,
      characters.class_id AS classId,
      characters.name AS characterName,
      classes.name AS className
    FROM sessions
    JOIN accounts ON accounts.id = sessions.account_id
    JOIN characters ON characters.id = sessions.active_character_id
    JOIN classes ON classes.id = characters.class_id
    WHERE sessions.token_hash = ?
      AND sessions.expires_at > CURRENT_TIMESTAMP
  `).get(tokenHash(token)) ?? null
}

function requireSession(database, request) {
  const session = currentSession(database, request)
  if (!session) {
    const error = new Error('Sign in to continue.')
    error.status = 401
    throw error
  }
  return session
}

function initializeCharacter(database, accountId, characterName, classId) {
  const result = database.prepare(`
    INSERT INTO characters (account_id, class_id, name, level, experience, talent_points)
    VALUES (?, ?, ?, 1, 0, 1)
  `).run(accountId, classId, characterName)
  const characterId = Number(result.lastInsertRowid)
  const insertSlot = database.prepare(`
    INSERT INTO character_ability_slots (character_id, slot_number, spell_id)
    VALUES (?, ?, ?)
  `)
  const starterSpells = database.prepare(`
    SELECT id FROM spells WHERE class_id = ? AND unlock_level = 1 ORDER BY id
  `).all(classId).map((s) => s.id)
  ;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => {
    insertSlot.run(characterId, index + 1, spellId)
  })
  return characterId
}

function registerAccount(database, request, payload) {
  const ip = requestIp(request)
  consumeRateLimit(`register:${ip}`, 3, 60 * 60 * 1000)
  const username = normalizeUsername(payload.username)
  const password = validatePassword(payload.password)
  const characterName = normalizeCharacterName(payload.characterName, username)
  const existing = database.prepare(`
    SELECT id FROM accounts WHERE username = ? COLLATE NOCASE
  `).get(username)
  if (existing) throw new Error('That username is already taken.')
  const accountLimit = database.prepare(`
    SELECT max_accounts AS maxAccounts
    FROM account_ip_allowances
    WHERE ip_address = ?
  `).get(ip)?.maxAccounts ?? 1
  const existingIpAccounts = database.prepare(`
    SELECT COUNT(*) AS count FROM accounts WHERE created_ip = ?
  `).get(ip).count
  if (existingIpAccounts >= accountLimit) {
    throw new Error(
      'This IP address has reached its account limit. Contact the server administrator for an exception.',
    )
  }

  const salt = randomBytes(16).toString('hex')
  database.exec('BEGIN')
  try {
    const accountResult = database.prepare(`
      INSERT INTO accounts (username, password_hash, password_salt, created_ip)
      VALUES (?, ?, ?, ?)
    `).run(username, passwordDigest(password, salt), salt, ip)
    const accountId = Number(accountResult.lastInsertRowid)
    const activeCharacterId = initializeCharacter(database, accountId, characterName, 1)
    initializeCharacter(database, accountId, characterName, 2)
    initializeCharacter(database, accountId, characterName, 3)
    const token = createSession(database, accountId, ip, activeCharacterId)
    database.exec('COMMIT')
    return {
      token,
      account: { id: accountId, username },
      profile: getProfile(database, activeCharacterId, accountId),
    }
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }
}

function loginAccount(database, request, payload) {
  const ip = requestIp(request)
  consumeRateLimit(`login:${ip}`, 8, 15 * 60 * 1000)
  const username = normalizeUsername(payload.username)
  const password = String(payload.password ?? '')
  const account = database.prepare(`
    SELECT
      accounts.id,
      accounts.username,
      accounts.password_hash AS passwordHash,
      accounts.password_salt AS passwordSalt
    FROM accounts
    WHERE accounts.username = ? COLLATE NOCASE
  `).get(username)
  if (!account || !verifyPassword(password, account)) {
    throw new Error('Invalid username or password.')
  }
  const activeCharacterId = database.prepare(`
    SELECT id FROM characters WHERE account_id = ? ORDER BY class_id LIMIT 1
  `).get(account.id)?.id
  if (!activeCharacterId) throw new Error('No character found for this account.')
  const token = createSession(database, account.id, ip, activeCharacterId)
  return {
    token,
    account: { id: account.id, username: account.username },
    profile: getProfile(database, activeCharacterId, account.id),
  }
}

export function getProfile(database, characterId, accountId) {
  if (!accountId) {
    const row = database.prepare('SELECT account_id FROM characters WHERE id = ?').get(characterId)
    accountId = row?.account_id ?? null
  }
  const character = database.prepare(`
    SELECT
      characters.id,
      characters.name,
      characters.level,
      characters.experience,
      characters.talent_points AS talentPoints,
      classes.id AS classId,
      classes.slug AS classSlug,
      classes.name AS className,
      classes.resource_name AS resourceName,
      classes.max_resource AS maxResource,
      classes.theme_color AS themeColor,
      classes.description AS classDescription
    FROM characters
    JOIN classes ON classes.id = characters.class_id
    WHERE characters.id = ?
  `).get(characterId)

  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 spells = 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 slots = database.prepare(`
    SELECT slot_number AS slotNumber, spell_id AS spellId
    FROM character_ability_slots
    WHERE character_id = ?
    ORDER BY slot_number
  `).all(characterId)
  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,
      COALESCE(character_talents.rank, 0) AS rank
    FROM talents
    LEFT JOIN talents AS prerequisite
      ON prerequisite.id = talents.prerequisite_talent_id
    LEFT JOIN character_talents
      ON character_talents.talent_id = talents.id
      AND character_talents.character_id = ?
    ORDER BY talents.class_id, talents.tier, talents.branch
  `).all(characterId)
  const inventory = database.prepare(`
    SELECT
      items.id,
      items.slug,
      items.name,
      items.slot,
      items.rarity,
      items.item_level AS itemLevel,
      items.healing_power AS healingPower,
      items.max_resource_bonus AS maxResourceBonus,
      items.glyph,
      items.description,
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName,
      character_inventory.quantity,
      character_inventory.equipped
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    LEFT JOIN item_set_items ON item_set_items.item_id = items.id
    LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
    WHERE character_inventory.character_id = ?
    ORDER BY items.slot, items.item_level DESC, items.id
  `).all(characterId).map((item) => ({ ...item, equipped: Boolean(item.equipped) }))
  const equippedItems = inventory.filter((item) => item.equipped)
  const gearStats = {
    averageItemLevel: equipmentSlots.length === 0
      ? 0
      : equippedItems.reduce((total, item) => total + item.itemLevel, 0) / equipmentSlots.length,
    healingPower: equippedItems.reduce((total, item) => total + item.healingPower, 0),
    maxResourceBonus: equippedItems.reduce((total, item) => total + item.maxResourceBonus, 0),
  }
  const dungeons = database.prepare(`
    SELECT
      dungeons.id,
      dungeons.slug,
      dungeons.name,
      dungeons.recommended_level AS recommendedLevel,
      dungeons.content_type AS contentType,
      dungeons.party_size AS partySize,
      dungeons.completion_item_level AS completionItemLevel,
      dungeons.experience_reward AS experienceReward,
      dungeons.image_url AS imageUrl,
      dungeons.description,
      locations.name AS locationName
    FROM dungeons
    JOIN locations ON locations.id = dungeons.location_id
    ORDER BY dungeons.id
  `).all()
  const dungeonDifficulties = database.prepare(`
    SELECT
      dungeon_difficulties.dungeon_id AS dungeonId,
      difficulties.id,
      difficulties.slug,
      difficulties.name,
      difficulties.dropped_item_level AS droppedItemLevel,
      difficulties.unlock_level AS unlockLevel,
      difficulties.health_multiplier AS healthMultiplier,
      difficulties.damage_multiplier AS damageMultiplier,
      difficulties.experience_multiplier AS experienceMultiplier,
      difficulties.description
    FROM dungeon_difficulties
    JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id
    ORDER BY dungeon_difficulties.dungeon_id, difficulties.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 damage,
      tank_damage AS tankDamage,
      party_damage AS partyDamage,
      description,
      image_url AS imageUrl
    FROM encounters
    ORDER BY dungeon_id, sequence
  `).all()
  const encounterLoot = database.prepare(`
    SELECT
      encounter_loot.encounter_id AS encounterId,
      encounter_loot.difficulty_id AS difficultyId,
      encounter_loot.drop_weight AS dropWeight,
      encounter_loot.drop_chance AS dropChance,
      items.id,
      items.slug,
      items.name,
      items.slot,
      items.rarity,
      items.item_level AS itemLevel,
      items.healing_power AS healingPower,
      items.max_resource_bonus AS maxResourceBonus,
      items.glyph,
      items.description,
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName
    FROM encounter_loot
    JOIN items ON items.id = encounter_loot.item_id
    LEFT JOIN item_set_items ON item_set_items.item_id = items.id
    LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
    ORDER BY encounter_loot.encounter_id, encounter_loot.difficulty_id, items.slot
  `).all()
  const completionLoot = database.prepare(`
    SELECT
      dungeon_completion_loot.dungeon_id AS dungeonId,
      items.id,
      items.slug,
      items.name,
      items.slot,
      items.rarity,
      items.item_level AS itemLevel,
      items.healing_power AS healingPower,
      items.max_resource_bonus AS maxResourceBonus,
      items.glyph,
      items.description,
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName
    FROM dungeon_completion_loot
    JOIN items ON items.id = dungeon_completion_loot.item_id
    LEFT JOIN item_set_items ON item_set_items.item_id = items.id
    LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
    ORDER BY dungeon_completion_loot.dungeon_id, items.slot
  `).all()
  const craftingRecipeRows = database.prepare(`
    SELECT
      crafting_recipes.id,
      crafting_recipes.difficulty_id AS difficultyId,
      crafting_recipes.source_dungeon_id AS sourceDungeonId,
      crafting_recipes.source_encounter_id AS sourceEncounterId,
      output.id AS itemId,
      output.slug,
      output.name,
      output.slot,
      output.rarity,
      output.item_level AS itemLevel,
      output.healing_power AS healingPower,
      output.max_resource_bonus AS maxResourceBonus,
      output.glyph,
      output.description,
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName
    FROM crafting_recipes
    JOIN items AS output ON output.id = crafting_recipes.item_id
    LEFT JOIN item_set_items ON item_set_items.item_id = output.id
    LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
    ORDER BY crafting_recipes.source_dungeon_id, crafting_recipes.difficulty_id, output.item_level, output.slot
  `).all()
  const craftingComponentRows = database.prepare(`
    SELECT
      crafting_recipe_components.recipe_id AS recipeId,
      crafting_recipe_components.quantity,
      components.id,
      components.slug,
      components.name,
      components.slot,
      components.rarity,
      components.item_level AS itemLevel,
      components.healing_power AS healingPower,
      components.max_resource_bonus AS maxResourceBonus,
      components.glyph,
      components.description,
      COALESCE(character_inventory.quantity, 0) AS owned
    FROM crafting_recipe_components
    JOIN items AS components ON components.id = crafting_recipe_components.item_id
    LEFT JOIN character_inventory
      ON character_inventory.item_id = components.id
      AND character_inventory.character_id = ?
    ORDER BY crafting_recipe_components.recipe_id, components.id
  `).all(characterId)
  const setBonuses = database.prepare(`
    SELECT
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName,
      item_set_bonuses.required_pieces AS requiredPieces,
      item_set_bonuses.effect_type AS effectType,
      item_set_bonuses.description,
      COALESCE(equipped_sets.equippedPieces, 0) AS equippedPieces
    FROM item_set_bonuses
    JOIN item_sets ON item_sets.id = item_set_bonuses.set_id
    LEFT JOIN (
      SELECT item_set_items.set_id AS setId, COUNT(*) AS equippedPieces
      FROM item_set_items
      JOIN character_inventory
        ON character_inventory.item_id = item_set_items.item_id
        AND character_inventory.character_id = ?
        AND character_inventory.equipped = 1
      GROUP BY item_set_items.set_id
    ) AS equipped_sets ON equipped_sets.setId = item_sets.id
    ORDER BY item_sets.id, item_set_bonuses.required_pieces
  `).all(characterId).map((bonus) => ({
    ...bonus,
    active: bonus.equippedPieces >= bonus.requiredPieces,
  }))
  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 leaderboardRuns = database.prepare(`
    SELECT
      rank,
      dungeonId,
      difficultyId,
      startPart,
      completedParts,
      characterName,
      className,
      characterLevel,
      averageItemLevel,
      resourceSpent,
      durationSeconds,
      completedAt
    FROM (
      SELECT
        ROW_NUMBER() OVER (
          PARTITION BY dungeon_id, difficulty_id, start_part, completed_parts
          ORDER BY
            resource_spent,
            average_item_level,
            duration_seconds,
            completed_at
        ) AS rank,
        dungeon_id AS dungeonId,
        difficulty_id AS difficultyId,
        start_part AS startPart,
        completed_parts AS completedParts,
        character_name AS characterName,
        class_name AS className,
        character_level AS characterLevel,
        average_item_level AS averageItemLevel,
        resource_spent AS resourceSpent,
        duration_seconds AS durationSeconds,
        completed_at AS completedAt
      FROM dungeon_runs
      WHERE result = 'victory'
        AND leaderboard_eligible = 1
    )
    WHERE rank <= 10
    ORDER BY dungeonId, difficultyId, startPart, completedParts, rank
  `).all()
  const dungeonCompletionCounts = new Map(database.prepare(`
    SELECT dungeon_id AS dungeonId, COUNT(*) AS count
    FROM dungeon_runs
    WHERE character_id = ?
      AND result = 'victory'
      AND start_part = 1
      AND completed_parts >= 1
    GROUP BY dungeon_id
  `).all(characterId).map((row) => [row.dungeonId, row.count]))
  const bossStatRows = database.prepare(`
    SELECT
      encounter_id AS encounterId,
      kills,
      pet_quantity AS petQuantity
    FROM character_boss_stats
    WHERE character_id = ?
  `).all(characterId)
  const pvpStats = database.prepare(`
    SELECT
      matches_played AS matchesPlayed,
      matches_won AS matchesWon
    FROM character_pvp_stats
    WHERE character_id = ?
  `).get(characterId)

  const settings = Object.fromEntries(
    database.prepare('SELECT key, value FROM game_settings').all()
      .map((setting) => [setting.key, Number(setting.value)]),
  )
  const currentLevel = database.prepare(`
    SELECT experience_required AS experienceRequired
    FROM level_progression
    WHERE level = ?
  `).get(character.level)
  const nextLevel = database.prepare(`
    SELECT experience_required AS experienceRequired
    FROM level_progression
    WHERE level = ?
  `).get(character.level + 1)

  return {
    character: {
      ...character,
      currentLevelExperience: currentLevel?.experienceRequired ?? character.experience,
      nextLevelExperience: nextLevel?.experienceRequired ?? character.experience,
    },
    classes: classes.map((gameClass) => ({
      ...gameClass,
      spells: spells.filter((spell) => spell.classId === gameClass.id),
      talents: talents.filter((talent) => talent.classId === gameClass.id),
    })),
    abilitySlots: Array.from({ length: 6 }, (_, index) => {
      const slot = slots.find((candidate) => candidate.slotNumber === index + 1)
      return slot?.spellId ?? null
    }),
    maxLevel: settings.max_level ?? 25,
    maxTalentPoints: settings.max_talent_points ?? 25,
    allocatedTalentPoints: talents.reduce((total, talent) => total + talent.rank, 0),
    completedDungeonParts: accountId
      ? (database.prepare('SELECT completed_dungeon_parts AS value FROM accounts WHERE id = ?').get(accountId)?.value ?? 0)
      : 0,
    completedRaidPhases: accountId
      ? (database.prepare('SELECT completed_raid_phases AS value FROM accounts WHERE id = ?').get(accountId)?.value ?? 0)
      : 0,
    equipmentSlots,
    inventory,
    gearStats,
    setBonuses,
    gearUpgradePaths,
    craftingRecipes: craftingRecipeRows.map((recipe) => {
      const components = craftingComponentRows
        .filter((component) => component.recipeId === recipe.id)
        .map(({ recipeId, owned, quantity, ...item }) => ({
          item,
          quantity,
          owned,
        }))
      const hasRequiredComponents = components.length > 0
        && components.every((component) => component.quantity > 0)
      const { itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName } = recipe
      return {
        id: recipe.id,
        difficultyId: recipe.difficultyId,
        sourceDungeonId: recipe.sourceDungeonId,
        sourceEncounterId: recipe.sourceEncounterId,
        item: {
          id: itemId,
          slug,
          name,
          slot,
          rarity,
          itemLevel,
          healingPower,
          maxResourceBonus,
          glyph,
          description,
          setId,
          setSlug,
          setName,
        },
        components,
        canCraft: hasRequiredComponents
          && components.every((component) => component.owned >= component.quantity),
      }
    }),
    dungeons: dungeons.map((dungeon) => ({
      ...dungeon,
      completionCount: dungeonCompletionCounts.get(dungeon.id) ?? 0,
      difficulties: dungeonDifficulties.filter(
        (difficulty) => difficulty.dungeonId === dungeon.id,
      ),
      encounters: encounters
        .filter((encounter) => encounter.dungeonId === dungeon.id)
        .map((encounter) => ({
          ...encounter,
          isBoss: encounter.encounterType === 'boss',
          lootTables: encounterLoot.filter(
            (entry) => entry.encounterId === encounter.id,
          ),
        })),
      completionLoot: completionLoot.filter((item) => item.dungeonId === dungeon.id),
      leaderboard: leaderboardRuns
        .filter((run) => run.dungeonId === dungeon.id)
        .map((run) => ({ ...run, rank: Number(run.rank) })),
      leaderboards: {
        part_1: leaderboardRuns
          .filter((run) => run.dungeonId === dungeon.id && run.startPart === 1 && run.completedParts === 1)
          .map((run) => ({ ...run, rank: Number(run.rank) })),
        part_2: leaderboardRuns
          .filter((run) => run.dungeonId === dungeon.id && run.startPart === 2 && run.completedParts === 1)
          .map((run) => ({ ...run, rank: Number(run.rank) })),
        part_3: leaderboardRuns
          .filter((run) => run.dungeonId === dungeon.id && run.startPart === 3 && run.completedParts === 1)
          .map((run) => ({ ...run, rank: Number(run.rank) })),
        full_run: leaderboardRuns
          .filter((run) => run.dungeonId === dungeon.id && run.startPart === 1 && run.completedParts === 3)
          .map((run) => ({ ...run, rank: Number(run.rank) })),
      },
    })),
    hunterStats: {
      bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])),
      bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])),
      pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
      pvpMatchesWon: pvpStats?.matchesWon ?? 0,
    },
  }
}

function exportCharacterData(database, characterId, classId) {
  const character = database.prepare(`
    SELECT
      level,
      experience,
      talent_points AS talentPoints
    FROM characters
    WHERE id = ?
  `).get(characterId)
  const slots = database.prepare(`
    SELECT slot_number AS slotNumber, spell_id AS spellId
    FROM character_ability_slots
    WHERE character_id = ?
    ORDER BY slot_number
  `).all(characterId)
  const talents = database.prepare(`
    SELECT
      talents.id,
      COALESCE(character_talents.rank, 0) AS rank
    FROM talents
    LEFT JOIN character_talents
      ON character_talents.talent_id = talents.id
      AND character_talents.character_id = ?
    WHERE talents.class_id = ?
    ORDER BY talents.id
  `).all(characterId, classId)
  const inventory = database.prepare(`
    SELECT
      items.id,
      items.slug,
      items.name,
      items.slot,
      items.rarity,
      items.item_level AS itemLevel,
      items.healing_power AS healingPower,
      items.max_resource_bonus AS maxResourceBonus,
      items.glyph,
      items.description,
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName,
      character_inventory.quantity,
      character_inventory.equipped
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    LEFT JOIN item_set_items ON item_set_items.item_id = items.id
    LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
    WHERE character_inventory.character_id = ?
    ORDER BY items.slot, items.item_level DESC, items.id
  `).all(characterId).map((item) => ({ ...item, equipped: Boolean(item.equipped) }))
  const talentRanks = {}
  for (const talent of talents) {
    if (talent.rank > 0) {
      talentRanks[String(talent.id)] = talent.rank
    }
  }
  return {
    level: character.level,
    experience: character.experience,
    talentPoints: character.talentPoints,
    abilitySlots: Array.from({ length: 6 }, (_, index) => {
      const slot = slots.find((candidate) => candidate.slotNumber === index + 1)
      return slot?.spellId ?? null
    }),
    talentRanks,
    inventory,
  }
}

function buildSyncSave(database, accountId, activeCharacterId) {
  const account = database.prepare(`
    SELECT
      completed_dungeon_parts AS completedDungeonParts,
      completed_raid_phases AS completedRaidPhases,
      last_saved_at AS lastSavedAt
    FROM accounts
    WHERE id = ?
  `).get(accountId)
  const characters = database.prepare(`
    SELECT
      id,
      class_id AS classId,
      name
    FROM characters
    WHERE account_id = ?
    ORDER BY class_id
  `).all(accountId)
  const activeClassId = characters.find((candidate) => candidate.id === activeCharacterId)?.classId
    ?? characters[0]?.classId
    ?? 1
  const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name
    ?? characters[0]?.name
    ?? 'Mira'
  const bossStats = database.prepare(`
    SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity
    FROM character_boss_stats
    WHERE character_id = ?
  `).all(activeCharacterId)
  const pvpStats = database.prepare(`
    SELECT matches_played AS matchesPlayed, matches_won AS matchesWon
    FROM character_pvp_stats
    WHERE character_id = ?
  `).get(activeCharacterId)
  return {
    version: 4,
    updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
    characterName,
    activeClassId,
    completedDungeonParts: account?.completedDungeonParts ?? 0,
    completedRaidPhases: account?.completedRaidPhases ?? 0,
    characters: Object.fromEntries(
      characters.map((character) => [
        character.classId,
        exportCharacterData(database, character.id, character.classId),
      ]),
    ),
    lootRolls: {},
    bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])),
    bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])),
    pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0,
    pvpMatchesWon: pvpStats?.matchesWon ?? 0,
  }
}

function clampInteger(value, fallback, min, max) {
  const numeric = Number(value)
  if (!Number.isInteger(numeric)) return fallback
  return Math.min(max, Math.max(min, numeric))
}

function importSyncSave(database, accountId, activeCharacterId, payload) {
  const save = payload?.save
  if (
    !save
    || typeof save !== 'object'
    || (Number(save.version) !== 4 && Number(save.version) !== 3)
    || typeof save.characterName !== 'string'
    || !save.characters
    || typeof save.characters !== 'object'
  ) {
    throw new Error('The local save snapshot is invalid.')
  }

  const maxLevel = Number(
    database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
  )
  const maxTalentPoints = Number(
    database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25,
  )
  const maxExperience = database.prepare(`
    SELECT experience_required AS experienceRequired
    FROM level_progression
    WHERE level = ?
  `).get(maxLevel).experienceRequired
  const classIds = database.prepare('SELECT id FROM classes ORDER BY id').all().map((row) => row.id)
  const existingCharacters = database.prepare(`
    SELECT
      id,
      class_id AS classId,
      name
    FROM characters
    WHERE account_id = ?
    ORDER BY class_id
  `).all(accountId)
  if (existingCharacters.length === 0) {
    throw new Error('No character found for this account.')
  }
  const baseCharacterName = existingCharacters.find((candidate) => candidate.id === activeCharacterId)?.name
    ?? existingCharacters[0].name
  const characterName = normalizeCharacterName(save.characterName, baseCharacterName)
  const itemRows = database.prepare(`
    SELECT id, slot
    FROM items
  `).all()
  const itemSlots = new Map(itemRows.map((item) => [item.id, item.slot]))
  const spellIdsByClass = new Map(
    classIds.map((classId) => [
      classId,
      new Set(
        database.prepare(`
          SELECT id
          FROM spells
          WHERE class_id = ?
        `).all(classId).map((spell) => spell.id),
      ),
    ]),
  )
  const talentRowsByClass = new Map(
    classIds.map((classId) => [
      classId,
      database.prepare(`
        SELECT
          id,
          max_rank AS maxRank
        FROM talents
        WHERE class_id = ?
      `).all(classId),
    ]),
  )
  const charactersByClass = new Map(existingCharacters.map((character) => [character.classId, character]))

  database.exec('BEGIN')
  try {
    for (const classId of classIds) {
      if (!charactersByClass.has(classId)) {
        const characterId = initializeCharacter(database, accountId, characterName, classId)
        charactersByClass.set(classId, { id: characterId, classId, name: characterName })
      }
    }

    database.prepare(`
      UPDATE accounts
      SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
      WHERE id = ?
    `).run(
      clampInteger(save.completedDungeonParts, 0, 0, 3),
      clampInteger(save.completedRaidPhases, 0, 0, 3),
      sqliteDateFromMs(save.updatedAt),
      accountId,
    )

    const replaceSlot = database.prepare(`
      INSERT INTO character_ability_slots (character_id, slot_number, spell_id)
      VALUES (?, ?, ?)
    `)
    const insertTalent = database.prepare(`
      INSERT INTO character_talents (character_id, talent_id, rank)
      VALUES (?, ?, ?)
    `)
    const insertInventory = database.prepare(`
      INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
      VALUES (?, ?, ?, ?)
    `)
    const insertBossStats = database.prepare(`
      INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity)
      VALUES (?, ?, ?, ?)
    `)

    for (const classId of classIds) {
      const local = save.characters[classId]
      if (!local || typeof local !== 'object') continue

      const characterId = charactersByClass.get(classId).id
      database.prepare(`
        UPDATE characters
        SET name = ?, level = ?, experience = ?, talent_points = ?
        WHERE id = ?
      `).run(
        characterName,
        clampInteger(local.level, 1, 1, maxLevel),
        clampInteger(local.experience, 0, 0, maxExperience),
        clampInteger(local.talentPoints, 1, 0, maxTalentPoints),
        characterId,
      )

      const rawSlots = Array.isArray(local.abilitySlots)
        ? local.abilitySlots.slice(0, 6)
        : []
      while (rawSlots.length < 6) rawSlots.push(null)
      const validSpellIds = spellIdsByClass.get(classId) ?? new Set()
      const seenSpellIds = new Set()
      const normalizedSlots = rawSlots.map((value) => {
        if (value === null) return null
        const spellId = Number(value)
        if (
          !Number.isInteger(spellId)
          || !validSpellIds.has(spellId)
          || seenSpellIds.has(spellId)
        ) {
          return null
        }
        seenSpellIds.add(spellId)
        return spellId
      })
      database.prepare(`
        DELETE FROM character_ability_slots
        WHERE character_id = ?
      `).run(characterId)
      normalizedSlots.forEach((spellId, index) => {
        replaceSlot.run(characterId, index + 1, spellId)
      })

      database.prepare(`
        DELETE FROM character_talents
        WHERE character_id = ?
          AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
      `).run(characterId, classId)
      const localTalentRanks = local.talentRanks && typeof local.talentRanks === 'object'
        ? local.talentRanks
        : {}
      for (const talent of talentRowsByClass.get(classId) ?? []) {
        const rank = clampInteger(localTalentRanks[String(talent.id)], 0, 0, talent.maxRank)
        if (rank > 0) {
          insertTalent.run(characterId, talent.id, rank)
        }
      }

      database.prepare(`
        DELETE FROM character_inventory
        WHERE character_id = ?
      `).run(characterId)
      database.prepare(`
        DELETE FROM character_boss_stats
        WHERE character_id = ?
      `).run(characterId)
      database.prepare(`
        DELETE FROM character_pvp_stats
        WHERE character_id = ?
      `).run(characterId)
      const inventoryByItemId = new Map()
      const equippedSlots = new Set()
      for (const item of Array.isArray(local.inventory) ? local.inventory : []) {
        const itemId = Number(item?.id)
        const slot = itemSlots.get(itemId)
        const quantity = clampInteger(item?.quantity, 0, 0, 9999)
        if (!slot || quantity <= 0) continue
        const current = inventoryByItemId.get(itemId) ?? { quantity: 0, equipped: false }
        current.quantity = Math.min(9999, current.quantity + quantity)
        if (
          Boolean(item?.equipped)
          && slot !== 'component'
          && !equippedSlots.has(slot)
        ) {
          current.equipped = true
          equippedSlots.add(slot)
        }
        inventoryByItemId.set(itemId, current)
      }
      for (const [itemId, itemState] of inventoryByItemId) {
        insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0)
      }
      if (classId === Number(save.activeClassId)) {
        const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {}
        const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {}
        for (const [encounterId, rawKills] of Object.entries(bossKills)) {
          const id = Number(encounterId)
          if (!Number.isInteger(id)) continue
          insertBossStats.run(
            characterId,
            id,
            clampInteger(rawKills, 0, 0, 1000000),
            clampInteger(bossPets[encounterId], 0, 0, 1000000),
          )
        }
        for (const [encounterId, rawPets] of Object.entries(bossPets)) {
          if (Object.hasOwn(bossKills, encounterId)) continue
          const id = Number(encounterId)
          if (!Number.isInteger(id)) continue
          insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000))
        }
        database.prepare(`
          INSERT INTO character_pvp_stats (character_id, matches_played, matches_won)
          VALUES (?, ?, ?)
        `).run(
          characterId,
          clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000),
          clampInteger(save.pvpMatchesWon, 0, 0, 1000000),
        )
      }
    }

    let syncedClassId = clampInteger(
      save.activeClassId,
      existingCharacters[0]?.classId ?? 1,
      classIds[0] ?? 1,
      classIds[classIds.length - 1] ?? 1,
    )
    if (!charactersByClass.has(syncedClassId)) {
      syncedClassId = existingCharacters[0]?.classId ?? 1
    }
    const syncedCharacterId = charactersByClass.get(syncedClassId)?.id ?? activeCharacterId
    database.prepare(`
      UPDATE sessions
      SET active_character_id = ?
      WHERE account_id = ?
    `).run(syncedCharacterId, accountId)

    database.exec('COMMIT')
    return {
      profile: getProfile(database, syncedCharacterId, accountId),
      save: buildSyncSave(database, accountId, syncedCharacterId),
    }
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }
}

function itemById(database, itemId) {
  return database.prepare(`
    SELECT
      items.id,
      items.slug,
      items.name,
      items.slot,
      items.rarity,
      items.item_level AS itemLevel,
      items.healing_power AS healingPower,
      items.max_resource_bonus AS maxResourceBonus,
      items.glyph,
      items.description,
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName
    FROM items
    LEFT JOIN item_set_items ON item_set_items.item_id = items.id
    LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
    WHERE items.id = ?
  `).get(itemId)
}

function formatLootRoll(database, context, record, dropChance) {
  let items = []
  if (record.id) {
    items = database.prepare(`
      SELECT
        items.id,
        items.slug,
        items.name,
        items.slot,
        items.rarity,
        items.item_level AS itemLevel,
        items.healing_power AS healingPower,
        items.max_resource_bonus AS maxResourceBonus,
        items.glyph,
        items.description,
        item_sets.id AS setId,
        item_sets.slug AS setSlug,
        item_sets.name AS setName,
        encounter_loot_roll_items.quantity,
        encounter_loot_roll_items.was_duplicate AS wasDuplicate,
        encounter_loot_roll_items.quantity_after AS quantityAfter
      FROM encounter_loot_roll_items
      JOIN items ON items.id = encounter_loot_roll_items.item_id
      LEFT JOIN item_set_items ON item_set_items.item_id = items.id
      LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
      WHERE encounter_loot_roll_items.roll_id = ?
      ORDER BY items.id
    `).all(record.id).map((item) => ({
      ...item,
      duplicate: Boolean(item.wasDuplicate),
    }))
  }
  if (items.length === 0 && record.itemId) {
    const item = itemById(database, record.itemId)
    if (item) {
      items = [{
        ...item,
        quantity: 1,
        duplicate: Boolean(record.wasDuplicate),
        quantityAfter: record.quantityAfter,
      }]
    }
  }
  const item = items[0] ?? null

  return {
    encounterId: context.id,
    encounterName: context.encounterName,
    difficultyId: context.difficultyId,
    difficultyName: context.difficultyName,
    dropChance,
    dropped: Boolean(record.dropped),
    item,
    items,
    awarded: Boolean(record.dropped),
    duplicate: items.some((candidate) => candidate.duplicate) || Boolean(record.wasDuplicate),
    quantityAfter: item?.quantityAfter ?? record.quantityAfter,
  }
}

function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) {
  if (!sourceEncounterId || !stage) return null
  const coin = database.prepare(`
    SELECT
      items.id,
      items.slug,
      items.name,
      items.slot,
      items.rarity,
      items.item_level AS itemLevel,
      items.healing_power AS healingPower,
      items.max_resource_bonus AS maxResourceBonus,
      items.glyph,
      items.description
    FROM encounter_loot
    JOIN items ON items.id = encounter_loot.item_id
    WHERE encounter_loot.encounter_id = ?
      AND items.item_level = ?
    ORDER BY encounter_loot.difficulty_id
    LIMIT 1
  `).get(sourceEncounterId, roguelikeCoinItemLevel(stage))
  if (!coin) return null
  const quantity = coinDropQuantity()
  const previousQuantity = database.prepare(`
    SELECT quantity
    FROM character_inventory
    WHERE character_id = ? AND item_id = ?
  `).get(characterId, coin.id)?.quantity ?? 0
  database.prepare(`
    INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
    VALUES (?, ?, ?, 0)
    ON CONFLICT(character_id, item_id)
    DO UPDATE SET quantity = quantity + ?
  `).run(characterId, coin.id, quantity, quantity)
  return {
    ...coin,
    quantity,
    duplicate: previousQuantity > 0,
    quantityAfter: previousQuantity + quantity,
  }
}

function rollWeightedLootEntry(entries) {
  const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0)
  let weightedRoll = Math.random() * totalWeight
  for (const entry of entries) {
    weightedRoll -= entry.dropWeight
    if (weightedRoll < 0) return entry
  }
  return entries[entries.length - 1]
}

function rollEncounterLoot(database, characterId, encounterId, difficultyId, runToken) {
  if (typeof runToken !== 'string' || runToken.length < 8 || runToken.length > 100) {
    throw new Error('A valid dungeon run token is required.')
  }

  const context = database.prepare(`
    SELECT
      encounters.id,
      encounters.name AS encounterName,
      encounters.dungeon_id AS dungeonId,
      dungeons.content_type AS contentType,
      difficulties.id AS difficultyId,
      difficulties.name AS difficultyName,
      difficulties.dropped_item_level AS droppedItemLevel,
      difficulties.unlock_level AS unlockLevel
    FROM encounters
    JOIN dungeons ON dungeons.id = encounters.dungeon_id
    JOIN dungeon_difficulties
      ON dungeon_difficulties.dungeon_id = encounters.dungeon_id
    JOIN difficulties
      ON difficulties.id = dungeon_difficulties.difficulty_id
    WHERE encounters.id = ? AND difficulties.id = ?
  `).get(encounterId, difficultyId)
  if (!context) throw new Error('That loot table is not available.')

  const character = database.prepare('SELECT level FROM characters WHERE id = ?').get(characterId)
  if (character.level < context.unlockLevel) {
    throw new Error(`${context.difficultyName} unlocks at level ${context.unlockLevel}.`)
  }

  const entries = database.prepare(`
    SELECT
      encounter_loot.drop_weight AS dropWeight,
      encounter_loot.drop_chance AS dropChance,
      items.id,
      items.slug,
      items.name,
      items.slot,
      items.rarity,
      items.item_level AS itemLevel,
      items.healing_power AS healingPower,
      items.max_resource_bonus AS maxResourceBonus,
      items.glyph,
      items.description,
      item_sets.id AS setId,
      item_sets.slug AS setSlug,
      item_sets.name AS setName
    FROM encounter_loot
    JOIN items ON items.id = encounter_loot.item_id
    LEFT JOIN item_set_items ON item_set_items.item_id = items.id
    LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id
    WHERE encounter_loot.encounter_id = ?
      AND encounter_loot.difficulty_id = ?
    ORDER BY items.id
  `).all(encounterId, difficultyId)
  if (entries.length === 0) throw new Error('This encounter has no configured loot.')

  const dropChance = entries[0].dropChance
  const findExistingRoll = database.prepare(`
    SELECT
      id,
      item_id AS itemId,
      dropped,
      was_duplicate AS wasDuplicate,
      quantity_after AS quantityAfter
    FROM encounter_loot_rolls
    WHERE character_id = ?
      AND run_token = ?
      AND encounter_id = ?
      AND difficulty_id = ?
  `)
  const existing = findExistingRoll.get(characterId, runToken, encounterId, difficultyId)
  if (existing) {
    return formatLootRoll(database, context, existing, dropChance)
  }

  database.exec('BEGIN IMMEDIATE')
  try {
    const concurrentRoll = findExistingRoll.get(
      characterId,
      runToken,
      encounterId,
      difficultyId,
    )
    if (concurrentRoll) {
      database.exec('COMMIT')
      return formatLootRoll(database, context, concurrentRoll, dropChance)
    }

    const selectedQuantities = new Map()
    if (Math.random() < dropChance) {
      const selected = rollWeightedLootEntry(entries)
      selectedQuantities.set(
        selected.id,
        coinDropQuantity(),
      )
    }

    if (selectedQuantities.size === 0) {
      database.prepare(`
        INSERT INTO encounter_loot_rolls
          (character_id, run_token, encounter_id, difficulty_id, dropped)
        VALUES (?, ?, ?, ?, 0)
      `).run(characterId, runToken, encounterId, difficultyId)
      database.exec('COMMIT')
      return formatLootRoll(database, context, {
        id: null,
        itemId: null,
        dropped: 0,
        wasDuplicate: 0,
        quantityAfter: 0,
      }, dropChance)
    }

    const primaryItemId = selectedQuantities.keys().next().value
    const primaryPreviousQuantity = database.prepare(`
      SELECT quantity
      FROM character_inventory
      WHERE character_id = ? AND item_id = ?
    `).get(characterId, primaryItemId)?.quantity ?? 0
    const rollResult = database.prepare(`
      INSERT INTO encounter_loot_rolls
        (
          character_id,
          run_token,
          encounter_id,
          difficulty_id,
          item_id,
          dropped,
          was_duplicate,
          quantity_after
        )
      VALUES (?, ?, ?, ?, ?, 1, ?, ?)
    `).run(
      characterId,
      runToken,
      encounterId,
      difficultyId,
      primaryItemId,
      primaryPreviousQuantity > 0 ? 1 : 0,
      primaryPreviousQuantity + selectedQuantities.get(primaryItemId),
    )
    const rollId = Number(rollResult.lastInsertRowid)
    for (const [itemId, quantity] of selectedQuantities) {
      const previousQuantity = database.prepare(`
        SELECT quantity
        FROM character_inventory
        WHERE character_id = ? AND item_id = ?
      `).get(characterId, itemId)?.quantity ?? 0
      database.prepare(`
        INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
        VALUES (?, ?, ?, 0)
        ON CONFLICT(character_id, item_id)
        DO UPDATE SET quantity = quantity + ?
      `).run(characterId, itemId, quantity, quantity)
      database.prepare(`
        INSERT INTO encounter_loot_roll_items
          (roll_id, item_id, quantity, was_duplicate, quantity_after)
        VALUES (?, ?, ?, ?, ?)
      `).run(
        rollId,
        itemId,
        quantity,
        previousQuantity > 0 ? 1 : 0,
        previousQuantity + quantity,
      )
    }
    database.exec('COMMIT')

    return formatLootRoll(database, context, {
      id: rollId,
      itemId: primaryItemId,
      dropped: 1,
      wasDuplicate: primaryPreviousQuantity > 0 ? 1 : 0,
      quantityAfter: primaryPreviousQuantity + selectedQuantities.get(primaryItemId),
    }, dropChance)
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }
}

function equipItem(database, characterId, itemId) {
  const item = database.prepare(`
    SELECT
      items.id,
      items.slot,
      character_inventory.equipped
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    WHERE character_inventory.character_id = ?
      AND items.id = ?
  `).get(characterId, itemId)
  if (!item) throw new Error('That item is not in the character inventory.')
  if (!equipmentSlots.includes(item.slot)) throw new Error('That item cannot be equipped.')
  if (item.equipped) return getProfile(database, characterId)

  database.exec('BEGIN')
  try {
    database.prepare(`
      UPDATE character_inventory
      SET equipped = 0
      WHERE character_id = ?
        AND item_id IN (SELECT id FROM items WHERE slot = ?)
    `).run(characterId, item.slot)
    database.prepare(`
      UPDATE character_inventory
      SET equipped = 1
      WHERE character_id = ? AND item_id = ?
    `).run(characterId, itemId)
    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  return getProfile(database, characterId)
}

function discardExtraItem(database, characterId, itemId) {
  const item = database.prepare(`
    SELECT
      items.name,
      character_inventory.quantity
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    WHERE character_inventory.character_id = ?
      AND items.id = ?
  `).get(characterId, itemId)
  if (!item) throw new Error('That item is not in the character inventory.')
  if (item.quantity <= 1) {
    throw new Error('Only extra copies can be discarded.')
  }

  database.exec('BEGIN')
  try {
    const result = database.prepare(`
      UPDATE character_inventory
      SET quantity = quantity - 1
      WHERE character_id = ?
        AND item_id = ?
        AND quantity > 1
    `).run(characterId, itemId)
    if (result.changes !== 1) {
      throw new Error('The extra copy is no longer available.')
    }
    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  return getProfile(database, characterId)
}

function breakdownItem(database, characterId, itemId) {
  const item = database.prepare(`
    SELECT
      items.id,
      items.name,
      items.slot,
      items.item_level AS itemLevel,
      character_inventory.quantity AS qty,
      character_inventory.equipped
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    WHERE character_inventory.character_id = ?
      AND items.id = ?
  `).get(characterId, itemId)
  if (!item) throw new Error('That item is not in the character inventory.')
  if (item.slot === componentSlot) throw new Error('Components cannot be broken down.')
  if (item.equipped && item.qty <= 1) {
    throw new Error('Equipped items cannot be broken down.')
  }

  const component = database.prepare(`
    SELECT id
    FROM items
    WHERE slot = ?
      AND item_level <= ?
    ORDER BY item_level DESC, id
    LIMIT 1
  `).get(componentSlot, item.itemLevel)
  if (!component) throw new Error('No component type exists for this item level.')

  const count = Math.floor(Math.random() * 3) + 1

  database.exec('BEGIN')
  try {
    if (item.qty <= 1) {
      database.prepare(`
        DELETE FROM character_inventory
        WHERE character_id = ? AND item_id = ?
      `).run(characterId, itemId)
    } else {
      database.prepare(`
        UPDATE character_inventory
        SET quantity = quantity - 1
        WHERE character_id = ? AND item_id = ? AND quantity > 1
      `).run(characterId, itemId)
    }

    database.prepare(`
      INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
      VALUES (?, ?, ?, 0)
      ON CONFLICT(character_id, item_id)
      DO UPDATE SET quantity = quantity + ?
    `).run(characterId, component.id, count, count)

    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  return getProfile(database, characterId)
}

function craftItem(database, characterId, recipeId) {
  const recipe = database.prepare(`
    SELECT
      crafting_recipes.id,
      crafting_recipes.item_id AS itemId,
      crafting_recipes.difficulty_id AS difficultyId,
      crafting_recipes.source_dungeon_id AS sourceDungeonId,
      crafting_recipes.source_encounter_id AS sourceEncounterId,
      items.slot,
      items.item_level AS itemLevel
    FROM crafting_recipes
    JOIN items ON items.id = crafting_recipes.item_id
    WHERE crafting_recipes.id = ?
  `).get(recipeId)
  if (!recipe) throw new Error('That crafting recipe does not exist.')
  if (!directCraftItemLevels.has(recipe.itemLevel)) {
    throw new Error('Upgrade the previous item tier instead.')
  }

  const components = database.prepare(`
    SELECT
      crafting_recipe_components.item_id AS itemId,
      crafting_recipe_components.quantity,
      COALESCE(character_inventory.quantity, 0) AS owned
    FROM crafting_recipe_components
    LEFT JOIN character_inventory
      ON character_inventory.item_id = crafting_recipe_components.item_id
      AND character_inventory.character_id = ?
    WHERE crafting_recipe_components.recipe_id = ?
  `).all(characterId, recipeId)
  if (components.length === 0) throw new Error('That recipe has no component requirements.')
  if (components.some((component) => component.quantity <= 0)) {
    throw new Error('Recipe components must require at least one item.')
  }
  const missing = components.find((component) => component.owned < component.quantity)
  if (missing) {
    const item = itemById(database, missing.itemId)
    throw new Error(`Need ${missing.quantity} ${item?.name ?? 'component'} to craft this item.`)
  }

  database.exec('BEGIN')
  try {
    for (const component of components) {
      database.prepare(`
        UPDATE character_inventory
        SET quantity = quantity - ?
        WHERE character_id = ? AND item_id = ?
      `).run(component.quantity, characterId, component.itemId)
      database.prepare(`
        DELETE FROM character_inventory
        WHERE character_id = ? AND item_id = ? AND quantity <= 0
      `).run(characterId, component.itemId)
    }

    database.prepare(`
      INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
      VALUES (?, ?, 1, 0)
      ON CONFLICT(character_id, item_id)
      DO UPDATE SET quantity = quantity + 1
    `).run(characterId, recipe.itemId)
    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  return getProfile(database, characterId)
}

function upgradeItem(database, characterId, itemId) {
  const item = database.prepare(`
    SELECT
      items.id,
      items.name,
      items.slot,
      items.item_level AS itemLevel,
      character_inventory.quantity,
      character_inventory.equipped
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    WHERE character_inventory.character_id = ?
      AND items.id = ?
  `).get(characterId, itemId)
  if (!item) throw new Error('That item is not in the character inventory.')
  if (item.slot === componentSlot) throw new Error('Components cannot be upgraded.')

  const currentRecipe = database.prepare(`
    SELECT id
    FROM crafting_recipes
    WHERE item_id = ?
  `).get(itemId)
  if (!currentRecipe) throw new Error('No upgrade is available for this item.')

  const pathRecipe = database.prepare(`
    SELECT
      crafting_recipes.id,
      crafting_recipes.item_id AS itemId
    FROM gear_upgrade_paths
    JOIN crafting_recipes ON crafting_recipes.item_id = gear_upgrade_paths.to_item_id
    WHERE gear_upgrade_paths.from_item_id = ?
  `).get(itemId)

  const targetRecipe = pathRecipe ?? database.prepare(`
    WITH next_tier AS (
      SELECT MIN(items.item_level) AS item_level
      FROM crafting_recipes
      JOIN items ON items.id = crafting_recipes.item_id
      WHERE items.slot = ?
        AND items.item_level > ?
    )
    SELECT
      crafting_recipes.id,
      crafting_recipes.item_id AS itemId
    FROM crafting_recipes
    JOIN items ON items.id = crafting_recipes.item_id
    JOIN next_tier ON next_tier.item_level = items.item_level
    WHERE items.slot = ?
    ORDER BY crafting_recipes.id
    LIMIT 1
  `).get(item.slot, item.itemLevel, item.slot)
  if (!targetRecipe) throw new Error('No upgrade is available for this item.')

  const components = database.prepare(`
    SELECT
      crafting_recipe_components.item_id AS itemId,
      crafting_recipe_components.quantity,
      COALESCE(character_inventory.quantity, 0) AS owned
    FROM crafting_recipe_components
    LEFT JOIN character_inventory
      ON character_inventory.item_id = crafting_recipe_components.item_id
      AND character_inventory.character_id = ?
    WHERE crafting_recipe_components.recipe_id = ?
  `).all(characterId, targetRecipe.id)
  if (components.length === 0) throw new Error('That upgrade has no component requirements.')
  if (components.some((component) => component.quantity <= 0)) {
    throw new Error('Upgrade components must require at least one item.')
  }
  const missing = components.find((component) => component.owned < component.quantity)
  if (missing) {
    const componentItem = itemById(database, missing.itemId)
    throw new Error(`Need ${missing.quantity} ${componentItem?.name ?? 'component'} to upgrade this item.`)
  }

  database.exec('BEGIN')
  try {
    for (const component of components) {
      database.prepare(`
        UPDATE character_inventory
        SET quantity = quantity - ?
        WHERE character_id = ? AND item_id = ?
      `).run(component.quantity, characterId, component.itemId)
    }
    database.prepare(`
      UPDATE character_inventory
      SET quantity = quantity - 1,
          equipped = 0
      WHERE character_id = ? AND item_id = ?
    `).run(characterId, itemId)
    database.prepare(`
      DELETE FROM character_inventory
      WHERE character_id = ? AND quantity <= 0
    `).run(characterId)
    if (item.equipped) {
      database.prepare(`
        UPDATE character_inventory
        SET equipped = 0
        WHERE character_id = ?
          AND item_id IN (SELECT id FROM items WHERE slot = ?)
      `).run(characterId, item.slot)
    }
    database.prepare(`
      INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
      VALUES (?, ?, 1, ?)
      ON CONFLICT(character_id, item_id)
      DO UPDATE SET quantity = quantity + 1,
                    equipped = CASE WHEN excluded.equipped = 1 THEN 1 ELSE equipped END
    `).run(characterId, targetRecipe.itemId, item.equipped ? 1 : 0)
    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  return getProfile(database, characterId)
}

function talentEffectCapacity(level) {
  return Math.min(4, Math.max(0, Math.floor(level / 5)))
}

function talentEffectSource(effectType) {
  if (effectType.startsWith('mend_')) return 'Mend'
  if (effectType.startsWith('radiance_')) return 'Radiance'
  if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
  return effectType
}

function allocateTalent(database, characterId, talentId) {
  const character = database.prepare(`
    SELECT class_id AS classId, level, talent_points AS talentPoints
    FROM characters
    WHERE id = ?
  `).get(characterId)
  const talent = database.prepare(`
    SELECT
      id,
      class_id AS classId,
      name,
      max_rank AS maxRank,
      tier,
      prerequisite_talent_id AS prerequisiteTalentId,
      prerequisite_rank AS prerequisiteRank,
      effect_type AS effectType
    FROM talents
    WHERE id = ?
  `).get(talentId)

  if (!talent || talent.classId !== character.classId) {
    throw new Error('That talent does not belong to the active class.')
  }

  if (character.classId === 1) {
    const currentRank = database.prepare(`
      SELECT rank
      FROM character_talents
      WHERE character_id = ? AND talent_id = ?
    `).get(characterId, talentId)?.rank ?? 0
    database.exec('BEGIN')
    try {
      if (currentRank > 0) {
        database.prepare(`
          DELETE FROM character_talents
          WHERE character_id = ? AND talent_id = ?
        `).run(characterId, talentId)
      } else {
        const capacity = talentEffectCapacity(character.level)
        if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
        const activeTalents = database.prepare(`
          SELECT
            talents.id,
            talents.name,
            talents.effect_type AS effectType
          FROM character_talents
          JOIN talents ON talents.id = character_talents.talent_id
          WHERE character_talents.character_id = ?
            AND talents.class_id = ?
            AND character_talents.rank > 0
        `).all(characterId, character.classId)
        const source = talentEffectSource(talent.effectType)
        const sourceConflict = activeTalents.find(
          (candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source,
        )
        if (sourceConflict) {
          throw new Error(`Only one ${source} spell effect can be active.`)
        }
        const activeCount = activeTalents.length
        if (activeCount >= capacity) {
          throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
        }
        database.prepare(`
          INSERT INTO character_talents (character_id, talent_id, rank)
          VALUES (?, ?, 1)
          ON CONFLICT(character_id, talent_id)
          DO UPDATE SET rank = 1
        `).run(characterId, talentId)
      }
      database.exec('COMMIT')
    } catch (error) {
      database.exec('ROLLBACK')
      throw error
    }
    return getProfile(database, characterId)
  }

  if (character.talentPoints <= 0) {
    throw new Error('No talent points are available.')
  }

  const currentRank = database.prepare(`
    SELECT rank
    FROM character_talents
    WHERE character_id = ? AND talent_id = ?
  `).get(characterId, talentId)?.rank ?? 0
  if (currentRank >= talent.maxRank) {
    throw new Error('That talent is already at maximum rank.')
  }

  const lowerTierPoints = database.prepare(`
    SELECT COALESCE(SUM(character_talents.rank), 0) AS points
    FROM character_talents
    JOIN talents ON talents.id = character_talents.talent_id
    WHERE character_talents.character_id = ?
      AND talents.class_id = ?
      AND talents.tier < ?
  `).get(characterId, character.classId, talent.tier).points
  const requiredTierPoints = (talent.tier - 1) * 5
  if (lowerTierPoints < requiredTierPoints) {
    throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`)
  }

  if (talent.prerequisiteTalentId) {
    const prerequisiteRank = database.prepare(`
      SELECT rank
      FROM character_talents
      WHERE character_id = ? AND talent_id = ?
    `).get(characterId, talent.prerequisiteTalentId)?.rank ?? 0
    if (prerequisiteRank < talent.prerequisiteRank) {
      throw new Error(`The prerequisite talent requires rank ${talent.prerequisiteRank}.`)
    }
  }

  database.exec('BEGIN')
  try {
    database.prepare(`
      INSERT INTO character_talents (character_id, talent_id, rank)
      VALUES (?, ?, 1)
      ON CONFLICT(character_id, talent_id)
      DO UPDATE SET rank = rank + 1
    `).run(characterId, talentId)
    database.prepare(`
      UPDATE characters SET talent_points = talent_points - 1 WHERE id = ?
    `).run(characterId)
    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  return getProfile(database, characterId)
}

function resetTalents(database, characterId) {
  const character = database.prepare(`
    SELECT class_id AS classId, level, talent_points AS talentPoints
    FROM characters
    WHERE id = ?
  `).get(characterId)
  const refunded = database.prepare(`
    SELECT COALESCE(SUM(character_talents.rank), 0) AS points
    FROM character_talents
    JOIN talents ON talents.id = character_talents.talent_id
    WHERE character_talents.character_id = ?
      AND talents.class_id = ?
  `).get(characterId, character.classId).points

  database.exec('BEGIN')
  try {
    database.prepare(`
      DELETE FROM character_talents
      WHERE character_id = ?
        AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
    `).run(characterId, character.classId)
    if (character.classId !== 1) {
      database.prepare(`
        UPDATE characters
        SET talent_points = MIN(level, talent_points + ?)
        WHERE id = ?
      `).run(refunded, characterId)
    }
    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  return getProfile(database, characterId)
}

function completeDungeon(database, characterId, accountId, dungeonId, difficultyId, runMetrics) {
  const resourceSpent = Number(runMetrics?.resourceSpent)
  const durationSeconds = Number(runMetrics?.durationSeconds)
  if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
    throw new Error('The run resource total is invalid.')
  }
  if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 86400) {
    throw new Error('The run duration is invalid.')
  }

  const dungeon = database.prepare(`
    SELECT
      dungeons.id,
      dungeons.name,
      dungeons.content_type AS contentType,
      dungeons.completion_item_level AS completionItemLevel,
      dungeons.experience_reward AS experienceReward,
      difficulties.id AS difficultyId,
      difficulties.name AS difficultyName,
      difficulties.unlock_level AS unlockLevel,
      difficulties.experience_multiplier AS experienceMultiplier,
      difficulties.dropped_item_level AS droppedItemLevel
    FROM dungeons
    JOIN dungeon_difficulties
      ON dungeon_difficulties.dungeon_id = dungeons.id
    JOIN difficulties
      ON difficulties.id = dungeon_difficulties.difficulty_id
    WHERE dungeons.id = ? AND difficulties.id = ?
  `).get(dungeonId, difficultyId)
  if (!dungeon) throw new Error('That difficulty is not available for this dungeon.')

  const character = database.prepare(`
    SELECT
      characters.id,
      characters.class_id AS classId,
      characters.name,
      characters.level,
      characters.experience,
      characters.talent_points AS talentPoints,
      classes.name AS className
    FROM characters
    JOIN classes ON classes.id = characters.class_id
    WHERE characters.id = ?
  `).get(characterId)
  if (character.level < dungeon.unlockLevel) {
    throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
  }
  const maxLevel = Number(
    database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
  )
  const maxTalentPoints = Number(
    database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25,
  )
  const maxExperience = database.prepare(`
    SELECT experience_required AS experienceRequired
    FROM level_progression
    WHERE level = ?
  `).get(maxLevel).experienceRequired
  const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
  const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
  const completedParts = completedPart - startPart + 1
  const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
  const rawPartDurations = runMetrics?.partDurationSeconds
  const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
    ? rawPartDurations.map(Number)
    : null
  const baseExperienceReward = Math.round(
    dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
  )
  const experienceReward = catchUpExperienceReward(
    database,
    accountId,
    characterId,
    baseExperienceReward,
    character.experience,
    character.level,
  )
  const newExperience = Math.min(character.experience + experienceReward, maxExperience)
  const newLevel = database.prepare(`
    SELECT MAX(level) AS level
    FROM level_progression
    WHERE experience_required <= ?
  `).get(newExperience).level
  const levelsGained = Math.max(0, newLevel - character.level)
  const newTalentPoints = Math.min(
    maxTalentPoints,
    character.talentPoints + levelsGained,
  )
  const unlockedAbilities = database.prepare(`
    SELECT id, name, unlock_level AS unlockLevel, glyph
    FROM spells
    WHERE class_id = ?
      AND unlock_level > ?
      AND unlock_level <= ?
    ORDER BY unlock_level, id
  `).all(character.classId, character.level, newLevel)
  const averageItemLevel = database.prepare(`
    SELECT COALESCE(SUM(items.item_level), 0) * 1.0 / ? AS value
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    WHERE character_inventory.character_id = ?
      AND character_inventory.equipped = 1
  `).get(equipmentSlots.length, characterId).value

  const isFullRun = startPart === 1 && completedPart >= 3
  const runRows = isFullRun
    ? [
        { startPart: 1, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[0] || 1)) },
        { startPart: 2, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[1] || 1)) },
        { startPart: 3, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[2] || 1)) },
        { startPart: 1, completedParts: 3, duration: durationSeconds },
      ]
    : [{ startPart, completedParts: completedParts, duration: durationSeconds }]

  database.exec('BEGIN')
  try {
    database.prepare(`
      UPDATE characters
      SET experience = ?, level = ?, talent_points = ?
      WHERE id = ?
    `).run(newExperience, newLevel, newTalentPoints, characterId)
    if (dungeon.contentType === 'raid') {
      database.prepare(`
        UPDATE accounts
        SET completed_raid_phases = MAX(completed_raid_phases, ?)
        WHERE id = ?
      `).run(completedPart, accountId)
    } else {
      database.prepare(`
        UPDATE accounts
        SET completed_dungeon_parts = MAX(completed_dungeon_parts, ?)
        WHERE id = ?
      `).run(completedPart, accountId)
    }
    const insertStmt = database.prepare(`
      INSERT INTO dungeon_runs
        (
          character_id, dungeon_id, difficulty_id, result,
          character_name, class_name, character_level,
          average_item_level, resource_spent, duration_seconds,
          leaderboard_eligible, start_part, completed_parts, completed_at
        )
      VALUES (?, ?, ?, 'victory', ?, ?, ?, ?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)
    `)
    for (const row of runRows) {
      insertStmt.run(
        characterId, dungeonId, difficultyId,
        character.name, character.className, character.level,
        averageItemLevel, resourceSpent, row.duration,
        row.startPart, row.completedParts,
      )
    }
    database.exec('COMMIT')
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }

  let bonusItem = null
  if (startPart === 1 && completedPart >= 3) {
    const bonusItems = database.prepare(`
      SELECT items.id, items.slug, items.name, items.slot, items.rarity,
        items.item_level AS itemLevel, items.healing_power AS healingPower,
        items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description
      FROM dungeon_completion_loot
      JOIN items ON items.id = dungeon_completion_loot.item_id
      WHERE dungeon_completion_loot.dungeon_id = ?
        AND items.item_level >= ?
      ORDER BY items.item_level, RANDOM()
      LIMIT 1
    `).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
    if (bonusItems.length > 0) {
      bonusItem = bonusItems[0]
      const rewardQuantity = rewardMultiplier
      const previousQuantity = database.prepare(`
        SELECT quantity FROM character_inventory
        WHERE character_id = ? AND item_id = ?
      `).get(characterId, bonusItem.id)?.quantity ?? 0
      database.prepare(`
        INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
        VALUES (?, ?, ?, 0)
        ON CONFLICT(character_id, item_id)
        DO UPDATE SET quantity = quantity + ?
      `).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
      bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
    }
  }

  return {
    dungeonName: dungeon.name,
    difficultyName: dungeon.difficultyName,
    droppedItemLevel: dungeon.droppedItemLevel,
    experienceGained: newExperience - character.experience,
    previousLevel: character.level,
    newLevel,
    levelsGained,
    talentPointsGained: levelsGained,
    resourceSpent,
    durationSeconds,
    averageItemLevel,
    unlockedAbilities,
    bonusItem,
    profile: getProfile(database, characterId, accountId),
  }
}

function completeRoguelike(database, characterId, accountId, runMetrics) {
  const dungeonId = Number(runMetrics?.dungeonId)
  const difficultyId = Number(runMetrics?.difficultyId)
  const encountersCleared = Number(runMetrics?.encountersCleared)
  const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3))
  const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level'
    ? 'pvp-boss-quarter-level'
    : runMetrics?.experienceMode === 'pvp-fight-twelfth-level'
      ? 'pvp-fight-twelfth-level'
      : runMetrics?.experienceMode === 'pvp-match-win-half-level'
        ? 'pvp-match-win-half-level'
        : runMetrics?.experienceMode === 'pvp-stadium-round-win-quarter-level'
          ? 'pvp-stadium-round-win-quarter-level'
          : runMetrics?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
            ? 'pvp-stadium-round-loss-tenth-level'
            : runMetrics?.experienceMode === 'pvp-stadium-match-half-level'
              ? 'pvp-stadium-match-half-level'
              : 'default'
  const fightsCleared = Number(runMetrics?.fightsCleared ?? encountersCleared)
  const resourceSpent = Number(runMetrics?.resourceSpent)
  const durationSeconds = Number(runMetrics?.durationSeconds)
  if (!Number.isInteger(dungeonId) || dungeonId < 1) {
    throw new Error('The roguelike dungeon is invalid.')
  }
  if (!Number.isInteger(difficultyId) || difficultyId < 1) {
    throw new Error('The roguelike difficulty is invalid.')
  }
  if (!Number.isInteger(encountersCleared) || encountersCleared < 0 || encountersCleared > 100000) {
    throw new Error('The roguelike progress total is invalid.')
  }
  if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) {
    throw new Error('The roguelike boss total is invalid.')
  }
  if (!Number.isInteger(fightsCleared) || fightsCleared < 0 || fightsCleared > 100000) {
    throw new Error('The roguelike fight total is invalid.')
  }
  if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) {
    throw new Error('The run resource total is invalid.')
  }
  if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 86400) {
    throw new Error('The run duration is invalid.')
  }

  const dungeon = database.prepare(`
    SELECT
      dungeons.id,
      dungeons.name,
      difficulties.id AS difficultyId,
      difficulties.name AS difficultyName,
      difficulties.unlock_level AS unlockLevel,
      difficulties.experience_multiplier AS experienceMultiplier,
      difficulties.dropped_item_level AS droppedItemLevel,
      dungeons.experience_reward AS experienceReward
    FROM dungeons
    JOIN dungeon_difficulties
      ON dungeon_difficulties.dungeon_id = dungeons.id
    JOIN difficulties
      ON difficulties.id = dungeon_difficulties.difficulty_id
    WHERE dungeons.id = ? AND difficulties.id = ?
  `).get(dungeonId, difficultyId)
  if (!dungeon) throw new Error('That difficulty is not available for this roguelike.')

  const character = database.prepare(`
    SELECT
      characters.id,
      characters.class_id AS classId,
      characters.level,
      characters.experience,
      characters.talent_points AS talentPoints
    FROM characters
    WHERE characters.id = ?
  `).get(characterId)
  if (character.level < dungeon.unlockLevel) {
    throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`)
  }
  const maxLevel = Number(
    database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25,
  )
  const maxTalentPoints = Number(
    database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25,
  )
  const maxExperience = database.prepare(`
    SELECT experience_required AS experienceRequired
    FROM level_progression
    WHERE level = ?
  `).get(maxLevel).experienceRequired
  let newExperience = character.experience
  let newLevel = character.level
  if (experienceMode === 'pvp-boss-quarter-level' || experienceMode === 'pvp-fight-twelfth-level') {
    const catchUpTargetLevel = database.prepare(`
      SELECT COALESCE(MAX(level), 0) AS level
      FROM characters
      WHERE account_id = ?
        AND id != ?
    `).get(accountId, characterId).level
    const rewardUnits = experienceMode === 'pvp-boss-quarter-level' ? bossesCleared : fightsCleared
    for (let rewardIndex = 0; rewardIndex < rewardUnits && newExperience < maxExperience; rewardIndex += 1) {
      const currentLevelFloor = database.prepare(`
        SELECT experience_required AS experienceRequired
        FROM level_progression
        WHERE level = ?
      `).get(newLevel).experienceRequired
      const nextLevelExperience = newLevel >= maxLevel
        ? maxExperience
        : database.prepare(`
          SELECT experience_required AS experienceRequired
          FROM level_progression
          WHERE level = ?
        `).get(newLevel + 1).experienceRequired
      const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
      const rewardRate = experienceMode === 'pvp-boss-quarter-level'
        ? (catchUpTargetLevel > newLevel ? 0.5 : 0.25)
        : (catchUpTargetLevel > newLevel ? 1 / 6 : 1 / 12)
      newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
      newLevel = database.prepare(`
        SELECT MAX(level) AS level
        FROM level_progression
        WHERE experience_required <= ?
      `).get(newExperience).level
    }
  } else if (
    experienceMode === 'pvp-match-win-half-level'
    || experienceMode === 'pvp-stadium-round-win-quarter-level'
    || experienceMode === 'pvp-stadium-round-loss-tenth-level'
    || experienceMode === 'pvp-stadium-match-half-level'
  ) {
    const currentLevelFloor = database.prepare(`
      SELECT experience_required AS experienceRequired
      FROM level_progression
      WHERE level = ?
    `).get(newLevel).experienceRequired
    const nextLevelExperience = newLevel >= maxLevel
      ? maxExperience
      : database.prepare(`
        SELECT experience_required AS experienceRequired
        FROM level_progression
        WHERE level = ?
      `).get(newLevel + 1).experienceRequired
    const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
    const rewardRate = experienceMode === 'pvp-stadium-round-win-quarter-level'
      ? 0.25
      : experienceMode === 'pvp-stadium-round-loss-tenth-level'
        ? 0.1
        : 0.5
    newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
    newLevel = database.prepare(`
      SELECT MAX(level) AS level
      FROM level_progression
      WHERE experience_required <= ?
    `).get(newExperience).level
  } else {
    const baseExperienceReward = Math.round(
      dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
    )
    const experienceReward = catchUpExperienceReward(
      database,
      accountId,
      characterId,
      baseExperienceReward,
      character.experience,
      character.level,
    )
    newExperience = Math.min(character.experience + experienceReward, maxExperience)
    newLevel = database.prepare(`
      SELECT MAX(level) AS level
      FROM level_progression
      WHERE experience_required <= ?
    `).get(newExperience).level
  }
  const levelsGained = Math.max(0, newLevel - character.level)
  const newTalentPoints = Math.min(
    maxTalentPoints,
    character.talentPoints + levelsGained,
  )
  const unlockedAbilities = database.prepare(`
    SELECT id, name, unlock_level AS unlockLevel, glyph
    FROM spells
    WHERE class_id = ?
      AND unlock_level > ?
      AND unlock_level <= ?
    ORDER BY unlock_level, id
  `).all(character.classId, character.level, newLevel)
  const averageItemLevel = database.prepare(`
    SELECT COALESCE(SUM(items.item_level), 0) * 1.0 / ? AS value
    FROM character_inventory
    JOIN items ON items.id = character_inventory.item_id
    WHERE character_inventory.character_id = ?
      AND character_inventory.equipped = 1
  `).get(equipmentSlots.length, characterId).value

  database.prepare(`
    UPDATE characters
    SET experience = ?, level = ?, talent_points = ?
    WHERE id = ?
  `).run(newExperience, newLevel, newTalentPoints, characterId)
  const bonusItem = awardRoguelikeCoin(
    database,
    characterId,
    Number(runMetrics?.lootSourceEncounterId),
    Number(runMetrics?.roguelikeStage),
  )

  return {
    dungeonName: `${dungeon.name} Roguelike`,
    difficultyName: dungeon.difficultyName,
    droppedItemLevel: dungeon.droppedItemLevel,
    experienceGained: newExperience - character.experience,
    previousLevel: character.level,
    newLevel,
    levelsGained,
    talentPointsGained: levelsGained,
    resourceSpent,
    durationSeconds,
    averageItemLevel,
    unlockedAbilities,
    bonusItem,
    profile: getProfile(database, characterId, accountId),
  }
}

function saveProfile(database, characterId, accountId, payload) {
  const classId = Number(payload.classId)
  const spellIds = Array.isArray(payload.abilitySlots)
    ? payload.abilitySlots.slice(0, 6).map((value) => value === null ? null : Number(value))
    : []

  while (spellIds.length < 6) spellIds.push(null)

  const gameClass = database.prepare('SELECT id FROM classes WHERE id = ?').get(classId)
  if (!gameClass) throw new Error('Selected class does not exist.')

  const getCharacterLevel = database.prepare(`
    SELECT level, class_id AS classId
    FROM characters
    WHERE id = ?
  `)
  const character = getCharacterLevel.get(characterId)
  if (!character) throw new Error('Character not found.')

  const selectedIds = spellIds.filter((id) => id !== null)
  if (new Set(selectedIds).size !== selectedIds.length) {
    throw new Error('The same ability cannot be equipped twice.')
  }

  if (selectedIds.length > 0) {
    const placeholders = selectedIds.map(() => '?').join(', ')
    const validSpells = database.prepare(`
      SELECT id
      FROM spells
      WHERE class_id = ?
        AND unlock_level <= ?
        AND id IN (${placeholders})
    `).all(classId, character.level, ...selectedIds)

    if (validSpells.length !== selectedIds.length) {
      throw new Error('One or more abilities are locked or belong to another class.')
    }
  }

  database.exec('BEGIN')
  try {
    let targetCharacterId = characterId
    if (character.classId !== classId) {
      const existing = database.prepare(`
        SELECT id FROM characters WHERE account_id = ? AND class_id = ?
      `).get(accountId, classId)
      if (existing) {
        targetCharacterId = existing.id
      } else {
        const char = database.prepare('SELECT name FROM characters WHERE id = ?').get(characterId)
        targetCharacterId = initializeCharacter(database, accountId, char.name, classId)
      }
      database.prepare(`
        UPDATE sessions SET active_character_id = ? WHERE account_id = ?
      `).run(targetCharacterId, accountId)
    }
    database.prepare('DELETE FROM character_ability_slots WHERE character_id = ?').run(targetCharacterId)
    const insertSlot = database.prepare(`
      INSERT INTO character_ability_slots (character_id, slot_number, spell_id)
      VALUES (?, ?, ?)
    `)
    spellIds.forEach((spellId, index) => {
      insertSlot.run(targetCharacterId, index + 1, spellId)
    })
    database.exec('COMMIT')
    return targetCharacterId
  } catch (error) {
    database.exec('ROLLBACK')
    throw error
  }
}

function cleanupPvpMemory(now = Date.now()) {
  for (const [ticketId, ticket] of pvpQueue.entries()) {
    if (now - ticket.updatedAt > pvpQueueTtlMs) pvpQueue.delete(ticketId)
  }
  for (const [matchId, match] of pvpMatches.entries()) {
    if (now - match.updatedAt > pvpMatchTtlMs) pvpMatches.delete(matchId)
  }
}

function validatePvpContentType(value) {
  if (value !== 'dungeon' && value !== 'raid' && value !== 'stadium') {
    throw new Error('The PvP content type is invalid.')
  }
  return value
}

function validatePvpStartStage(value) {
  const startStage = Number(value)
  if (!Number.isInteger(startStage) || startStage < 1 || startStage > 1000) {
    throw new Error('The PvP start stage is invalid.')
  }
  return startStage
}

function pvpPlayerInfo(session) {
  return {
    accountId: session.accountId,
    characterId: session.characterId,
    characterName: session.characterName,
    className: session.className,
  }
}

function pvpSnapshot(match) {
  return {
    id: match.id,
    contentType: match.contentType,
    startStage: match.startStage,
    createdAt: match.createdAt,
    players: match.players,
    states: match.states,
    statuses: match.statuses,
    progress: match.progress,
    upgradeChoices: match.upgradeChoices,
    rematchRequests: match.rematchRequests,
    updatedAt: match.updatedAt,
  }
}

function createPvpMatch(contentType, startStage, players, now = Date.now()) {
  const matchId = randomBytes(12).toString('base64url')
  const match = {
    id: matchId,
    contentType,
    startStage,
    createdAt: now,
    players,
    states: {},
    statuses: {},
    progress: {},
    upgradeChoices: {},
    rematchRequests: {},
    updatedAt: now,
  }
  pvpMatches.set(matchId, match)
  return match
}

function joinPvpQueue(session, payload) {
  const now = Date.now()
  cleanupPvpMemory(now)
  const contentType = validatePvpContentType(payload.contentType)
  const startStage = validatePvpStartStage(payload.startStage)
  const existingTicket = [...pvpQueue.values()].find((ticket) =>
    ticket.accountId === session.accountId
      && ticket.characterId === session.characterId
      && ticket.contentType === contentType
      && ticket.startStage === startStage
  )
  if (existingTicket?.matchId) {
    const match = pvpMatches.get(existingTicket.matchId)
    if (match) {
      const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
      return { ticketId: existingTicket.id, status: 'matched', side, match: pvpSnapshot(match) }
    }
  }

  const opponent = [...pvpQueue.values()]
    .filter((ticket) =>
      !ticket.matchId
        && ticket.contentType === contentType
        && ticket.startStage === startStage
        && ticket.accountId !== session.accountId
    )
    .sort((left, right) => left.createdAt - right.createdAt)[0]
  const player = pvpPlayerInfo(session)
  if (opponent) {
    const match = createPvpMatch(contentType, startStage, {
      a: { side: 'a', ...opponent.player },
      b: { side: 'b', ...player },
    }, now)
    opponent.matchId = match.id
    opponent.updatedAt = now
    const ticketId = randomBytes(12).toString('base64url')
    pvpQueue.set(ticketId, {
      id: ticketId,
      accountId: session.accountId,
      characterId: session.characterId,
      contentType,
      startStage,
      player,
      matchId: match.id,
      createdAt: now,
      updatedAt: now,
    })
    return { ticketId, status: 'matched', side: 'b', match: pvpSnapshot(match) }
  }

  if (existingTicket) {
    existingTicket.updatedAt = now
    return { ticketId: existingTicket.id, status: 'waiting' }
  }

  const ticketId = randomBytes(12).toString('base64url')
  pvpQueue.set(ticketId, {
    id: ticketId,
    accountId: session.accountId,
    characterId: session.characterId,
    contentType,
    startStage,
    player,
    createdAt: now,
    updatedAt: now,
  })
  return { ticketId, status: 'waiting' }
}

function checkPvpQueue(session, ticketId) {
  cleanupPvpMemory()
  const ticket = pvpQueue.get(ticketId)
  if (!ticket || ticket.accountId !== session.accountId) {
    const error = new Error('PvP queue ticket not found.')
    error.status = 404
    throw error
  }
  ticket.updatedAt = Date.now()
  if (!ticket.matchId) return { ticketId, status: 'waiting' }
  const match = pvpMatches.get(ticket.matchId)
  if (!match) return { ticketId, status: 'waiting' }
  const side = match.players.a.accountId === session.accountId ? 'a' : 'b'
  return { ticketId, status: 'matched', side, match: pvpSnapshot(match) }
}

function cancelPvpQueue(session, ticketId) {
  const ticket = pvpQueue.get(ticketId)
  if (ticket && ticket.accountId === session.accountId && !ticket.matchId) {
    pvpQueue.delete(ticketId)
  }
  return { ok: true }
}

function requirePvpMatchForSession(session, matchId) {
  cleanupPvpMemory()
  const match = pvpMatches.get(matchId)
  if (!match) {
    const error = new Error('PvP match not found.')
    error.status = 404
    throw error
  }
  const side = match.players.a.accountId === session.accountId ? 'a'
    : match.players.b.accountId === session.accountId ? 'b'
      : null
  if (!side) {
    const error = new Error('That PvP match belongs to another account.')
    error.status = 403
    throw error
  }
  return { match, side }
}

function updatePvpMatchState(session, matchId, payload) {
  const { match, side } = requirePvpMatchForSession(session, matchId)
  const status = ['playing', 'upgrade-choice', 'shop', 'won', 'lost'].includes(payload.status)
    ? payload.status
    : 'playing'
  const progress = {
    stage: validatePvpStartStage(payload.stage),
    encounterIndex: Math.max(0, Math.floor(Number(payload.encounterIndex) || 0)),
    encountersCleared: Math.max(0, Math.floor(Number(payload.encountersCleared) || 0)),
    enemyHealth: Math.max(0, Number(payload.enemyHealth) || 0),
    alive: Boolean(payload.alive),
    elapsedTicks: Math.max(0, Math.floor(Number(payload.elapsedTicks) || 0)),
  }
  const currentProgress = match.progress[side]
  if (
    currentProgress
    && (
      progress.stage < currentProgress.stage
      || (
        progress.stage === currentProgress.stage
        && progress.encounterIndex < currentProgress.encounterIndex
      )
    )
  ) {
    return pvpSnapshot(match)
  }
  match.states[side] = payload.state ?? null
  match.statuses[side] = status
  match.progress[side] = progress
  match.updatedAt = Date.now()
  return pvpSnapshot(match)
}

function submitPvpUpgradeChoice(session, matchId, payload) {
  const { match, side } = requirePvpMatchForSession(session, matchId)
  const encounterIndex = Math.max(0, Math.floor(Number(payload.encounterIndex) || 0))
  if (!match.upgradeChoices[side]) match.upgradeChoices[side] = {}
  match.upgradeChoices[side][String(encounterIndex)] = {
    encounterIndex,
    buffId: String(payload.buffId ?? ''),
    debuffId: String(payload.debuffId ?? ''),
    purchases: Array.isArray(payload.purchases) ? payload.purchases.map((purchase) => String(purchase)) : [],
    shopReady: Boolean(payload.shopReady),
  }
  match.updatedAt = Date.now()
  return pvpSnapshot(match)
}

function requestPvpRematch(session, matchId) {
  const { match, side } = requirePvpMatchForSession(session, matchId)
  if (match.nextMatchId) {
    const nextMatch = pvpMatches.get(match.nextMatchId)
    if (nextMatch) return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
  }
  match.rematchRequests = match.rematchRequests ?? {}
  match.rematchRequests[side] = true
  match.updatedAt = Date.now()
  const opponentSide = side === 'a' ? 'b' : 'a'
  if (!match.rematchRequests[opponentSide]) {
    return { status: 'waiting', match: pvpSnapshot(match), side }
  }
  const nextMatch = createPvpMatch(
    match.contentType,
    match.startStage,
    {
      a: match.players.a,
      b: match.players.b,
    },
    Date.now(),
  )
  match.nextMatchId = nextMatch.id
  match.updatedAt = Date.now()
  return { status: 'matched', side, match: pvpSnapshot(nextMatch) }
}

export function gameApiPlugin() {
  return {
    name: 'ashen-halls-game-api',
    configureServer(server) {
      server.middlewares.use(handleApiRequest)
    },
    configurePreviewServer(server) {
      server.middlewares.use(handleApiRequest)
    },
  }
}

async function handleAuthApiRoute(database, request, response) {
  if (request.url === '/api/auth/register' && request.method === 'POST') {
    const payload = await readJson(request)
    const result = registerAccount(database, request, payload)
    sendJson(
      response,
      201,
      { account: result.account, profile: result.profile, token: result.token },
      { 'Set-Cookie': sessionCookie(result.token, request) },
    )
    return true
  }

  if (request.url === '/api/auth/login' && request.method === 'POST') {
    const payload = await readJson(request)
    const result = loginAccount(database, request, payload)
    sendJson(
      response,
      200,
      { account: result.account, profile: result.profile, token: result.token },
      { 'Set-Cookie': sessionCookie(result.token, request) },
    )
    return true
  }

  if (request.url === '/api/auth/session' && request.method === 'GET') {
    const session = currentSession(database, request)
    if (!session) {
      sendJson(response, 200, { account: null, profile: null })
      return true
    }
    sendJson(response, 200, {
      account: { id: session.accountId, username: session.username },
      profile: getProfile(database, session.characterId, session.accountId),
    })
    return true
  }

  if (request.url === '/api/auth/logout' && request.method === 'POST') {
    const token = requestSessionToken(request)
    if (token) {
      database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
    }
    sendJson(
      response,
      200,
      { ok: true },
      { 'Set-Cookie': sessionCookie('', request, 0) },
    )
    return true
  }

  return false
}

export async function handleAuthApiRequest(request, response, next = null) {
  if (!request.url?.startsWith('/api/auth/')) {
    if (next) {
      next()
      return
    }
    sendJson(response, 404, { error: 'API route not found.' })
    return
  }

  if (request.method === 'OPTIONS') {
    sendCorsPreflight(request, response)
    return
  }

  setCorsHeaders(response, request)

  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')
  ensureRuntimeMigrations(database)

  try {
    const ip = requestIp(request)
    consumeRateLimit(`auth:${ip}`, 120, 60 * 1000)
    database.prepare(`
      DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
    `).run()
    if (!(await handleAuthApiRoute(database, request, response))) {
      sendJson(response, 404, { error: 'API route not found.' })
    }
  } catch (error) {
    const status = Number(error?.status) || 400
    const headers = error?.retryAfter
      ? { 'Retry-After': String(error.retryAfter) }
      : {}
    sendJson(
      response,
      status,
      { error: error instanceof Error ? error.message : 'Unable to process request.' },
      headers,
    )
  } finally {
    database.close()
  }
}

export async function handleApiRequest(request, response, next) {
  if (!request.url?.startsWith('/api/')) {
    next()
    return
  }

  if (request.method === 'OPTIONS') {
    sendCorsPreflight(request, response)
    return
  }

  setCorsHeaders(response, request)

  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 (!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')
  ensureRuntimeMigrations(database)

  try {
    const ip = requestIp(request)
    consumeRateLimit(`api:${ip}`, 900, 60 * 1000)
    database.prepare(`
      DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
    `).run()

    if (await handleAuthApiRoute(database, request, response)) {
      return
    }

    if (request.url === '/api/catalog' && request.method === 'GET') {
      sendJson(response, 200, catalogPayload(getProfile(database, 1)))
      return
    }

    const session = requireSession(database, request)

    if (request.url === '/api/profile/sync-save' && request.method === 'GET') {
      sendJson(response, 200, buildSyncSave(database, session.accountId, session.characterId))
      return
    }

    if (request.url === '/api/profile/sync-save' && request.method === 'PUT') {
      const payload = await readJson(request, 512 * 1024)
      sendJson(response, 200, importSyncSave(database, session.accountId, session.characterId, payload))
      return
    }

    if (request.url === '/api/profile' && request.method === 'GET') {
      sendJson(response, 200, getProfile(database, session.characterId, session.accountId))
      return
    }

    if (request.url === '/api/profile' && request.method === 'PUT') {
      const payload = await readJson(request)
      const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
      sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId))
      return
    }

    if (request.url === '/api/pvp/queue' && request.method === 'POST') {
      const payload = await readJson(request)
      sendJson(response, 200, joinPvpQueue(session, payload))
      return
    }

    const pvpQueueTicket = request.url.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
    if (pvpQueueTicket && request.method === 'GET') {
      sendJson(response, 200, checkPvpQueue(session, pvpQueueTicket[1]))
      return
    }

    if (pvpQueueTicket && request.method === 'DELETE') {
      sendJson(response, 200, cancelPvpQueue(session, pvpQueueTicket[1]))
      return
    }

    const pvpMatchState = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/state$/)
    if (pvpMatchState && request.method === 'POST') {
      const payload = await readJson(request, 128 * 1024)
      sendJson(response, 200, updatePvpMatchState(session, pvpMatchState[1], payload))
      return
    }

    const pvpUpgradeChoice = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/upgrade-choice$/)
    if (pvpUpgradeChoice && request.method === 'POST') {
      const payload = await readJson(request)
      sendJson(response, 200, submitPvpUpgradeChoice(session, pvpUpgradeChoice[1], payload))
      return
    }

    const pvpRematch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/)
    if (pvpRematch && request.method === 'POST') {
      sendJson(response, 200, requestPvpRematch(session, pvpRematch[1]))
      return
    }

    const pvpMatch = request.url.match(/^\/api\/pvp\/matches\/([A-Za-z0-9_-]+)$/)
    if (pvpMatch && request.method === 'GET') {
      sendJson(response, 200, pvpSnapshot(requirePvpMatchForSession(session, pvpMatch[1]).match))
      return
    }

    const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
    if (dungeonCompletion && request.method === 'POST') {
      const payload = await readJson(request)
      sendMutatingResult(
        response,
        database,
        session.accountId,
        completeDungeon(
          database,
          session.characterId,
          session.accountId,
          Number(dungeonCompletion[1]),
          Number(payload.difficultyId),
          payload,
        ),
      )
      return
    }

    if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
      const payload = await readJson(request)
      sendMutatingResult(
        response,
        database,
        session.accountId,
        completeRoguelike(database, session.characterId, session.accountId, payload),
      )
      return
    }

    const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
    if (talentAllocation && request.method === 'POST') {
      sendMutatingResult(
        response,
        database,
        session.accountId,
        allocateTalent(database, session.characterId, Number(talentAllocation[1])),
      )
      return
    }

    if (request.url === '/api/talents/reset' && request.method === 'POST') {
      sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId))
      return
    }

    const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
    if (itemEquip && request.method === 'POST') {
      sendMutatingResult(
        response,
        database,
        session.accountId,
        equipItem(database, session.characterId, Number(itemEquip[1])),
      )
      return
    }

    const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
    if (itemDiscard && request.method === 'POST') {
      sendMutatingResult(
        response,
        database,
        session.accountId,
        discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
      )
      return
    }

    const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
    if (itemBreakdown && request.method === 'POST') {
      sendMutatingResult(
        response,
        database,
        session.accountId,
        breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
      )
      return
    }

    const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
    if (recipeCraft && request.method === 'POST') {
      sendMutatingResult(
        response,
        database,
        session.accountId,
        craftItem(database, session.characterId, Number(recipeCraft[1])),
      )
      return
    }

    const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
    if (itemUpgrade && request.method === 'POST') {
      sendMutatingResult(
        response,
        database,
        session.accountId,
        upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
      )
      return
    }

    const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
    if (encounterLootRoll && request.method === 'POST') {
      const payload = await readJson(request)
      sendMutatingResult(
        response,
        database,
        session.accountId,
        rollEncounterLoot(
          database,
          session.characterId,
          Number(encounterLootRoll[1]),
          Number(payload.difficultyId),
          payload.runToken,
        ),
      )
      return
    }

    sendJson(response, 404, { error: 'API route not found.' })
  } catch (error) {
    const status = Number(error?.status) || 400
    const headers = error?.retryAfter
      ? { 'Retry-After': String(error.retryAfter) }
      : {}
    sendJson(
      response,
      status,
      { error: error instanceof Error ? error.message : 'Unable to process request.' },
      headers,
    )
  } finally {
    database.close()
  }
}
