import starterProfile from './offline-starter-profile.json'
import { bundledCatalogHash } from './offline-catalog-meta'
import {
  catchUpExperienceReward as catchUpExperienceRewardForTarget,
  coinDropQuantity,
  experienceForLevel,
  roguelikeCoinItemLevel,
  scaledCurrentLevelExperience,
  scaledPvpBossExperience,
  scaledPvpFightExperience,
} from './shared/rewardRules.mjs'
import type {
  Account,
  AuthSession,
  CharacterProfile,
  DungeonReward,
  LootRoll,
  Item,
  EquipmentSlot,
  PetAward,
} from './profile'

export type GameMode = 'online' | 'offline'

export interface GameRepository {
  loadSession(): Promise<AuthSession>
  register(username: string, password: string, characterName: string): Promise<AuthSession>
  login(username: string, password: string): Promise<AuthSession>
  logout(): Promise<void>
  loadProfile(): Promise<CharacterProfile>
  saveProfile(classId: number, abilitySlots: Array<number | null>): Promise<CharacterProfile>
  completeDungeon(
    dungeonId: number,
    difficultyId: number,
    resourceSpent: number,
    durationSeconds: number,
    completedPart?: number,
    startPart?: number,
    partDurationSeconds?: [number, number, number],
    hardMode?: boolean,
  ): Promise<DungeonReward>
  completeRoguelike(
    dungeonId: number,
    difficultyId: number,
    encountersCleared: number,
    resourceSpent: number,
    durationSeconds: number,
    options?: {
      bossesCleared?: number
      experienceMode?: 'default' | 'pvp-boss-quarter-level' | 'pvp-fight-twelfth-level' | 'pvp-match-win-half-level' | 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level'
      fightsCleared?: number
      lootSourceEncounterId?: number
      roguelikeStage?: number
    },
  ): Promise<DungeonReward>
  allocateTalent(talentId: number): Promise<CharacterProfile>
  resetTalents(): Promise<CharacterProfile>
  equipItem(itemId: number): Promise<CharacterProfile>
  discardExtraItem(itemId: number): Promise<CharacterProfile>
  breakdownItem(itemId: number): Promise<CharacterProfile>
  craftItem(recipeId: number): Promise<CharacterProfile>
  upgradeItem(itemId: number): Promise<CharacterProfile>
  rollEncounterLoot(
    encounterId: number,
    difficultyId: number,
    runToken: string,
  ): Promise<LootRoll>
  recordBossKill(
    encounterId: number,
    options?: { petVariant?: 'normal' | 'purple' },
  ): Promise<{
    profile: CharacterProfile
    petAwarded: PetAward | null
  }>
  recordPvpMatch(won: boolean): Promise<CharacterProfile>
}

type CharacterData = {
  level: number
  experience: number
  talentPoints: number
  abilitySlots: Array<number | null>
  talentRanks: Record<string, number>
  inventory: Item[]
}

type OfflineSave = {
  version: 4
  updatedAt: number
  characterName: string
  activeClassId: number
  completedDungeonParts: number
  completedRaidPhases: number
  dungeonCompletions?: Record<string, number>
  characters: Record<number, CharacterData>
  lootRolls: Record<string, LootRoll>
  bossKills: Record<string, number>
  bossPets: Record<string, number>
  pvpMatchesPlayed: number
  pvpMatchesWon: number
}

type OnlineCache = {
  version: 1
  account: Account
  save: OfflineSave
  dirty: boolean
}

type CatalogCache = {
  version: 1
  hash: string
  profile: CharacterProfile
}

export type CloudSyncStatus = {
  available: boolean
  dirty: boolean
}

export type CloudSaveAge = 'server-newer' | 'local-newer' | 'same' | 'unknown'

export type CloudSaveSummary = {
  updatedAt: number | null
  characterName: string
  activeClassId: number
}

export type CloudSyncComparison = {
  relation: CloudSaveAge
  local: CloudSaveSummary
  server: CloudSaveSummary
}

export type CloudSyncChoice = 'local' | 'server'

type RepositoryMode = 'online' | 'offline-local' | 'offline-cached'

type NetworkError = Error & {
  network?: boolean
}

type LocalSaveStore = {
  readSave: () => OfflineSave | null
  writeSave: (save: OfflineSave) => void
  readAccount: () => Account | null
}

const modeKey = 'chronicle.repositoryMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1'
const catalogCacheKey = 'chronicle.catalog.v1'
const catalogBundleKey = 'chronicle.catalog.bundleHash.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
const BOSS_PET_DROP_RATE = 1 / 500
let activeCatalogCache: CatalogCache | null = null

function clone<T>(value: T): T {
  return structuredClone(value)
}

function normalizedSaveTimestamp(value: unknown): number {
  const timestamp = Number(value)
  return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0
}

function stampSave(save: OfflineSave, updatedAt = Date.now()): OfflineSave {
  save.updatedAt = updatedAt
  return save
}

function saveSummary(save: OfflineSave): CloudSaveSummary {
  return {
    updatedAt: save.updatedAt > 0 ? save.updatedAt : null,
    characterName: save.characterName,
    activeClassId: save.activeClassId,
  }
}

function compareSaveAge(local: OfflineSave, server: OfflineSave): CloudSaveAge {
  if (local.updatedAt <= 0 || server.updatedAt <= 0) return 'unknown'
  if (Math.abs(local.updatedAt - server.updatedAt) < 1000) return 'same'
  return server.updatedAt > local.updatedAt ? 'server-newer' : 'local-newer'
}

function toGameMode(mode: RepositoryMode): GameMode {
  return mode === 'online' ? 'online' : 'offline'
}

function dispatchModeChange(mode: RepositoryMode) {
  if (typeof window === 'undefined') return
  window.dispatchEvent(new CustomEvent<GameMode>('chronicle:mode-changed', {
    detail: toGameMode(mode),
  }))
}

function readMode(): RepositoryMode {
  const stored = localStorage.getItem(modeKey)
  if (stored === 'offline-cached' || stored === 'offline-local' || stored === 'online') {
    return stored
  }
  if (stored === 'offline') return 'offline-local'
  return 'online'
}

function writeMode(mode: RepositoryMode) {
  localStorage.setItem(modeKey, mode)
  dispatchModeChange(mode)
}

function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
  const p = v1.profile
  const classes = [1, 2, 3]
  const characters: Record<number, CharacterData> = {}
  for (const cid of classes) {
    const gameClass = p.classes.find((c) => c.id === cid)!
    const talentRanks: Record<string, number> = {}
    for (const t of gameClass.talents) {
      talentRanks[String(t.id)] = t.rank
    }
    characters[cid] = {
      level: cid === p.character.classId ? p.character.level : 1,
      experience: cid === p.character.classId ? p.character.experience : 0,
      talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
      abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
      talentRanks,
      inventory: cid === p.character.classId ? clone(p.inventory) : [],
    }
  }
  return {
    version: 4,
    updatedAt: 0,
    characterName: p.character.name,
    activeClassId: p.character.classId,
    completedDungeonParts: p.completedDungeonParts,
    completedRaidPhases: p.completedRaidPhases ?? 0,
    dungeonCompletions: Object.fromEntries(
      p.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
    ),
    characters,
    lootRolls: v1.lootRolls ?? {},
    bossKills: p.hunterStats?.bossKills ?? {},
    bossPets: p.hunterStats?.bossPets ?? {},
    pvpMatchesPlayed: p.hunterStats?.pvpMatchesPlayed ?? 0,
    pvpMatchesWon: p.hunterStats?.pvpMatchesWon ?? 0,
  }
}

