// Point the DB at a closed port before the pool is built; the settings read is
// monkeypatched in every test that reaches it, so no query runs.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
// A brand URL, so the og:image absolutization of an uploaded path is exercised
// rather than being dead code in the test environment.
process.env.BRAND_URL = process.env.BRAND_URL || 'https://shard.example'
// BRAND_LOGO defaults to empty (no logo image rendered), which would make the
// "og:image still comes from env" assertions below pass vacuously.
process.env.BRAND_LOGO = process.env.BRAND_LOGO || '/brand/logo.png'

const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')

// The cached, settings-aware HTML shell (docs/website/THEMING_AND_NAV.md §4.3).
// Three properties are load-bearing enough to lock here: that an untouched
// instance gets byte-for-byte the shell it got before this feature existed, that
// a DB fault still serves a page, and that the steady state is one cached string
// rather than a settings read per page view.
const htmlShell = require('../src/utils/htmlShell')
const settings = require('../src/model/settings/settings.model')
const brand = require('../src/config/brand')
const db = require('../src/utils/db')

after(() => db.close())

const originalGetShellBrand = settings.getShellBrand
afterEach(() => {
  settings.getShellBrand = originalGetShellBrand
})

// A stand-in for the built client/dist/index.html: the two tags the shell
// rewrites plus the stylesheet link the theme block has to follow.
const TEMPLATE = `<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Vite App</title>
    <meta name="description" content="placeholder" />
    <link rel="stylesheet" href="/assets/index-abc123.css" />
  </head>
  <body><div id="root"></div></body>
</html>`

