/**
 * Accent-anchored categorical palette for the promo-code pages.
 *
 * The accent is an arbitrary colour (any hex the user picks, not a fixed set).
 * Series colours are *derived* from its hue rather than set to it: a stacked bar
 * or a donut is unreadable if its segments share a hue, so the accent sets the
 * anchor and everything else fans out from there.
 *
 * Colours are emitted as `oklch()`, which is perceptually uniform — equal steps
 * in lightness/chroma look equal, so no series comes out muddier than its
 * neighbours the way it does in HSL.
 */

/** Any CSS hex colour, e.g. "#4c6ef5". */
export type PromoAccent = string;

/**
 * How series colours relate to each other.
 *   multi — hues fan out from the accent, so every series is a different colour
 *   mono  — one hue (the accent), series separated by lightness only
 *
 * Mono reads more calmly and is safer for colour-blind viewers, since it never
 * relies on hue to distinguish anything. The trade-off is real: past ~6 series,
 * lightness alone runs out of usable steps and adjacent slices start to look
 * alike, where the multi fan stays legible.
 */
export type PromoPaletteMode = "multi" | "mono";

/** Solid vs gradient fills for chart series, bars and tiles. */
export type PromoAccentStyle = "solid" | "gradient";

/**
 * How much dimensionality charts render with.
 *   flat   — plain fills
 *   glossy — specular highlight, contact shadow, soft lift
 *   3d     — glossy plus extrusion. Safe on bars (length still maps linearly to
 *            value); on the donut it tilts, which makes near slices read larger
 *            than equal-value far ones. That caveat is surfaced in the UI.
 */
export type PromoChartDepth = "flat" | "glossy" | "3d";

export const DEFAULT_ACCENT = "#4c6ef5";

/** Preset swatches offered alongside the free picker. */
export const ACCENT_PRESETS: Array<{ value: string; label: string }> = [
  { value: "#4c6ef5", label: "Indigo" },
  { value: "#7950f2", label: "Violet" },
  { value: "#be4bdb", label: "Grape" },
  { value: "#228be6", label: "Blue" },
  { value: "#12b886", label: "Teal" },
  { value: "#15aabf", label: "Cyan" },
  { value: "#fd7e14", label: "Orange" },
  { value: "#e8590c", label: "Rust" },
  { value: "#876328", label: "Karma Bronze" },
];

