#!/usr/bin/env bash

# The theme record and the selection flow.
#
# ThemeProfileModel.js is the whole of the record's grammar: what a theme is
# made of, which parts are optional, what happens to a stored record that is
# half wrong, and which curated accent an arbitrary colour is nearest to. All
# of it is pure and runs under Node, so it is checked directly rather than
# through the shell.
#
# Two things here are easy to break silently and expensive when broken:
#
#   - `shippedProfiles()` with no argument is the pre-catalog fallback. The
#     shell renders with it for the instant before config/themes.json loads,
#     and forever on a machine where that file is missing. It must stay the
#     three built-in records, unchanged, even though every runtime caller now
#     passes the ten-theme catalog.
#   - A stored custom with one bad palette key must lose the FIELD, not the
#     profile. Dropping the profile would delete somebody's saved theme
#     because a single hex went wrong.
#
# The live half pins the selection flow: a light/dark flip lands on the theme
# you last chose on that side, never a forced reset to Moon and Day.

set -euo pipefail

repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
catalog="$repo_dir/config/dot/quickshell/config/themes.json"

node - "$model" "$catalog" <<'JS'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const model = require(process.argv[2])
const catalog = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'))
// ThemeCatalog.normalizeTheme() stamps `shipped: true` on every record before
// handing the list to the model, so the fixture has to as well -- without it
// the model would treat catalog themes as editable customs.
const shippedCatalog = catalog.themes.map(theme => ({ ...theme, shipped: true }))

// ── Back-compat: no argument is still the three built-in fallbacks ──────────
const shipped = model.shippedProfiles()
assert.deepEqual(shipped, [
  { id: 'moon', name: 'Moon', scheme: 'dark', accent: '#82aaff', secondary: '#b172b0', shipped: true },
  { id: 'moon-rose', name: 'Moon Rose', scheme: 'dark', accent: '#ff757f', secondary: '#c099ff', shipped: true },
  { id: 'day', name: 'Day', scheme: 'light', accent: '#2e7de9', secondary: '#9854f1', shipped: true }
])

// ── The catalog's records survive the same copy, palettes included ──────────
const full = model.shippedProfiles(shippedCatalog)
assert.equal(full.length, 10)
assert.deepEqual(full.map(p => p.id), shippedCatalog.map(t => t.id))
for (const profile of full) {
  assert.ok(model.normalizePalette(profile.palette), `${profile.id} lost its palette`)
  assert.ok(model.normalizeAnsi(profile.ansi), `${profile.id} lost its ansi block`)
  // Shipped themes carry no effects snapshot, so applying one leaves the
  // user's blur, shadows and motion exactly where they were.
  assert.equal(profile.effects, undefined)
  assert.equal(profile.shipped, true)
}

const moonPalette = shippedCatalog.find(t => t.id === 'moon').palette
const moonAnsi = shippedCatalog.find(t => t.id === 'moon').ansi

// ── Optional fields survive a stored record ────────────────────────────────
const storedInput = {
  id: 'custom-full', name: 'Full', scheme: 'dark',
  accent: '#86E1FC', secondary: '#82AAFF',
  palette: moonPalette, ansi: moonAnsi,
  effects: { blurEnabled: true, blurSize: 40, shadowSharp: 'yes', glowRange: 12 }
}
const stored = model.validCustomProfiles([storedInput], shippedCatalog)[0]
assert.equal(stored.accent, '#86e1fc')
assert.deepEqual(stored.palette, model.normalizePalette(moonPalette))
assert.deepEqual(stored.ansi, model.normalizeAnsi(moonAnsi))
// Effects are per-key: out of range clamps, wrong-typed drops, the rest lands.
assert.deepEqual(stored.effects, { blurEnabled: true, blurSize: 20, glowRange: 12 })

// ── An invalid palette drops the FIELD, never the profile ───────────────────
const partial = {
  ...storedInput, id: 'custom-partial', name: 'Partial',
  palette: { bg: '#222436' }, ansi: 'not an object', effects: { blurSize: 'wide' }
}
const kept = model.validCustomProfiles([partial], shippedCatalog)
assert.equal(kept.length, 1)
assert.equal(kept[0].id, 'custom-partial')
assert.equal(kept[0].palette, undefined)
assert.equal(kept[0].ansi, undefined)
assert.equal(kept[0].effects, undefined)
assert.equal(kept[0].accent, '#86e1fc')

