// Live shard state derived from the WS feed: who is online, the gold-supply
// series, and per-house decay stage. The ingest dispatcher calls the write
// methods; the public read endpoints call the list/count methods. Writes take
// camelCase semantic objects and map to the snake_case columns; only the keys
// present are written (so a char.vitals refresh doesn't clobber login fields).

const db = require('./shardState.db')

const MAX_ECONOMY = 1000

// Small coercion helpers, kept out of the upsert builders below so those stay
// flat (each inline `?? null` / ternary otherwise adds to cognitive complexity).
const orNull = (v) => v ?? null
const toDate = (v) => (v ? new Date(v) : null)
// Owner is an actor object (or null for an abandoned house); flatten to columns.
const ownerFields = (owner) => ({
  owner_serial: orNull(owner?.serial),
  owner_acct: orNull(owner?.acct),
  owner_name: orNull(owner?.name),
})

// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
// partial refresh only touches the fields it carries.
function onlineFields(data) {
  const map = {
    name: data.name,
    acct: data.acct,
    web_id: data.webId,
    map: data.map,
    x: data.x,
    y: data.y,
    z: data.z,
    hits: data.hits,
    hits_max: data.hitsMax,
    mana: data.mana,
    mana_max: data.manaMax,
    stam: data.stam,
    stam_max: data.stamMax,
    str: data.str,
    dex: data.dex,
    int: data.int,
  }
  const fields = {}
  for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v
  return fields
}

// Upsert an online player (mob.login) or refresh their vitals (char.vitals).
async function upsertOnline(data) {
  if (!data || !data.serial) return
  await db.upsertOnline(data.serial, onlineFields(data))
}

const setOffline = (serial) => db.removeOnline(serial)
const clearOnline = () => db.clearOnline()
const onlineCount = () => db.countOnline()

function shapeOnline(r) {
  return {
    serial: r.serial,
    name: r.name,
    acct: r.acct,
    webId: r.web_id,
    map: r.map,
    x: r.x,
    y: r.y,
    z: r.z,
    hits: r.hits,
    hitsMax: r.hits_max,
    mana: r.mana,
    manaMax: r.mana_max,
    stam: r.stam,
    stamMax: r.stam_max,
    str: r.str,
    dex: r.dex,
    int: r.int,
    updatedAt: r.updated_at,
  }
}

// Only players whose account is linked to a website user (opt-in visibility).
async function listOnlineLinked() {
  const rows = await db.listOnlineLinked()
  return rows.map(shapeOnline)
}

async function listOnline() {
  const rows = await db.listOnline()
  return rows.map(shapeOnline)
}

// Append a gold-supply sample (economy.supply).
async function addEconomySample({ accounts, gold, t }) {
  await db.insertEconomy({ accounts, gold, t })
}

async function listEconomy(limit = 100) {
  const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY)
  const rows = await db.listEconomy(n)
  // Return oldest → newest for charting.
  return rows
    .map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t }))
    .reverse()
}

async function latestEconomy() {
  const r = await db.latestEconomy()
  return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null
}

// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage.
async function upsertHouse(data) {
  if (!data || !data.serial) return
  const fields = {
    stage: data.stage ?? null,
    map: data.map ?? null,
    x: data.x ?? null,
    y: data.y ?? null,
    z: data.z ?? null,
    region: data.region ?? null,
    name: data.name ?? null,
    owner_serial: data.ownerSerial ?? null,
    owner_acct: data.ownerAcct ?? null,
    built_on: data.builtOn ? new Date(data.builtOn) : null,
    last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
    is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
  }
  await db.upsertHouse(data.serial, fields)
}

function shapeHouse(r) {
  return {
    serial: r.serial,
    stage: r.stage,
    map: r.map,
    x: r.x,
    y: r.y,
    z: r.z,
    region: r.region,
    name: r.name,
    ownerSerial: r.owner_serial,
    ownerAcct: r.owner_acct,
    // Registry fields (Protocol 2.0 house.update); undefined on decay-only rows.
    ownerName: r.owner_name,
    coOwners: r.co_owners,
    friends: r.friends,
    price: r.price == null ? null : Number(r.price),
    decay: r.decay,
    inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
    builtOn: r.built_on,
    lastRefreshed: r.last_refreshed,
    isIdoc: Boolean(r.is_idoc),
    updatedAt: r.updated_at,
  }
}

async function listIdoc() {
  const rows = await db.listIdocHouses()
  return rows.map(shapeHouse)
}

