const fs = require('fs')
const path = require('path')

const db = require('./shardAtlas.db')
const settings = require('../settings/settings.model')
const { slugify } = require('../../utils/spawnAtlasParse')
const {
  AtlasSourceError,
  PARSER_VERSION,
  buildAtlas,
  hashSources,
  sameSources,
} = require('../../utils/spawnAtlasSource')
const log = require('../../utils/logger')('shardAtlas')

// The spawn atlas, refreshed from the shard's own ServUO tree.
//
// The tree is the single source of truth. Nothing is precomputed and committed,
// because a shard's maps change over its lifetime — facets get added, replaced
// or renamed — and a snapshot in the repo would go stale against the world
// players actually see. So the atlas is re-derived on every boot.
//
// Two rules govern the boot path:
//
//   1. **It never blocks startup.** No configured path, an unreadable path, a
//      malformed file, a database error — all of it is caught and logged. The
//      site comes up either way, serving whatever atlas it already had.
//   2. **A facet disappearing is not applied automatically.** Losing a facet is
//      the signature of a half-copied or mid-update tree as much as of a real
//      map change, and the two are indistinguishable from here. The refresh is
//      staged for a human instead, and an admin approves or rejects it.
//
// Everything else — new facets, renamed regions, changed spawns — applies
// straight away, because none of it can silently destroy data an operator would
// miss.

const SETTING_KEY = 'spawn_atlas_servuo_path'

/**
 * Where the ServUO tree lives.
 *
 * The admin setting wins over the environment so an operator can point the
 * atlas at a different tree without a redeploy, matching how the rest of the
 * shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
 * remains as the deploy-time default, since the path usually describes a mount
 * that the deployment sets up.
 */
async function getServuoPath() {
  try {
    const configured = await settings.get(SETTING_KEY)
    if (configured && String(configured).trim() !== '') return String(configured).trim()
  } catch {
    // Settings unavailable is not fatal — fall through to the env default.
  }
  const fromEnv = process.env.SERVUO_PATH
  return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
}

async function setServuoPath(value, updatedBy = null) {
  return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
}

/**
 * Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
 *
 * Never committed and never shipped — creature sprites come out of the
 * operator's own client `.mul`/`.uop` files, which are theirs, not ours to
 * redistribute. Absent (the normal case) every `art` stays NULL and the UI
 * renders text-only.
 */
function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) {
  try {
    const file = path.join(dir, 'spawnAtlas.art.json')
    if (!fs.existsSync(file)) return {}
    const map = JSON.parse(fs.readFileSync(file, 'utf8'))
    return map && typeof map === 'object' ? map : {}
  } catch (err) {
    log.warn('spawn atlas art map could not be read', { error: err.message })
    return {}
  }
}

/**
 * Flatten each point's types into `shard_spawn_point_types` rows.
 *
 * A spawner may legitimately list the same type twice, and the primary key is
 * (point_id, slug), so duplicates collapse to the larger max rather than
 * failing the insert.
 */
function pointTypeRows(points) {
  const rows = []
  points.forEach((point, i) => {
    const bySlug = new Map()
    for (const entry of point.types ?? []) {
      const slug = slugify(entry.type)
      if (slug === '') continue
      bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
    }
    for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
  })
  return rows
}

async function applyAtlas(atlas) {
  return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
}

/**
 * Refresh the atlas from the configured ServUO tree.
 *
 * Returns a result describing what happened rather than throwing, so the caller
 * — including the boot path — can log it and move on:
 *
 *   `skipped`     no path configured
 *   `unavailable` path configured but unreadable / missing required files
 *   `unchanged`   source hashes match the loaded atlas; nothing parsed
 *   `imported`    parsed and applied
 *   `needsReview` parsed, but a facet would be lost; staged for an admin
 *   `failed`      parsed or applied and something went wrong
 *
 * `force` skips the hash check (an admin asking for a reimport) and `approve`
 * additionally accepts facet loss (an admin approving a staged refresh).
 */
/**
 * Was the loaded atlas built by THIS parser?
 *
 * An atlas imported before `parserVersion` existed reports undefined, which is
 * correctly "no" — those are exactly the ones carrying the old readings.
 */
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION

