// theme_visual — the strict write path and the fail-safe read path.
//
// The two halves are deliberately asymmetric (see utils/themeResolve.js): a
// write is rejected with the offending field named, while a read drops bad
// fields one at a time and falls back to the shipped :root. These tests lock
// that asymmetry, because it is the thing most likely to get "tidied" into a
// single shared check later.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

const { validateThemeVisual, resolveThemeTokens, themeOptions } = require('../src/utils/themeResolve')
const { PRESETS, FONT_OPTIONS, SHADOW_OPTIONS } = require('../src/config/themePresets')

// ── validateThemeVisual (write path) ──────────────────────────────────
test('accepts a bare preset choice', () => {
  for (const preset of ['runic-gateway', 'modern', 'fantasy', 'custom']) {
    assert.equal(validateThemeVisual({ preset }).ok, true, preset)
  }
  assert.equal(validateThemeVisual({ preset: 'fantasy', custom: null }).ok, true)
})

test('rejects an unknown preset', () => {
  const res = validateThemeVisual({ preset: 'parchment' })
  assert.equal(res.ok, false)
  assert.match(res.message, /preset must be one of/)
})

test('rejects a non-object, and unknown top-level fields', () => {
  for (const bad of [null, 4, 'fantasy', []]) {
    assert.equal(validateThemeVisual(bad).ok, false)
  }
  const res = validateThemeVisual({ preset: 'modern', mode: 'light' })
  assert.equal(res.ok, false)
  assert.match(res.message, /unknown field\(s\) mode/)
})

test('accepts custom colors, fonts and structure from the closed sets', () => {
  const res = validateThemeVisual({
    preset: 'custom',
    custom: {
      colors: { accent: '#c9973f', bg: '#000' },
      fonts: { display: 'Cinzel, Georgia, serif', sans: 'Inter, Arial, sans-serif' },
      structure: { radiusCard: '4px', shadowDepth: SHADOW_OPTIONS[0].value },
    },
  })
  assert.equal(res.ok, true, res.message)
})

test('rejects a color that is not a hex literal', () => {
  // The point of the closed set: a CSS function or keyword never reaches a
  // custom property value, whatever it would or would not have done there.
  for (const bad of ['red', 'rgb(1,2,3)', 'url(http://x/y)', '#12345', 'var(--bg)', '#ff0000; x']) {
    const res = validateThemeVisual({ preset: 'custom', custom: { colors: { accent: bad } } })
    assert.equal(res.ok, false, bad)
    assert.match(res.message, /colors: invalid value for accent/)
  }
})

test('rejects a font stack that is not on the shortlist', () => {
  const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS, sans-serif' } } })
  assert.equal(res.ok, false)
  assert.match(res.message, /fonts: invalid value for sans/)
})

test('rejects a font offered for a different role', () => {
  // Cinzel is a display face and is not in the sans list.
  const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Cinzel, Georgia, serif' } } })
  assert.equal(res.ok, false)
})

test('rejects an out-of-range or unitless radius', () => {
  for (const bad of ['1000px', '4', '4em', '-4px', 'calc(4px + 1px)']) {
    const res = validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: bad } } })
    assert.equal(res.ok, false, bad)
  }
  assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: '999px' } } }).ok, true)
})

test('rejects a shadow that is not one of the listed depths', () => {
  const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: '0 0 99px red' } } })
  assert.equal(res.ok, false)
})

test('rejects unknown groups and unknown fields inside a group', () => {
  assert.equal(validateThemeVisual({ preset: 'custom', custom: { spacing: { unit: '8px' } } }).ok, false)
  const res = validateThemeVisual({ preset: 'custom', custom: { colors: { border: '#fff' } } })
  assert.equal(res.ok, false)
  assert.match(res.message, /invalid value for border/)
})

// ── resolveThemeTokens (read path) ────────────────────────────────────

// The acceptance criterion the whole feature rests on: absent means absent, and
// the caller writes nothing.
test('no row, or an unusable one, resolves to null', () => {
  for (const nothing of [null, undefined, '', 'not json', '4', '"x"', '[]', '{}', '{"preset":"custom"}']) {
    assert.equal(resolveThemeTokens(nothing), null, JSON.stringify(nothing))
  }
})

test('a preset resolves to its full palette', () => {
  const tokens = resolveThemeTokens(JSON.stringify({ preset: 'fantasy' }))
  assert.deepEqual(tokens, PRESETS.fantasy.tokens)
  // Not a partial palette: the supporting shades move with it, or a warm theme
  // keeps dark-blue borders.
  assert.equal(tokens['--line'], '#4a3721')
  assert.equal(tokens['--blue'], '#382613')
})

test('semantic status colors are never themed', () => {
  for (const preset of Object.values(PRESETS)) {
    assert.equal('--mode-live' in preset.tokens, false)
    assert.equal('--mode-maint' in preset.tokens, false)
  }
})

test('the derived panel gradient is never written as a literal', () => {
  for (const preset of Object.values(PRESETS)) {
    assert.equal('--panel-grad' in preset.tokens, false)
  }
})

test('runic-gateway is exactly the shipped stylesheet values', () => {
  const t = PRESETS['runic-gateway'].tokens
  assert.equal(t['--bg'], '#0e1318')
  assert.equal(t['--accent'], '#7f99bd')
  assert.equal(t['--radius-pill'], '999px')
  assert.equal(t['--radius-panel'], '12px')
  assert.equal(t['--radius-card'], '10px')
  assert.equal(t['--radius-input'], '8px')
  assert.equal(t['--serif'], 'Georgia, "Times New Roman", serif')
  assert.equal(t['--display'], 'Cinzel, Georgia, serif')
  assert.equal(t['--sans'], '"Helvetica Neue", Arial, sans-serif')
})