// ── The shipped list a caller passes owns the id and name space ─────────────
const clash = model.createCustomProfile([], {
  name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1'
}, shippedCatalog)
assert.equal(clash.profile.name, 'Nord 2')
assert.equal(clash.profile.id, 'custom-nord-2')
const free = model.createCustomProfile([], {
  name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1'
})
assert.equal(free.profile.name, 'Nord')
assert.equal(free.profile.id, 'custom-nord')
// A stored custom colliding with a catalog name is RENAMED, never dropped —
// upgrades that add shipped themes must not delete a user's saved theme. The
// same record against the three-entry fallback keeps its name, because there
// "Nord" is free. Ids stay the identity either way.
const impostor = {
  id: 'custom-nord', name: 'Nord', scheme: 'dark',
  accent: '#88c0d0', secondary: '#81a1c1', shipped: false
}
const renamed = model.validCustomProfiles([impostor], shippedCatalog)
assert.equal(renamed.length, 1)
assert.equal(renamed[0].id, 'custom-nord')
assert.equal(renamed[0].name, 'Nord 2')
assert.equal(model.validCustomProfiles([impostor])[0].name, 'Nord')

// ── createCustomProfile carries the optional fields through ─────────────────
const rich = model.createCustomProfile([], {
  name: 'Rich', scheme: 'dark', accent: '#86e1fc', secondary: '#82aaff',
  palette: moonPalette, ansi: moonAnsi, effects: { animationsEnabled: false }
}, shippedCatalog)
assert.deepEqual(rich.profile.palette, model.normalizePalette(moonPalette))
assert.deepEqual(rich.profile.ansi, model.normalizeAnsi(moonAnsi))
assert.deepEqual(rich.profile.effects, { animationsEnabled: false })

// ── editProfile passes the fields through ───────────────────────────────────
const recolored = model.resaturatePalette(moonPalette, 1.4)
const editedCustom = model.editProfile(rich.profiles, rich.profile, {
  palette: recolored
}, shippedCatalog)
assert.equal(editedCustom.profile.id, rich.profile.id)
assert.deepEqual(editedCustom.profile.palette, recolored)
// Untouched fields are not collateral damage of a palette edit.
assert.deepEqual(editedCustom.profile.ansi, model.normalizeAnsi(moonAnsi))
assert.deepEqual(editedCustom.profile.effects, { animationsEnabled: false })
assert.equal(editedCustom.profiles.length, 1)

// Forking a shipped theme carries its whole palette, so the fork looks
// identical until the edit lands -- and leaves the original untouched.
const nord = full.find(p => p.id === 'nord')
const fork = model.editProfile([], nord, { accent: '#a3be8c', secondary: '#88c0d0' }, shippedCatalog)
assert.equal(fork.profile.shipped, false)
assert.equal(fork.profile.name, 'Nord custom')
assert.equal(fork.profile.accent, '#a3be8c')
assert.deepEqual(fork.profile.palette, model.normalizePalette(nord.palette))
assert.deepEqual(fork.profile.ansi, model.normalizeAnsi(nord.ansi))
assert.deepEqual(nord, full.find(p => p.id === 'nord'))

// ── nearestCuratedName is what keeps accentName in sync ─────────────────────
// Nearest by hue, per scheme, because each curated name carries a different
// pair on each side. This is the function that stops GNOME's accent enum,
// kitty's border and the lock screen going stale after a custom edit.
for (const [accent, expected] of [
  ['#cba6f7', 'orchid'],     // Catppuccin mauve
  ['#fabd2f', 'amber'],      // Gruvbox yellow
  ['#808080', 'slate'],      // a desaturated grey is slate, not a hue guess
  ['#82aaff', 'blue'],       // Moon
  ['#a7c080', 'green'],      // Everforest green
  ['#88c0d0', 'teal']        // Nord frost
])
  assert.equal(model.nearestCuratedName('dark', accent), expected, `dark ${accent}`)
for (const [accent, expected] of [
  ['#cba6f7', 'orchid'],
  ['#fabd2f', 'amber'],
  ['#808080', 'slate'],
  ['#2e7de9', 'blue'],       // Day
  ['#a7c080', 'green'],
  ['#88c0d0', 'teal']
])
  assert.equal(model.nearestCuratedName('light', accent), expected, `light ${accent}`)
assert.equal(model.nearestCuratedName('dark', 'not-a-colour'), 'blue')

// Every shipped theme resolves to a real curated name.
for (const theme of full)
  assert.ok(model.curatedAccents()[model.nearestCuratedName(theme.scheme, theme.accent)],
    `${theme.id} has no curated accent`)

// ── Derivation produces palettes the validators accept ──────────────────────
const derived = model.derivePalette(moonPalette, {
  scheme: 'dark', bg: '#101020', fg: '#e8e8f8', accent: '#86e1fc'
})
assert.ok(model.normalizePalette(derived))
assert.equal(derived.bg, '#101020')
assert.equal(derived.fg, '#e8e8f8')
// The surfaces and text tints moved with the ground rather than staying behind.
assert.notEqual(derived.bgDark, moonPalette.bgDark)
assert.notEqual(derived.fgDim, moonPalette.fgDim)
// Tokens the wells do not own are inherited untouched, so a small edit stays
// a small edit.
assert.equal(derived.green, moonPalette.green)
assert.equal(model.derivePalette(null, { scheme: 'dark' }), null)