function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 }): OfflineSave {
  return normalizeSaveAbilitySlots({
    ...v2,
    version: 4,
    updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
    completedRaidPhases: 0,
    bossKills: {},
    bossPets: {},
    pvpMatchesPlayed: 0,
    pvpMatchesWon: 0,
  })
}

function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 }): OfflineSave {
  return normalizeSaveAbilitySlots({
    ...v3,
    version: 4,
    updatedAt: normalizedSaveTimestamp((v3 as { updatedAt?: unknown }).updatedAt),
    bossKills: {},
    bossPets: {},
    pvpMatchesPlayed: 0,
    pvpMatchesWon: 0,
  })
}

function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
  const slots = Array.isArray(abilitySlots)
    ? abilitySlots
      .slice(0, ABILITY_SLOT_COUNT)
      .map((value) => {
        if (value === null || value === undefined) return null
        const id = Number(value)
        return Number.isInteger(id) ? id : null
      })
    : []
  while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
  return slots
}

function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
  save.updatedAt = normalizedSaveTimestamp(save.updatedAt)
  for (const character of Object.values(save.characters)) {
    character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
  }
  return save
}

function normalizeOfflineSave(raw: unknown): OfflineSave | null {
  if (!raw || typeof raw !== 'object') return null
  const candidate = raw as {
    version?: number
    profile?: CharacterProfile
    lootRolls?: Record<string, LootRoll>
  }
  if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave)
  if (candidate.version === 3) {
    return upgradeV3Save(candidate as Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 3 })
  }
  if (candidate.version === 2) {
    return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases' | 'bossKills' | 'bossPets' | 'pvpMatchesPlayed' | 'pvpMatchesWon'> & { version: 2 })
  }
  if (candidate.version === 1 && candidate.profile) {
    return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
  }
  return null
}

function readSaveKey(key: string): OfflineSave | null {
  const serialized = localStorage.getItem(key)
  if (!serialized) return null
  try {
    const raw = JSON.parse(serialized)
    const save = normalizeOfflineSave(raw)
    if (!save) return null
    if (raw.version !== 4) {
      localStorage.setItem(key, JSON.stringify(save))
    }
    return save
  } catch {
    return null
  }
}

function readOfflineSave(): OfflineSave | null {
  return readSaveKey(offlineSaveKey)
}

function writeOfflineSave(save: OfflineSave) {
  localStorage.setItem(offlineSaveKey, JSON.stringify(save))
}

function readOnlineCache(): OnlineCache | null {
  const serialized = localStorage.getItem(onlineCacheKey)
  if (!serialized) return null
  try {
    const raw = JSON.parse(serialized) as {
      version?: number
      account?: Account
      save?: unknown
      dirty?: boolean
    }
    if (
      raw.version !== 1
      || !raw.account
      || typeof raw.account.id !== 'number'
      || typeof raw.account.username !== 'string'
    ) {
      return null
    }
    const save = normalizeOfflineSave(raw.save)
    if (!save) return null
    const cache: OnlineCache = {
      version: 1,
      account: raw.account,
      save,
      dirty: Boolean(raw.dirty),
    }
    if ((raw.save as { version?: number } | undefined)?.version !== 4) {
      writeOnlineCache(cache)
    }
    return cache
  } catch {
    return null
  }
}

function writeOnlineCache(cache: OnlineCache) {
  localStorage.setItem(onlineCacheKey, JSON.stringify(cache))
}

function clearOnlineCache() {
  localStorage.removeItem(onlineCacheKey)
}

function bundledCatalog(): CatalogCache {
  return {
    version: 1,
    hash: bundledCatalogHash,
    profile: starterProfile as CharacterProfile,
  }
}

function readCatalogCache(): CatalogCache | null {
  if (localStorage.getItem(catalogBundleKey) !== bundledCatalogHash) {
    localStorage.removeItem(catalogCacheKey)
    localStorage.setItem(catalogBundleKey, bundledCatalogHash)
    activeCatalogCache = null
    return null
  }
  const serialized = localStorage.getItem(catalogCacheKey)
  if (!serialized) return null
  try {
    const raw = JSON.parse(serialized) as CatalogCache
    if (raw.version !== 1 || typeof raw.hash !== 'string' || !raw.profile) return null
    return raw
  } catch {
    return null
  }
}

function writeCatalogCache(cache: CatalogCache) {
  localStorage.setItem(catalogBundleKey, bundledCatalogHash)
  localStorage.setItem(catalogCacheKey, JSON.stringify(cache))
  activeCatalogCache = cache
}

function activeCatalog(): CatalogCache {
  if (activeCatalogCache) return activeCatalogCache
  activeCatalogCache = readCatalogCache() ?? bundledCatalog()
  return activeCatalogCache
}

function buildProfile(save: OfflineSave): CharacterProfile {
  const static_ = clone(activeCatalog().profile)
  const cd = save.characters[save.activeClassId]
  const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!

  static_.character.name = save.characterName
  static_.character.level = cd.level
  static_.character.experience = cd.experience
  static_.character.talentPoints = cd.talentPoints
  static_.character.classId = gameClass.id
  static_.character.classSlug = gameClass.slug
  static_.character.className = gameClass.name
  static_.character.resourceName = gameClass.resourceName
  static_.character.maxResource = gameClass.maxResource
  static_.character.themeColor = gameClass.themeColor
  static_.character.classDescription = gameClass.description
  static_.character.currentLevelExperience = experienceForLevel(cd.level)
  static_.character.nextLevelExperience = cd.level >= static_.maxLevel
    ? experienceForLevel(static_.maxLevel)
    : experienceForLevel(cd.level + 1)

  static_.abilitySlots = cd.abilitySlots

  for (const c of static_.classes) {
    for (const t of c.talents) {
      t.rank = cd.talentRanks[String(t.id)] ?? 0
    }
  }

  static_.allocatedTalentPoints = gameClass.talents.reduce((s, t) => s + t.rank, 0)

  static_.inventory = cd.inventory
  updateGearStats(static_)
  updateSetBonuses(static_)
  updateCraftingRecipes(static_)
  static_.completedDungeonParts = save.completedDungeonParts
  static_.completedRaidPhases = save.completedRaidPhases
  static_.dungeons = static_.dungeons.map((dungeon) => ({
    ...dungeon,
    completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
  }))
  static_.hunterStats = {
    bossKills: clone(save.bossKills ?? {}),
    bossPets: clone(save.bossPets ?? {}),
    pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0,
    pvpMatchesWon: save.pvpMatchesWon ?? 0,
  }

  return static_
}

