import parseColor from "parse-color";

export class YaakColor {
  private readonly appearance: "dark" | "light" = "light";

  private lightness = 0;
  private chroma = 0;
  private hue = 0;
  private alpha = 1;

  constructor(cssColor: string, appearance: "dark" | "light" = "light") {
    try {
      this.set(cssColor);
      this.appearance = appearance;
    } catch (err) {
      console.log("Failed to parse CSS color", cssColor, err);
    }
  }

  static transparent(): YaakColor {
    return new YaakColor("rgb(0,0,0)", "light").translucify(1);
  }

  static white(): YaakColor {
    return new YaakColor("rgb(0,0,0)", "light").lower(999);
  }

  static black(): YaakColor {
    return new YaakColor("rgb(0,0,0)", "light").lift(999);
  }

  set(cssColor: string): YaakColor {
    let fixedCssColor = cssColor;
    if (cssColor.startsWith("#") && cssColor.length === 9) {
      const [r, g, b, a] = hexToRgba(cssColor);
      fixedCssColor = `rgba(${r},${g},${b},${a})`;
    }

    const oklch = parseOklch(fixedCssColor);
    if (oklch != null) {
      this.lightness = oklch.lightness;
      this.chroma = oklch.chroma;
      this.hue = oklch.hue;
      this.alpha = oklch.alpha;
      return this;
    }

    const { rgba } = parseColor(fixedCssColor);
    const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]);
    this.lightness = lightness;
    this.chroma = chroma;
    this.hue = hue;
    this.alpha = rgba[3] ?? 1;
    return this;
  }

  clone(): YaakColor {
    return new YaakColor(this.css(), this.appearance);
  }

  themeColor(cssColor: string): YaakColor {
    return new YaakColor(cssColor, this.appearance);
  }

  lower(mod: number): YaakColor {
    return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
  }

  lift(mod: number): YaakColor {
    return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
  }

  liftMax(): YaakColor {
    return this.lift(999);
  }

  lowerMax(): YaakColor {
    return this.lower(999);
  }

  themeSurface(): YaakColor {
    return new YaakColor(
      this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)",
      this.appearance,
    );
  }

  minLightness(n: number): YaakColor {
    const color = this.clone();
    if (color.lightness < n) {
      color.lightness = n;
    }
    return color;
  }

  isDark(): boolean {
    return this.lightness < 50;
  }

  translucify(mod: number): YaakColor {
    const color = this.clone();
    color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
    return color;
  }

  opacify(mod: number): YaakColor {
    const color = this.clone();
    color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1);
    return color;
  }

  desaturate(mod: number): YaakColor {
    const color = this.clone();
    color.chroma = color.chroma - color.chroma * mod;
    return color;
  }

  saturate(mod: number): YaakColor {
    const color = this.clone();
    color.chroma = this.chroma + this.chroma * mod;
    return color;
  }

  lighterThan(color: YaakColor): boolean {
    return this.lightness > color.lightness;
  }

  contrastRatio(background: YaakColor): number {
    const foreground = this.alpha < 1 ? this.compositeOver(background) : this;
    const foregroundLuminance = foreground.relativeLuminance();
    const backgroundLuminance = background.relativeLuminance();
    const lighter = Math.max(foregroundLuminance, backgroundLuminance);
    const darker = Math.min(foregroundLuminance, backgroundLuminance);
    return (lighter + 0.05) / (darker + 0.05);
  }

  withContrast(background: YaakColor, minContrast: number): YaakColor {
    const darker = this.clone();
    darker.lightness = 0;
    darker.chroma = 0;
    darker.hue = 0;

    const lighter = this.clone();
    lighter.lightness = 100;
    lighter.chroma = 0;
    lighter.hue = 0;

    const darkerContrast = darker.contrastRatio(background);
    const lighterContrast = lighter.contrastRatio(background);
    let useLighterColor = lighterContrast >= darkerContrast;

    // Saturated accent surfaces often read better with white text even when
    // black has the higher numeric contrast. Keep yellow-ish light accents dark
    // by requiring white to clear a modest contrast floor first.
    if (minContrast >= 3 && lighterContrast >= 2.5) {
      useLighterColor = true;
    }

    const selectedContrast = useLighterColor ? lighterContrast : darkerContrast;
    if (selectedContrast < minContrast) {
      return useLighterColor ? lighter : darker;
    }

    let minLightness = 0;
    let maxLightness = 100;
    const color = this.clone();

    for (let i = 0; i < 24; i += 1) {
      color.lightness = (minLightness + maxLightness) / 2;
      const contrast = color.contrastRatio(background);

      if (useLighterColor) {
        if (contrast >= minContrast) {
          maxLightness = color.lightness;
        } else {
          minLightness = color.lightness;
        }
      } else if (contrast >= minContrast) {
        minLightness = color.lightness;
      } else {
        maxLightness = color.lightness;
      }
    }

    color.lightness = useLighterColor ? maxLightness : minLightness;
    return color;
  }

  compositeOver(background: YaakColor): YaakColor {
    const [fgR, fgG, fgB] = this.rgb();
    const [bgR, bgG, bgB] = background.rgb();
    const alpha = this.alpha + background.alpha * (1 - this.alpha);

    if (alpha <= 0) {
      return YaakColor.transparent();
    }

    const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha;
    const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha;
    const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha;

    return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance);
  }

  css(): string {
    const [r, g, b] = this.rgb();
    return rgbaToHex(r, g, b, this.alpha);
  }

  hexNoAlpha(): string {
    const [r, g, b] = this.rgb();
    return rgbaToHexNoAlpha(r, g, b);
  }

  private relativeLuminance(): number {
    const [r, g, b] = this.rgb();
    const red = srgbToLinear(r / 255);
    const green = srgbToLinear(g / 255);
    const blue = srgbToLinear(b / 255);
    return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
  }

  private rgb(): [number, number, number] {
    return oklchToRgb(this.lightness, this.chroma, this.hue);
  }

  private _lighten(mod: number): YaakColor {
    const color = this.clone();
    color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100);
    return color;
  }

  private _darken(mod: number): YaakColor {
    const color = this.clone();
    color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
    return color;
  }
}