// The one place the server duplicates the stylesheet, so the one place that can
// drift: switching to runic-gateway after trying another preset must land back
// on exactly what :root ships, not on a stale copy of it.
test('the runic-gateway preset matches theme.css :root token for token', () => {
  const fs = require('node:fs')
  const path = require('node:path')
  const cssPath = path.join(__dirname, '..', '..', 'client', 'src', 'styles', 'theme.css')
  const root = /:root\s*\{([\s\S]*?)\}/.exec(fs.readFileSync(cssPath, 'utf8'))
  assert.ok(root, 'theme.css has a :root block')
  const declared = {}
  for (const line of root[1].split(';')) {
    const m = /^\s*(--[a-z0-9-]+)\s*:\s*([\s\S]+?)\s*$/i.exec(line.replace(/\/\*[\s\S]*?\*\//g, ''))
    if (m) declared[m[1]] = m[2]
  }
  for (const [token, value] of Object.entries(PRESETS['runic-gateway'].tokens)) {
    // --shadow-card is the exception: theme.css writes rgba() unspaced and the
    // preset writes it spaced, which is the same computed value. Compare with
    // whitespace normalized rather than exempting the token entirely.
    assert.equal(
      String(declared[token]).replace(/\s+/g, ''),
      value.replace(/\s+/g, ''),
      `${token} drifted from theme.css`,
    )
  }
})

test('custom fields layer on top of the preset, per field', () => {
  const tokens = resolveThemeTokens(
    JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#ffffff' } } }),
  )
  assert.equal(tokens['--accent'], '#ffffff') // overridden
  assert.equal(tokens['--bg'], PRESETS.fantasy.tokens['--bg']) // untouched
  assert.equal(tokens['--radius-card'], '2px') // untouched
})

test('custom with no preset base yields only the fields that were set', () => {
  const tokens = resolveThemeTokens(
    JSON.stringify({ preset: 'custom', custom: { structure: { radiusCard: '4px' } } }),
  )
  assert.deepEqual(tokens, { '--radius-card': '4px' })
})

// Fail-safe, field by field: a hand-edited row degrades to the shipped default
// for the bad field only, rather than rendering a broken site or throwing.
test('an invalid field is dropped without discarding its neighbours', () => {
  const tokens = resolveThemeTokens(
    JSON.stringify({ preset: 'custom', custom: { colors: { accent: 'red', bg: '#000000' } } }),
  )
  assert.deepEqual(tokens, { '--bg': '#000000' })
})

test('an unknown preset still applies the custom fields', () => {
  const tokens = resolveThemeTokens(
    JSON.stringify({ preset: 'parchment', custom: { colors: { accent: '#ffffff' } } }),
  )
  assert.deepEqual(tokens, { '--accent': '#ffffff' })
})

test('accepts an already-parsed object as well as the stored string', () => {
  assert.deepEqual(resolveThemeTokens({ preset: 'modern' }), PRESETS.modern.tokens)
})

test('every resolved value is a plain string', () => {
  const tokens = resolveThemeTokens(JSON.stringify({ preset: 'modern' }))
  for (const [name, value] of Object.entries(tokens)) {
    assert.match(name, /^--[a-z-]+$/, name)
    assert.equal(typeof value, 'string', name)
  }
})

// ── themeOptions (the catalog the admin form is built from) ───────────

// The reason the catalog is served rather than duplicated client-side: every
// option offered must be one validateThemeVisual() accepts.
test('every offered font and shadow validates', () => {
  const opts = themeOptions()
  for (const [role, options] of Object.entries(opts.fonts)) {
    for (const o of options) {
      const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { [role]: o.value } } })
      assert.equal(res.ok, true, `${role}: ${o.value} — ${res.message || ''}`)
    }
  }
  for (const o of opts.shadows) {
    const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: o.value } } })
    assert.equal(res.ok, true, o.value)
  }
})

test('every offered preset id validates and every field name is editable', () => {
  const opts = themeOptions()
  for (const p of opts.presets) {
    assert.equal(validateThemeVisual({ preset: p.id }).ok, true, p.id)
  }
  for (const { name } of opts.colorFields) {
    assert.equal(validateThemeVisual({ preset: 'custom', custom: { colors: { [name]: '#123456' } } }).ok, true, name)
  }
  for (const { name } of opts.radiusFields) {
    assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { [name]: '5px' } } }).ok, true, name)
  }
})

// The form reads each control's current value out of the preset map by token
// name, so every advertised field must actually resolve to one.
test('every advertised field names a token the presets declare', () => {
  const opts = themeOptions()
  for (const { name, token } of [...opts.colorFields, ...opts.radiusFields]) {
    assert.ok(token.startsWith('--'), `${name} → ${token}`)
    assert.ok(token in opts.shippedTokens, `${token} missing from the shipped theme`)
    for (const p of opts.presets) {
      if (p.tokens) assert.ok(token in p.tokens, `${token} missing from preset ${p.id}`)
    }
  }
})

test('the shipped default font stacks are reachable from the shortlist', () => {
  // An admin who customizes fonts must be able to get back to today's look
  // without resetting the whole theme.
  const serifValues = FONT_OPTIONS.serif.map((o) => o.value)
  const sansValues = FONT_OPTIONS.sans.map((o) => o.value)
  const displayValues = FONT_OPTIONS.display.map((o) => o.value)
  assert.ok(serifValues.includes('Georgia, "Times New Roman", serif'))
  assert.ok(sansValues.includes('"Helvetica Neue", Arial, sans-serif'))
  assert.ok(displayValues.includes('Cinzel, Georgia, serif'))
})
