// ── Shard feature visibility ───────────────────────────────────────────────
//
// Admin-configurable, per-feature and per-field audience control over every
// shard-derived surface on the site. Replaces the hardcoded split that used to
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
//
// Design rules (docs/link/v3.md §3):
//
//   • Visibility lives HERE, on the website — never in the sidecar. The sidecar
//     is a dumb forwarder: it accepts frames, stores them, forwards them
//     verbatim, and serves store-backed reads. It defines no audiences.
//   • Every default reproduces the behavior that shipped before this module, so
//     installing it changes nothing until an admin edits the config.
//   • Two rules an admin CANNOT override:
//       1. `acct` / `webId` are admin-only, always. They are not in-game
//          visible (unlike a character name) and are not configurable fields.
//       2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
//          Fail closed — this is what keeps the kind map a security boundary
//          rather than a convenience filter.
//
// The audience ladder is ordered; each rung implies the ones below it.

const db = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const auth = require('./auth')
const log = require('./logger')('shard-visibility')

// ── The ladder ─────────────────────────────────────────────────────────────

const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
const RANK = new Map(LADDER.map((level, i) => [level, i]))

const isLevel = (level) => RANK.has(level)

// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
// point: an unrecognised value must always lose. A single shared fallback cannot
// do that — whichever direction it picks, it fails open on one side. So:
//
//   • an unknown VIEWER level floors to the bottom rung (grants nothing), and
//   • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
//
// With one `rank()` defaulting to admin, a viewer level that fell through (a
// typo, a future rung this build doesn't know, a value from a caller that
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')

// True when a viewer at `viewer` satisfies a requirement of `required`.
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)

// Exported for tests/diagnostics; `meets` is what callers should use.
const rank = viewerRank

// ── Features ───────────────────────────────────────────────────────────────
//
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
// not listed here is visible whenever the feature itself is.
//
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).

const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }

// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
// frames nest actors (`leader.acct`), but several read models flatten them
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
// anonymous callers while the same account name was correctly stripped from the
// live `house.decay` frame.
//
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
// fail closed for shapes nobody has written yet.
const LOCKED_SUFFIXES = ['acct', 'webid']
const isLockedField = (key) => {
  const k = String(key).toLowerCase()
  return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
}

const FEATURES = {
  // ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
  status: { audience: 'anonymous', fields: {} },
  activity: { audience: 'anonymous', fields: {} },
  champs: { audience: 'anonymous', fields: {} },
  guilds: { audience: 'anonymous', fields: {} },
  governors: { audience: 'anonymous', fields: {} },
  // The public Houses page showed IDOC location only; owner/price were staff.
  // `owner` is the actor object on the house.decay/house.update frames;
  // `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
  // REST read models. Both are listed so one rule covers the wire and the read
  // model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
  houses: {
    audience: 'anonymous',
    fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
  },
  // /public/shard/online listed linked staff to everyone but gated location to
  // admin+moderator — which is exactly the `staff` rung.
  presence: { audience: 'anonymous', fields: { location: 'staff' } },

  // ── New in v3. ──
  ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
  atlas: { audience: 'anonymous', fields: {} },
  // `name` is the ranked character's name inside points.board's `top` entries, and
  // it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
  // ("characterName"). projectValue matches on the literal JSON key, so a rule
  // named for the field's meaning rather than its key silently does nothing — the
  // same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
  // leaderboards payload `name` can only be a character name: the board's own
  // display name arrives as `nameString`/`nameNumber`.
  leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
  // Shop name, owner character name and vendor location are already globally
  // visible in-game via the stock Vendor Search gump, so publishing them is not
  // a new disclosure — but they stay configurable so an admin can tighten them.
  //
  // `ownerName` and `location` were pre-wired here by Part A, before the frame
  // existed; both were re-checked against the real `vendor.listing` and both are
  // genuine keys on it (unlike leaderboards' `characterName`, which was inert).
  // `location` is a NESTED object on the wire and on the read model precisely so
  // that one rule hides map, coordinates, region and house together — five flat
  // keys would be five rules that drift apart.
  //
  // `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
  // lists both: an admin who hides the owner's name and is left with a serial
  // that every other board resolves back to that name has not hidden anything.
  market: {
    audience: 'anonymous',
    fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
  },
}

const FEATURE_NAMES = Object.keys(FEATURES)
const isFeature = (name) => Object.hasOwn(FEATURES, name)

// ── Kind → feature ─────────────────────────────────────────────────────────
//
// Every event kind that may ever leave the admin channel must appear here.
// Anything else is admin-only by omission (rule 2). This map is seeded from
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
// same kinds it did — now attributed to a feature that an admin can re-gate.