function updateGearStats(profile: CharacterProfile) {
  const equipped = profile.inventory.filter((item) => item.equipped)
  profile.gearStats = {
    averageItemLevel: profile.equipmentSlots.length === 0
      ? 0
      : equipped.reduce((total, item) => total + item.itemLevel, 0)
        / profile.equipmentSlots.length,
    healingPower: equipped.reduce((total, item) => total + item.healingPower, 0),
    maxResourceBonus: equipped.reduce(
      (total, item) => total + item.maxResourceBonus,
      0,
    ),
  }
}

function updateSetBonuses(profile: CharacterProfile) {
  const equippedSetCounts = new Map<number, number>()
  for (const item of profile.inventory) {
    if (!item.equipped || !item.setId) continue
    equippedSetCounts.set(item.setId, (equippedSetCounts.get(item.setId) ?? 0) + 1)
  }
  profile.setBonuses = (profile.setBonuses ?? []).map((bonus) => {
    const equippedPieces = equippedSetCounts.get(bonus.setId) ?? 0
    return {
      ...bonus,
      equippedPieces,
      active: equippedPieces >= bonus.requiredPieces,
    }
  })
}

function updateCraftingRecipes(profile: CharacterProfile) {
  const owned = new Map(profile.inventory.map((item) => [item.id, item.quantity]))
  profile.craftingRecipes = (profile.craftingRecipes ?? []).map((recipe) => {
    const components = recipe.components.map((component) => ({
      ...component,
      owned: owned.get(component.item.id) ?? 0,
    }))
    const hasRequiredComponents = components.length > 0
      && components.every((component) => component.quantity > 0)
    return {
      ...recipe,
      components,
      canCraft: hasRequiredComponents
        && components.every((component) => component.owned >= component.quantity),
    }
  })
}

function addInventoryItem(inventory: Item[], item: Omit<Item, 'quantity' | 'equipped'>, quantity: number) {
  const existing = inventory.find((candidate) => candidate.id === item.id)
  if (existing) {
    existing.quantity += quantity
    return { duplicate: true, quantityAfter: existing.quantity }
  }
  inventory.push({
    ...item,
    quantity,
    equipped: false,
  })
  return { duplicate: false, quantityAfter: quantity }
}

type CraftingRecipe = CharacterProfile['craftingRecipes'][number]

function selectUpgradeRecipe(
  paths: CharacterProfile['gearUpgradePaths'],
  recipes: CraftingRecipe[],
  item: Pick<Item, 'id' | 'slot' | 'itemLevel'>,
) {
  const path = paths.find((candidate) => candidate.fromItemId === item.id)
  if (path) {
    const pathRecipe = recipes.find((recipe) => recipe.item.id === path.toItemId)
    if (pathRecipe) return pathRecipe
  }
  const candidates = recipes.filter((recipe) =>
    recipe.item.slot === item.slot
    && recipe.item.itemLevel > item.itemLevel
  )
  const nextItemLevel = Math.min(...candidates.map((recipe) => recipe.item.itemLevel))
  if (!Number.isFinite(nextItemLevel)) return undefined
  return candidates.find((recipe) => recipe.item.itemLevel === nextItemLevel)
}

function catchUpExperienceReward(
  baseReward: number,
  currentExperience: number,
  currentLevel: number,
  targetLevel: number,
) {
  if (targetLevel <= currentLevel) return baseReward
  return catchUpExperienceRewardForTarget(
    baseReward,
    currentExperience,
    experienceForLevel(targetLevel),
  )
}

function highestOtherClassLevel(save: OfflineSave) {
  const activeClass = save.activeClassId
  return Object.entries(save.characters)
    .filter(([classId]) => Number(classId) !== activeClass)
    .reduce((highest, [, character]) => Math.max(highest, character.level), 0)
}

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

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

type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
  1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
  10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
  20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
  25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
}
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])

type WindowWithApiBase = Window & {
  CAPACITOR_API_BASE_URL?: string
  CAPACITOR_AUTH_API_BASE_URL?: string
}

function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
  const levels = Object.keys(COMPONENT_ITEMS).map(Number).sort((a, b) => a - b)
  let best = levels[0]
  for (const level of levels) {
    if (level <= itemLevel) best = level
  }
  return COMPONENT_ITEMS[best]
}

function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave {
  const characters = clone(existingSave?.characters ?? {})
  for (const gameClass of profile.classes) {
    if (!characters[gameClass.id]) {
      characters[gameClass.id] = emptyCharacterData(gameClass.id)
    }
  }
  const activeClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)
  if (!activeClass) {
    throw new Error('The active class does not exist in the cached profile.')
  }
  const talentRanks: Record<string, number> = {}
  for (const talent of activeClass.talents) {
    talentRanks[String(talent.id)] = talent.rank
  }
  characters[profile.character.classId] = {
    level: profile.character.level,
    experience: profile.character.experience,
    talentPoints: profile.character.talentPoints,
    abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
    talentRanks,
    inventory: clone(profile.inventory),
  }
  return {
    version: 4,
    updatedAt: existingSave?.updatedAt ?? Date.now(),
    characterName: profile.character.name,
    activeClassId: profile.character.classId,
    completedDungeonParts: profile.completedDungeonParts,
    completedRaidPhases: profile.completedRaidPhases,
    dungeonCompletions: Object.fromEntries(
      profile.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
    ),
    characters,
    lootRolls: clone(existingSave?.lootRolls ?? {}),
    bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}),
    bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}),
    pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0,
    pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0,
  }
}

function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
  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 bossPetName(encounterName: string) {
  return `${encounterName} Pet`
}

function purpleBossPetName(encounterName: string) {
  return `Purple ${encounterName} Pet`
}

function maybeAwardBossPet(
  save: OfflineSave,
  encounter: { id: number; enemyName: string },
  variant: 'normal' | 'purple' = 'normal',
): PetAward | null {
  if (Math.random() >= BOSS_PET_DROP_RATE) return null
  const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id)
  const previousQuantity = save.bossPets?.[key] ?? 0
  save.bossPets = {
    ...(save.bossPets ?? {}),
    [key]: previousQuantity + 1,
  }
  return {
    encounterId: encounter.id,
    petName: variant === 'purple'
      ? purpleBossPetName(encounter.enemyName)
      : bossPetName(encounter.enemyName),
    quantity: 1,
    duplicate: previousQuantity > 0,
    quantityAfter: previousQuantity + 1,
  }
}

function recordBossKillInSave(
  save: OfflineSave,
  encounter: { id: number; enemyName: string },
  options?: { petVariant?: 'normal' | 'purple' },
): PetAward | null {
  const key = String(encounter.id)
  save.bossKills = {
    ...(save.bossKills ?? {}),
    [key]: (save.bossKills?.[key] ?? 0) + 1,
  }
  return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal')
}

