/* eslint-disable import-x/extensions */
// Single source of truth mapping each semantic token to a color-scale step,
// per mode and accent. Shared by `buildTheme.ts` (emits the CSS variables /
// themeVariables) and the repo-root `scripts/generate-palette.ts` contrast
// audit (resolves the steps a token pair actually uses), so the two can never
// drift. A token resolves to `null` when it is not emitted for the given accent.

import type { AccentName } from "./paletteSpecs.ts";

export type { AccentName } from "./paletteSpecs.ts";

export type Mode = "dark" | "light";
export type ScaleNum = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;

export interface TokenStep {
  source: "grayscale" | "self";
  step: ScaleNum;
  alpha?: string;
}
export interface TokenLiteral {
  literal: string;
}
export type ResolvedToken = TokenLiteral | TokenStep;

export interface TokenContext {
  mode: Mode;
  isGrayscale: boolean;
  accent: AccentName;
}

export type TokenResolver = (ctx: TokenContext) => ResolvedToken | null;

// `self` reads the accent's own palette; `gray` always reads grayscale (base
// tokens whose value is fixed regardless of accent, e.g. text-muted/on-accent).
const step = (
  source: "grayscale" | "self",
  dark: ScaleNum,
  light: ScaleNum,
  alpha?: string,
): TokenResolver => {
  return ({ mode }) => {
    const resolved: TokenStep = {
      source,
      step: mode === "dark" ? dark : light,
    };
    if (alpha) resolved.alpha = alpha;
    return resolved;
  };
};

const self = (dark: ScaleNum, light: ScaleNum = dark, alpha?: string) =>
  step("self", dark, light, alpha);
const gray = (dark: ScaleNum, light: ScaleNum = dark) =>
  step("grayscale", dark, light);
const selfAdaptive =
  (
    grayscaleDark: ScaleNum,
    coloredDark: ScaleNum,
    grayscaleLight: ScaleNum = grayscaleDark,
    coloredLight: ScaleNum = coloredDark,
  ): TokenResolver =>
  ({ isGrayscale, mode }) => ({
    source: "self",
    step: (() => {
      if (mode === "dark") return isGrayscale ? grayscaleDark : coloredDark;
      return isGrayscale ? grayscaleLight : coloredLight;
    })(),
  });

// Emitted only in the grayscale theme; colored sub-themes inherit the value
// through the CSS cascade (they never override it).
const grayscaleOnly =
  (resolver: TokenResolver): TokenResolver =>
  (ctx) =>
    ctx.isGrayscale ? resolver(ctx) : null;

const perMode = (values: Record<Mode, string>): TokenResolver =>
  grayscaleOnly(({ mode }) => ({ literal: values[mode] }));

