// The SPA's HTML shell: index.html templated with this instance's branding.
//
// This used to be a one-liner at module load in app.js — read the built
// index.html, template it from BRAND_* env, serve that one string forever. The
// admin-configurable brand assets (docs/website/THEMING_AND_NAV.md §4.3) make
// the favicon and OG image settings-driven, which is a lifecycle change rather
// than an `await`: the shell now depends on a row that can change while the
// process runs.
//
// Three properties this module exists to guarantee:
//
//   • It is a cached string in the steady state. A settings read per page view
//     would put the database on the critical path of every SPA route, including
//     during an outage where the API is already degraded.
//   • A DB fault never fails the page. A read error renders the env-only shell —
//     exactly what the code did before this feature — and that fallback is
//     cached like any other, so an outage cannot turn every page view into a
//     failing query.
//   • With no brand_assets and no theme_visual row it is BYTE-IDENTICAL to what
//     app.js served before. That is an acceptance criterion of §9, and the
//     reason the theme <style> block and the asset overrides are appended only
//     when they exist rather than always emitted with default values.
//
// Invalidation is explicit — the settings controller calls invalidate() after a
// successful write to brand_assets or theme_visual — with a TTL as a safety net.
// The cache is per process: in a scaled deployment the process that handled the
// write is the only one that learns of it, so without the TTL every other worker
// would serve the old favicon until the next restart.

const brand = require('../config/brand')

// How long a rendered shell is trusted without an explicit invalidation. Short
// enough that a second process converges on its own, long enough that this is
// still one render per process per five minutes rather than one per request.
const TTL_MS = 5 * 60 * 1000

// A stored theme reaches the browser twice: in this block, and again as inline
// properties once the SPA has fetched /public/settings. The block exists purely
// so a themed instance does not paint the shipped palette for one frame first;
// the client drops it (by id) as soon as it has the authoritative payload — see
// contexts/SiteContext.jsx.
const THEME_STYLE_ID = 'theme-boot'

// Belt and braces over the theme validators. Every token name comes from a fixed
// map and every value from a closed set (hex color, curated font stack, bounded
// px, listed shadow), so nothing that reaches here can carry markup today. These
// two patterns make that a property of the HTML writer rather than of a validator
// three modules away that someone may one day loosen.
const SAFE_TOKEN_NAME = /^--[a-zA-Z0-9-_]+$/
const SAFE_TOKEN_VALUE = /^[a-zA-Z0-9 ,.()#%_'"/-]+$/

let template = null // the built index.html, read once
let cached = null // { html, at }
let inflight = null // de-dupes a burst of requests on a cold cache
let generation = 0 // bumped by invalidate(); an in-flight render checks it

// Escape user/brand text for safe interpolation into the HTML shell.
function htmlEscape(s) {
  return String(s).replace(
    /[&<>"']/g,
    (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]),
  )
}

/**
 * An uploaded asset path is always relative (`/uploads/…`), but og:image is read
 * off-site by scrapers that handle a relative URL poorly. Absolutize it against
 * BRAND_URL when we have one.
 *
 * Env values pass through untouched even when relative: the shell an instance
 * gets today is the operator's choice and must not change just because this
 * module now exists.
 */
function absolutize(url) {
  if (!brand.url || !url.startsWith('/')) return url
  return `${brand.url.replace(/\/+$/, '')}${url}`
}

/**
 * Render the shell. Pure — every input is a parameter, so a test can assert the
 * byte-identical property without a database.
 *
 * @param {string} html  the built index.html
 * @param {{logo?: string, favicon?: string, theme?: object|null, moduleEntries?: string[]}} [overrides]
 *   effective brand assets and theme; anything absent falls back to BRAND_* env.
 *   `moduleEntries` are the same-origin URLs of installed modules' client chunks.
 * @returns {string}
 */
function render(html, overrides = {}) {
  const title = htmlEscape(brand.name)
  const desc = htmlEscape(brand.description)
  // Effective values: an uploaded override wins over env, absence means env.
  const logo = overrides.logo ? absolutize(overrides.logo) : brand.logo
  const favicon = overrides.favicon || brand.favicon
  const tags = [
    `<meta property="og:title" content="${title}" />`,
    `<meta property="og:description" content="${desc}" />`,
    '<meta property="og:type" content="website" />',
    brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
    logo ? `<meta property="og:image" content="${htmlEscape(logo)}" />` : '',
    '<meta name="twitter:card" content="summary_large_image" />',
    `<meta name="twitter:title" content="${title}" />`,
    `<meta name="twitter:description" content="${desc}" />`,
    favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
    themeStyleTag(overrides.theme),
  ]
    .filter(Boolean)
    .join('\n    ')
  const scripts = moduleScriptTags(overrides.moduleEntries)
  const withHead = html
    .replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
    .replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
    .replace(/<\/head>/i, `    ${tags}\n  </head>`)
  if (scripts.length === 0) return withHead
  return withHead.replace(/<\/body>/i, `    ${scripts.join('\n    ')}\n  </body>`)
}

// Installed modules' prebuilt client chunks (docs/website/MODULE_API.md §3.1).
//
// `type="module"` with a `src`, never inline: `script-src 'self'` admits a
// same-origin src with no nonce, and an inline tag would be blocked outright —
// which is also why the shared dependencies ride on window.__rg rather than an
// import map, since an import map has to be inline.
//
// **Injected before `</body>`, not into `</head>`, and the position is the
// contract.** Module scripts are deferred, so they execute in document order
// after core's own bundle — which is where `window.__rg` is published, and what
// every one of a module's imports resolves against. Vite happens to hoist core's
// entry script into `<head>` today, which would make a `</head>` injection work
// too; that is a bundler's emit choice, and if it ever changed, every module in
// the wild would break on its first import with nothing in this repo having been
// edited. Last in the body is after core's script wherever core's script is.
//
// The path is built by the loader from the module id and the entry's basename,
// both already validated, so nothing operator-supplied reaches the attribute.
// It is re-checked here anyway: what may appear in an HTML attribute should be a
// property of the code that writes the HTML, not of a validator two files away
// staying strict.
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9][A-Za-z0-9._-]*\.js$/