// The shell app.js served BEFORE this phase, reproduced verbatim. The point of
// the test is that this string and the new renderer's output are identical for
// an instance with no brand_assets and no theme_visual row (§9), so it is copied
// rather than imported.
function legacyRenderIndexHtml(html) {
  const htmlEscape = (s) =>
    String(s).replace(
      /[&<>"']/g,
      (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]),
    )
  const title = htmlEscape(brand.name)
  const desc = htmlEscape(brand.description)
  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)}" />` : '',
    brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
    '<meta name="twitter:card" content="summary_large_image" />',
    `<meta name="twitter:title" content="${title}" />`,
    `<meta name="twitter:description" content="${desc}" />`,
    brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
  ]
    .filter(Boolean)
    .join('\n    ')
  return 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>`)
}

// ── The byte-identical guarantee (§9) ─────────────────────────────────

test('with no overrides the shell is byte-identical to the pre-feature one', () => {
  assert.equal(htmlShell.render(TEMPLATE, {}), legacyRenderIndexHtml(TEMPLATE))
})

test('an empty theme and empty assets are the same as no overrides at all', () => {
  // A row that parsed to nothing usable resolves to null/undefined rather than
  // to an empty block, or "reset" would leave a `<style>:root{}` behind forever.
  assert.equal(htmlShell.render(TEMPLATE, { theme: null }), legacyRenderIndexHtml(TEMPLATE))
  assert.equal(htmlShell.render(TEMPLATE, { theme: {} }), legacyRenderIndexHtml(TEMPLATE))
})

// ── Brand assets ──────────────────────────────────────────────────────

test('an uploaded favicon replaces the env one and touches nothing else', () => {
  const html = htmlShell.render(TEMPLATE, { favicon: '/uploads/1-a.png' })
  assert.match(html, /<link rel="icon" href="\/uploads\/1-a\.png" \/>/)
  assert.ok(!html.includes(`href="${brand.favicon}"`), 'the env favicon is gone')
  // §9: setting only the favicon changes the favicon only.
  const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
  assert.equal(ogImage ? ogImage[1] : '', brand.logo, 'og:image still resolves from env')
})

test('an uploaded logo becomes og:image, absolutized against BRAND_URL', () => {
  const html = htmlShell.render(TEMPLATE, { logo: '/uploads/2-b.png' })
  assert.match(html, /<meta property="og:image" content="https:\/\/shard\.example\/uploads\/2-b\.png" \/>/)
})

test('an env logo is passed through untouched even when relative', () => {
  // The shell an instance gets today is the operator's choice; only an uploaded
  // path — which is always relative and is read off-site by scrapers — is made
  // absolute. Anything else would break the byte-identical guarantee above.
  const html = htmlShell.render(TEMPLATE, {})
  const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
  assert.equal(ogImage ? ogImage[1] : '', brand.logo)
})

// ── The theme boot block (removes the first-paint flash) ───────────────

test('a resolved theme is emitted as a :root block after the stylesheet', () => {
  const html = htmlShell.render(TEMPLATE, { theme: { '--accent': '#123456', '--bg': '#0b0f14' } })
  assert.match(html, /<style id="theme-boot">:root\{--accent:#123456;--bg:#0b0f14\}<\/style>/)
  // Custom properties are equal-specificity, so the later block wins: it must
  // come after the built stylesheet or a themed instance would paint :root.
  assert.ok(
    html.indexOf('theme-boot') > html.indexOf('/assets/index-abc123.css'),
    'the theme block follows the stylesheet link',
  )
})

test('a token that could carry markup is dropped, not escaped into the block', () => {
  const html = htmlShell.render(TEMPLATE, {
    theme: { '--accent': '#123456', '--x': '</style><script>alert(1)</script>', 'color': 'red' },
  })
  assert.match(html, /<style id="theme-boot">:root\{--accent:#123456\}<\/style>/)
  assert.ok(!html.includes('alert(1)'), 'no injected markup survives')
  assert.ok(!html.includes('color:red'), 'a non-custom-property name never reaches the block')
})

// ── Caching, invalidation and the DB-fault fallback ────────────────────

test('the shell is rendered once and then served from cache', async () => {
  let reads = 0
  settings.getShellBrand = async () => {
    reads += 1
    return { logo: brand.logo, favicon: '/uploads/cached.png', theme: null }
  }
  htmlShell.init(TEMPLATE)
  const first = await htmlShell.get()
  const second = await htmlShell.get()
  assert.equal(reads, 1, 'a settings read per page view would put the DB on every route')
  assert.equal(first, second)
  assert.match(first, /\/uploads\/cached\.png/)
})

test('a burst of requests on a cold cache does one read', async () => {
  let reads = 0
  settings.getShellBrand = async () => {
    reads += 1
    await new Promise((r) => setTimeout(r, 5))
    return { logo: brand.logo, favicon: brand.favicon, theme: null }
  }
  htmlShell.init(TEMPLATE)
  await Promise.all([htmlShell.get(), htmlShell.get(), htmlShell.get()])
  assert.equal(reads, 1)
})

test('invalidate() makes the next request re-read', async () => {
  let favicon = '/uploads/old.png'
  let reads = 0
  settings.getShellBrand = async () => {
    reads += 1
    return { logo: brand.logo, favicon, theme: null }
  }
  htmlShell.init(TEMPLATE)
  assert.match(await htmlShell.get(), /old\.png/)
  favicon = '/uploads/new.png'
  assert.match(await htmlShell.get(), /old\.png/, 'still cached until told otherwise')
  htmlShell.invalidate()
  assert.match(await htmlShell.get(), /new\.png/)
  assert.equal(reads, 2)
})

test('the cache expires on its own, so a second process converges', async () => {
  let favicon = '/uploads/old.png'
  settings.getShellBrand = async () => ({ logo: brand.logo, favicon, theme: null })
  htmlShell.init(TEMPLATE)
  assert.match(await htmlShell.get(), /old\.png/)
  // Nothing invalidates here: this is the worker that did NOT handle the write.
  favicon = '/uploads/new.png'
  const realNow = Date.now
  Date.now = () => realNow() + htmlShell.TTL_MS + 1
  try {
    assert.match(await htmlShell.get(), /new\.png/)
  } finally {
    Date.now = realNow
  }
})

test('a settings read that throws serves the env-only shell instead of failing', async () => {
  settings.getShellBrand = async () => {
    throw new Error('ER_CON_COUNT_ERROR')
  }
  htmlShell.init(TEMPLATE)
  assert.equal(await htmlShell.get(), legacyRenderIndexHtml(TEMPLATE))
})

test('the fallback is cached too — an outage is not a query per page view', async () => {
  let reads = 0
  settings.getShellBrand = async () => {
    reads += 1
    throw new Error('down')
  }
  htmlShell.init(TEMPLATE)
  await htmlShell.get()
  await htmlShell.get()
  assert.equal(reads, 1)
})

test('an invalidation during a render is not overwritten by the stale result', async () => {
  let favicon = '/uploads/old.png'
  settings.getShellBrand = async () => {
    const value = favicon
    await new Promise((r) => setTimeout(r, 10))
    return { logo: brand.logo, favicon: value, theme: null }
  }
  htmlShell.init(TEMPLATE)
  const inflight = htmlShell.get() // reads 'old'
  favicon = '/uploads/new.png'
  htmlShell.invalidate() // the write lands mid-render
  await inflight
  assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached')
})

// ── Installed modules' client chunks (MODULE_API.md §3.1) ──────────────────

// The built shell as Vite actually emits it: core's entry is a module script in
// <head>, and the injection has to land AFTER it wherever it is.
const BUILT_TEMPLATE = `<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Vite App</title>
    <meta name="description" content="placeholder" />
    <script type="module" crossorigin src="/assets/index-abc123.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-abc123.css" />
  </head>
  <body><div id="root"></div></body>