// Insertion order is significant: it is the order tokens are emitted into
// the palette CSS / themeVariables, with the grayscale-only block first.
export const tokenScaleMap: Record<string, TokenResolver> = {
  /* grayscale-only base tokens */
  translucent: perMode({ dark: "#1f1e1e55", light: "#ffffff66" }),

  /* grayscale-only shadow colors — the layers of the `--shadow-*` tokens in
     core.css. A dark ground swallows a drop shadow that reads on a light one,
     and makes the white inset rim that disappears on a light one glare, so dark
     mode deepens the drop layers and dims the rims. The lowered inset is not
     deepened: it falls on the element's own ground, not on the page, and in
     dark mode that ground can be light (a pressed neutral `filled` button, an
     `enabled` Switch track), where a half-black inset reads as a smear. */
  "shadow-highlight": perMode({ dark: "#ffffff40", light: "#ffffff33" }),
  "dark-shadow": perMode({ dark: "#00000080", light: "#00000030" }),
  "soft-shadow": perMode({ dark: "#00000066", light: "#00000025" }),
  "shadow-lowered-dark": perMode({ dark: "#000000", light: "#00000030" }),
  "shadow-lowered-highlight": perMode({
    dark: "#ffffff12",
    light: "#ffffff15",
  }),
  "bar-dark-shadow": perMode({ dark: "#00000066", light: "#00000020" }),
  "bar-soft-shadow": perMode({ dark: "#0000004d", light: "#00000018" }),

  /* grayscale-only backgrounds */
  screen: grayscaleOnly(self(2, 3)),
  highlight: grayscaleOnly(self(4, 1)),

  /* grayscale-only texts */
  "disabled-sharp": grayscaleOnly(gray(8, 9)),
  "disabled-muted": grayscaleOnly(gray(7, 7)),
  "disabled-interactive": grayscaleOnly(gray(7, 6)),
  "disabled-interactive-muted": grayscaleOnly(gray(4, 4)),
  sharp: grayscaleOnly(gray(10, 11)),
  muted: grayscaleOnly(gray(9, 10)),

  /* grayscale-only unsorted */
  "form-border-disabled": grayscaleOnly(gray(7, 6)),
  "form-placeholder": grayscaleOnly(gray(8, 9)),
  "form-disabled-text": grayscaleOnly(gray(9, 10)),
  "interactive-filled-disabled": grayscaleOnly(gray(5, 6)),
  "interactive-tonal-disabled": grayscaleOnly(gray(5, 4)),
  "interactive-outlined-disabled": grayscaleOnly(gray(6, 6)),
  "interactive-accent-outlined-disabled": grayscaleOnly(gray(6, 6)),

  /* backgrounds */
  surface: self(3, 2),
  // The accent's solid fill under `on-accent` ink (Avatar, Badge, BrandLogo,
  // the web Switch track). Neutral, it is the `filled` button's ground: the
  // sharp ink turned into a ground, black in light mode and white in dark.
  enabled: selfAdaptive(10, 6, 11, 8),
  "highlight-accent": self(4, 3),
  lowered: self(1, 4),
  // The counterpart of `lowered`: the fill of an element standing out of an
  // inset track — a SegmentedBar's selected chip, ConnectionState's bar.
  // Accented it is the accent's fill; in the neutral theme it stays the lightest
  // step, because that element has to be lighter than the track under it. That
  // is the opposite of what a neutral filled *button* needs (darker than the
  // surface it sits on), which is why the two are separate tokens.
  emphasis: selfAdaptive(6, 5, 1, 9),
  "screen-gradient-start": self(3, 4),
  "screen-gradient-middle": self(2, 5),
  "screen-gradient-end": self(1, 6),

  /* borders */
  "border-muted": self(7, 5),
  "border-sharp": self(8, 9),

  /* interactive */
  // PressableBox's `tonal` variant, the default material of Button,
  // IconButton and PressableListItem: a ground lighter than the page it sits
  // on, lifted by `shadow-s`. Its ground is a *tone*, not the accent's fill, so
  // the hue is carried by the `on-tonal` ink. That is what separates it from
  // `emphasis`, which is the accent's fill — a SegmentedBar chip has to win
  // against its track, a tonal pressable does not.
  // The two modes get there from opposite ends. Light starts at the accent's
  // own `surface` step and walks down (2 → 3 → 4): the deeper tints read more
  // of the hue but turn salmon rather than red, and the hue is `on-tonal`'s
  // job anyway. Dark has no pale end, so it takes the accent's own dark ground
  // (6 → 7), one notch under the filled fill. Neutral walks the card steps in
  // both (1 → 2 → 3 light, 6 → 7 dark, which has nothing above 7 to press
  // into).
  "interactive-tonal-pressable": selfAdaptive(6, 6, 1, 2),
  "interactive-tonal-hover": selfAdaptive(7, 7, 2, 3),
  "interactive-tonal-focus": selfAdaptive(7, 7, 2, 3),
  "interactive-tonal-active": selfAdaptive(7, 7, 3, 4),

  // PressableBox's `filled` variant: the accent's fill. Colored, it takes the
  // same steps in every accent. Neutral, it is the sharp ink turned into a
  // ground — near black in light mode (11 → 10 → 9), near white in dark
  // (10 → 11 → 9) — so the neutral filled button reads as the strongest action
  // rather than a dull gray, and its `on-accent` label flips to dark in dark
  // mode.
  // Colored dark press has no step of its own: the scale jumps from 7
  // (#555555) to 8 (#BCBCBC, where the text tones start), and white ink on
  // #BCBCBC is 1.9:1 — so it holds at hover's value rather than climbing into
  // the text tones or receding to a darker step.
  "interactive-filled-pressable": selfAdaptive(10, 6, 11, 9),
  "interactive-filled-hover": selfAdaptive(11, 7, 10, 8),
  "interactive-filled-focus": selfAdaptive(11, 7, 10, 8),
  "interactive-filled-active": selfAdaptive(9, 7, 9, 7),

  // A ground-only state set for a control that has no rest ground at all
  // (PressableBox's `soft`): the fill stays a tone of the surrounding surface —
  // toward the screen in light mode, a step up in dark — so the label keeps its
  // own color instead of flipping onto an accent fill.
  "interactive-soft-hover": self(5, 3),
  "interactive-soft-focus": self(5, 3),
  "interactive-soft-active": self(6, 4),

  "interactive-outlined-pressable": self(7, 9),
  "interactive-outlined-hover": self(8, 7),
  "interactive-outlined-focus": self(8, 7),
  "interactive-outlined-active": self(8, 7),
  "interactive-outlined-outline-focus": self(8, 7),

  "interactive-active": self(9),
  "interactive-pressable": self(10),
  "interactive-hover": self(11),

  /* texts */
  accent: selfAdaptive(11, 10),
  // The ink of the `filled` and `enabled` grounds: white on every colored fill
  // and on the near-black neutral one in light mode; dark on the near-white
  // neutral fill in dark mode.
  "on-accent": ({ isGrayscale, mode }) => ({
    source: "grayscale",
    step: ((): ScaleNum => {
      if (mode === "light") return 1;
      return isGrayscale ? 1 : 11;
    })(),
  }),
  "on-accent-muted": selfAdaptive(7, 10, 4, 4),
  // `emphasis` is a light chip in the neutral theme and the accent's fill when
  // accented, so its ink is the only one that flips with the accent.
  "on-emphasis": ({ isGrayscale, mode }) => ({
    source: "grayscale",
    step: ((): ScaleNum => {
      if (mode === "dark") return 11;
      return isGrayscale ? 11 : 1;
    })(),
  }),
  // The ink of a `tonal` pressable — label, icon and caret. A light ground
  // can only tint (a red light enough for dark ink is a pink), so the accent is
  // carried by the ink there: `accent` when accented, and the ambient sharp
  // when neutral. A dark ground already *is* the accent's, where an accent ink
  // would be a tint of the color under it (4:1), so it keeps the sharp ink.
  "on-tonal": ({ isGrayscale, mode }) => {
    if (mode === "dark") return { source: "grayscale", step: 10 };
    return isGrayscale
      ? { source: "grayscale", step: 11 }
      : { source: "self", step: 10 };
  },

  /* specials */
  selection: self(10, 10, "40"),
};

export const resolveToken = (
  token: string,
  ctx: TokenContext,
): ResolvedToken | null => {
  const resolver = tokenScaleMap[token];
  if (!resolver) throw new Error(`Unknown token: ${token}`);
  return resolver(ctx);
};

// The value a token actually resolves to in a theme, following grayscale-only
// base tokens into their inherited grayscale value (used by the contrast audit,
// where e.g. `text-muted` must resolve even on a colored surface).
export const resolveTokenEffective = (
  token: string,
  ctx: TokenContext,
): ResolvedToken => {
  return (
    resolveToken(token, ctx) ??
    resolveToken(token, {
      mode: ctx.mode,
      isGrayscale: true,
      accent: "grayscale",
    })!
  );
};