async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
  // An explicit override wins outright — it is a one-off "use this tree", and it
  // must not be silently overruled by the configured path the way an env default
  // would be.
  const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
  if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }

  let hashes
  try {
    hashes = hashSources(root)
  } catch (err) {
    if (err instanceof AtlasSourceError) {
      return { status: 'unavailable', reason: err.message, code: err.code, path: root }
    }
    return { status: 'failed', reason: err.message, path: root }
  }

  const meta = await db.getMeta().catch(() => null)
  const loaded = meta?.source
    ? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
    : null

  // Two things make a loaded atlas stale: the tree changed, or the PARSER did.
  // Only checking the tree would strand an install whose maps never change on
  // whatever an older build derived — a corrected parse would ship and never
  // reach the data.
  if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
    return { status: 'unchanged', path: root }
  }

  // A rejected refresh must not re-prompt on every boot. It stays rejected until
  // the tree changes again, at which point the hashes differ and it is a new
  // decision.
  const pending = await db.getPending().catch(() => null)
  if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
    return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
  }

  let atlas
  try {
    atlas = buildAtlas(root)
  } catch (err) {
    return { status: 'failed', reason: err.message, path: root }
  }

  const currentFacets = await db.getFacets().catch(() => [])
  const incomingFacets = atlas.facets
  const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet))
  const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet))

  // Losing a facet is indistinguishable here from a half-copied tree, so it is
  // staged rather than applied — but startup is never blocked by it.
  if (removedFacets.length > 0 && !approve) {
    const summary = {
      hashes,
      path: root,
      currentFacets,
      incomingFacets,
      removedFacets,
      addedFacets,
      counts: atlas.meta.counts,
    }
    await db.setPending(summary, 'pending').catch((err) => {
      log.warn('could not stage spawn atlas refresh', { error: err.message })
    })
    return { status: 'needsReview', ...summary }
  }

  try {
    const counts = await applyAtlas(atlas)
    return { status: 'imported', path: root, counts, addedFacets, removedFacets }
  } catch (err) {
    return { status: 'failed', reason: err.message, path: root }
  }
}

/** Admin approved a staged refresh: apply it, facet loss and all. */
async function approvePending(options = {}) {
  return refresh({ ...options, approve: true, force: true })
}

/**
 * Admin rejected a staged refresh: keep the current atlas and remember the
 * decision against those exact source hashes, so it does not re-prompt every
 * boot. A further change to the tree produces different hashes and asks again.
 */
async function rejectPending() {
  const pending = await db.getPending()
  if (!pending) return { status: 'none' }
  await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected')
  return { status: 'rejected' }
}

/** Everything the admin panel needs to describe atlas state. */
async function status({ path: pathOverride = '' } = {}) {
  const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
  const [meta, pending, facets] = await Promise.all([
    db.getMeta().catch(() => null),
    db.getPending().catch(() => null),
    db.getFacets().catch(() => []),
  ])

  let treeReadable = false
  let drift = null
  if (root !== '') {
    try {
      const hashes = hashSources(root)
      treeReadable = true
      const loaded = meta?.source
        ? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
        : null
      // Same question `refresh` asks: an import picks something up when either
      // the tree or the parser has moved on.
      drift = !sameSources(hashes, loaded) || !currentParser(meta)
    } catch {
      treeReadable = false
    }
  }

  return {
    configured: root !== '',
    path: root,
    treeReadable,
    drift,
    facets,
    importedAt: meta?.importedAt ?? null,
    counts: meta?.counts ?? null,
    pending,
  }
}

/**
 * Boot hook. Best-effort by contract: it logs and returns, never throws, so a
 * missing tree or a bad file can never stop the site coming up.
 */
async function refreshOnBoot() {
  try {
    const result = await refresh()
    switch (result.status) {
      case 'imported':
        log.info('spawn atlas refreshed from ServUO tree', {
          ...result.counts,
          added: result.addedFacets,
        })
        break
      case 'needsReview':
        log.warn(
          'spawn atlas refresh staged for admin review — a facet would be removed; ' +
            'the existing atlas is unchanged',
          { removed: result.removedFacets, added: result.addedFacets },
        )
        break
      case 'unavailable':
        log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path })
        break
      case 'failed':
        log.warn('spawn atlas refresh failed', { reason: result.reason })
        break
      default:
        break
    }
    return result
  } catch (err) {
    log.warn('spawn atlas refresh errored', { error: err.message })
    return { status: 'failed', reason: err.message }
  }
}

// ── Reads ──────────────────────────────────────────────────────────────────
//
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
// than in the controller, for the same reason shardState does it: the column
// names are an implementation detail of the import, and the browser contract
// should not move when a column is renamed.

const jsonOr = (value, fallback) => {
  if (value == null) return fallback
  if (typeof value !== 'string') return value
  try {
    return JSON.parse(value)
  } catch {
    return fallback
  }
}

const shapeCreature = (row) => ({
  slug: row.slug,
  name: row.name,
  // `total` is the summed MaxCount across every spawner (how many can be alive
  // at once); `points` is how many spawners mention it. They answer different
  // questions and the UI shows both.
  total: row.total,
  points: row.points,
  facets: jsonOr(row.facets, {}),
  art: row.art || null,
})

