const fs = require('fs')

const posts = require('../../../model/posts/posts.model')
const wiki = require('../../../model/wiki/wiki.model')
const settings = require('../../../model/settings/settings.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const registries = require('../../../modules/registries')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml')
const { parseJsonSetting } = require('../../../utils/settingsJson')
const { validateThemeVisual } = require('../../../utils/themeResolve')
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
const htmlShell = require('../../../utils/htmlShell')

const log = require('../../../utils/logger')('admin')

// Fire the announcement pipeline the moment a post transitions INTO
// "published news" — a false→true publish while in news, or a category change
// into news while already published. Enqueues one announce_jobs row whose two
// legs (in-game town crier + Discord #news) are then delivered with independent
// retry by the dispatcher worker (utils/announceWorker). Fire-and-forget and
// self-guarding (enqueueIfNeeded never throws and de-dupes via the post's
// existing announce_job_id) so a pipeline hiccup never breaks saving a post.
// Awaited (not fire-and-forget) because enqueue is purely local DB work — one
// INSERT + a back-pointer UPDATE, no network — so it never blocks on the sidecar
// or Discord (that happens later in the worker). Awaiting keeps the de-dup guard
// (post.announce_job_id) reliable against rapid double-publishes. Still guarded:
// enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save.
async function announceIfNewlyPublished(post, transition) {
  // enqueueIfNeeded returns a truthy job id EXACTLY on a real transition into
  // published news (and null on an edit/re-publish or a hiccup) — reuse that as
  // the single "newly published news" signal for the push too, so we never
  // double-fire on edits or replicate the transition logic.
  const jobId = await announceJobs.enqueueIfNeeded(post, transition)
  // Tell whoever is listening that a post was saved, and what the transition
  // was. Core's CMS is the only writer of posts, and a module may mirror one
  // somewhere core knows nothing about — module-uo keeps UO's in-game Town Cryer
  // News gump in step this way. Awaited but never throwing, so a subscriber's
  // sidecar hiccup cannot break saving a post: the same guarantee the enqueue
  // above gives.
  await registries.dispatchPostHook('onSaved', { post, transition })
  // Opt-in push tickle to news.post subscribers, on the same transition.
  // Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving.
  if (jobId) {
    Promise.resolve(pushDispatch.publish('news.post', { ref: String(post.id) })).catch((err) =>
      log.warn('news push failed', { postId: post.id, message: err.message }),
    )
  }
}