const KIND_FEATURE = new Map(
  Object.entries({
    // status / lifecycle
    'server.hello': 'status',
    'server.shutdown': 'status',
    'server.crashed': 'status',
    'economy.supply': 'status',
    // activity feed
    'player.death': 'activity',
    'player.murdered': 'activity',
    'mob.killed': 'activity',
    'quest.complete': 'activity',
    'skill.gain': 'activity',
    'fame.change': 'activity',
    'karma.change': 'activity',
    'mob.login': 'activity',
    'mob.logout': 'activity',
    // boards
    'champ.update': 'champs',
    'champ.remove': 'champs',
    'guild.update': 'guilds',
    'guild.remove': 'guilds',
    'guild.join': 'guilds',
    'city.update': 'governors',
    'presence.online': 'presence',
    'region.enter': 'presence',
    // house.decay is the IDOC signal the public Houses page renders. The full
    // registry (house.update / house.remove — owner, price, co-owners) stays
    // off the map deliberately, so it remains admin-only exactly as before.
    'house.decay': 'houses',
    // v3
    'world.ruleset': 'ruleset',
    'points.board': 'leaderboards',
    // vendor.listing IS mapped, but the market feature ships with its stream
    // disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
    // inventories would be the site's biggest bandwidth consumer and no page
    // needs it live. An admin can turn it on.
    'vendor.listing': 'market',
    'vendor.listing.remove': 'market',
  }),
)

// Features whose SSE fan-out is off unless an admin enables it. The REST reads
// are unaffected; only the live stream is suppressed.
const DEFAULT_STREAM_OFF = new Set(['market'])

// Back-compat: the set of kinds that reach an anonymous viewer under the default
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
// this. Derived from the map above rather than hand-maintained, so the two can
// no longer drift.
const PUBLIC_KINDS = new Set(
  [...KIND_FEATURE.entries()]
    .filter(([, feature]) => {
      if (DEFAULT_STREAM_OFF.has(feature)) return false
      return FEATURES[feature].audience === 'anonymous'
    })
    .map(([kind]) => kind),
)

// ── Config (DB-backed, cached) ─────────────────────────────────────────────

const CONFIG_TTL_MS = 5000
let cache = null
let cachedAt = 0

// Merge a stored row over its compiled default. Unknown feature names in the DB
// are ignored (a stale row from a removed feature must not resurrect it), and an
// invalid rung falls back to the default rather than failing open.
function applyRow(name, row) {
  const base = FEATURES[name]
  const audience = isLevel(row?.audience) ? row.audience : base.audience
  const fields = { ...base.fields }
  for (const [field, level] of Object.entries(row?.fieldRules || {})) {
    if (isLockedField(field)) continue // rule 1: not configurable
    if (isLevel(level)) fields[field] = level
  }
  return {
    enabled: row ? !!row.enabled : true,
    audience,
    fields,
    stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
  }
}

function compileDefaults() {
  const out = {}
  for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
  return out
}

// Read the config, cached briefly. Falls back to compiled defaults if the DB is
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
// "what the site did before" rather than to "everything is public".
async function getConfig() {
  const now = Date.now()
  if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
  try {
    const rows = await db.listAll()
    const byName = new Map(rows.map((r) => [r.feature, r]))
    const out = {}
    for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
    cache = out
    cachedAt = now
  } catch (err) {
    log.error('getConfig; falling back to defaults', err)
    cache = cache || compileDefaults()
    cachedAt = now
  }
  return cache
}

const invalidate = () => {
  cache = null
  cachedAt = 0
}

// ── Viewer level ───────────────────────────────────────────────────────────
//
// anonymous  no session
// logged_in  authenticated, no linked game account
// player     authenticated with a linked game account
// staff      admin | moderator — the same set as the existing `modAccess` gate.
//            `editor` is a CONTENT role with no shard privilege today, so it
//            resolves by link status like any other member; mapping it to staff
//            here would silently widen what editors can see.
// admin      admin
//
// Staff always satisfy the `player` rung (rank order guarantees it) even without
// a linked account, matching the existing rule that /player/* is role-agnostic
// self-service.

// Same TTL as the config cache: this decides a privilege rung, so an unlinked
// (or newly relinked) account must not keep the old answer for long. Anonymous,
// staff and admin callers short-circuit before this runs, so the lookup only
// costs a query on the logged-in-member path.
const LINK_TTL_MS = CONFIG_TTL_MS
const linkCache = new Map() // userId → { hasLink, at }