const shapePlace = (row) => ({
  facet: row.facet,
  label: row.label,
  spawners: Number(row.spawners) || 0,
  maxAlive: Number(row.max_alive) || 0,
})

const shapePoint = (row) => ({
  id: row.id,
  facet: row.facet,
  name: row.name || null,
  x: row.x,
  y: row.y,
  width: row.width,
  height: row.height,
  range: row.spawn_range,
  maxCount: row.max_count,
  minDelay: row.min_delay,
  maxDelay: row.max_delay,
  todStart: row.tod_start,
  todEnd: row.tod_end,
  todMode: row.tod_mode,
  region: row.region || null,
  landmark: row.landmark || null,
  label: row.label,
})

/**
 * Paginated creature search. Returns the page plus the unpaginated total, so
 * the UI can say "showing 50 of 800" without a second round trip.
 */
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
  const [rows, total] = await Promise.all([
    db.listCreatures({ q, facet, limit, offset }),
    db.countCreatures({ q, facet }),
  ])
  return { total, limit, offset, creatures: rows.map(shapeCreature) }
}

/**
 * One creature: its totals, the places it spawns (the aggregate the atlas
 * exists for), the individual spawners, and what else shares those spawners.
 *
 * `null` when the slug is unknown — the controller turns that into a 404.
 */
async function getCreature(slug, { facet = '', points = 200 } = {}) {
  const row = await db.getCreature(slug)
  if (!row) return null
  const [places, pointRows, alsoHere] = await Promise.all([
    db.listCreaturePlaces(slug, { facet }),
    db.listCreaturePoints(slug, { facet, limit: points }),
    db.listCreatureCompanions(slug),
  ])
  return {
    ...shapeCreature(row),
    places: places.map(shapePlace),
    // `spawners`, not `points`: shapeCreature already uses `points` for the
    // COUNT of spawners, and reusing the key for the list of them would make the
    // same field a number on the search route and an array here.
    spawners: pointRows.map(shapePoint),
    // Bounded by the query, so a creature on hundreds of spawners returns a page
    // rather than the world.
    spawnersTruncated: pointRows.length >= points,
    alsoHere: alsoHere.map((r) => ({
      slug: r.slug,
      name: r.name,
      shared: Number(r.shared) || 0,
    })),
  }
}

async function listRegions(opts = {}) {
  const rows = await db.listRegions(opts)
  return rows.map((r) => ({
    facet: r.facet,
    name: r.name,
    type: r.type || null,
    priority: r.priority,
    parent: r.parent || null,
    rects: jsonOr(r.rects, []),
  }))
}

async function listLandmarks(opts = {}) {
  const rows = await db.listLandmarks(opts)
  return rows.map((r) => ({
    facet: r.facet,
    name: r.name,
    group: r.grp || null,
    x: r.x,
    y: r.y,
    z: r.z,
  }))
}

async function listChampions(opts = {}) {
  const rows = await db.listChampions(opts)
  return rows.map((r) => ({
    slug: r.slug,
    name: r.name,
    group: r.grp || null,
    // '' on the wire means "randomised at activation"; `randomType` says so
    // explicitly rather than making the client infer it from an empty string.
    type: r.type || null,
    randomType: !!r.random_type,
    facet: r.facet,
    x: r.x,
    y: r.y,
    z: r.z,
    radius: r.radius,
    label: r.label || null,
  }))
}

/**
 * What is loaded: the facet list, the counts, and when it was imported.
 *
 * Deliberately does NOT report the source path, the per-file hashes or whether
 * a refresh is pending. Those describe the operator's filesystem, and this is a
 * public endpoint; the admin status route carries them instead.
 */
async function publicMeta() {
  const [meta, facets] = await Promise.all([
    db.getMeta().catch(() => null),
    db.getFacets().catch(() => []),
  ])
  return {
    importedAt: meta?.importedAt ?? null,
    generatedAt: meta?.generatedAt ?? null,
    // The parse counts, not the row counts: `unresolvedPoints` is what lets the
    // page state its own placement accuracy instead of implying it is complete.
    counts: meta?.counts ?? null,
    facets,
  }
}

const listFacets = () => db.getFacets()

module.exports = {
  refresh,
  refreshOnBoot,
  approvePending,
  rejectPending,
  status,
  getServuoPath,
  setServuoPath,
  pointTypeRows,
  loadArtMap,
  SETTING_KEY,
  searchCreatures,
  getCreature,
  listRegions,
  listLandmarks,
  listChampions,
  listFacets,
  publicMeta,
}