/** sRGB channel (0–1) to linear light. */
function toLinear(c: number): number {
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

export interface Oklch {
  l: number;
  c: number;
  h: number;
}

/**
 * Hex to OKLCH, via linear sRGB and OKLab (Ottosson's matrices).
 *
 * Needed because the accent is now a free colour: to fan a palette around it we
 * must know its hue, and hue is only meaningful in a perceptual space. Deriving
 * it from raw RGB would put "yellow" and "blue" at misleading angles.
 */
export function hexToOklch(hex: string): Oklch {
  const clean = hex.replace("#", "").trim();
  const full =
    clean.length === 3
      ? clean
          .split("")
          .map((ch) => ch + ch)
          .join("")
      : clean;
  const int = Number.parseInt(full.slice(0, 6), 16);
  if (!Number.isFinite(int)) return { l: 0.62, c: 0.15, h: 265 };

  const r = toLinear(((int >> 16) & 255) / 255);
  const g = toLinear(((int >> 8) & 255) / 255);
  const b = toLinear((int & 255) / 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_;

  const hue = (Math.atan2(B, A) * 180) / Math.PI;
  return {
    l: L,
    c: Math.sqrt(A * A + B * B),
    h: (hue + 360) % 360,
  };
}

/** Linear light back to an sRGB channel byte. */
function toSrgbByte(linear: number): number {
  const v =
    linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055;
  return Math.max(0, Math.min(255, Math.round(v * 255)));
}

/**
 * OKLCH back to a hex string.
 *
 * Needed because Mantine's own colour parser (`toRgba`) understands hex, rgb()
 * and hsl() — but **not** oklch(). Feeding it oklch made `autoContrast` and the
 * derived `-filled-hover` / `-light` variants resolve to garbage, which rendered
 * active tab pills as white text on a white background. Any colour handed to
 * Mantine's theme must therefore be hex.
 *
 * Out-of-gamut values are clamped per channel rather than gamut-mapped — good
 * enough for UI shades, and it never yields an invalid colour.
 */
export function oklchToHex(l: number, c: number, hDeg: number): string {
  const h = (hDeg * Math.PI) / 180;
  const a = c * Math.cos(h);
  const b = c * Math.sin(h);

  const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
  const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
  const s_ = l - 0.0894841775 * a - 1.291485548 * b;

  const L = l_ ** 3;
  const M = m_ ** 3;
  const S = s_ ** 3;

  const r = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S;
  const g = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S;
  const bl = -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S;

  const hex = [toSrgbByte(r), toSrgbByte(g), toSrgbByte(bl)]
    .map((n) => n.toString(16).padStart(2, "0"))
    .join("");
  return `#${hex}`;
}

/** Hue angle of an accent, for anchoring the palette fan. */
export function accentHue(accent: PromoAccent): number {
  return hexToOklch(accent).h;
}

/** A single series colour: `from`/`to` drive gradient fills, `solid` flat ones. */
export interface SeriesColor {
  solid: string;
  from: string;
  to: string;
}

/** Golden angle — successive hues land as far apart as possible. */
const GOLDEN_ANGLE = 137.508;

/** Beyond this many series a tight fan stops being distinguishable. */
const FAN_LIMIT = 6;

/**
 * `count` colours anchored at the accent hue.
 *
 * Up to `FAN_LIMIT` series get a tight analogous fan centred on the accent, so a
 * 2–6 series chart looks intentionally colour-matched. Past that it switches to
 * golden-angle rotation *starting* at the accent hue: series 0 is still the
 * accent, but the rest spread the wheel so 20 campaigns remain tellable apart.
 */
export function buildSeriesPalette(
  accent: PromoAccent,
  count: number,
  style: PromoAccentStyle = "solid",
  mode: PromoPaletteMode = "multi",
): SeriesColor[] {
  const baseHue = accentHue(accent);
  const { c: accentChroma } = hexToOklch(accent);
  const fan = count <= FAN_LIMIT;
  const n = Math.max(1, count);

  return Array.from({ length: Math.max(0, count) }, (_, i) => {
    // Mono: one hue, separated by lightness. Spread across a wide band
    // (0.84 → 0.38) so adjacent series stay tellable apart as long as possible.
    if (mode === "mono") {
      const t = n === 1 ? 0 : i / (n - 1);
      const lightness = 0.84 - 0.46 * t;
      const chroma =
        Math.max(0.05, Math.min(0.17, accentChroma)) * (0.55 + 0.45 * (1 - t));
      const solid = oklchToHex(lightness, chroma, baseHue);
      const to = oklchToHex(
        Math.max(0.2, lightness - (style === "gradient" ? 0.12 : 0.06)),
        chroma,
        baseHue,
      );
      return { solid, from: solid, to };
    }

    const hue = fan
      ? (baseHue + (i - (count - 1) / 2) * 30 + 360) % 360
      : (baseHue + i * GOLDEN_ANGLE) % 360;

    // Cycle lightness and chroma so hues landing close together still differ in
    // shade — this is what keeps the golden-angle range readable.
    const lightness = 0.62 + 0.07 * ((i % 3) - 1);
    const chroma = 0.15 + 0.03 * (i % 2);

    const solid = `oklch(${lightness.toFixed(3)} ${chroma.toFixed(3)} ${hue.toFixed(1)})`;
    const to =
      style === "gradient"
        ? `oklch(${(lightness - 0.1).toFixed(3)} ${(chroma + 0.02).toFixed(3)} ${((hue + 24) % 360).toFixed(1)})`
        : `oklch(${(lightness - 0.06).toFixed(3)} ${chroma.toFixed(3)} ${hue.toFixed(1)})`;

    return { solid, from: solid, to };
  });
}

/** CSS gradient for a series fill. Direction defaults to top-to-bottom. */
export function seriesGradient(color: SeriesColor, angle = 180): string {
  return `linear-gradient(${angle}deg, ${color.from}, ${color.to})`;
}

/**
 * Fixed-role colours that must stay stable across a page — internal vs curated
 * bookings are compared against each other in the donut, the table badges and
 * the KPI hint, so they always take the first two fan slots.
 */
export function buildRolePalette(
  accent: PromoAccent,
  style: PromoAccentStyle = "solid",
  mode: PromoPaletteMode = "multi",
): { internal: SeriesColor; external: SeriesColor } {
  const [internal, external] = buildSeriesPalette(accent, 2, style, mode);
  return { internal, external };
}

/**
 * Companion colour for the page backdrop's second bloom.
 *
 * The glass background hardcoded Mantine's grape, which ignored the accent
 * entirely — picking bronze still produced a purple wash. In multi mode this is
 * the next hue along the fan; in mono it is a lighter tint of the accent itself,
 * so a mono theme stays single-hue right through the background.
 */
export function accentCompanion(
  accent: PromoAccent,
  mode: PromoPaletteMode = "multi",
): string {
  const { h, c } = hexToOklch(accent);
  const chroma = Math.max(0.05, Math.min(0.18, c));
  if (mode === "mono") return oklchToHex(0.74, chroma * 0.7, h);
  return oklchToHex(0.66, chroma, (h + 48) % 360);
}

/**
 * The colour for anything that isn't a real category.
 *
 * Used for every muted slice, bar and tile across the promo pages — an aggregated
 * "Others", a "None" contact bucket, "Pending first login", "No Country Info", "No
 * membership number". All of those were previously hardcoded to Mantine's grey,
 * which meant changing the accent restyled the charts but left these stranded on a
 * colour belonging to no theme at all.
 *
 * It is a pale tint of the accent, at the accent's own hue, so a theme change
 * carries every one of them along with the data colours.
 *
 * Lightness is fixed rather than derived from the accent's, so the tint is
 * consistently pale whether someone picks a dark or a light accent. Chroma is cut
 * right back: at full strength a pale tint of a saturated accent still competes with
 * the data series, and these are meant to recede.
 *
 * It stays above 0.85 but not near white — donut slices are stroked in the body
 * colour, so an almost-white fill would blend into its own outline and read as a gap
 * in the ring rather than a share of the total.
 *
 * Measured in OKLab against a 12-series palette, worst case over four accents:
 * dE 0.195 in multi mode (the default) and dE 0.060 in mono, against dE 0.128 from
 * the white card behind it. WCAG contrast is the wrong yardstick here — it only sees
 * lightness, so it scores two pale tints of different saturation as near-identical
 * when they are plainly tellable apart.
 */
export function buildMutedColor(accent: PromoAccent): SeriesColor {
  const { h, c } = hexToOklch(accent);
  const chroma = Math.max(0.04, Math.min(0.09, c * 0.5));
  const solid = oklchToHex(0.88, chroma, h);
  return { solid, from: solid, to: oklchToHex(0.82, chroma, h) };
}

/** Lightness/chroma variants of the accent itself, for chrome and states. */
export function accentVariants(accent: PromoAccent) {
  const { h, c } = hexToOklch(accent);
  const chroma = Math.max(0.06, Math.min(0.19, c));
  return {
    base: accent,
    hover: `oklch(0.54 ${chroma.toFixed(3)} ${h.toFixed(1)})`,
    soft: `color-mix(in srgb, ${accent} 12%, transparent)`,
    border: `color-mix(in srgb, ${accent} 42%, transparent)`,
    text: `oklch(0.44 ${chroma.toFixed(3)} ${h.toFixed(1)})`,
  };
}

/**
 * A 10-shade Mantine colour scale generated from the accent.
 *
 * Mantine's `primaryColor` must name a 10-tuple in `theme.colors`, not a hex — so
 * driving buttons, tabs and badges from an arbitrary accent means synthesising
 * the ramp. Lightness runs from very pale (index 0) to deep (index 9) at the
 * accent's own hue, with chroma easing off at the extremes so the palest and
 * darkest steps don't look artificially saturated.
 *
 * Index 6 is Mantine's default "filled" shade, so it is pinned to the accent
 * itself — a button rendered at index 6 is exactly the colour that was picked.
 */
export function buildAccentScale(
  accent: PromoAccent,
): readonly [
  string,
  string,
  string,
  string,
  string,
  string,
  string,
  string,
  string,
  string,
] {
  const { h, c, l } = hexToOklch(accent);
  const chroma = Math.max(0.04, Math.min(0.2, c));
  // Keep the anchor away from the very ends, or one side of the ramp collapses.
  const anchor = Math.max(0.34, Math.min(0.78, l));

  /*
   * The ramp is built *around* the accent rather than on a fixed lightness table.
   *
   * Index 6 is Mantine's filled shade, so it must be the exact colour picked. But
   * a fixed table put #876328 (L 0.525) at index 6 between 0.680 and 0.550 —
   * index 7 came out lighter than index 6, which inverts every hover and active
   * state Mantine derives from the scale. Interpolating from the anchor keeps
   * lightness strictly decreasing for any input colour.
   */
  const PALEST = 0.97;
  const darkest = Math.max(0.26, anchor - 0.2);

  /*
   * Only pin the raw hex when its lightness is inside the usable band. A
   * near-black or near-white pick (L 0.15 or 0.97) would otherwise sit at index 6
   * far outside its neighbours and invert the ramp again — the clamp exists
   * precisely because such a colour cannot serve as a filled-button shade.
   */
  const canPin = Math.abs(l - anchor) < 0.02;

  const shades = Array.from({ length: 10 }, (_, i) => {
    if (i === 6 && canPin) return accent;
    const lightness =
      i < 6
        ? PALEST + (anchor - PALEST) * (i / 6) // 0.97 → anchor
        : i === 6
          ? anchor
          : anchor + (darkest - anchor) * ((i - 6) / 3); // anchor → darkest
    // Chroma peaks around the anchor and eases off at both extremes, so pale
    // steps don't look neon and dark ones don't look muddy.
    const distance = Math.abs(i - 6) / 6;
    const c2 = chroma * (1 - 0.82 * distance ** 1.4);
    // Hex, not oklch — Mantine cannot parse oklch (see oklchToHex).
    return oklchToHex(lightness, c2, h);
  });

  return shades as unknown as readonly [
    string,
    string,
    string,
    string,
    string,
    string,
    string,
    string,
    string,
    string,
  ];
}

/** How much gloss and shadow surfaces carry. Theme-controlled. */
export type PromoSurface = "flat" | "soft" | "deep";

/** CSS variables for the chosen surface treatment. */
export function surfaceVars(surface: PromoSurface): Record<string, string> {
  switch (surface) {
    case "flat":
      return {
        "--promo-shadow": "none",
        "--promo-shadow-hover": "none",
        "--promo-gloss": "0",
        "--promo-lift": "0px",
      };
    case "deep":
      return {
        "--promo-shadow":
          "0 10px 26px -14px light-dark(rgba(15,23,42,0.38), rgba(0,0,0,0.75))",
        "--promo-shadow-hover":
          "0 20px 44px -18px light-dark(rgba(15,23,42,0.48), rgba(0,0,0,0.85))",
        "--promo-gloss": "0.6",
        "--promo-lift": "-4px",
      };
    case "soft":
    default:
      return {
        "--promo-shadow":
          "0 4px 14px -10px light-dark(rgba(15,23,42,0.22), rgba(0,0,0,0.55))",
        "--promo-shadow-hover":
          "0 12px 28px -14px light-dark(rgba(15,23,42,0.3), rgba(0,0,0,0.65))",
        "--promo-gloss": "0.28",
        "--promo-lift": "-3px",
      };
  }
}
