#!/usr/bin/env node
// Generate the Adom VS Code themes FROM the app's design tokens.
//
// Why this exists (John 2026-07-25: "vscode should feel like its part of the entire
// app, as if they are one consistent app"):
//
// The themes were hand-authored, so they drifted. When the brand sweep corrected the
// Adom teal to #00b8b1, the four themes still carried the old #00b8b0 in 148 places —
// the editor and the app disagreed about the single most brand-critical colour, and
// nothing in the build could notice. Hand-patching those 148 values would fix today
// and re-break on the next token change.
//
// So the themes are no longer a source of colour. `src/routes/styles.css` is the ONLY
// place a hex is decided; this script reads the :root and [data-scheme='kickstand']
// blocks and projects them onto VS Code's colour keys. Change a token, re-run, and the
// editor follows automatically.
//
// The structural guarantee is ASSERT_NO_UNMAPPED below: every colour in the template
// must resolve to a token, or the script exits non-zero. A new hardcoded hex cannot
// sneak into a theme without failing the build — which is exactly the failure mode
// that let #00b8b0 survive the sweep.
//
//   node tools/gen-vscode-themes.mjs [outDir]

import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const outDir = resolve(process.argv[2] ?? join(root, 'vscode-themes'));

// ---------------------------------------------------------------------------
// 1. Read the canonical tokens out of styles.css
// ---------------------------------------------------------------------------
// Comments are stripped BEFORE parsing. A token comment that mentions another token
// ("/* 11.9:1 on --bg: ... */") otherwise parses as a declaration and yields a garbage value.
// This has now caught two separate parsers, so it is handled at the source rather than by
// keeping token names out of prose.
const css = readFileSync(join(root, 'src/routes/styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');

function readTokenBlock(selectorRe) {
    const m = css.match(new RegExp(selectorRe.source + String.raw`\s*\{([\s\S]*?)\n\}`, 'm'));
    if (!m) throw new Error(`token block not found: ${selectorRe}`);
    const out = {};
    for (const [, name, value] of m[1].matchAll(/--([a-z0-9-]+):\s*([^;]+);/g)) {
        out[name] = value.split('/*')[0].trim();
    }
    return out;
}

const studio = readTokenBlock(/^:root(?!\[)/);
const kickstand = { ...studio, ...readTokenBlock(/^:root\[data-scheme='kickstand'\]/) };
const slate = { ...studio, ...readTokenBlock(/^:root\[data-scheme='slate'\]/) };
// Bright Inverse is Studio Bright with its two darks traded, so it must spread over
// studioBright, NOT studio -- spreading over studio would silently drop Bright's text
// ramp and make the generated theme disagree with the name and with HD's own CSS.
// It is declared after studioBright below for that reason.
// Studio Bright shares Studio's ground and only overrides text tokens, so it spreads over
// studio rather than standing alone. The brighten flag below does the chrome promotion.
const studioBright = { ...studio, ...readTokenBlock(/^:root\[data-scheme='studio-bright'\]/) };
const studioBrightInverse = {
    ...studioBright,
    // Slug renamed 2026-07-25: the flagship block is [data-scheme='studio'] now. The var
    // name keeps its history ("bright inverse") because that is what the scheme IS.
    ...readTokenBlock(/^:root\[data-scheme='studio'\]/)
};

// Only real hex values are usable here; a token whose value is `var(--other)` has
// already been resolved by the spread above or is a font, not a colour.
const TOKENS = [
    'bg', 'surface', 'elevated', 'overlay', 'border',
    'text', 'text-body', 'text-2', 'text-3',
    'accent', 'accent-hover', 'accent-bright', 'on-accent',
    'blue', 'blue-fill', 'purple', 'green', 'red', 'yellow'
];

for (const scheme of [studio, kickstand, slate, studioBright, studioBrightInverse]) {
    for (const t of TOKENS) {
        if (!/^#[0-9a-f]{6}$/i.test(scheme[t] ?? '')) {
            throw new Error(`token --${t} is not a 6-digit hex: ${scheme[t]}`);
        }
    }
}

// ---------------------------------------------------------------------------
// 2. Map every legacy hex in the template to the token that OWNS that role
// ---------------------------------------------------------------------------
// Keyed by the value the hand-authored themes used. The comment on each line is why
// that role belongs to that token, because the mapping is the actual design decision
// here — everything else is mechanics.
const LEGACY_TO_TOKEN = {
    '#00b8b0': 'accent',        // the wrong teal, one digit off. THE reason this file exists.
    '#00b8b1': 'accent',        // already-correct teal
    '#00d4cb': 'accent-hover',
    '#00e6dc': 'accent-bright',
    '#05221f': 'on-accent',     // text/icons sitting on a teal fill
    '#0d1117': 'bg',
    '#161b22': 'surface',
    '#1c2128': 'elevated',
    '#21262d': 'overlay',
    '#30363d': 'border',
    '#e6edf3': 'text',
    '#c9d1d9': 'text-body',
    '#adb6c0': 'text-body',     // off-token grey; body copy is what it was doing
    '#8b949e': 'text-2',
    '#6e7681': 'text-2',        // off-token grey at 4.4:1 on --bg, i.e. BELOW AA for
                                // normal text. It was VS Code's descriptionForeground,
                                // which is text a person actually reads, so it goes to
                                // --text-2 (7.0:1) rather than staying decorative.
    '#484f58': 'text-3',        // 2.3:1 — borders and disabled only, never live text
    '#64abff': 'blue',          // informational TEXT / links
    '#0061ef': 'blue-fill',     // brand blue: fills only, fails as text
    '#8c6bf7': 'purple',
    '#3fb950': 'green',
    '#f85149': 'red',
    '#d29922': 'yellow',

    // --- syntax highlighting -------------------------------------------------
    // These six came from GitHub Dark's palette and were the last place the editor
    // used colours the app doesn't have. Folding them onto the same semantic tokens
    // is the point of the exercise: a keyword in the editor is now literally the same
    // red as a destructive action in HD.
    //
    // Measured on all three grounds before committing to this — every one clears WCAG
    // AA, and all but purple clear AAA:
    //           Studio    Kickstand  Contrast
    //   red      5.6:1      5.2:1     6.3:1
    //   blue     7.9:1      7.4:1     8.8:1
    //   purple   5.0:1      4.7:1     5.6:1   <- lowest, still above the 4.5 AA floor
    //   green    7.4:1      6.9:1     8.3:1
    //   yellow   7.5:1      7.0:1     8.3:1
    // They land slightly below the originals (7-10:1), which is the intent: past ~14:1
    // dark-mode text haloes, and 5-8:1 is the comfortable band for dense code.
    '#ff7b72': 'red',           // keywords, deleted lines
    '#79c0ff': 'blue',          // entities, function names
    '#58a6ff': 'blue',          // links
    '#a78bfa': 'purple',        // constants
    '#56d364': 'green',         // strings, added lines
    '#e3b341': 'yellow'         // string literals, modified lines
};

const ASSERT_NO_UNMAPPED = true;

/** Re-point one colour string at the given scheme, preserving any alpha suffix. */
function retint(value, scheme, unmapped) {
    if (typeof value !== 'string' || !value.startsWith('#')) return value;
    const base = value.slice(0, 7).toLowerCase();
    const alpha = value.slice(7); // '', '55', 'ff', ...
    const token = LEGACY_TO_TOKEN[base];
    if (!token) {
        unmapped.add(value);
        return value;
    }
    return scheme[token] + alpha;
}

// ---------------------------------------------------------------------------
// Text-brightness tier
// ---------------------------------------------------------------------------
// John 2026-07-25: "make the text brighter in adom studio vscode theme cuz i'm finding it a
// bit hard to read". He was right, and it was a ROLE error, not a taste one.
//
// The template mapped VS Code's PRIMARY chrome text onto --text-2, which this project's own
// design skill defines as "secondary text, labels" at 6.2:1 on --bg. But the file explorer,
// the status bar and the general UI foreground are not secondary text -- they are the main
// content of those surfaces, read constantly. Only descriptions and inactive items are
// genuinely secondary.
//
// So these keys move up to --text-body (12.3:1, the token documented as "the default for
// running text"). Everything NOT listed keeps --text-2, which preserves the hierarchy:
// active > primary > secondary. Brightening all of it would flatten that and read worse.
const PRIMARY_TEXT_KEYS = new Set([
    'foreground',                 // general UI text
    'sideBar.foreground',         // the file tree: a lot of text, read constantly
    'sideBarSectionHeader.foreground',
    'statusBar.foreground',
    'panel.foreground',
    'panelTitle.activeForeground',
    'list.foreground',
    'list.inactiveSelectionForeground',
    'menu.foreground',
    'quickInput.foreground',
    'editorWidget.foreground',
    'input.foreground',
    'dropdown.foreground',
    'titleBar.activeForeground',
    'breadcrumb.foreground',
    'terminal.foreground'
]);

// Syntax lift. `keyword` is the one that matters: it is everywhere in code and sat at 5.0:1,
// the lowest ratio in the theme.
//
// Lighten WITHIN the hue rather than swapping to a brighter token. The first attempt mapped
// purple -> accent-bright and red -> yellow, which is wrong twice over: teal is already
// `string`, so keywords and strings would have collided and syntax distinction would collapse;
// and `invalid` must stay RED, because the colour IS the meaning. Raising lightness keeps both
// the hue and the semantics and only changes how legible it is.
const SYNTAX_TARGET_RATIO = 7.0; // AAA, matching what the other syntax colours already clear

function srgbToLin(c) {
    c /= 255;
    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function relLum(hex) {
    const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
    return 0.2126 * srgbToLin(r) + 0.7152 * srgbToLin(g) + 0.0722 * srgbToLin(b);
}
function contrastRatio(a, b) {
    const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x);
    return (hi + 0.05) / (lo + 0.05);
}
/** Mix `hex` toward black by `t` (0..1). Twin of lighten(), for surfaces that must RECEDE. */
function darken(hex, t) {
    const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
    const out = ch.map((c) => Math.round(c * (1 - t)));
    return '#' + out.map((c) => c.toString(16).padStart(2, '0')).join('');
}
/** Mix `hex` toward white by `t` (0..1), preserving hue far better than scaling channels. */
function lighten(hex, t) {
    const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
    const out = ch.map((c) => Math.round(c + (255 - c) * t));
    return '#' + out.map((c) => c.toString(16).padStart(2, '0')).join('');
}
/** Smallest lightening of `hex` that reaches the target ratio on `bg`. Returns hex unchanged
 *  if it already passes, so a colour that is fine is never touched. */
function liftToRatio(hex, bg, target = SYNTAX_TARGET_RATIO) {
    if (contrastRatio(hex, bg) >= target) return hex;
    for (let t = 0.05; t <= 1.0001; t += 0.05) {
        const c = lighten(hex, t);
        if (contrastRatio(c, bg) >= target) return c;
    }
    return '#ffffff';
}

function retintTheme(template, scheme, name, unmapped, brighten = false) {
    const out = { ...template, name, colors: {}, tokenColors: [] };
    for (const [k, v] of Object.entries(template.colors)) {
        let value = retint(v, scheme, unmapped);
        // Promote only where the ROLE is primary, and only when the template had it at the
        // secondary token — never override a key that was already emphasis-level.
        if (brighten && PRIMARY_TEXT_KEYS.has(k) && value.slice(0, 7) === scheme['text-2']) {
            value = scheme['text-body'] + value.slice(7);
        }
        out.colors[k] = value;
    }
    for (const tc of template.tokenColors) {
        const settings = { ...tc.settings };
        for (const k of ['foreground', 'background']) {
            if (settings[k]) settings[k] = retint(settings[k], scheme, unmapped);
        }
        if (brighten && settings.foreground) {
            const base = settings.foreground.slice(0, 7);
            // Comments are the one place --text-2 is CORRECT: they are meant to recede, so
            // they are exempt. Everything else gets lifted to the target only if it is short.
            if (base.toLowerCase() !== scheme['text-2'].toLowerCase()) {
                settings.foreground = liftToRatio(base, scheme.bg) + settings.foreground.slice(7);
            }
        }
        out.tokenColors.push({ ...tc, settings });
    }
    return out;
}

// ---------------------------------------------------------------------------
// Surface-distinction law (John 2026-07-27: "my rule stands for all themes",
// and "Dark 2026 ... that's generally the approach we should learn from")
// ---------------------------------------------------------------------------
// The reference is VS Code's own 2026-dark theme, extracted from this exact
// code-server build (editor #121314):
//   - tab.activeBackground == editor.background  ("that tab goes to THIS content")
//   - ONE chrome surface, LIGHTER than the ground (#191A1B): inactive tabs, the
//     tab strip, sideBar, activityBar, panel, statusBar, titleBar, inputs, dropdowns
//   - overlays one step lighter again (#202122): menus, quick input, hover widgets
// Projected onto the app ladder (bg < surface < elevated < overlay) that is:
// ground=bg, chrome=surface, overlays=overlay — which the DEFAULT scheme already
// satisfies with pure tokens. The law exists for schemes whose ladder collapses
// (Studio's bg token IS the body color): each relationship is verified per scheme
// and repaired by climbing to the next distinct token, or a computed shade when
// no token qualifies. Values already compliant are never touched.
// 1.09:1 is the app's own bg→surface step (#0d1117 vs #161b22 measures 1.10),
// the smallest delta the design system treats as "a different surface".
const SURFACE_MIN = 1.09;

function stepUntil(hex, ref, minRatio, dir) {
    if (contrastRatio(hex, ref) >= minRatio) return hex;
    for (let t = 0.04; t <= 1.0001; t += 0.04) {
        const c = dir === 'darker' ? darken(hex, t) : lighten(hex, t);
        if (contrastRatio(c, ref) >= minRatio) return c;
    }
    return null; // no headroom in this direction — caller flips direction
}

/** A surface `minRatio` LIGHTER than `ref` (2026 chrome/overlay direction), snapping
 *  to the nearest qualifying scheme token so the palette stays in-family; computed
 *  shade only when no token qualifies; darker only if lighter has no headroom. */
function resolveAbove(base, ref, minRatio, scheme) {
    if (relLum(base) > relLum(ref) && contrastRatio(base, ref) >= minRatio) return base;
    for (const d of ['lighter', 'darker']) {
        const refL = relLum(ref);
        const sameSide = (h) => (d === 'darker' ? relLum(h) < refL : relLum(h) > refL);
        const toks = ['surface', 'elevated', 'overlay', 'bg']
            .map((t) => scheme[t])
            .filter((h) => h && sameSide(h) && contrastRatio(h, ref) >= minRatio)
            .sort((a, b) => contrastRatio(a, ref) - contrastRatio(b, ref));
        if (toks.length) return toks[0];
        const c = stepUntil(base, ref, minRatio, d);
        if (c) return c;
    }
    return base;
}

const CHROME_KEYS = [
    'tab.inactiveBackground', 'editorGroupHeader.tabsBackground',
    'sideBar.background', 'input.background', 'dropdown.background'
];
const OVERLAY_KEYS = [
    'editorWidget.background', 'quickInput.background',
    'editorHoverWidget.background', 'menu.background'
];

function enforceSurfaceDistinction(colors, label, scheme) {
    const body = colors['editor.background'];
    const fixes = [];
    const fix = (key, next) => {
        if (colors[key] !== next) { fixes.push(`${key} ${colors[key] ?? '(unset)'} -> ${next}`); colors[key] = next; }
    };

    // 1. Active tab IS the content ground (2026). The accent top border the
    //    template already carries is what says "active"; the chrome being
    //    lighter is what says the others are not.
    fix('tab.activeBackground', body);
    fix('tab.unfocusedActiveBackground', body);
    fix('tab.hoverBackground', body);

    // 2. ONE chrome surface, lighter than the ground: inactive tabs, strip,
    //    file explorer, text boxes, dropdowns.
    const chrome = resolveAbove(colors['tab.inactiveBackground'] ?? body, body, SURFACE_MIN, scheme);
    for (const k of CHROME_KEYS) fix(k, chrome);

    // 3. Overlays one step lighter than the chrome.
    const overlay = resolveAbove(colors['editorWidget.background'] ?? chrome, chrome, SURFACE_MIN, scheme);
    for (const k of OVERLAY_KEYS) fix(k, overlay);

    // Popover affordances: a shadow and real borders so floating surfaces read
    // as floating even where fills sit close.
    if (!colors['widget.shadow']) fix('widget.shadow', darken(body, 0.6) + 'aa');
    if (!colors['quickInput.border']) fix('quickInput.border', scheme.border);
    if (!colors['notificationCenter.border']) fix('notificationCenter.border', scheme.border);

    if (fixes.length) console.log(`  ${label}: surface-distinction corrections\n    ${fixes.join('\n    ')}`);
    return colors;
}

// ---------------------------------------------------------------------------
// 3. Derive the eye-comfort variants from the token set, don't hand-pick them
// ---------------------------------------------------------------------------
// Dark mode is easier on the eyes because the panel emits less light (John's own
// reasoning), but past roughly 14:1 white-on-black the text haloes — so the variants
// move the GROUND, and let the text tokens ride along, instead of cranking the text.
const dimmed = {
    ...studio,
    bg: studio.surface,        // lift the floor one step: less delta with a lit room
    surface: studio.elevated,
    elevated: studio.overlay,
    text: studio['text-body']  // drop peak emphasis from 16:1 to 12.3:1
};

const contrast = {
    ...studio,
    bg: '#000000',
    surface: studio.bg,
    elevated: studio.surface,
    overlay: studio.elevated,
    text: '#ffffff'
};

// ---------------------------------------------------------------------------
// 4. Emit
// ---------------------------------------------------------------------------
const templatePath = process.env.ADOM_THEME_TEMPLATE
    ?? join(root, 'tools/vscode-theme-template.json');
const template = JSON.parse(readFileSync(templatePath, 'utf8'));

const VARIANTS = [
    // FIVE, matching HD's five schemes exactly. HD pushes these names into the editor, and an
    // unknown name makes VS Code silently fall back to its default LIGHT theme -- so this list
    // and VSCODE_THEME_FOR_SCHEME in settings.ts must stay in lockstep. That is what
    // scripts/check-vscode-theme-names.sh enforces.
    // Adom Studio Dark and Adom Studio Bright are NO LONGER template projections either
    // (John 2026-08-01): both are emitted by gen-adom-studio-v2.mjs (invoked below)
    // alongside the flagship, so they get the exact same brand-accurate Dark-2026
    // re-grounding -- Dark at the deeper #0d1117 ground, Bright at that same ground with
    // the text anchor lifted to #e6edf3 (+ the 7:1 within-hue syntax lift). Removed from
    // this list to avoid two sources writing the same files.
    { file: 'adom-kickstand', label: 'Adom Kickstand', scheme: kickstand },
    { file: 'adom-slate', label: 'Adom Slate', scheme: slate },
    // The flagship 'Adom Studio' is NO LONGER a template projection: since
    // 2026-07-27 it is the Dark-2026-derived design emitted by
    // gen-adom-studio-v2.mjs (invoked below), which owns the name and the
    // adom-studio-color-theme.json file. The old projection is abandoned.
];

// Wipe the themes dir before writing. Without this, a RENAMED theme leaves its old file
// behind: the pack then ships a theme that package.json no longer declares, and the stale
// name keeps resolving on any machine whose settings still point at it -- while a machine
// with a fresh install gets VS Code's default LIGHT theme instead, silently. That white
// editor has now happened twice for adjacent reasons.
rmSync(join(outDir, 'themes'), { recursive: true, force: true });
mkdirSync(join(outDir, 'themes'), { recursive: true });
const unmapped = new Set();

for (const v of VARIANTS) {
    const theme = retintTheme(template, v.scheme, v.label, unmapped, v.brighten === true);
    enforceSurfaceDistinction(theme.colors, v.label, v.scheme);
    // THE ONE-GROUND CONTRACT (John 2026-08-02, same rule as gen-adom-studio-v2):
    // unify sideBar onto the editor ground so BOTH Claude chat modes, file bodies,
    // and the active tab all share one color; inactive tabs/strip keep the raised
    // chrome tone. See gen-adom-studio-v2.mjs for the full rationale.
    theme.colors['sideBar.background'] = theme.colors['editor.background'];
    theme.colors['sideBar.foreground'] = theme.colors['editor.foreground'];
    writeFileSync(
        join(outDir, 'themes', `${v.file}-color-theme.json`),
        JSON.stringify(theme, null, 2) + '\n'
    );
    console.log(`  ${v.label.padEnd(20)} ground ${v.scheme.bg}  accent ${v.scheme.accent}`);
}

// Adom Studio v2 (the STANDARD, John 2026-07-27) is built by its own script —
// Dark 2026 re-grounded on the brand, not a template projection. Run it here so
// one command still emits the whole pack, and register it in the manifest below.
execFileSync(process.execPath, [join(root, 'tools/gen-adom-studio-v2.mjs'), outDir], { stdio: 'inherit' });

// Identity + version rule (John 2026-08-02): the pack is adom.adom-theme and its version
// is LOCKED to the adom-theme wiki package (one number for both, bumped together there).
// This locally-emitted manifest is the dev preview of that contract; the shipping manifest
// lives in the wiki package's vscode/package.json.
writeFileSync(join(outDir, 'package.json'), JSON.stringify({
    name: 'adom-theme',
    displayName: 'Adom Theme',
    publisher: 'adom',
    version: '2.0.0',
    description:
        'The Adom brand theme family for VS Code. Generated from the Adom theme tokens ' +
        'and version-locked to the adom-theme wiki package, so the editor and the app ' +
        'can never disagree about a colour.',
    engines: { vscode: '^1.70.0' },
    categories: ['Themes'],
    contributes: {
        themes: [
            ...VARIANTS.map((v) => ({
                label: v.label,
                uiTheme: 'vs-dark',
                path: `./themes/${v.file}-color-theme.json`
            })),
            { label: 'Adom Studio', uiTheme: 'vs-dark', path: './themes/adom-studio-color-theme.json' }
        ]
    }
}, null, 2) + '\n');

if (unmapped.size) {
    console.error(`\nFAIL: ${unmapped.size} colour(s) in the template map to no token:`);
    for (const u of [...unmapped].sort()) console.error(`  ${u}`);
    console.error('\nAdd each to LEGACY_TO_TOKEN with the role it plays, or replace it in');
    console.error('the template with a colour that already has one. A theme is not allowed');
    console.error('to introduce a hex the app does not have.');
    if (ASSERT_NO_UNMAPPED) process.exit(1);
}

console.log(`\nOK: ${VARIANTS.length} themes -> ${outDir}  (every colour resolved to a token)`);