function awardRoguelikeCoin(
  profile: CharacterProfile,
  sourceEncounterId: number | undefined,
  stage: number | undefined,
): DungeonReward['bonusItem'] {
  if (!sourceEncounterId || !stage) return null
  const targetItemLevel = roguelikeCoinItemLevel(stage)
  const sourceEncounter = profile.dungeons
    .flatMap((dungeon) => dungeon.encounters)
    .find((encounter) => encounter.id === sourceEncounterId)
  const coin = sourceEncounter?.lootTables
    .filter((entry) => entry.itemLevel === targetItemLevel)
    .sort((left, right) => left.difficultyId - right.difficultyId)[0]
  if (!coin) return null
  const {
    encounterId: _encounterId,
    difficultyId: _difficultyId,
    dropWeight: _dropWeight,
    dropChance: _dropChance,
    ...coinItem
  } = coin
  void _encounterId
  void _difficultyId
  void _dropWeight
  void _dropChance
  const quantity = coinDropQuantity()
  const added = addInventoryItem(profile.inventory, {
    ...coinItem,
    slot: coinItem.slot as EquipmentSlot,
    rarity: coinItem.rarity as Item['rarity'],
  }, quantity)
  return {
    ...coinItem,
    quantity,
    duplicate: added.duplicate,
    quantityAfter: added.quantityAfter,
  }
}

function readAuthToken(): string {
  return localStorage.getItem(authTokenKey) ?? ''
}

function writeAuthToken(token: string) {
  localStorage.setItem(authTokenKey, token)
}

function clearAuthToken() {
  localStorage.removeItem(authTokenKey)
}

function configuredBaseUrl(value: string | undefined): string {
  return value ? value.replace(/\/+$/, '') : ''
}

function getApiBaseUrl(path: string): string {
  const browserWindow = typeof window === 'undefined'
    ? undefined
    : window as WindowWithApiBase
  if (path.startsWith('/api/auth/')) {
    if (browserWindow?.CAPACITOR_AUTH_API_BASE_URL) {
      return configuredBaseUrl(browserWindow.CAPACITOR_AUTH_API_BASE_URL)
    }
    if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AUTH_API_BASE_URL) {
      return configuredBaseUrl(import.meta.env.VITE_AUTH_API_BASE_URL)
    }
  }
  if (browserWindow?.CAPACITOR_API_BASE_URL) {
    return configuredBaseUrl(browserWindow.CAPACITOR_API_BASE_URL)
  }
  if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
    return configuredBaseUrl(import.meta.env.VITE_API_BASE_URL)
  }
  return ''
}

export async function requestGameApiJson<T>(path: string, init?: RequestInit): Promise<T> {
  const baseUrl = getApiBaseUrl(path)
  const url = baseUrl ? `${baseUrl}${path}` : path
  const headers = new Headers(init?.headers)
  const token = readAuthToken()
  if (token && !headers.has('Authorization')) {
    headers.set('Authorization', `Bearer ${token}`)
  }
  let response: Response
  try {
    response = await fetch(url, {
      ...init,
      headers,
    })
  } catch (reason) {
    const networkError = new Error('Unable to reach the game server.') as NetworkError
    networkError.network = true
    networkError.cause = reason
    throw networkError
  }
  const body = await response.json()
  if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
  return body
}

async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
  return requestGameApiJson(path, init)
}

function isNetworkError(reason: unknown): reason is NetworkError {
  return reason instanceof Error && Boolean((reason as NetworkError).network)
}

async function loadServerCatalog(): Promise<CatalogCache> {
  return requestJson('/api/catalog')
}

async function refreshCatalogFromServer(): Promise<CatalogCache | null> {
  try {
    const catalog = await loadServerCatalog()
    if (catalog.version !== 1 || !catalog.hash || !catalog.profile) return null
    if (catalog.hash !== activeCatalog().hash || !readCatalogCache()) {
      writeCatalogCache(catalog)
    }
    return catalog
  } catch {
    return null
  }
}

function cachedOnlineSession(): AuthSession | null {
  const cache = readOnlineCache()
  if (!cache) return null
  return {
    account: cache.account,
    profile: buildProfile(cache.save),
  }
}

function resumeCachedOnlineSession(): AuthSession | null {
  const session = cachedOnlineSession()
  if (!session) return null
  writeMode('offline-cached')
  return session
}

function cacheActiveOnlineProfile(profile: CharacterProfile, dirty = false) {
  const cache = readOnlineCache()
  if (!cache) return
  writeOnlineCache({
    ...cache,
    save: mergeProfileIntoSave(profile, cache.save),
    dirty,
  })
}

async function loadServerSyncSave(): Promise<OfflineSave> {
  return requestJson('/api/profile/sync-save')
}

async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: CharacterProfile; save: OfflineSave }> {
  return requestJson('/api/profile/sync-save', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ save }),
  })
}

async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
  const cache = readOnlineCache()
  if (session.token) writeAuthToken(session.token)
  await refreshCatalogFromServer()
  if (!session.account || !session.profile) {
    if (session.account && cache?.account.id === session.account.id) {
      return {
        account: session.account,
        profile: buildProfile(cache.save),
        token: session.token,
      }
    }
    return session
  }
  if (cache?.account.id === session.account.id && cache.dirty) {
    writeOnlineCache({
      ...cache,
      account: session.account,
    })
    return {
      account: session.account,
      profile: buildProfile(cache.save),
      token: session.token,
    }
  }
  try {
    const save = await loadServerSyncSave()
    writeOnlineCache({
      version: 1,
      account: session.account,
      save,
      dirty: false,
    })
  } catch {
    writeOnlineCache({
      version: 1,
      account: session.account,
      save: mergeProfileIntoSave(session.profile, cache?.account.id === session.account.id ? cache.save : undefined),
      dirty: false,
    })
  }
  return session
}

async function fallbackToCachedOnline<T>(reason: unknown, localAction: () => Promise<T>): Promise<T> {
  if (!isNetworkError(reason) || !readOnlineCache()) throw reason
  writeMode('offline-cached')
  return localAction()
}

async function withOnlineFallback<T>(
  onlineAction: () => Promise<T>,
  localAction: () => Promise<T>,
  onSuccess?: (result: T) => void | Promise<void>,
): Promise<T> {
  try {
    const result = await onlineAction()
    await onSuccess?.(result)
    return result
  } catch (reason) {
    return fallbackToCachedOnline(reason, localAction)
  }
}

async function loadOnlineSessionFromServer(): Promise<AuthSession> {
  const session = await requestJson<AuthSession>('/api/auth/session')
  return finalizeOnlineSession(session)
}

