// the seasons: palettes built from madoromi (vendor/madoromi), samhain's colours measured from
// pilot's iroshizuku inks. each season's day half is madoromi's hiru and its night half its
// yoru, which is the light-dark() pair palette.css already has.
//
// a season keeps haiku's shapes and contrast. every grey keeps its exact lightness from
// palette.css and only takes the season's grey ink as a faint tint, so the bevels stay the same
// steps. the accents are madoromi's inks, but madoromi is tuned soft (2.2:1 at its lowest) and
// docs need more, so each ink keeps its hue and chroma and only moves its lightness until it
// reaches the floors below. a season that can't fails the build.

import fs from "node:fs";
import { DEFAULT_PALETTE } from "./themes";
import path from "node:path";

export type Mode = "light" | "dark";
type Lch = [number, number, number];

interface Flavour { colours: Record<string, { hex: string; oklch: Lch }> }
interface Madoromi { flavours: Record<string, Flavour>; roles: Record<string, string>; seasons: { id: string; kanji: string; english: string }[] }

export interface Palette {
  id: string;
  label: string;
  kanji?: string;
  /** the desk and selection of each half, for the picker's chips and the browser's bar colour */
  chip: Record<Mode, { desk: string; select: string }>;
}

/** the ink each season's desk and selection come from */
const SEASON_INK: Record<string, string> = { haru: "botan", natsu: "sora", aki: "beni", fuyu: "ai" };

const CODE_ROLES: Record<string, string> = {
  comment: "comment", string: "string", constant: "number", keyword: "keyword",
  function: "function", type: "type", property: "property", parameter: "parameter",
};

// ---- colour maths: oklch <-> srgb, and wcag contrast ----

const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const fromLinear = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);

function hexToLch(hex: string): Lch {
  const n = hex.slice(1);
  const full = n.length === 3 ? [...n].map((c) => c + c).join("") : n;
  const [r, g, b] = [0, 2, 4].map((i) => toLinear(parseInt(full.slice(i, i + 2), 16) / 255));
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
  const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
  const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
  const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
  return [L, Math.hypot(A, B), ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360];
}

function lchToRgb([L, C, h]: Lch): [number, number, number] {
  const a = C * Math.cos((h * Math.PI) / 180), b = C * Math.sin((h * Math.PI) / 180);
  const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
  const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
  const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
  return [
    4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
  ];
}

const inGamut = (c: Lch) => lchToRgb(c).every((v) => v >= -1e-4 && v <= 1 + 1e-4);

/** pulls chroma in until the colour fits on screen, so the hue never moves */
function clampToGamut([L, C, h]: Lch): Lch {
  if (inGamut([L, C, h])) return [L, C, h];
  let lo = 0, hi = C;
  for (let i = 0; i < 24; i++) {
    const mid = (lo + hi) / 2;
    if (inGamut([L, mid, h])) lo = mid;
    else hi = mid;
  }
  return [L, lo, h];
}

function lchToHex(c: Lch): string {
  const rgb = lchToRgb(clampToGamut([Math.min(1, Math.max(0, c[0])), c[1], c[2]]));
  return "#" + rgb.map((v) => Math.round(fromLinear(Math.min(1, Math.max(0, v))) * 255).toString(16).padStart(2, "0")).join("");
}