function parseOklch(
  cssColor: string,
): { lightness: number; chroma: number; hue: number; alpha: number } | null {
  const match = cssColor
    .trim()
    .match(
      /^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i,
    );
  if (match == null) return null;

  const [, lightnessValue, chromaValue, hueValue, slashAlpha, commaAlpha] = match;
  if (lightnessValue == null || chromaValue == null || hueValue == null) return null;

  const lightness = parseOklchLightness(lightnessValue);
  const chroma = parseCssNumber(chromaValue, 1);
  const hue = normalizeHue(parseCssNumber(hueValue.replace(/deg$/i, ""), 1));
  const alpha = parseCssNumber(slashAlpha ?? commaAlpha ?? "1", 1);

  if (
    !Number.isFinite(lightness) ||
    !Number.isFinite(chroma) ||
    !Number.isFinite(hue) ||
    !Number.isFinite(alpha)
  ) {
    return null;
  }

  return {
    lightness: clamp(lightness, 0, 100),
    chroma: Math.max(0, chroma),
    hue,
    alpha: clamp(alpha, 0, 1),
  };
}

function parseCssNumber(value: string, percentScale: number): number {
  const normalized = value.trim();
  if (normalized.endsWith("%")) {
    return (Number.parseFloat(normalized) / 100) * percentScale;
  }
  return Number.parseFloat(normalized);
}

function parseOklchLightness(value: string): number {
  const parsed = parseCssNumber(value, 100);
  return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100;
}

function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
  const red = srgbToLinear(r / 255);
  const green = srgbToLinear(g / 255);
  const blue = srgbToLinear(b / 255);

  const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue;
  const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue;
  const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue;

  const lRoot = Math.cbrt(l);
  const mRoot = Math.cbrt(m);
  const sRoot = Math.cbrt(s);

  const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot;
  const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot;
  const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot;

  return [
    lightness * 100,
    Math.sqrt(a * a + okb * okb),
    normalizeHue(radToDeg(Math.atan2(okb, a))),
  ];
}

function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] {
  const l = clamp(lightness, 0, 100) / 100;
  const a = Math.cos(degToRad(hue)) * chroma;
  const b = Math.sin(degToRad(hue)) * chroma;

  const lRoot = l + 0.3963377774 * a + 0.2158037573 * b;
  const mRoot = l - 0.1055613458 * a - 0.0638541728 * b;
  const sRoot = l - 0.0894841775 * a - 1.291485548 * b;

  const lCube = lRoot * lRoot * lRoot;
  const mCube = mRoot * mRoot * mRoot;
  const sCube = sRoot * sRoot * sRoot;

  const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
  const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
  const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;

  return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255];
}

function srgbToLinear(value: number): number {
  return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
}

function linearToSrgb(value: number): number {
  const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
  return clamp(srgb, 0, 1);
}

function normalizeHue(value: number): number {
  const hue = value % 360;
  return hue < 0 ? hue + 360 : hue;
}

function degToRad(value: number): number {
  return (value * Math.PI) / 180;
}

function radToDeg(value: number): number {
  return (value * 180) / Math.PI;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

function rgbaToHex(r: number, g: number, b: number, a: number): string {
  const toHex = (n: number): string => {
    const hex = Number(Math.round(n)).toString(16);
    return hex.length === 1 ? `0${hex}` : hex;
  };
  return `#${[toHex(r), toHex(g), toHex(b), toHex(a * 255)].join("").toUpperCase()}`;
}

function rgbaToHexNoAlpha(r: number, g: number, b: number): string {
  const toHex = (n: number): string => {
    const hex = Number(Math.round(n)).toString(16);
    return hex.length === 1 ? `0${hex}` : hex;
  };
  return `#${[toHex(r), toHex(g), toHex(b)].join("").toUpperCase()}`;
}

function hexToRgba(hex: string): [number, number, number, number] {
  const fromHex = (value: string): number => {
    if (value === "") return 255;
    return Number(`0x${value}`);
  };

  const r = fromHex(hex.slice(1, 3));
  const g = fromHex(hex.slice(3, 5));
  const b = fromHex(hex.slice(5, 7));
  const a = fromHex(hex.slice(7, 9));

  return [r, g, b, a / 255];
}