const saturated = model.resaturatePalette(moonPalette, 1.5)
assert.ok(model.normalizePalette(saturated))
// At zero the colour tokens go fully grey while the grounds keep most of their
// tint -- backgrounds move at quarter strength so the ground stays a ground.
const grey = model.resaturatePalette(moonPalette, 0)
assert.ok(model.normalizePalette(grey))
const channels = hex => [hex.slice(1, 3), hex.slice(3, 5), hex.slice(5, 7)]
for (const key of ['green', 'red', 'magenta', 'accentAlt']) {
  const [r, g, b] = channels(grey[key])
  assert.ok(r === g && g === b, `${key} did not desaturate to grey: ${grey[key]}`)
}
const [bgR, bgG, bgB] = channels(grey.bg)
assert.ok(!(bgR === bgG && bgG === bgB), 'the ground desaturated at full strength')
// Neutral is a round trip through HSV, so it is near-identical rather than
// byte-identical; what matters is that it stays a palette and stays in family.
const neutral = model.resaturatePalette(moonPalette, 1)
assert.ok(model.normalizePalette(neutral))
for (const key of model.PALETTE_KEYS)
  for (let offset = 1; offset < 7; offset += 2)
    assert.ok(Math.abs(parseInt(neutral[key].slice(offset, offset + 2), 16)
      - parseInt(moonPalette[key].slice(offset, offset + 2), 16)) <= 3,
      `${key} drifted at neutral saturation: ${neutral[key]} vs ${moonPalette[key]}`)
assert.equal(model.resaturatePalette({ bg: '#000000' }, 1.2), null)

const ansi = model.deriveAnsi(moonPalette, '#86e1fc')
assert.ok(model.normalizeAnsi(ansi))
assert.equal(ansi.blue, '#86e1fc')
assert.equal(ansi.white, moonPalette.fgDim)
assert.equal(ansi.brightWhite, moonPalette.fg)
assert.equal(model.deriveAnsi({ bg: '#000000' }, '#86e1fc'), null)

assert.equal(model.mixHex('#000000', '#ffffff', 0.5), '#808080')
assert.equal(model.mixHex('#000000', '#ffffff', 0), '#000000')
assert.equal(model.mixHex('#000000', '#ffffff', 1), '#ffffff')

// ── The unchanged core still holds ──────────────────────────────────────────
const first = model.createCustomProfile([], {
  name: '  Ocean  ', scheme: 'dark', accent: '#86E1FC', secondary: '#82AAFF'
})
assert.deepEqual(first.profile, {
  id: 'custom-ocean', name: 'Ocean', scheme: 'dark',
  accent: '#86e1fc', secondary: '#82aaff', shipped: false
})
const second = model.createCustomProfile(first.profiles, {
  name: 'ocean', scheme: 'light', accent: '#007197', secondary: '#2e7de9'
})
assert.equal(second.profile.name, 'ocean 2')
assert.equal(second.profile.id, 'custom-ocean-2')
const bounded = model.createCustomProfile(second.profiles, {
  name: 'A theme name that is deliberately much longer than forty characters',
  scheme: 'dark', accent: '#c3e88d', secondary: '#86e1fc'
})
assert.equal(bounded.profile.name.length, 40)

assert.equal(model.deleteProfile(fork.profiles, 'nord', shippedCatalog).removed, false)
assert.equal(model.deleteProfile(fork.profiles, fork.profile.id, shippedCatalog).removed, true)

assert.deepEqual(model.profileCatalog([
  first.profile,
  { id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false },
  { id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false }
], shippedCatalog), [...full, first.profile])

assert.equal(model.hsvToHex(0, 100, 100), '#ff0000')
assert.equal(model.hsvToHex(120, 100, 100), '#00ff00')
assert.equal(model.hsvToHex(240, 100, 100), '#0000ff')
assert.equal(model.hsvToHex(360, 100, 100), '#ff0000')
assert.equal(model.hsvToHex(0, 0, 50), '#808080')
assert.deepEqual(model.hexToHsv('#ff0000'), { h: 0, s: 100, v: 100 })
assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 })
assert.equal(model.hexToHsv('not-a-colour'), null)
assert.equal(model.curatedNameForProfile(shipped[0]), 'blue')
assert.equal(model.curatedNameForProfile(shipped[1]), 'rose')
assert.equal(model.matchingShippedProfile('dark', '#ff757f', '#c099ff', shippedCatalog).id, 'moon-rose')
assert.equal(model.matchingShippedProfile('light', '#2e7de9', '#9854f1', shippedCatalog).id, 'day')
assert.equal(model.curatedNameForProfile({
  id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark',
  accent: '#123456', secondary: '#654321', shipped: false
}), '')