const serverRepository: GameRepository = {
  loadSession: async () => {
    try {
      return await loadOnlineSessionFromServer()
    } catch (reason) {
      if (isNetworkError(reason)) {
        const fallback = resumeCachedOnlineSession()
        if (fallback) return fallback
      }
      throw reason
    }
  },
  register: async (username, password, characterName) =>
    finalizeOnlineSession(await requestJson('/api/auth/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password, characterName }),
    })),
  login: async (username, password) =>
    finalizeOnlineSession(await requestJson('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
    })),
  logout: async () => {
    try {
      await requestJson('/api/auth/logout', { method: 'POST' })
    } catch (reason) {
      if (!isNetworkError(reason)) throw reason
    }
    clearAuthToken()
    clearOnlineCache()
    writeMode('online')
  },
  loadProfile: () =>
    readOnlineCache()
      ? cachedOnlineLocalRepository.loadProfile()
      : withOnlineFallback(
        () => requestJson('/api/profile'),
        () => cachedOnlineLocalRepository.loadProfile(),
        (profile) => {
          cacheActiveOnlineProfile(profile)
        },
      ),
  saveProfile: (classId, abilitySlots) =>
    cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
  completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
    cachedOnlineLocalRepository.completeDungeon(
      dungeonId,
      difficultyId,
      resourceSpent,
      durationSeconds,
      completedPart,
      startPart,
      partDurationSeconds,
      hardMode,
    ),
  completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
    cachedOnlineLocalRepository.completeRoguelike(
      dungeonId,
      difficultyId,
      encountersCleared,
      resourceSpent,
      durationSeconds,
      options,
    ),
  allocateTalent: (talentId) =>
    cachedOnlineLocalRepository.allocateTalent(talentId),
  resetTalents: () =>
    cachedOnlineLocalRepository.resetTalents(),
  equipItem: (itemId) =>
    cachedOnlineLocalRepository.equipItem(itemId),
  discardExtraItem: (itemId) =>
    cachedOnlineLocalRepository.discardExtraItem(itemId),
  breakdownItem: (itemId) =>
    cachedOnlineLocalRepository.breakdownItem(itemId),
  craftItem: (recipeId) =>
    cachedOnlineLocalRepository.craftItem(recipeId),
  upgradeItem: (itemId) =>
    cachedOnlineLocalRepository.upgradeItem(itemId),
  rollEncounterLoot: (encounterId, difficultyId, runToken) =>
    cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
  recordBossKill: (encounterId, options) =>
    cachedOnlineLocalRepository.recordBossKill(encounterId, options),
  recordPvpMatch: (won) =>
    cachedOnlineLocalRepository.recordPvpMatch(won),
}

function emptyCharacterData(classId: number): CharacterData {
  const static_ = clone(activeCatalog().profile)
  const gc = static_.classes.find((c) => c.id === classId)!
  const talentRanks: Record<string, number> = {}
  for (const t of gc.talents) talentRanks[String(t.id)] = 0
  const inventory: Item[] = []
  const startingAbilitySlots: Array<number | null> = gc.spells
    .filter((s) => s.unlockLevel === 1)
    .slice(0, ABILITY_SLOT_COUNT)
    .map((s) => s.id)
  while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
  return {
    level: 1,
    experience: 0,
    talentPoints: 1,
    abilitySlots: startingAbilitySlots,
    talentRanks,
    inventory,
  }
}

function requireStoredSave(store: LocalSaveStore): OfflineSave {
  const save = store.readSave()
  if (!save) throw new Error('No local character exists yet.')
  return save
}

function writeStoreSave(store: LocalSaveStore, save: OfflineSave) {
  store.writeSave(stampSave(save))
}