function luminance(hex: string): number {
  const n = hex.slice(1);
  const full = n.length === 3 ? [...n].map((c) => c + c).join("") : n;
  const [r, g, b] = [0, 2, 4].map((i) => toLinear(parseInt(full.slice(i, i + 2), 16) / 255));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

export function contrast(a: string, b: string): number {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

/** the ink's hue and chroma at the first lightness, walking away from the background, that
 *  reaches the target contrast against every one of them */
function fit(ink: Lch, from: number, against: string[], target: number, mode: Mode, what: string): string {
  const step = mode === "light" ? -0.005 : 0.005;
  for (let L = from; L >= 0 && L <= 1; L += step) {
    const hex = lchToHex([L, ink[1], ink[2]]);
    if (against.every((bg) => contrast(hex, bg) >= target)) return hex;
  }
  throw new Error(`${what} can't reach ${target}:1`);
}

// ---- reading the two sources ----

/** palette.css's light-dark() pairs: haiku, and the base every season tints */
function haiku(): Record<string, Record<Mode, string>> {
  const css = fs.readFileSync(path.join(process.cwd(), "src/styles/palette.css"), "utf8");
  const out: Record<string, Record<Mode, string>> = {};
  for (const [, name, light, dark, both] of css.matchAll(/^\s*(--[\w-]+): (?:light-dark\((#[0-9a-f]{3,6}), (#[0-9a-f]{3,6})\)|(#[0-9a-f]{3,6}));/gim)) {
    out[name] = { light: light ?? both, dark: dark ?? both };
  }
  return out;
}

const madoromi = (): Madoromi =>
  JSON.parse(fs.readFileSync(path.join(process.cwd(), "vendor/madoromi/madoromi.json"), "utf8"));

// ---- one season ----

function season(id: string, m: Madoromi, base: Record<string, Record<Mode, string>>): Record<string, Record<Mode, string>> {
  const out: Record<string, Record<Mode, string>> = {};
  for (const mode of ["light", "dark"] as Mode[]) {
    const flavour = m.flavours[`${id}-${mode === "light" ? "hiru" : "yoru"}`];
    const ink = (name: string) => flavour.colours[name].oklch;
    const grey = ink("kami");
    // a token tinted in one half only keeps haiku's colour in the other
    const set = (name: string, hex: string) => ((out[name] ??= { ...base[name] })[mode] = hex);
    const at = (name: string) => out[name]?.[mode] ?? base[name][mode];
    /** the haiku colour's lightness with another hue and chroma */
    const relight = (name: string, hue: number, chroma: number) => lchToHex([hexToLch(base[name][mode])[0], chroma, hue]);

    // greys: haiku's lightness, the season's grey ink as a tint, fading out at white and black.
    // haiku's warm paper edge (0.042) counts as one too
    for (const [name, v] of Object.entries(base)) {
      const [L, C] = hexToLch(v[mode]);
      if (C > 0.045) continue;
      const fade = Math.min(1, (1 - L) * 60, L * 6);
      set(name, lchToHex([L, (mode === "light" ? 0.012 : 0.016) * fade, grey[2]]));
    }

    const own = ink(SEASON_INK[id]);
    // about as saturated as haiku's own desk, so a night desk stays dark instead of glowing
    for (const n of ["--desk", "--desk-glow", "--desk-deep"]) set(n, relight(n, own[2], Math.min(own[1], hexToLch(base[n][mode])[1] * 1.25)));
    for (const n of ["--hover", "--press", "--ico-hover"]) set(n, relight(n, own[2], n === "--hover" ? 0.03 : 0.05));

    // the selection carries white text, so its middle stop is fitted and the ends follow it
    const mid = fit(own, hexToLch(base["--select-mid"][mode])[0], [at("--on-select")], 4.5, "light", `${id} ${mode} selection`);
    const midL = hexToLch(mid)[0];
    set("--select-mid", mid);
    set("--select", lchToHex([midL + 0.12, own[1], own[2]]));
    set("--select-end", lchToHex([midL - 0.07, own[1], own[2]]));

    const link = ink(m.roles.link);
    set("--link", fit(link, hexToLch(base["--link"][mode])[0], [at("--paper")], 7, mode, `${id} ${mode} links`));
    set("--link-chrome", fit(link, hexToLch(base["--link-chrome"][mode])[0], [at("--chrome"), at("--chrome-hover")], 7, mode, `${id} ${mode} links on chrome`));
    set("--focus", fit(link, hexToLch(base["--focus"][mode])[0], [at("--field")], 3, mode, `${id} ${mode} focus ring`));

    const match = ink(m.roles.match), gold = ink("yamabuki");
    for (const n of ["--mark", "--mark-edge", "--mark-on-select"]) set(n, relight(n, match[2], Math.min(match[1], 0.12)));
    set("--flash", relight("--flash", gold[2], 0.08));
    // the tab is a pale wash of the season's own ink, like haiku's yellow is of its gold
    for (const n of ["--tab", "--tab-mid", "--tab-end", "--tab-hi", "--tab-lo"]) {
      const [L, C] = hexToLch(base[n][mode]);
      set(n, lchToHex([L, Math.min(C, own[1]), own[2]]));
    }
    set("--tab-text", relight("--tab-text", own[2], 0.04));
    const tabText = contrast(at("--tab-text"), at("--tab-end"));
    if (tabText < 7) throw new Error(`${id} ${mode}: the tab's text is ${tabText.toFixed(2)}:1, under 7:1`);

    for (const [token, role] of Object.entries(CODE_ROLES)) {
      const c = ink(m.roles[role]);
      set(`--code-${token}`, fit(c, c[0], [at("--well")], 4.5, mode, `${id} ${mode} code ${token}`));
    }

    // and the floors for what came out of the greys
    for (const [fg, bg, min] of [["--text", "--paper", 7], ["--text", "--chrome", 7], ["--dim", "--chrome", 3.5], ["--faint", "--sheet", 3]] as const) {
      const c = contrast(at(fg), at(bg));
      if (c < min) throw new Error(`${id} ${mode}: ${fg} on ${bg} is ${c.toFixed(2)}:1, under ${min}:1`);
    }
  }
  return out;
}

type Tokens = Record<string, Record<Mode, string>>;
let built: { css: string; palettes: Palette[]; tokens: Map<string, Tokens> } | undefined;

/** the seasons' css, one `:root[data-palette]` block each, and the list for the picker */
export function palettes(): { css: string; palettes: Palette[]; tokens: Map<string, Tokens> } {
  if (built) return built;
  const m = madoromi(), base = haiku();
  const list: Palette[] = [{ id: "haiku", label: "haiku", chip: chip(base) }];
  const all = new Map([["haiku", base]]);
  let css = "";
  for (const s of m.seasons) {
    const tokens = season(s.id, m, base);
    css += `${paletteSelector(s.id)} {\n` + Object.entries(tokens).map(([n, v]) => `  ${n}: light-dark(${v.light}, ${v.dark});`).join("\n") + "\n}\n";
    list.push({ id: s.id, label: s.id, kanji: s.kanji, chip: chip({ ...base, ...tokens }) });
    all.set(s.id, { ...base, ...tokens });
  }
  return (built = { css, palettes: list, tokens: all });
}

/** every colour of one palette's half, by token name */
export function colours(id: string, mode: Mode): Record<string, string> {
  const tokens = palettes().tokens.get(id);
  if (!tokens) throw new Error(`no palette called ${id}`);
  return Object.fromEntries(Object.entries(tokens).map(([n, v]) => [n, v[mode]]));
}

/** a palette's rules apply when it's picked, and the default's also when nothing is */
export function paletteSelector(id: string): string {
  if (id === "haiku") return ":root";
  const picked = `:root[data-palette="${id}"]`;
  return id === DEFAULT_PALETTE ? `:root:not([data-palette]), ${picked}` : picked;
}

function chip(t: Record<string, Record<Mode, string>>): Palette["chip"] {
  return {
    light: { desk: t["--desk"].light, select: t["--select-mid"].light },
    dark: { desk: t["--desk"].dark, select: t["--select-mid"].dark },
  };
}