assert.equal(model.PALETTE_KEYS.length, 19)
assert.equal(model.ANSI_KEYS.length, 16)
assert.equal(Object.keys(model.EFFECT_SPEC).length, 10)

console.log('theme profiles contract: PASS')
JS

harness="$repo_dir/config/dot/quickshell/theme-profiles-harness.qml"
state_home="$(mktemp -d /tmp/panama-theme-profiles.XXXXXX)"

qs_for_test() {
    XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \
        QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@"
}

cleanup() {
    qs_for_test kill >/dev/null 2>&1 || true
    rm -rf "$state_home"
}
trap cleanup EXIT

qs_for_test --daemonize >/dev/null
for _ in $(seq 1 100); do
    qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break
    sleep 0.1
done
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \
    || { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; }

status() { qs_for_test ipc call theme-profiles-test status; }

# The catalog is live here, so the profile list is the ten shipped themes, not
# the three-entry pre-load fallback. The catalog arrives through an async
# FileView read, so under a loaded machine (the full suite) it can land a
# beat after the IPC target does -- wait for it rather than asserting the
# race.
for _ in $(seq 1 50); do
    jq -e '(.profiles | length) == 10' <<<"$(status)" >/dev/null 2>&1 && break
    sleep 0.1
done
jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null \
    || { printf 'theme profiles contract: catalog never loaded ten themes: %s\n' "$(status)" >&2; exit 1; }

# ── A scheme flip lands on the remembered theme for that side ───────────────
# This is the whole point of themeDark/themeLight. Before them, flipping to
# light forced Day and flipping back forced Moon, so choosing Everforest and
# then turning on the light meant losing it.
qs_for_test ipc call theme-profiles-test scheme light >/dev/null
jq -e '.active.id == "day" and .active.scheme == "light"' <<<"$(status)" >/dev/null

qs_for_test ipc call theme-profiles-test select latte >/dev/null
jq -e '.active.id == "latte" and .active.scheme == "light"' <<<"$(status)" >/dev/null

qs_for_test ipc call theme-profiles-test scheme dark >/dev/null
jq -e '.active.id == "moon" and .active.scheme == "dark"' <<<"$(status)" >/dev/null

qs_for_test ipc call theme-profiles-test select nord >/dev/null
jq -e '.active.id == "nord" and .active.scheme == "dark"' <<<"$(status)" >/dev/null

qs_for_test ipc call theme-profiles-test scheme light >/dev/null
jq -e '.active.id == "latte"' <<<"$(status)" >/dev/null \
    || { printf 'theme profiles contract: light did not return to the theme chosen there\n' >&2; exit 1; }

qs_for_test ipc call theme-profiles-test scheme dark >/dev/null
jq -e '.active.id == "nord"' <<<"$(status)" >/dev/null \
    || { printf 'theme profiles contract: dark did not return to the theme chosen there\n' >&2; exit 1; }

# ── Editing a shipped theme forks it, carrying its palette ──────────────────
qs_for_test ipc call theme-profiles-test edit '#a3be8c' '#88c0d0' >/dev/null
edited="$(status)"
jq -e '.active.shipped == false and .active.accent == "#a3be8c"
    and .active.name == "Nord custom" and (.stored | length) == 1
    and (.active.palette | length) == 19' <<<"$edited" >/dev/null
custom_id="$(jq -r '.active.id' <<<"$edited")"

qs_for_test ipc call theme-profiles-test save '  Forest  ' >/dev/null
saved="$(status)"
jq -e '.active.name == "Forest" and (.stored | length) == 2
    and (.active.effects | length) == 10' <<<"$saved" >/dev/null \
    || { printf 'theme profiles contract: saving did not snapshot the effects\n' >&2; exit 1; }
forest_id="$(jq -r '.active.id' <<<"$saved")"

# ── Shipped themes cannot be deleted; deleting the active custom falls back ─
[[ "$(qs_for_test ipc call theme-profiles-test remove nord)" == "false" ]]
[[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]]
# themeDark pointed at the deleted record, so resolution falls through to the
# catalog default rather than leaving the desktop on a theme that is gone.
jq -e '.active.id == "moon" and .active.shipped == true' <<<"$(status)" >/dev/null
[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]]
jq -e '(.stored | length) == 0' <<<"$(status)" >/dev/null

trap - EXIT
cleanup
printf 'theme profiles service contract: PASS\n'