// Houses owned by the given game accounts (admin: a user's linked accounts).
async function listHousesForAccounts(accounts) {
  const rows = await db.listHousesByAccounts(accounts)
  return rows.map(shapeHouse)
}

// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
// Richer per-house snapshot than the decay-transition feed. Writes only the
// registry columns (+ shared location/owner fields); is_idoc/stage stay owned by
// the house.decay path, so the two feeds never clobber each other. owner is an
// actor object (or null for an abandoned house).
async function upsertHouseRegistry(data) {
  if (!data || !data.serial) return
  const fields = {
    name: orNull(data.name),
    ...ownerFields(data.owner || null),
    co_owners: orNull(data.coOwners),
    friends: orNull(data.friends),
    price: orNull(data.price),
    decay: orNull(data.decay),
    region: orNull(data.region),
    map: orNull(data.map),
    x: orNull(data.x),
    y: orNull(data.y),
    z: orNull(data.z),
    built_on: toDate(data.builtOn),
    last_refreshed: toDate(data.lastRefreshed),
    in_registry: 1,
  }
  await db.upsertHouse(data.serial, fields)
}

const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve())

async function listHouses() {
  const rows = await db.listRegistryHouses()
  return rows.map(shapeHouse)
}

// Online players on the given game accounts (admin: a user's linked accounts).
async function listOnlineForAccounts(accounts) {
  const rows = await db.listOnlineByAccounts(accounts)
  return rows.map(shapeOnline)
}

// ── Champion spawns ────────────────────────────────────────────────────────
// Upsert a champ spawn's state (champ.update). The full event is stored in
// `payload` for the category-specific fields; a few columns are hoisted out for
// querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up).
async function upsertChamp(ev) {
  if (!ev || !ev.serial) return
  await db.upsertChamp(ev.serial, {
    category: orNull(ev.category),
    type: orNull(ev.type),
    name: orNull(ev.name),
    status: orNull(ev.status),
    active: ev.active ? 1 : 0,
    map: orNull(ev.map),
    x: orNull(ev.x),
    y: orNull(ev.y),
    z: orNull(ev.z),
    boss_up: ev.bossUp ? 1 : 0,
    payload: JSON.stringify(ev),
    t: Number.isFinite(ev.t) ? ev.t : null,
  })
}

const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve())
const clearChamps = () => db.clearChamps()

// Return the stored champ.update payload (the shape the sidecar/UI expect),
// falling back to the hoisted columns if an older row lacks a payload.
function shapeChamp(r) {
  const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
  return payload || {
    kind: 'champ.update',
    serial: r.serial,
    category: r.category,
    type: r.type,
    name: r.name,
    status: r.status,
    active: Boolean(r.active),
    map: r.map,
    x: r.x,
    y: r.y,
    z: r.z,
    bossUp: Boolean(r.boss_up),
    t: r.t,
  }
}

async function listChamps() {
  const rows = await db.listChamps()
  return rows.map(shapeChamp)
}

// Replace the whole board with a fresh snapshot (sidecar GET /champs on connect).
async function replaceChamps(spawns) {
  await db.clearChamps()
  for (const ev of spawns || []) await upsertChamp(ev)
}

// ── Help-page (support) queue ──────────────────────────────────────────────
// Upsert a page (page.new / page.updated). The `sender` actor object carries the
// name/acct/webId; the rest are top-level fields.
async function upsertPage(ev) {
  const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial))
  if (!pageId) return
  const sender = ev.sender || {}
  await db.upsertPage(pageId, {
    type: orNull(ev.type),
    sender_name: orNull(sender.name),
    sender_acct: orNull(sender.acct),
    web_id: orNull(sender.webId),
    message: orNull(ev.message),
    map: orNull(ev.map),
    x: orNull(ev.x),
    y: orNull(ev.y),
    z: orNull(ev.z),
    sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
    handled: ev.handled ? 1 : 0,
    handler: orNull(ev.handler),
    payload: JSON.stringify(ev),
  })
}

const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve())
const clearPages = () => db.clearPages()

function shapePage(r) {
  const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
  return {
    pageId: r.page_id,
    type: r.type,
    sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id },
    message: r.message,
    map: r.map,
    x: r.x,
    y: r.y,
    z: r.z,
    sentMs: r.sent_ms == null ? null : Number(r.sent_ms),
    handled: Boolean(r.handled),
    handler: r.handler,
    updatedAt: r.updated_at,
    // Keep the raw payload available for any field not hoisted above.
    payload: payload || undefined,
  }
}

async function listPages() {
  const rows = await db.listPages()
  return rows.map(shapePage)
}

// Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect).
async function replacePages(pages) {
  await db.clearPages()
  for (const ev of pages || []) await upsertPage(ev)
}

// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
// Upsert a guild's roster snapshot (guild.update). The leader is an actor object
// flattened into leader_* columns; the full event lives in `payload`.
async function upsertGuild(ev) {
  if (!ev || ev.id == null) return
  const leader = ev.leader || {}
  await db.upsertGuild(ev.id, {
    name: ev.name ?? null,
    abbr: ev.abbr ?? null,
    members: ev.members ?? null,
    online: ev.online ?? null,
    alliance: ev.alliance ?? null,
    leader_serial: leader.serial ?? null,
    leader_name: leader.name ?? null,
    leader_acct: leader.acct ?? null,
    leader_web_id: leader.webId ?? null,
    payload: JSON.stringify(ev),
    t: Number.isFinite(ev.t) ? ev.t : null,
  })
}

const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
const clearGuilds = () => db.clearGuilds()

function shapeGuild(r) {
  const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
  return payload || {
    kind: 'guild.update',
    id: r.id,
    name: r.name,
    abbr: r.abbr,
    members: r.members,
    online: r.online,
    alliance: r.alliance,
    leader: r.leader_serial
      ? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
      : null,
    t: r.t,
  }
}

async function listGuilds() {
  const rows = await db.listGuilds()
  return rows.map(shapeGuild)
}

// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
async function replaceGuilds(guilds) {
  await db.clearGuilds()
  for (const ev of guilds || []) await upsertGuild(ev)
}

// The guild an actor leads (cross-link on the character sheet). Leadership only —
// see the db note; membership for rank-and-file isn't in the feed, so we return
// null rather than show a possibly-stale guess.
async function findGuildForActor({ serial, acct }) {
  const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
  const g = rows[0]
  if (!g) return null
  return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
}

// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
async function listGuildsLedForAccounts(accounts) {
  const rows = await db.listGuildsLedByAccounts(accounts)
  return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
}

// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
// Upsert a city's governance snapshot (city.update) AND capture term history.
// Term capture runs first (it reads the CURRENT open term to decide whether the
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
async function upsertGovernor(ev) {
  if (!ev || !ev.city) return
  await recordGovernorTransition(ev)
  const gov = ev.governor
  const elect = ev.governorElect
  await db.upsertGovernor(ev.city, {
    governor_serial: orNull(gov?.serial),
    governor_name: orNull(gov?.name),
    governor_acct: orNull(gov?.acct),
    governor_web_id: orNull(gov?.webId),
    elect_serial: orNull(elect?.serial),
    elect_name: orNull(elect?.name),
    elect_acct: orNull(elect?.acct),
    election_phase: orNull(ev.electionPhase),
    candidates: orNull(ev.candidates),
    auto_pick_at: toDate(ev.autoPickAt),
    payload: JSON.stringify(ev),
    t: Number.isFinite(ev.t) ? ev.t : null,
  })
}

// Close the open term and open a new one when the governor CHANGES. Idempotent:
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
// city.update events never spawn spurious terms).
async function recordGovernorTransition(ev) {
  const gov = ev.governor || null
  const newSerial = gov ? gov.serial ?? null : null
  const t = Number.isFinite(ev.t) ? ev.t : Date.now()
  const open = await db.currentGovernorTerm(ev.city)
  const openSerial = open ? open.governor_serial : null
  if (open && openSerial === newSerial) return // unchanged — nothing to record
  if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
  if (newSerial) {
    await db.openGovernorTerm({
      city: ev.city,
      serial: newSerial,
      name: gov.name ?? null,
      acct: gov.acct ?? null,
      webId: gov.webId ?? null,
      startedAt: t,
    })
  }
}

function shapeGovernor(r) {
  const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
  return payload || {
    kind: 'city.update',
    city: r.city,
    governor: r.governor_serial
      ? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
      : null,
    governorElect: r.elect_serial
      ? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
      : null,
    electionPhase: r.election_phase,
    candidates: r.candidates,
    t: r.t,
  }
}

async function listGovernors() {
  const rows = await db.listGovernors()
  return rows.map(shapeGovernor)
}

// Cities the given game accounts currently govern (cross-link badge).
async function listGovernorshipsForAccounts(accounts) {
  const rows = await db.listGovernorshipsByAccounts(accounts)
  return rows.map(shapeGovernor)
}