function createLocalRepository(store: LocalSaveStore): GameRepository {
  return {
    async loadSession() {
      const save = store.readSave()
      return {
        account: save ? (store.readAccount() ?? offlineAccount) : null,
        profile: save ? buildProfile(save) : null,
      }
    },
    async register() {
      throw new Error('Account registration requires online mode.')
    },
    async login() {
      throw new Error('Account login requires online mode.')
    },
    async logout() {
      writeMode('online')
    },
    async loadProfile() {
      return buildProfile(requireStoredSave(store))
    },
    async saveProfile(classId, abilitySlots) {
      const save = requireStoredSave(store)
      const static_ = clone(starterProfile) as CharacterProfile
      const gameClass = static_.classes.find((candidate) => candidate.id === classId)
      if (!gameClass) throw new Error('Selected class does not exist.')

      const slots = normalizeAbilitySlots(abilitySlots)
      const selectedIds = slots.filter((id): id is number => id !== null)
      if (new Set(selectedIds).size !== selectedIds.length) {
        throw new Error('The same ability cannot be equipped twice.')
      }
      const activeChar = save.characters[save.activeClassId]
      const validIds = new Set(
        gameClass.spells
          .filter((spell) => spell.unlockLevel <= activeChar.level)
          .map((spell) => spell.id),
      )
      if (selectedIds.some((id) => !validIds.has(id))) {
        throw new Error('One or more abilities are locked or belong to another class.')
      }

      if (!save.characters[classId]) {
        save.characters[classId] = emptyCharacterData(classId)
      }
      save.characters[classId].abilitySlots = slots
      save.activeClassId = classId
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
    void startPart
    void partDurationSeconds
    if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
      throw new Error('The run resource total is invalid.')
    }
    if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
      throw new Error('The run duration is invalid.')
    }
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
    const difficulty = dungeon?.difficulties.find(
      (candidate) => candidate.id === difficultyId,
    )
    if (!dungeon || !difficulty) {
      throw new Error('That difficulty is not available for this dungeon.')
    }
    const cd = save.characters[save.activeClassId]
    if (cd.level < difficulty.unlockLevel) {
      throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
    }

    const previousLevel = cd.level
    const previousExperience = cd.experience
    const partCount = completedPart ?? 1
    const rewardMultiplier = hardMode ? 2 : 1
    const baseExperienceReward = Math.round(
      dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
    )
    const experienceReward = catchUpExperienceReward(
      baseExperienceReward,
      previousExperience,
      previousLevel,
      highestOtherClassLevel(save),
    )
    const maxExperience = experienceForLevel(profile.maxLevel)
    const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
    let newLevel = previousLevel
    while (
      newLevel < profile.maxLevel
      && experienceForLevel(newLevel + 1) <= newExperience
    ) {
      newLevel += 1
    }
    const levelsGained = newLevel - previousLevel

    const gameClass = profile.classes.find(
      (candidate) => candidate.id === save.activeClassId,
    )!
    const unlockedAbilities = gameClass.spells
      .filter(
        (spell) =>
          spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
      )
      .map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))

    cd.experience = newExperience
    cd.level = newLevel
    cd.talentPoints = Math.min(
      profile.maxTalentPoints,
      cd.talentPoints + levelsGained,
    )

    if (dungeon.contentType === 'raid') {
      save.completedRaidPhases = Math.max(save.completedRaidPhases, partCount)
    } else {
      save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
    }
    save.dungeonCompletions = {
      ...(save.dungeonCompletions ?? {}),
      [String(dungeonId)]: (save.dungeonCompletions?.[String(dungeonId)] ?? 0) + 1,
    }

    let bonusItem: DungeonReward['bonusItem'] = null
    if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
      const targetItemLevel = dungeon.completionItemLevel ?? difficulty.droppedItemLevel + 3
      const eligibleLoot = dungeon.completionLoot.filter(
        (item) => item.itemLevel >= targetItemLevel,
      )
      if (eligibleLoot.length > 0) {
        const rewardItemLevel = Math.min(...eligibleLoot.map((item) => item.itemLevel))
        const rewardPool = eligibleLoot.filter((item) => item.itemLevel === rewardItemLevel)
        const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
        const existing = profile.inventory.find((item) => item.id === selected.id)
        const duplicate = Boolean(existing)
        const rewardQuantity = rewardMultiplier
        let quantityAfter = rewardQuantity
        if (existing) {
          existing.quantity += rewardQuantity
          quantityAfter = existing.quantity
        } else {
          profile.inventory.push({
            ...selected,
            quantity: rewardQuantity,
            equipped: false,
          })
        }
        cd.inventory = profile.inventory
        bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
      }
    }

      writeStoreSave(store, save)
    const updatedProfile = buildProfile(save)

    return {
      dungeonName: dungeon.name,
      difficultyName: difficulty.name,
      droppedItemLevel: difficulty.droppedItemLevel,
      experienceGained: newExperience - previousExperience,
      previousLevel,
      newLevel,
      levelsGained,
      talentPointsGained: levelsGained,
      resourceSpent,
      durationSeconds,
      averageItemLevel: updatedProfile.gearStats.averageItemLevel,
      unlockedAbilities,
      bonusItem,
      profile: updatedProfile,
    }
    },
    async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
    if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
      throw new Error('The roguelike progress total is invalid.')
    }
    if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
      throw new Error('The run resource total is invalid.')
    }
    if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
      throw new Error('The run duration is invalid.')
    }
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
    const difficulty = dungeon?.difficulties.find(
      (candidate) => candidate.id === difficultyId,
    )
    if (!dungeon || !difficulty) {
      throw new Error('That difficulty is not available for this roguelike.')
    }
    const cd = save.characters[save.activeClassId]
    if (cd.level < difficulty.unlockLevel) {
      throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
    }

    const previousLevel = cd.level
    const previousExperience = cd.experience
    const maxExperience = experienceForLevel(profile.maxLevel)
    const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
    const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
      ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
      : options?.experienceMode === 'pvp-fight-twelfth-level'
        ? scaledPvpFightExperience(
          previousExperience,
          previousLevel,
          Math.max(0, Math.floor(options.fightsCleared ?? encountersCleared)),
          profile.maxLevel,
          highestOtherClassLevel(save),
        )
        : options?.experienceMode === 'pvp-match-win-half-level'
          ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
          : options?.experienceMode === 'pvp-stadium-round-win-quarter-level'
            ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.25)
            : options?.experienceMode === 'pvp-stadium-round-loss-tenth-level'
              ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.1)
              : options?.experienceMode === 'pvp-stadium-match-half-level'
                ? scaledCurrentLevelExperience(previousExperience, previousLevel, profile.maxLevel, 0.5)
                : null
    const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
    const newExperience = scaledReward
      ? scaledReward.experience
      : Math.min(
        previousExperience
        + catchUpExperienceReward(
          baseRoguelikeReward,
          previousExperience,
          previousLevel,
          highestOtherClassLevel(save),
        ),
        maxExperience,
      )
    let newLevel = scaledReward?.level ?? previousLevel
    while (
      newLevel < profile.maxLevel
      && experienceForLevel(newLevel + 1) <= newExperience
    ) {
      newLevel += 1
    }
    const levelsGained = newLevel - previousLevel

    const gameClass = profile.classes.find(
      (candidate) => candidate.id === save.activeClassId,
    )!
    const unlockedAbilities = gameClass.spells
      .filter(
        (spell) =>
          spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
      )
      .map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))

    cd.experience = newExperience
    cd.level = newLevel
    cd.talentPoints = Math.min(
      profile.maxTalentPoints,
      cd.talentPoints + levelsGained,
    )
    let petAwarded: PetAward | null = null
    if (options?.lootSourceEncounterId) {
      const sourceEncounter = profile.dungeons
        .flatMap((candidate) => candidate.encounters)
        .find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss)
      if (sourceEncounter) {
        petAwarded = recordBossKillInSave(save, sourceEncounter, {
          petVariant: 'purple',
        })
      }
    }
    const bonusItem = awardRoguelikeCoin(
      profile,
      options?.lootSourceEncounterId,
      options?.roguelikeStage,
    )
    cd.inventory = profile.inventory

      writeStoreSave(store, save)
    const updatedProfile = buildProfile(save)

    return {
      dungeonName: `${dungeon.name} Roguelike`,
      difficultyName: difficulty.name,
      droppedItemLevel: difficulty.droppedItemLevel,
      experienceGained: newExperience - previousExperience,
      previousLevel,
      newLevel,
      levelsGained,
      talentPointsGained: levelsGained,
      resourceSpent,
      durationSeconds,
      averageItemLevel: updatedProfile.gearStats.averageItemLevel,
      unlockedAbilities,
      bonusItem,
      petAwarded,
      profile: updatedProfile,
    }
    },
    async allocateTalent(talentId) {
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const cd = save.characters[save.activeClassId]
    const gameClass = profile.classes.find(
      (candidate) => candidate.id === save.activeClassId,
    )!
    const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
    if (!talent) throw new Error('That talent does not belong to the active class.')
    if (save.activeClassId === 1) {
      if ((cd.talentRanks[String(talentId)] ?? 0) > 0) {
        cd.talentRanks[String(talentId)] = 0
      } else {
        const capacity = talentEffectCapacity(cd.level)
        if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
        const source = talentEffectSource(talent.effectType)
        const sourceConflict = gameClass.talents.find(
          (candidate) =>
            candidate.id !== talentId
            && (cd.talentRanks[String(candidate.id)] ?? 0) > 0
            && talentEffectSource(candidate.effectType) === source,
        )
        if (sourceConflict) {
          throw new Error(`Only one ${source} spell effect can be active.`)
        }
        const activeCount = gameClass.talents.reduce(
          (total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
          0,
        )
        if (activeCount >= capacity) {
          throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
        }
        cd.talentRanks[String(talentId)] = 1
      }
      writeStoreSave(store, save)
      return buildProfile(save)
    }
    if (cd.talentPoints <= 0) {
      throw new Error('No talent points are available.')
    }
    if (talent.rank >= talent.maxRank) {
      throw new Error('That talent is already at maximum rank.')
    }
    const lowerTierPoints = gameClass.talents
      .filter((candidate) => candidate.tier < talent.tier)
      .reduce((total, candidate) => total + candidate.rank, 0)
    const requiredTierPoints = (talent.tier - 1) * 5
    if (lowerTierPoints < requiredTierPoints) {
      throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`)
    }
    if (talent.prerequisiteTalentId) {
      const prerequisite = gameClass.talents.find(
        (candidate) => candidate.id === talent.prerequisiteTalentId,
      )
      if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
        throw new Error(
          `The prerequisite talent requires rank ${talent.prerequisiteRank}.`,
        )
      }
    }
    cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
    cd.talentPoints -= 1
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async resetTalents() {
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const cd = save.characters[save.activeClassId]
    const gameClass = profile.classes.find(
      (candidate) => candidate.id === save.activeClassId,
    )!
    const refunded = gameClass.talents.reduce(
      (total, talent) => total + talent.rank,
      0,
    )
    for (const talent of gameClass.talents) {
      cd.talentRanks[String(talent.id)] = 0
    }
    if (save.activeClassId !== 1) {
      cd.talentPoints = Math.min(
        profile.maxTalentPoints,
        cd.talentPoints + refunded,
      )
    }
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async equipItem(itemId) {
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const item = profile.inventory.find((candidate) => candidate.id === itemId)
    if (!item) throw new Error('That item is not in the character inventory.')
    for (const candidate of profile.inventory) {
      if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
    }
    save.characters[save.activeClassId].inventory = profile.inventory
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async discardExtraItem(itemId) {
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const item = profile.inventory.find((candidate) => candidate.id === 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.')
    item.quantity -= 1
    save.characters[save.activeClassId].inventory = profile.inventory
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async breakdownItem(itemId) {
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const item = profile.inventory.find((candidate) => candidate.id === itemId)
    if (!item) throw new Error('That item is not in the character inventory.')
    if (item.slot === 'component') throw new Error('Components cannot be broken down.')
    if (item.equipped && item.quantity <= 1) {
      throw new Error('Equipped items cannot be broken down.')
    }
    const componentTemplate = componentForItemLevel(item.itemLevel)
    if (!componentTemplate) throw new Error('No component type exists for this item level.')

    if (item.quantity <= 1) {
      profile.inventory.splice(profile.inventory.indexOf(item), 1)
    } else {
      item.quantity -= 1
    }

    const existing = profile.inventory.find((c) => c.id === componentTemplate.id)
    const count = Math.floor(Math.random() * 3) + 1
    if (existing) {
      existing.quantity += count
    } else {
      profile.inventory.push({
        id: componentTemplate.id,
        slug: componentTemplate.slug,
        name: componentTemplate.name,
        slot: 'component' as EquipmentSlot,
        rarity: 'common' as const,
        itemLevel: componentTemplate.itemLevel,
        healingPower: 0,
        maxResourceBonus: 0,
        glyph: componentTemplate.glyph,
        description: componentTemplate.description,
        quantity: count,
        equipped: false,
      })
    }

    save.characters[save.activeClassId].inventory = profile.inventory
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async craftItem(recipeId) {
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
    if (!recipe) throw new Error('That crafting recipe does not exist.')
    const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
    if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
    if (recipe.components.length === 0) throw new Error('That recipe has no component requirements.')
    const missing = recipe.components.find((component) => component.owned < component.quantity)
    if (missing) {
      throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
    }

    for (const component of recipe.components) {
      if (component.quantity <= 0) throw new Error('Recipe components must require at least one item.')
      const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
      if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
      owned.quantity -= component.quantity
    }
    for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
      if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
    }

    addInventoryItem(profile.inventory, recipe.item, 1)
    save.characters[save.activeClassId].inventory = profile.inventory
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async upgradeItem(itemId) {
      const save = requireStoredSave(store)
    const profile = buildProfile(save)
    const item = profile.inventory.find((candidate) => candidate.id === itemId)
    if (!item) throw new Error('That item is not in the character inventory.')
    if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
    const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
    const targetRecipe = currentRecipe
      ? selectUpgradeRecipe(profile.gearUpgradePaths ?? [], profile.craftingRecipes, item)
      : null
    if (!targetRecipe) throw new Error('No upgrade is available for this item.')
    if (targetRecipe.components.length === 0) throw new Error('That upgrade has no component requirements.')
    const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
    if (missing) {
      throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`)
    }

    for (const component of targetRecipe.components) {
      if (component.quantity <= 0) throw new Error('Upgrade components must require at least one item.')
      const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
      if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`)
      owned.quantity -= component.quantity
    }
    const wasEquipped = item.equipped
    item.quantity -= 1
    item.equipped = false
    for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
      if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
    }
    if (wasEquipped) {
      for (const candidate of profile.inventory) {
        if (candidate.slot === targetRecipe.item.slot) candidate.equipped = false
      }
    }
    addInventoryItem(profile.inventory, targetRecipe.item, 1)
    if (wasEquipped) {
      const upgraded = profile.inventory.find((candidate) => candidate.id === targetRecipe.item.id)
      if (upgraded) upgraded.equipped = true
    }
    save.characters[save.activeClassId].inventory = profile.inventory
      writeStoreSave(store, save)
      return buildProfile(save)
    },
    async rollEncounterLoot(encounterId, difficultyId, runToken) {
    if (runToken.length < 8 || runToken.length > 100) {
      throw new Error('A valid dungeon run token is required.')
    }
      const save = requireStoredSave(store)
    const rollKey = `${runToken}:${encounterId}:${difficultyId}`
    if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])

    const profile = buildProfile(save)
    const encounter = profile.dungeons
      .flatMap((dungeon) => dungeon.encounters)
      .find((candidate) => candidate.id === encounterId)
    const difficulty = profile.dungeons
      .flatMap((dungeon) => dungeon.difficulties)
      .find((candidate) => candidate.id === difficultyId)
    const entries = encounter?.lootTables.filter(
      (entry) => entry.difficultyId === difficultyId,
    ) ?? []
    if (!encounter || !difficulty || entries.length === 0) {
      throw new Error('This encounter has no configured loot.')
    }
    const dropChance = entries[0].dropChance
    const petAwarded = encounter.isBoss
      ? recordBossKillInSave(save, encounter)
      : null
    const items: LootRoll['items'] = []

    const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
    if (Math.random() < dropChance) {
      const selected = rollWeightedLootEntry(entries)
      selectedQuantities.set(selected.id, {
        entry: selected,
        quantity: coinDropQuantity(),
      })
    }

    for (const { entry, quantity } of selectedQuantities.values()) {
        const {
          encounterId: _encounterId,
          difficultyId: _difficultyId,
          dropWeight: _dropWeight,
          dropChance: _dropChance,
          ...rolledItem
        } = entry
        void _encounterId
        void _difficultyId
        void _dropWeight
        void _dropChance
        const added = addInventoryItem(profile.inventory, {
          ...rolledItem,
          slot: rolledItem.slot as EquipmentSlot,
          rarity: rolledItem.rarity as Item['rarity'],
        }, quantity)
        items.push({
          ...rolledItem,
          slot: rolledItem.slot as EquipmentSlot,
          rarity: rolledItem.rarity as Item['rarity'],
          quantity,
          duplicate: added.duplicate,
          quantityAfter: added.quantityAfter,
        })
    }

    const item = items[0] ?? null
    const result: LootRoll = {
      encounterId,
      encounterName: encounter.enemyName,
      difficultyId,
      difficultyName: difficulty.name,
      dropChance,
      dropped: items.length > 0,
      item,
      items,
      awarded: Boolean(item),
      duplicate: items.some((candidate) => candidate.duplicate),
      quantityAfter: item?.quantityAfter ?? 0,
      petAwarded,
    }
    save.lootRolls[rollKey] = result
    save.characters[save.activeClassId].inventory = profile.inventory
      writeStoreSave(store, save)
      return clone(result)
    },
    async recordBossKill(encounterId, options) {
      const save = requireStoredSave(store)
      const profile = buildProfile(save)
      const encounter = profile.dungeons
        .flatMap((dungeon) => dungeon.encounters)
        .find((candidate) => candidate.id === encounterId && candidate.isBoss)
      if (!encounter) throw new Error('Boss encounter not found.')
      const petAwarded = recordBossKillInSave(save, encounter, {
        petVariant: options?.petVariant ?? 'normal',
      })
      writeStoreSave(store, save)
      return {
        profile: buildProfile(save),
        petAwarded,
      }
    },
    async recordPvpMatch(won) {
      const save = requireStoredSave(store)
      save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
      if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
      writeStoreSave(store, save)
      return buildProfile(save)
    },
  }
}

const offlineRepository = createLocalRepository({
  readSave: readOfflineSave,
  writeSave: writeOfflineSave,
  readAccount: () => offlineAccount,
})

const cachedOnlineLocalRepository = createLocalRepository({
  readSave: () => readOnlineCache()?.save ?? null,
  writeSave: (save) => {
    const cache = readOnlineCache()
    if (!cache) throw new Error('No cached account save exists yet.')
    writeOnlineCache({
      ...cache,
      save,
      dirty: true,
    })
  },
  readAccount: () => readOnlineCache()?.account ?? null,
})

const cachedOnlineRepository: GameRepository = {
  async loadSession() {
    try {
      const session = await loadOnlineSessionFromServer()
      if (session.account && session.profile) {
        writeMode('online')
        return session
      }
    } catch {
      // Fall through to local cached mirror.
    }
    return cachedOnlineLocalRepository.loadSession()
  },
  async register() {
    throw new Error('Account registration requires online mode.')
  },
  async login() {
    throw new Error('Account login requires online mode.')
  },
  async logout() {
    clearAuthToken()
    clearOnlineCache()
    writeMode('online')
  },
  loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
  saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
  completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
    cachedOnlineLocalRepository.completeDungeon(
      dungeonId,
      difficultyId,
      resourceSpent,
      durationSeconds,
      completedPart,
      startPart,
      partDurationSeconds,
      hardMode,
    ),
  completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
    cachedOnlineLocalRepository.completeRoguelike(
      dungeonId,
      difficultyId,
      encountersCleared,
      resourceSpent,
      durationSeconds,
      options,
    ),
  allocateTalent: (talentId) => cachedOnlineLocalRepository.allocateTalent(talentId),
  resetTalents: () => cachedOnlineLocalRepository.resetTalents(),
  equipItem: (itemId) => cachedOnlineLocalRepository.equipItem(itemId),
  discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId),
  breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId),
  craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId),
  upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
  rollEncounterLoot: (encounterId, difficultyId, runToken) =>
    cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
  recordBossKill: (encounterId, options) =>
    cachedOnlineLocalRepository.recordBossKill(encounterId, options),
  recordPvpMatch: (won) =>
    cachedOnlineLocalRepository.recordPvpMatch(won),
}

export function getGameMode(): GameMode {
  return toGameMode(readMode())
}

export function getCloudSyncStatus(): CloudSyncStatus {
  const cache = readOnlineCache()
  return {
    available: Boolean(cache),
    dirty: Boolean(cache?.dirty),
  }
}

export async function syncCloudSave(): Promise<CharacterProfile> {
  const cache = readOnlineCache()
  if (!cache) {
    throw new Error('No signed-in save is available for cloud sync.')
  }
  await refreshCatalogFromServer()
  const synced = await pushServerSyncSave(cache.save)
  await refreshCatalogFromServer()
  writeOnlineCache({
    version: 1,
    account: cache.account,
    save: synced.save,
    dirty: false,
  })
  writeMode('online')
  return buildProfile(synced.save)
}

export async function previewCloudSaveSync(): Promise<CloudSyncComparison> {
  const cache = readOnlineCache()
  if (!cache) {
    throw new Error('No signed-in save is available for cloud sync.')
  }
  await refreshCatalogFromServer()
  const serverSave = await loadServerSyncSave()
  return {
    relation: compareSaveAge(cache.save, serverSave),
    local: saveSummary(cache.save),
    server: saveSummary(serverSave),
  }
}

export async function applyCloudSaveSync(choice: CloudSyncChoice): Promise<CharacterProfile> {
  const cache = readOnlineCache()
  if (!cache) {
    throw new Error('No signed-in save is available for cloud sync.')
  }
  await refreshCatalogFromServer()
  if (choice === 'server') {
    const serverSave = await loadServerSyncSave()
    writeOnlineCache({
      version: 1,
      account: cache.account,
      save: serverSave,
      dirty: false,
    })
    writeMode('online')
    return buildProfile(serverSave)
  }
  const synced = await pushServerSyncSave(cache.save)
  await refreshCatalogFromServer()
  writeOnlineCache({
    version: 1,
    account: cache.account,
    save: synced.save,
    dirty: false,
  })
  writeMode('online')
  return buildProfile(synced.save)
}

export function selectOnlineMode() {
  writeMode('online')
}

export function createOfflineCharacter(characterName: string): AuthSession {
  const name = characterName.trim() || 'Mira'
  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.')
  }
  const characters: Record<number, CharacterData> = {}
  for (const cid of [1, 2, 3]) {
    characters[cid] = emptyCharacterData(cid)
  }
  const save: OfflineSave = {
    version: 4,
    updatedAt: Date.now(),
    characterName: name,
    activeClassId: 1,
    completedDungeonParts: 0,
    completedRaidPhases: 0,
    characters,
    lootRolls: {},
    bossKills: {},
    bossPets: {},
    pvpMatchesPlayed: 0,
    pvpMatchesWon: 0,
  }
  writeOfflineSave(save)
  writeMode('offline-local')
  return { account: offlineAccount, profile: buildProfile(save) }
}

export function resumeOfflineCharacter(): AuthSession | null {
  const save = readOfflineSave()
  if (!save) return null
  writeMode('offline-local')
  return { account: offlineAccount, profile: buildProfile(save) }
}

export function hasOfflineCharacter(): boolean {
  return readOfflineSave() !== null
}

export function activeGameRepository(): GameRepository {
  const mode = readMode()
  if (mode === 'offline-local') return offlineRepository
  if (mode === 'offline-cached') return cachedOnlineRepository
  return serverRepository
}