</html>`

test('a module chunk is injected as a same-origin module script', () => {
  const html = htmlShell.render(TEMPLATE, { moduleEntries: ['/modules/uo/entry.js'] })
  assert.match(html, /<script type="module" src="\/modules\/uo\/entry\.js"><\/script>/)
})

test('the injection lands after core’s own bundle, not before it', () => {
  // The property the whole client contract rests on: module scripts are deferred
  // and execute in document order, so core's bundle must run first — it is what
  // publishes window.__rg, and every import in the chunk resolves against it.
  // Injecting into </head> would work today only because Vite hoists core's
  // entry there; before </body> is after it wherever a bundler decides to put it.
  const html = htmlShell.render(BUILT_TEMPLATE, { moduleEntries: ['/modules/uo/entry.js'] })
  assert.ok(
    html.indexOf('/assets/index-abc123.js') < html.indexOf('/modules/uo/entry.js'),
    'the module chunk must come after core’s bundle',
  )
  assert.ok(html.indexOf('/modules/uo/entry.js') < html.indexOf('</body>'))
  assert.ok(html.indexOf('</head>') < html.indexOf('/modules/uo/entry.js'))
})

test('several modules keep the order they were given', () => {
  const html = htmlShell.render(TEMPLATE, {
    moduleEntries: ['/modules/aaa/entry.js', '/modules/zzz/entry.js'],
  })
  assert.ok(html.indexOf('/modules/aaa/') < html.indexOf('/modules/zzz/'))
})

test('no installed modules leaves the shell byte-identical', () => {
  // A bare core must serve exactly what it served before this PR — including
  // when the list is absent rather than empty, which is what a render before
  // modules.load() produces.
  const baseline = legacyRenderIndexHtml(TEMPLATE)
  assert.equal(htmlShell.render(TEMPLATE, { moduleEntries: [] }), baseline)
  assert.equal(htmlShell.render(TEMPLATE, {}), baseline)
})

test('anything that is not a module chunk URL is refused, not escaped into the page', () => {
  // The loader builds these from a validated id and a validated basename, so
  // none of this is reachable today. It is enforced here anyway: what may appear
  // in a script src should be a property of the code writing the HTML, not of a
  // validator two files away staying strict.
  const html = htmlShell.render(TEMPLATE, {
    moduleEntries: [
      'https://evil.example/entry.js',        // off-origin
      '/modules/uo/../../etc/passwd',         // traversal
      '/modules/UO/entry.js',                 // not a valid module id
      '/modules/uo/entry.js"></script><script>alert(1)</script>', // attribute break-out
      '/modules/uo/../secrets.js',
      '/uploads/entry.js',                    // right shape, wrong root
      42,
      null,
    ],
  })
  assert.ok(!html.includes('<script type="module" src='), html)
  assert.equal(htmlShell.render(TEMPLATE, { moduleEntries: [] }), legacyRenderIndexHtml(TEMPLATE))
})

test('get() renders without module scripts when the loader has never scanned', async () => {
  // db/seed.js's problem, one layer up: this file is required by app.js, and a
  // render that reached the loader before modules.load() would throw §7.6's
  // guard on a request path. A bare shell is the right answer.
  settings.getShellBrand = async () => ({ logo: brand.logo, favicon: brand.favicon, theme: null })
  htmlShell.init(TEMPLATE)
  const html = await htmlShell.get()
  assert.ok(!html.includes('/modules/'))
})