async function hasLinkedAccount(userId) {
  const hit = linkCache.get(userId)
  const now = Date.now()
  if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
  let hasLink = false
  try {
    const links = await shardLinks.listForUser(userId)
    hasLink = Array.isArray(links) && links.length > 0
  } catch (err) {
    log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
  }
  linkCache.set(userId, { hasLink, at: now })
  return hasLink
}

// Drop a user's cached link status (called when a link is created or removed so
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
const forgetUser = (userId) => linkCache.delete(userId)

async function viewerLevel(req) {
  const viewer = req.user || auth.getUserFromRequest(req)
  if (!viewer) return 'anonymous'
  if (viewer.role === 'admin') return 'admin'
  if (viewer.role === 'moderator') return 'staff'
  return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
}

// ── Enforcement ────────────────────────────────────────────────────────────

// Route gate. 404 when the feature is disabled (do not leak that it exists);
// 403 when it exists but the viewer sits below its audience. Stashes the
// resolved level on the request so controllers can project without re-resolving.
function requireFeature(name) {
  return async (req, res, next) => {
    try {
      const config = await getConfig()
      const feature = config[name]
      if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
      const level = await viewerLevel(req)
      req.viewerLevel = level
      if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
      return next()
    } catch (err) {
      log.error(`requireFeature(${name})`, err)
      return res.status(500).json({ message: 'Internal Server Error' })
    }
  }
}

// Strip the fields a viewer at `level` may not see. Applies the locked rules
// first (so acct/webId can never survive below admin), then the feature's
// configured field rules. Recurses into arrays and nested objects because the
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
// instance is a value, not a bag of fields: rebuilding one key-by-key would
// return `{}` (a Date has no enumerable own properties), which is how the DB-
// backed read models — whose rows carry real Date columns — differ from the
// pure-JSON wire frames the projection was first written against.
const isPlainObject = (v) => {
  if (v === null || typeof v !== 'object') return false
  const proto = Object.getPrototypeOf(v)
  return proto === Object.prototype || proto === null
}

function projectValue(value, rules, level) {
  if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
  if (!isPlainObject(value)) return value
  const out = {}
  for (const [key, v] of Object.entries(value)) {
    // Locked fields are checked by meaning first, so no configured rule (and no
    // flattened spelling) can widen them past `admin`.
    const required = isLockedField(key) ? 'admin' : rules[key]
    if (required && !meets(level, required)) continue
    out[key] = projectValue(v, rules, level)
  }
  return out
}

// Project a payload for one feature. `level` defaults to admin-equivalent only
// when explicitly passed; callers should always pass a resolved level.
function projectFeature(name, payload, level, config) {
  const feature = config?.[name]
  const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
  return projectValue(payload, rules, level)
}

// Convenience for controllers: resolve config once, project, return.
async function project(name, payload, req) {
  const config = await getConfig()
  const level = req.viewerLevel || (await viewerLevel(req))
  return projectFeature(name, payload, level, config)
}

// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
function kindVisibleTo(kind, level, config) {
  if (level === 'admin') return true
  const name = KIND_FEATURE.get(kind)
  if (!name) return false // rule 2: unmapped ⇒ admin-only
  const feature = config?.[name]
  if (!feature || !feature.enabled || !feature.stream) return false
  return meets(level, feature.audience)
}

// The event kinds a viewer at `level` may read under the CURRENT config. This is
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
// this kind, given what the admin has configured?".
//
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
// feature whose live firehose is off (market) is still readable from the stored
// history. Unmapped kinds are absent by construction (rule 2).
function visibleKinds(level, config) {
  return [...KIND_FEATURE.entries()]
    .filter(([, name]) => {
      const feature = config?.[name]
      return !!feature && feature.enabled && meets(level, feature.audience)
    })
    .map(([kind]) => kind)
}

// The features a viewer at `level` can actually see — drives SPA nav so it never
// renders a link that would 403.
function visibleFeatures(level, config) {
  return FEATURE_NAMES.filter((name) => {
    const feature = config[name]
    return feature.enabled && meets(level, feature.audience)
  })
}

module.exports = {
  LADDER,
  FEATURES,
  FEATURE_NAMES,
  LOCKED_FIELDS,
  KIND_FEATURE,
  PUBLIC_KINDS,
  DEFAULT_STREAM_OFF,
  isLevel,
  isFeature,
  isLockedField,
  rank,
  meets,
  getConfig,
  invalidate,
  compileDefaults,
  viewerLevel,
  forgetUser,
  requireFeature,
  projectFeature,
  project,
  kindVisibleTo,
  visibleKinds,
  visibleFeatures,
}