// Term history for a city (look-back), newest first.
async function listGovernorHistory(city, limit = 100) {
  const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
  const rows = await db.listGovernorTerms(city, n)
  return rows.map((r) => ({
    city: r.city,
    governor: r.governor_serial
      ? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
      : null,
    startedAt: r.started_at == null ? null : Number(r.started_at),
    endedAt: r.ended_at == null ? null : Number(r.ended_at),
    votes: r.votes,
  }))
}

// Upsert governors without clearing (cities are fixed, no remove event); term
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
async function replaceGovernors(cities) {
  for (const ev of cities || []) await upsertGovernor(ev)
}

// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
async function setPresence(ev) {
  if (!ev) return
  await db.setPresence({
    count: ev.count,
    byFacet: ev.byFacet || null,
    byRegion: ev.byRegion || null,
    t: ev.t,
  })
}

async function latestPresence() {
  const r = await db.latestPresence()
  if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
  const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
  return {
    count: Number(r.count) || 0,
    byFacet: parse(r.by_facet),
    byRegion: parse(r.by_region),
    t: r.t == null ? null : Number(r.t),
  }
}

// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
//
// The whole frame is stored in `payload` and served back whole. Nothing is
// normalized out of it: it is a flat description of config read as one page, and
// splitting it into columns would mean a schema change every time the shard grows
// a new block. `rev` and `expansion` are hoisted only because they are cheap to
// index/display, following shard_champs' payload-plus-hoisted-columns pattern.
async function setRuleset(ev) {
  if (!ev) return
  await db.setRuleset({
    rev: ev.rev ?? null,
    expansion: ev.expansion ?? null,
    payload: JSON.stringify(ev),
    t: ev.t,
  })
}

// The stored ruleset, or null when the shard has never published one (an old
// plugin, or Bridge.RulesetEnabled=false). Null is a real answer here — the page
// says "not published yet" rather than rendering an empty ruleset as if the shard
// had no rules — so it is deliberately not smoothed into {}.
async function getRuleset() {
  const r = await db.getRuleset()
  if (!r) return null
  const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
  if (!payload) return null
  return { ...payload, updatedAt: r.updated_at }
}

// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
//
// The whole frame is stored in `payload`; the columns beside it are hoisted for
// listing and ordering only. The top-N list deliberately stays inside the payload
// (see schema.sql) — it is a fixed-size list read whole, like the governor board's
// candidates.
async function upsertPointsBoard(ev) {
  if (!ev || !ev.system) return
  await db.upsertPointsBoard({
    system: String(ev.system).slice(0, 48),
    name: ev.nameString ?? null,
    nameCliloc: ev.nameNumber,
    maxPoints: ev.maxPoints,
    players: ev.players,
    showOnGump: ev.showOnGump !== false,
    payload: JSON.stringify(ev),
    t: ev.t,
  })
}

// A stored frame plus the freshness stamp. `top` is normalized to an array so a
// caller never has to guard it — a board with nobody on it is a real state (a
// system nobody has scored in yet), distinct from a system that was never
// published at all, which is absent from the table entirely.
function shapePointsBoard(r) {
  const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {}
  return {
    ...payload,
    system: r.system,
    top: Array.isArray(payload.top) ? payload.top : [],
    updatedAt: r.updated_at,
  }
}

async function listPointsBoards() {
  const rows = await db.listPointsBoards()
  return rows.map(shapePointsBoard)
}

async function getPointsBoard(system) {
  const r = await db.getPointsBoard(system)
  return r ? shapePointsBoard(r) : null
}

function safeJson(s) {
  try {
    return JSON.parse(s)
  } catch {
    return null
  }
}

module.exports = {
  upsertOnline,
  setOffline,
  clearOnline,
  onlineCount,
  listOnline,
  listOnlineLinked,
  listOnlineForAccounts,
  addEconomySample,
  listEconomy,
  latestEconomy,
  upsertHouse,
  listIdoc,
  listHousesForAccounts,
  upsertHouseRegistry,
  removeHouse,
  listHouses,
  upsertChamp,
  removeChamp,
  clearChamps,
  listChamps,
  replaceChamps,
  upsertPage,
  removePage,
  clearPages,
  listPages,
  replacePages,
  upsertGuild,
  removeGuild,
  clearGuilds,
  listGuilds,
  replaceGuilds,
  findGuildForActor,
  listGuildsLedForAccounts,
  upsertGovernor,
  listGovernors,
  listGovernorshipsForAccounts,
  listGovernorHistory,
  replaceGovernors,
  setPresence,
  latestPresence,
  setRuleset,
  getRuleset,
  upsertPointsBoard,
  listPointsBoards,
  getPointsBoard,
}
