// ── Shard event ingest dispatcher ──────────────────────────────────────────
//
// The single entry point for every event that arrives on the uo-link WebSocket
// feed (and for backfilled /history events on reconnect). It routes by kind:
//   • state-changing kinds update shard_online / shard_economy / shard_houses,
//   • notable kinds are appended to the append-only shard_events log,
//   • every kind is fanned out to the SSE broadcaster (which decides public vs
//     admin visibility).
// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged
// to shard_events — they only update state — keeping the event log lean.
//
// Dependencies are injected (defaulting to the real models) so the routing can
// be unit-tested with mocked writes.

const shardEventsModel = require('../model/shardEvents/shardEvents.model')
const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const settingsModel = require('../model/settings/settings.model')
const broadcaster = require('./shardBroadcast')
const pushDispatch = require('./pushDispatch')
const defaultLog = require('./logger')('shard-ingest')

// Notable kinds appended to the shard_events log. High-frequency/session kinds
// (char.vitals, economy.supply, mob.login/logout, account.login.attempt,
// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled
// specially — logged only on the transition INTO IDOC.
const LOGGED_KINDS = new Set([
  'vendor.sale',
  'player.death',
  'player.murdered',
  'mob.killed',
  'quest.complete',
  'skill.gain',
  'fame.change',
  'karma.change',
  'audit.set',
  'audit.command',
  'admin.audit',
  'cheat.fastwalk',
  'link.request',
  'server.hello',
  'server.shutdown',
  'server.crashed',
  // Protocol 2.0: a real-time guild join (the board itself is state, not logged).
  'guild.join',
  // Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
  'account.audit',
  'account.unlinked',
])

// Tracks the current shard boot id so a restart (changed bootId on server.hello)
// can be detected and stale online state dropped. Module-level so it survives
// across events within a process; reset() is exposed for tests.
const state = { bootId: null }
function reset() {
  state.bootId = null
}

// Should this event be written to the append-only log?
function shouldLog(event) {
  if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC'
  return LOGGED_KINDS.has(event.kind)
}

// ServUO's stock Server.cfg name. An operator who never set one publishes this
// verbatim, so it carries no more information than a blank — matched
// case-insensitively and trim-tolerantly, but ONLY as an exact whole value: a
// shard genuinely called "My Shard Reborn" keeps its name.
const STOCK_SHARD_NAME = 'my shard'

/**
 * The name to publish for the shard: its own, or this instance's when it has
 * effectively not given one.
 *
 * Deliberately not a general "blank means brand" rule applied across the wire —
 * it is scoped to this one field, where the two names denote the same thing.
 */
async function resolveShardName(shard, deps) {
  const given = String(shard ?? '').trim()
  if (given !== '' && given.toLowerCase() !== STOCK_SHARD_NAME) return given
  try {
    return (await deps.settings.getInstanceName()) || given
  } catch {
    // A ruleset that publishes the stock name is still better than one that
    // fails to store because the settings read hiccuped.
    return given
  }
}

// Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) {
  const { shardState, uoLinkConfig, log } = deps
  switch (event.kind) {
    case 'server.hello': {
      const incoming = event.bootId || null
      if (incoming && state.bootId && incoming !== state.bootId) {
        log.warn('shard restarted (bootId changed) — clearing online roster', {
          from: state.bootId,
          to: incoming,
        })
        await shardState.clearOnline()
      }
      if (incoming) state.bootId = incoming
      await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
      return
    }
    case 'server.shutdown':
    case 'server.crashed':
      // Shard is going away — nobody is online anymore.
      await shardState.clearOnline()
      await uoLinkConfig.recordStatus({ pluginConnected: false })
      return
    case 'mob.login': {
      const who = event.who || {}
      await shardState.upsertOnline({
        serial: who.serial,
        name: who.name,
        acct: who.acct,
        webId: event.webId,
        map: event.map,
        x: event.x,
        y: event.y,
        z: event.z,
      })
      return
    }
    case 'mob.logout': {
      const who = event.who || {}
      if (who.serial) await shardState.setOffline(who.serial)
      return
    }
    case 'char.vitals':
      await shardState.upsertOnline({
        serial: event.serial,
        hits: event.hits,
        hitsMax: event.hitsMax,
        mana: event.mana,
        manaMax: event.manaMax,
        stam: event.stam,
        stamMax: event.stamMax,
        str: event.str,
        dex: event.dex,
        int: event.int,
        map: event.map,
        x: event.x,
        y: event.y,
      })
      return
    case 'economy.supply':
      await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t })
      return
    case 'house.decay':
      await shardState.upsertHouse({
        serial: event.serial,
        stage: event.to,
        map: event.map,
        x: event.x,
        y: event.y,
        z: event.z,
        region: event.region,
        name: event.name,
        ownerSerial: event.ownerSerial,
        ownerAcct: event.ownerAcct,
        builtOn: event.builtOn,
        lastRefreshed: event.lastRefreshed,
      })
      return
    case 'champ.update':
      await shardState.upsertChamp(event)
      return
    case 'champ.remove':
      await shardState.removeChamp(event.serial)
      return
    case 'page.new':
    case 'page.updated':
      await shardState.upsertPage(event)
      return
    case 'page.closed':
      await shardState.removePage(event.pageId)
      return
    // ── Protocol 2.0 boards ──────────────────────────────────────────────
    case 'guild.update':
      await shardState.upsertGuild(event)
      return
    case 'guild.remove':
      await shardState.removeGuild(event.id)
      return
    case 'city.update':
      // Upserts the board AND captures term history (idempotent).
      await shardState.upsertGovernor(event)
      return
    case 'presence.online':
      await shardState.setPresence(event)
      return
    case 'house.update':
      await shardState.upsertHouseRegistry(event)
      return
    case 'house.remove':
      await shardState.removeHouse(event.serial)
      return
    // ── Protocol 3.0 ─────────────────────────────────────────────────────
    // The shard re-emits its whole ruleset on every sidecar connect, so this is
    // an overwrite, not an append — and deliberately NOT in LOGGED_KINDS: it
    // would put a duplicate row in the event log on every reconnect, and
    // server.hello already marks each of those.
    case 'world.ruleset':
      // A shard whose operator never edited Server.cfg publishes ServUO's stock
      // "My Shard". That is the shard saying *unnamed*, not a name, so the site
      // answers with its own — the rules page reading "My Shard" under a header
      // reading UOMysticmoon is the shard failing to introduce itself.
      //
      // Normalized HERE rather than on read because the ruleset is also live: the
      // same `event` object is handed to the SSE broadcast a few lines below, and
      // a read-time fix would be undone by the next reconnect's frame.
      event.shard = await resolveShardName(event.shard, deps)
      await shardState.setRuleset(event)
      return
    // Board state, like guild.update — the newest frame for a system replaces the
    // previous one, so it is NOT in LOGGED_KINDS. Logging would append a row every
    // time anyone's score moved the top ten, which is a board, not an event.
    case 'points.board':
      await shardState.upsertPointsBoard(event)
      return
    // Player-vendor market index. Each frame is authoritative for one shop, so
    // the model replaces that vendor's whole listing set rather than merging.
    //
    // NOT in LOGGED_KINDS, and this is the strongest case of the three v3 kinds:
    // one frame carries up to 250 listings, the sweep re-emits a shop on any
    // price change, and appending each of those to the event log would make
    // shard_events mostly a price history nobody reads. The market IS the state.
    case 'vendor.listing':
      await deps.shardMarket.upsertVendor(event)
      return
    case 'vendor.listing.remove':
      await deps.shardMarket.removeVendor(event.serial)
      return
    case 'account.unlinked':
      // A player ran [unlink in game (or a site-side unlink echoed back) — drop
      // our local link mirror so attribution stops immediately.
      if (event.account) await deps.shardLinks.removeByAccount(event.account)
      return
    // guild.join / account.audit → logged; region.enter → broadcast-only.
    default:
      // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
      // broadcasting still happen in ingest().
  }
}

// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill`
// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the
// live ticker). Never throws — a bad single event must not kill the feed.
// Resolve the injectable dependencies to their live defaults (tests override a
// subset). Split out so ingest() isn't penalised for the fan of `|| default`s.
function resolveDeps(deps) {
  return {
    shardEvents: deps.shardEvents || shardEventsModel,
    shardState: deps.shardState || shardStateModel,
    shardLinks: deps.shardLinks || shardLinksModel,
    shardMarket: deps.shardMarket || shardMarketModel,
    uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
    settings: deps.settings || settingsModel,
    broadcast: deps.broadcast || broadcaster.broadcast,
    pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
    log: deps.log || defaultLog,
  }
}

async function ingest(event, deps = {}) {
  const d = resolveDeps(deps)

  if (!event || typeof event.kind !== 'string') return { logged: false, stored: false }
  // ws.hello / pong are transport frames, not game events.
  if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false }

  const t = Number.isFinite(event.t) ? event.t : Date.now()
  let stored = false
  let logged = false

  try {
    await applyStateChange(event, d)
  } catch (err) {
    d.log.warn('state-change write failed', { kind: event.kind, message: err.message })
  }

  if (shouldLog(event)) {
    logged = true
    try {
      stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event })
    } catch (err) {
      d.log.warn('event log write failed', { kind: event.kind, message: err.message })
    }
  }

  if (!deps.fromBackfill) {
    // Broadcast is async since v3 (it reads the visibility config to decide what
    // each subscriber may see). Fire-and-forget, like the push fan-out below: a
    // slow config read must never delay or fail ingest.
    Promise.resolve(d.broadcast(event)).catch((err) =>
      d.log.warn('broadcast failed', { kind: event.kind, message: err.message }),
    )
    // Opt-in push fan-out, off the same event source as the SSE broadcast.
    // Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
    // fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
    Promise.resolve(d.pushDispatch(event, { shardLinks: d.shardLinks })).catch((err) =>
      d.log.warn('push dispatch failed', { kind: event.kind, message: err.message }),
    )
  }

  return { logged, stored }
}

module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state }