// ── Dashboard & site mode ─────────────────────────────────────────────
async function dashboard(req, res) {
  try {
    return res.json({
      site_mode: (await settings.get('site_mode')) || 'live',
      last_change: {
        at: await settings.get('site_mode_changed_at'),
        by: await settings.get('site_mode_changed_by'),
      },
      counts: {
        posts: await posts.counts(),
        users: await users.count(),
      },
      recent_activity: await activity.list({ limit: 10 }),
    })
  } catch (err) {
    log.error('dashboard', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function setSiteMode(req, res) {
  const { mode } = req.body
  try {
    const changedAt = new Date().toISOString()
    await settings.setMany(
      {
        site_mode: mode,
        site_mode_changed_at: changedAt,
        site_mode_changed_by: req.user.username,
      },
      req.user.id,
    )
    await activity.log({ req, action: 'site_mode.change', detail: { mode } })
    log.info('site mode changed', { mode, by: req.user.username, ip: req.ip })
    return res.json({ site_mode: mode, changed_at: changedAt, changed_by: req.user.username })
  } catch (err) {
    log.error('setSiteMode', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Posts ─────────────────────────────────────────────────────────────
async function listPosts(req, res) {
  try {
    let dbCategory = null
    if (req.query.category) {
      dbCategory = posts.toDbCategory(req.query.category)
      if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
    }
    return res.json(await posts.listAll(dbCategory))
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function getPost(req, res) {
  try {
    const post = await posts.getById(Number(req.params.id))
    if (!post) return res.status(404).json({ message: 'Not found' })
    return res.json(post)
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function createPost(req, res) {
  const dbCategory = posts.toDbCategory(req.body.category)
  if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
  if (dbCategory === 'screenshot' && !req.body.image_url) {
    return res.status(400).json({ message: 'Screenshots require an image_url' })
  }
  try {
    const created = await posts.create({
      category: dbCategory,
      title: req.body.title,
      slug: req.body.slug || null,
      excerpt: req.body.excerpt || null,
      body: req.body.body || null,
      image_url: req.body.image_url || null,
      published: Boolean(req.body.published),
      author_id: req.user.id,
    })
    await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } })
    await announceIfNewlyPublished(created, { wasPublished: false, wasNews: false })
    return res.status(201).json(created)
  } catch (err) {
    log.error('createPost', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function updatePost(req, res) {
  const id = Number(req.params.id)
  try {
    const current = await posts.getById(id)
    if (!current) return res.status(404).json({ message: 'Not found' })

    const fields = {}
    for (const key of ['title', 'slug', 'excerpt', 'body', 'image_url']) {
      if (key in req.body) fields[key] = req.body[key] || null
    }
    if ('category' in req.body) {
      const dbCategory = posts.toDbCategory(req.body.category)
      if (!dbCategory) return res.status(400).json({ message: 'Unknown category' })
      fields.category = dbCategory
    }
    if ('published' in req.body) {
      fields.published = req.body.published ? 1 : 0
      if (req.body.published && !current.published_at) fields.published_at = new Date()
    }

    const updated = await posts.update(id, fields)
    await activity.log({ req, action: 'post.update', detail: { id } })
    await announceIfNewlyPublished(updated, {
      wasPublished: Boolean(current.published),
      wasNews: current.category === 'news',
    })
    return res.json(updated)
  } catch (err) {
    log.error('updatePost', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function publishPost(req, res) {
  const id = Number(req.params.id)
  try {
    const current = await posts.getById(id)
    if (!current) return res.status(404).json({ message: 'Not found' })
    const updated = await posts.setPublished(id, Boolean(req.body.published))
    await activity.log({
      req,
      action: 'post.publish',
      detail: { id, published: Boolean(req.body.published) },
    })
    await announceIfNewlyPublished(updated, {
      wasPublished: Boolean(current.published),
      wasNews: current.category === 'news',
    })
    return res.json(updated)
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function deletePost(req, res) {
  const id = Number(req.params.id)
  try {
    const current = await posts.getById(id)
    await posts.remove(id)
    await activity.log({ req, action: 'post.delete', detail: { id } })
    // And that it is gone. A subscriber decides for itself whether it was
    // mirroring this one — core does not know, and asking would mean core
    // holding a predicate that belongs to the subscriber (`inGump` used to live
    // right here, and it was UO's question, not the CMS's).
    await registries.dispatchPostHook('onDeleted', { post: current, id })
    return res.json({ id })
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// GET /admin/posts/:id/announce — the announcement job for a post (or null if it
// was never announced), for the status panel on the post editor.
async function getAnnounceStatus(req, res) {
  const id = Number(req.params.id)
  try {
    const job = await announceJobs.getByPostId(id)
    return res.json(job || null)
  } catch (err) {
    log.error('getAnnounceStatus', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// POST /admin/posts/:id/announce/retry — reset one delivery leg to pending so
// the dispatcher re-attempts it (e.g. after fixing the news channel / sidecar).
async function retryAnnounceLeg(req, res) {
  const id = Number(req.params.id)
  const leg = req.body.leg
  try {
    const job = await announceJobs.resetLeg(id, leg)
    if (!job) return res.status(404).json({ message: 'No announcement job for this post' })
    await activity.log({ req, action: 'post.announce.retry', detail: { id, leg } })
    return res.json(job)
  } catch (err) {
    log.error('retryAnnounceLeg', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function uploadImage(req, res) {
  if (!req.file) return res.status(400).json({ message: 'No image uploaded' })
  const imageUrl = `/uploads/${req.file.filename}`
  await activity.log({ req, action: 'post.upload', detail: { image_url: imageUrl } })
  return res.status(201).json({ image_url: imageUrl })
}

// Generalized upload used by rich-text editors (wiki, etc.). Same multer config
// as the screenshot upload; returns a neutral { url }.
async function uploadFile(req, res) {
  if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
  const url = `/uploads/${req.file.filename}`
  await activity.log({ req, action: 'upload', detail: { url } })
  return res.status(201).json({ url })
}

// ── Wiki pages ─────────────────────────────────────────────────────────
async function listWiki(req, res) {
  try {
    const q = (req.query.q || '').trim()
    if (q) return res.json(await wiki.search(q, { publishedOnly: false }))

    const filters = {}
    if (req.query.category) {
      const category = await wiki.getCategoryBySlug(req.query.category)
      filters.categoryId = category ? category.id : -1 // unknown → match nothing
    }
    if (req.query.tag) {
      const tag = await wiki.getTagBySlug(req.query.tag)
      filters.tagId = tag ? tag.id : -1 // unknown → match nothing
    }
    if (req.query.status === 'draft') filters.published = false
    if (req.query.status === 'published') filters.published = true
    return res.json(await wiki.listAll(filters))
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function getWiki(req, res) {
  try {
    const page = await wiki.getBySlug(req.params.slug)
    if (!page) return res.status(404).json({ message: 'Not found' })
    return res.json(page)
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// Resolve a category_id from the request, validating it exists. Returns
// { ok, value } so the caller can distinguish "not provided" from "invalid".
async function resolveCategoryId(body) {
  if (!('category_id' in body) || body.category_id == null || body.category_id === '') {
    return { ok: true, value: null }
  }
  const category = await wiki.getCategoryById(Number(body.category_id))
  if (!category) return { ok: false }
  return { ok: true, value: category.id }
}

async function createWiki(req, res) {
  try {
    if (await wiki.getBySlug(req.body.slug)) {
      return res.status(409).json({ message: 'A page with that slug already exists' })
    }
    const cat = await resolveCategoryId(req.body)
    if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })

    const page = await wiki.create({
      slug: req.body.slug,
      title: req.body.title,
      body: req.body.body || null,
      excerpt: req.body.excerpt || null,
      categoryId: cat.value,
      published: req.body.published !== false,
      tags: Array.isArray(req.body.tags) ? req.body.tags : undefined,
      updatedBy: req.user.id,
    })
    await activity.log({ req, action: 'wiki.create', detail: { slug: page.slug } })
    return res.status(201).json(page)
  } catch (err) {
    log.error('createWiki', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function updateWiki(req, res) {
  try {
    const existing = await wiki.getBySlug(req.params.slug)
    if (!existing) return res.status(404).json({ message: 'Not found' })

    const input = { updatedBy: req.user.id }
    if ('title' in req.body) input.title = req.body.title
    if ('body' in req.body) input.body = req.body.body || null
    if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null
    if ('published' in req.body) input.published = Boolean(req.body.published)
    if ('change_note' in req.body) input.changeNote = req.body.change_note
    if ('tags' in req.body) input.tags = Array.isArray(req.body.tags) ? req.body.tags : []
    if ('category_id' in req.body) {
      const cat = await resolveCategoryId(req.body)
      if (!cat.ok) return res.status(400).json({ message: 'Unknown category' })
      input.categoryId = cat.value
    }

    const page = await wiki.update(req.params.slug, input)
    await activity.log({ req, action: 'wiki.update', detail: { slug: req.params.slug } })
    return res.json(page)
  } catch (err) {
    log.error('updateWiki', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function publishWiki(req, res) {
  try {
    const page = await wiki.setPublished(req.params.slug, Boolean(req.body.published))
    if (!page) return res.status(404).json({ message: 'Not found' })
    await activity.log({
      req,
      action: 'wiki.publish',
      detail: { slug: req.params.slug, published: Boolean(req.body.published) },
    })
    return res.json(page)
  } catch (err) {
    log.error('publishWiki', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function deleteWiki(req, res) {
  try {
    await wiki.remove(req.params.slug)
    await activity.log({ req, action: 'wiki.delete', detail: { slug: req.params.slug } })
    return res.json({ slug: req.params.slug })
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Wiki revisions ─────────────────────────────────────────────────────
async function listWikiRevisions(req, res) {
  try {
    const revisions = await wiki.listRevisions(req.params.slug)
    if (revisions == null) return res.status(404).json({ message: 'Not found' })
    return res.json(revisions)
  } catch (err) {
    log.error('listWikiRevisions', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function getWikiRevision(req, res) {
  try {
    const rev = await wiki.getRevision(req.params.slug, Number(req.params.id))
    if (!rev) return res.status(404).json({ message: 'Not found' })
    return res.json(rev)
  } catch (err) {
    log.error('getWikiRevision', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function restoreWikiRevision(req, res) {
  try {
    const page = await wiki.restoreRevision(req.params.slug, Number(req.params.id), req.user.id)
    if (!page) return res.status(404).json({ message: 'Not found' })
    await activity.log({
      req,
      action: 'wiki.revision.restore',
      detail: { slug: req.params.slug, revision: Number(req.params.id) },
    })
    return res.json(page)
  } catch (err) {
    log.error('restoreWikiRevision', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Wiki tags ──────────────────────────────────────────────────────────
async function listWikiTags(req, res) {
  try {
    return res.json(await wiki.listTags())
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Wiki categories ────────────────────────────────────────────────────
async function listWikiCategories(req, res) {
  try {
    return res.json(await wiki.listCategories())
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function createWikiCategory(req, res) {
  try {
    if (await wiki.getCategoryBySlug(req.body.slug)) {
      return res.status(409).json({ message: 'A category with that slug already exists' })
    }
    const category = await wiki.createCategory({
      slug: req.body.slug,
      title: req.body.title,
      description: req.body.description || null,
      sortOrder: Number(req.body.sort_order) || 0,
    })
    await activity.log({ req, action: 'wiki.category.create', detail: { slug: category.slug } })
    return res.status(201).json(category)
  } catch (err) {
    log.error('createWikiCategory', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function updateWikiCategory(req, res) {
  const id = Number(req.params.id)
  try {
    const existing = await wiki.getCategoryById(id)
    if (!existing) return res.status(404).json({ message: 'Not found' })

    const input = {}
    if ('title' in req.body) input.title = req.body.title
    if ('description' in req.body) input.description = req.body.description || null
    if ('sort_order' in req.body) input.sortOrder = Number(req.body.sort_order) || 0
    if ('slug' in req.body && req.body.slug !== existing.slug) {
      const clash = await wiki.getCategoryBySlug(req.body.slug)
      if (clash) return res.status(409).json({ message: 'A category with that slug already exists' })
      input.slug = req.body.slug
    }

    const category = await wiki.updateCategory(id, input)
    await activity.log({ req, action: 'wiki.category.update', detail: { id } })
    return res.json(category)
  } catch (err) {
    log.error('updateWikiCategory', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function deleteWikiCategory(req, res) {
  const id = Number(req.params.id)
  try {
    const existing = await wiki.getCategoryById(id)
    if (!existing) return res.status(404).json({ message: 'Not found' })
    await wiki.removeCategory(id) // pages in it become uncategorized
    await activity.log({ req, action: 'wiki.category.delete', detail: { id } })
    return res.json({ id })
  } catch (err) {
    log.error('deleteWikiCategory', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Settings ──────────────────────────────────────────────────────────
async function getSettings(req, res) {
  try {
    return res.json(await settings.getAll())
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function updateSettings(req, res) {
  const updates = req.body
  if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
    return res.status(400).json({ message: 'Expected an object of key/value settings' })
  }
  // Enum-constrained keys are validated here (the store itself is schemaless).
  if (
    settings.REGISTRATION_KEY in updates &&
    !settings.REGISTRATION_MODES.includes(updates[settings.REGISTRATION_KEY])
  ) {
    return res.status(400).json({ message: 'Invalid player_registration value' })
  }
  // `game_account_signup` was validated here until Phase 3 slice 3. The key and
  // its stored value are unchanged, but the four legal modes are a fact about a
  // ServUO shard, so module-uo owns them and validates on its own route. This
  // endpoint takes arbitrary keys either way, and an unrecognised value resolves
  // to `disabled` on read — the module's gate fails closed, which is the right
  // direction for "may this player mint a game account".
  // App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
  // boolean or those two strings and normalize, reject anything else.
  if (settings.MOBILE_APP_LINKS_KEY in updates) {
    const v = updates[settings.MOBILE_APP_LINKS_KEY]
    if (v !== true && v !== false && v !== 'true' && v !== 'false') {
      return res.status(400).json({ message: 'Invalid mobile_app_links_enabled value' })
    }
    updates[settings.MOBILE_APP_LINKS_KEY] = String(v === true || v === 'true')
  }
  // The homepage teaser is rich text (HTML) from the shared editor — sanitize it
  // against the same allowlist as post/wiki bodies so a stored value is safe (the
  // client re-sanitizes on render as defense in depth).
  if (typeof updates.homepage_teaser === 'string') {
    updates.homepage_teaser = cleanBody(updates.homepage_teaser)
  }
  // theme_visual is JSON whose values become CSS custom properties, so every
  // one has to come from the closed sets in config/themePresets.js. The read
  // path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently
  // storing a value that will never apply is a bad admin experience — reject it
  // with the offending field named instead. Accepts an object or the stringified
  // form, and stores it stringified either way, since settings.value is TEXT.
  if ('theme_visual' in updates) {
    const raw = updates.theme_visual
    const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
    if (typeof raw === 'string' && parsed === null) {
      return res.status(400).json({ message: 'theme_visual must be a JSON object' })
    }
    const check = validateThemeVisual(parsed)
    if (!check.ok) return res.status(400).json({ message: check.message })
    updates.theme_visual = JSON.stringify(parsed)
  }
  // brand_assets holds the only settings values written straight into HTML the
  // browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
  // the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
  // dropped rather than stored as null, keeping "a field is absent" the single
  // meaning of "falls back to BRAND_* env".
  if ('brand_assets' in updates) {
    const raw = updates.brand_assets
    const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
    if (typeof raw === 'string' && parsed === null) {
      return res.status(400).json({ message: 'brand_assets must be a JSON object' })
    }
    const check = validateBrandAssets(parsed)
    if (!check.ok) return res.status(400).json({ message: check.message })
    updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
  }
  // The three nav rows are JSON too, and without this they would reach
  // settingsDb.set as objects and be stored as the string "[object Object]".
  // Shape only — whether a key names a route the nav actually declares is the
  // client's question, and utils/navOverrides.js says why. Resolved on the way
  // in so the stored row carries no dead fields, and so `hidden` can never land
  // on the nav editor's own row.
  for (const key of NAV_KEYS) {
    if (!(key in updates)) continue
    const raw = updates[key]
    const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
    if (typeof raw === 'string' && parsed === null) {
      return res.status(400).json({ message: `${key} must be a JSON object` })
    }
    const check = validateNavOverrides(parsed, key)
    if (!check.ok) return res.status(400).json({ message: check.message })
    updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
  }
  try {
    await settings.setMany(updates, req.user.id)
    // The HTML shell is templated from brand_assets and theme_visual, and is
    // cached per process (utils/htmlShell.js) — a write that can change it has
    // to say so, or the favicon an admin just uploaded appears only after the
    // cache's TTL.
    if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
    await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
    return res.json(await settings.getAll())
  } catch (err) {
    log.error('updateSettings', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Brand assets ──────────────────────────────────────────────────────
//
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
// itself is never widened (§9: "no second upload path with weaker validation") —
// these only ever tighten it:
//
//   • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
//     fact that the stored extension comes from that map is exactly what makes
//     the upload path safe (§4.10). Every browser this app supports takes a PNG
//     icon. Small cap: a favicon is a handful of KB.
//   • logo — a header mark next to the site title, not a page image.
//   • hero — a full-bleed background, so it keeps the shared ceiling.
//
// The cap is checked after multer has written the file rather than by a second
// multer instance: one upload config, one allowlist, and the oversized file is
// unlinked before we answer.
const ASSET_RULES = {
  logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
  hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
  favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
}

const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)

// Best-effort cleanup of a file we have decided not to keep. A failure here is
// a stray file in /uploads, not something the caller can act on.
async function discardUpload(file) {
  try {
    await fs.promises.unlink(file.path)
  } catch (err) {
    log.error('discardUpload', err)
  }
}

/**
 * POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
 * brand_assets row at it in the same call.
 *
 * One call rather than "upload, then PUT the settings row": a half-completed
 * save would otherwise leave a file in /uploads that nothing references, and the
 * per-slot rules above need the slot at upload time anyway. Admin-only, matching
 * the gate on the settings it writes — POST /admin/uploads is reachable by
 * editors, who have no business changing the site's identity.
 */
async function uploadBrandAsset(req, res) {
  const { slot } = req.params
  const rules = ASSET_RULES[slot]
  if (!rules) {
    if (req.file) await discardUpload(req.file)
    return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
  }
  if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
  if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
    await discardUpload(req.file)
    return res.status(400).json({ message: `${rules.label} must be a PNG image` })
  }
  if (req.file.size > rules.maxBytes) {
    await discardUpload(req.file)
    return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
  }

  const url = `/uploads/${req.file.filename}`
  try {
    // Read-modify-write the row: uploading a logo must not clear a hero the
    // admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
    // with one bad slot does not block setting another.
    const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
    const next = { ...current, [slot]: url }
    await settings.set('brand_assets', JSON.stringify(next), req.user.id)
    htmlShell.invalidate()
    await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
    return res.status(201).json({ url, brand_assets: next })
  } catch (err) {
    log.error('uploadBrandAsset', err)
    // The row is the point of the call; a stored file nothing points at is
    // litter, so it goes back out with the error.
    await discardUpload(req.file)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// Delete one settings row — the "reset to defaults" primitive.
//
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
// hardcoded NAV arrays; the *absence* of the row is what selects them
// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not
// store a copy of the defaults, or the next change to a default would not reach
// an instance that had ever pressed reset.
//
// The key allowlist is the point of the route: an unrestricted DELETE would let
// a stray request drop site_mode or the uo-link config, where absence means
// something else entirely. Deleting a key that is not set succeeds — reset is
// idempotent and the UI should not have to know whether a row exists.
async function deleteSetting(req, res) {
  const { key } = req.params
  if (!settings.DELETABLE_KEYS.includes(key)) {
    return res.status(400).json({ message: 'Setting is not resettable' })
  }
  try {
    await settings.remove(key)
    if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
    await activity.log({ req, action: 'settings.reset', detail: { key } })
    return res.json({ message: 'Setting reset to default' })
  } catch (err) {
    log.error('deleteSetting', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Activity log ──────────────────────────────────────────────────────
async function listActivity(req, res) {
  const limit = Math.min(Number(req.query.limit) || 50, 200)
  const offset = Number(req.query.offset) || 0
  try {
    return res.json(await activity.list({ limit, offset }))
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── User management ───────────────────────────────────────────────────
async function listUsers(req, res) {
  try {
    return res.json(await users.list())
  } catch (err) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
//
// Lived in usersShard.controller.js until PR 4, purely because the detail page it
// backs is mostly shard panels — MODULE_SYSTEM.md §1.9 called that out as core
// semantics that ended up in the UO controller by proximity. Reading a user is
// core's, and it stays here when the shard panels leave.
async function getUser(req, res) {
  try {
    const user = await users.getById(Number(req.params.id))
    if (!user) return res.status(404).json({ message: 'Not found' })
    return res.json(user)
  } catch (err) {
    log.error('getUser', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function createUser(req, res) {
  try {
    if (await users.getRawByUsername(req.body.username)) {
      return res.status(409).json({ message: 'Username already taken' })
    }
    const user = await users.createUser({
      username: req.body.username,
      password: req.body.password,
      role: req.body.role || 'admin',
      email: req.body.email || null,
      status: req.body.status || 'active',
    })
    await activity.log({
      req,
      action: 'user.create',
      detail: { id: user.id, username: user.username, role: user.role },
    })
    return res.status(201).json(user)
  } catch (err) {
    log.error('createUser', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function updateUser(req, res) {
  const id = Number(req.params.id)
  try {
    const target = await users.getById(id)
    if (!target) return res.status(404).json({ message: 'Not found' })

    // If the username is changing, make sure no other user already has it —
    // return 409 rather than letting the DB unique constraint throw a 500.
    if (req.body.username && req.body.username !== target.username) {
      const clash = await users.getRawByUsername(req.body.username)
      if (clash) return res.status(409).json({ message: 'Username already taken' })
    }

    // Don't let the last admin demote themselves out of admin access.
    if (target.role === 'admin' && req.body.role && req.body.role !== 'admin') {
      if ((await users.countAdmins()) <= 1) {
        return res.status(400).json({ message: 'Cannot demote the last admin' })
      }
    }
    const user = await users.update(id, {
      username: req.body.username,
      password: req.body.password,
      role: req.body.role,
      email: req.body.email,
      status: req.body.status,
    })
    await activity.log({ req, action: 'user.update', detail: { id } })
    // Distinct audit trail for the security-sensitive fields (role & status),
    // so a promotion/ban is greppable beyond the generic user.update entry.
    if (req.body.role && req.body.role !== target.role) {
      await activity.log({
        req,
        action: 'admin.user.role_change',
        detail: { id, from: target.role, to: req.body.role },
      })
    }
    if (req.body.status && req.body.status !== target.status) {
      await activity.log({
        req,
        action: 'admin.user.status_change',
        detail: { id, from: target.status, to: req.body.status },
      })
    }
    return res.json(user)
  } catch (err) {
    log.error('updateUser', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function deleteUser(req, res) {
  const id = Number(req.params.id)
  try {
    if (id === req.user.id) {
      return res.status(400).json({ message: 'You cannot delete your own account' })
    }
    const target = await users.getById(id)
    if (!target) return res.status(404).json({ message: 'Not found' })
    if (target.role === 'admin' && (await users.countAdmins()) <= 1) {
      return res.status(400).json({ message: 'Cannot delete the last admin' })
    }
    await users.remove(id)
    await activity.log({ req, action: 'user.delete', detail: { id } })
    return res.json({ id })
  } catch (err) {
    log.error('deleteUser', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// ── Admin: a user's trusted devices & MFA (admin only) ─────────────────────
// Staff-facing view/revocation of another user's trusted devices, plus an MFA
// reset for a locked-out user. All actions are audit-logged with the acting admin
// (via activity.log's req) and the target user id.
function toAdminTrustedDevice(r) {
  return {
    id: r.id,
    platform: r.platform,
    deviceName: r.device_name || null,
    userAgent: r.user_agent || null,
    createdAt: r.created_at,
    lastUsedAt: r.last_used_at || r.created_at,
    expiresAt: r.expires_at,
  }
}

async function listUserTrustedDevices(req, res) {
  const id = Number(req.params.id)
  try {
    const target = await users.getById(id)
    if (!target) return res.status(404).json({ message: 'Not found' })
    const rows = await trustedDevices.listActiveForUser(id)
    return res.json(rows.map(toAdminTrustedDevice))
  } catch (err) {
    log.error('listUserTrustedDevices', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function revokeUserTrustedDevice(req, res) {
  const id = Number(req.params.id)
  const deviceId = Number(req.params.deviceId)
  try {
    const target = await users.getById(id)
    if (!target) return res.status(404).json({ message: 'Not found' })
    const n = await trustedDevices.revokeByIdForUser(deviceId, id)
    if (n) {
      await activity.log({ req, action: 'admin.trusted_device.revoke', detail: { userId: id, deviceId } })
      log.info('admin revoked trusted device', { adminId: req.user.id, userId: id, deviceId })
    }
    return res.json({ revoked: n > 0 })
  } catch (err) {
    log.error('revokeUserTrustedDevice', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

async function revokeAllUserTrustedDevices(req, res) {
  const id = Number(req.params.id)
  try {
    const target = await users.getById(id)
    if (!target) return res.status(404).json({ message: 'Not found' })
    const n = await trustedDevices.revokeAllForUser(id)
    await activity.log({ req, action: 'admin.trusted_device.revoke_all', detail: { userId: id, count: n } })
    log.info('admin revoked all trusted devices', { adminId: req.user.id, userId: id, count: n })
    return res.json({ revoked: n })
  } catch (err) {
    log.error('revokeAllUserTrustedDevices', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// Reset a locked-out user's MFA: turn TOTP off, drop every trusted device, and
// clear their recovery codes. Lets an admin recover a user who lost their
// authenticator; the user can then sign in with their password alone and re-enroll.
async function resetUserMfa(req, res) {
  const id = Number(req.params.id)
  try {
    const target = await users.getById(id)
    if (!target) return res.status(404).json({ message: 'Not found' })
    await users.disableTotp(id)
    await trustedDevices.revokeAllForUser(id)
    await recoveryCodes.clearForUser(id)
    await activity.log({ req, action: 'admin.user.totp.reset', detail: { userId: id } })
    log.info('admin reset user MFA', { adminId: req.user.id, userId: id })
    return res.json({ ok: true })
  } catch (err) {
    log.error('resetUserMfa', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

module.exports = {
  dashboard,
  setSiteMode,
  listPosts,
  getPost,
  createPost,
  updatePost,
  publishPost,
  deletePost,
  getAnnounceStatus,
  retryAnnounceLeg,
  uploadImage,
  uploadFile,
  listWiki,
  getWiki,
  createWiki,
  updateWiki,
  publishWiki,
  deleteWiki,
  listWikiRevisions,
  getWikiRevision,
  restoreWikiRevision,
  listWikiTags,
  listWikiCategories,
  createWikiCategory,
  updateWikiCategory,
  deleteWikiCategory,
  getSettings,
  updateSettings,
  deleteSetting,
  uploadBrandAsset,
  ASSET_RULES,
  listActivity,
  listUsers,
  getUser,
  createUser,
  updateUser,
  deleteUser,
  listUserTrustedDevices,
  revokeUserTrustedDevice,
  revokeAllUserTrustedDevices,
  resetUserMfa,
}