function moduleScriptTags(entries) {
  if (!Array.isArray(entries)) return []
  return entries
    .filter((src) => typeof src === 'string' && MODULE_ENTRY_PATH.test(src))
    .map((src) => `<script type="module" src="${htmlEscape(src)}"></script>`)
}

// The admin theme as a :root block, or '' when this instance has never been
// themed. Injected last in <head> so it follows the built stylesheet and wins
// the equal-specificity tie against theme.css's own :root.
function themeStyleTag(theme) {
  if (!theme || typeof theme !== 'object') return ''
  const decls = Object.entries(theme)
    .filter(([name, value]) => SAFE_TOKEN_NAME.test(name) && typeof value === 'string' && SAFE_TOKEN_VALUE.test(value))
    .map(([name, value]) => `${name}:${value}`)
    .join(';')
  return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
}

/**
 * Provide the built index.html. Called once at boot by app.js; a separate step
 * from get() so the file read stays synchronous and startup still fails loudly
 * if the client build is unreadable.
 */
function init(html) {
  template = html
  cached = null
  inflight = null
  generation += 1
}

/** Drop the cached shell. Called after any write that can change it. */
function invalidate() {
  cached = null
  inflight = null
  generation += 1
}

/**
 * The current shell. Renders on a cold or expired cache, otherwise returns the
 * cached string. Never rejects: a settings read that fails yields the env-only
 * shell.
 *
 * @returns {Promise<string>}
 */
async function get() {
  if (template === null) throw new Error('htmlShell.init() was never called')
  if (cached && Date.now() - cached.at < TTL_MS) return cached.html
  if (inflight) return inflight

  const startedAt = generation
  const run = (async () => {
    let overrides = {}
    try {
      // Required lazily: this module is loaded by app.js at boot, and the
      // settings model pulls in the DB pool. Requiring it at the top would make
      // the HTML shell a startup-time dependency of the database.
      // eslint-disable-next-line global-require
      const settings = require('../model/settings/settings.model')
      overrides = await settings.getShellBrand()
    } catch {
      // A DB fault must never fail the page (§4.3). Fall back to the env-only
      // shell — the pre-feature behaviour — and cache it, so an outage does not
      // mean a failing query per page view.
      overrides = {}
    }
    // The module list is in-memory and filesystem-derived, so unlike the brand
    // read above it cannot fail on a DB fault and needs no fallback of its own.
    // Required lazily for the same reason the settings model is: app.js requires
    // this file, and the loader would otherwise be pulled into that chain.
    let moduleEntries = []
    try {
      // eslint-disable-next-line global-require
      moduleEntries = require('../modules/loader').clientEntryUrls()
    } catch {
      // The only reachable throw is §7.6's guard — the shell rendered before
      // modules.load() ran, which app.js's ordering makes impossible and a test
      // that renders in isolation makes possible. A page with no module scripts
      // is the right answer either way; it is what a bare core serves.
      moduleEntries = []
    }
    // Note for whoever builds the admin Modules screen: a state change after boot
    // (an operator disabling a module) has to call invalidate(), exactly as a
    // brand-asset write does. The TTL converges on its own within five minutes;
    // the explicit call is what makes the toggle feel like it did something.
    const html = render(template, { ...overrides, moduleEntries })
    // An invalidation that landed while this read was in flight means the value
    // we just read may already be stale. Serve it, but do not cache it.
    if (generation === startedAt) cached = { html, at: Date.now() }
    // Only retire our own registration: an invalidation during the read may have
    // already started a newer render, and clearing that one would cost an extra
    // render on the next request.
    if (inflight === run) inflight = null
    return html
  })()
  inflight = run
  return run
}

module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID }
