#!/usr/bin/env python3
"""
cube_contrast.py -- Objective contrast rating for a Rubik's cube color palette
from Nix spectrophotometer measurements.

WHAT IT DOES
------------
1. Parses a Nix Toolkit CSV export (semicolon-separated, scientific notation).
2. Lets you define palettes: a named set of six (or N) measured colors,
   drawn from any cube(s) in the file.
3. Enumerates every unordered color pair.  On a scrambled cube ANY color can
   sit next to ANY other, so all C(N,2) pairs are live -- 15 for a six-color
   palette.  No adjacency weighting; the bottleneck is the unweighted minimum.
4. For each pair computes the CAM16-UCS color difference (Euclidean
   distance in CAM16-UCS) under a stated viewing condition, for
     - a normal observer
     - protanopia, deuteranopia and tritanopia (Machado, Oliveira &
       Fernandes 2009, severity 1.0)
   CAM16-UCS is used throughout because it was fitted on large color
   differences and treats colorfulness on an equal footing with lightness.
   (CIEDE2000 was fitted on small differences and under-counts chroma-only
   gaps such as white vs saturated yellow.)
5. Reports, per palette:
     - the MINIMUM pairwise distance under each observer (the contrast rating)
     - which pair is the bottleneck
     - the full 15-row table
   and writes everything to CSV + Markdown.

CONTRAST RATING
---------------
The single number to quote for a palette is the minimum pairwise CAM16-UCS
distance across all pairs for the normal observer.  CAM16-UCS is scaled so
that ~1 unit is a just-noticeable difference; rough anchors:
    < 1    not perceptible
    1 - 2  perceptible on close inspection
    2 - 10 perceptible at a glance
    > 10   clearly different colors
Anything you want to identify in a fraction of a second at arm's length under
uncontrolled lighting should be far above 10.  The CVD columns are the same
metric after simulating the dichromat's cone loss; they answer "what is the
worst pair for a protan / deutan / tritan observer".

USAGE
-----
    python cube_contrast.py NIX_OP_Colors.csv
    python cube_contrast.py NIX_OP_Colors.csv --list
    python cube_contrast.py NIX_OP_Colors.csv --exclude Kanyon --exclude "MGC 2x2"
    python cube_contrast.py NIX_OP_Colors.csv \
        --palette "GAN OP = GAN V100 Matte: White, Black, Yellow, Red, Blue, Green" \
        --palette "GAN std = GAN V100 Matte: White, Yellow, Red, Orange, Blue, Green"

With no --palette flags the script auto-builds, for every cube in the file
that has the colors available, a "standard" palette (W Y R O B G) and an
"OP" palette (W K Y R B G) so the two schemes can be compared cube by cube.

Color references inside a --palette spec may also be qualified with a cube
name to build cross-cube palettes:
    --palette "Mix = White@GAN ME Matte, Black@MGC 2x2, Yellow@RS3 2020, ..."

MEASUREMENT CONDITIONS (declare these when publishing numbers)
--------------------------------------------------------------
Nix Mini 3, M2 (UV-excluded), 45/0 geometry, D50 / 2 degree observer as
exported.  Pieces measured in place on the assembled cube unless noted.
The Nix Mini 3 does not export reflectance spectra, so cross-illuminant
analysis is not possible from this file; the exported D50 Lab is adapted to
D65 with CAT16 before CAM16 and CVD simulation.

WHY THE MINIMUM, NOT THE SUM
----------------------------
The sum (or mean) of all 15 distances rewards palettes for having a few
enormous gaps -- black/white alone contributes ~80 -- and can hide a single
confusable pair behind them.  Recognition on a cube fails at the weakest pair,
not on average; a palette with fourteen perfect pairs and one bad one is a bad
palette.  The sum is reported as a secondary statistic and as a tiebreaker
between palettes whose minima are close, but it is not the rating.

CAVEATS
-------
* CAM16-UCS assumes a viewing condition (L_A, Y_b, surround).  The numbers
  shift somewhat with lighting; declare the condition with the numbers.
  Rankings are stable across reasonable conditions.
* Machado 2009 tritan simulation is an approximation (the authors themselves
  flag it); treat the tritan column as indicative only.  Protan/deutan are
  well supported.
* The Nix Mini 3 samples a 13 mm spot.  Facelets narrower than that pick up
  chamfer / neighboring-piece light; measure disassembled pieces on a matte
  black backing for publication numbers.

DEPENDENCIES
------------
    pip install colour-science numpy
"""

from __future__ import annotations

import argparse
import csv
import re
import sys
from dataclasses import dataclass
from itertools import combinations
from pathlib import Path

import numpy as np
import colour
from colour.blindness import matrix_cvd_Machado2009

# colour-science warns that Machado's tritan matrices are an approximation
# (see CAVEATS in the docstring).  Acknowledged; silence it.
colour.utilities.filter_warnings(colour_usage_warnings=True)

# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------

D50 = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D50"]
D65 = colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D65"]
XYZ_W_D65 = colour.xy_to_XYZ(D65) * 100.0

# CAM16 viewing conditions. L_A = 64 cd/m^2 ~ 318 lux, a normally lit room.
# Y_b = 20 = mid-gray background.  Change to model a venue.
CAM16_L_A = 64.0
CAM16_Y_B = 20.0
CAM16_SURROUND = colour.VIEWING_CONDITIONS_CAM16["Average"]

STANDARD_SCHEME = ["White", "Yellow", "Red", "Orange", "Blue", "Green"]
OP_SCHEME = ["White", "Black", "Yellow", "Red", "Blue", "Green"]

CVD_TYPES = {
    "protan": "Protanomaly",
    "deutan": "Deuteranomaly",
    "tritan": "Tritanomaly",
}


# ----------------------------------------------------------------------------
# Data model
# ----------------------------------------------------------------------------

@dataclass(frozen=True)
class Swatch:
    cube: str          # Nix "Saved Collection Name" (normalized)
    name: str          # Nix "User Color Name" (normalized)
    index: int         # Nix row index, for traceability
    Lab: tuple         # measured L*, a*, b* under D50/2
    hex: str           # Nix's own sRGB rendering, display only

    @property
    def label(self) -> str:
        return f"{self.name}@{self.cube}"


def norm(s: str) -> str:
    return re.sub(r"\s+", " ", s.strip().strip('"')).strip()


def load_nix_csv(path: Path) -> list[Swatch]:
    """Parse a Nix Toolkit library export."""
    text = path.read_text(encoding="utf-8-sig")
    lines = text.splitlines()
    # First line is 'sep=;', next two are metadata, then header.
    hdr_i = next(i for i, ln in enumerate(lines) if ln.startswith("Index;"))
    reader = csv.reader(lines[hdr_i:], delimiter=";")
    header = next(reader)

    # The header repeats 'L' (Lab and LCh).  Take the first L,a,b triple.
    iL = header.index("L")
    ia = header.index("a")
    ib = header.index("b")
    i_cube = header.index("Saved Collection Name")
    i_name = header.index("User Color Name")
    i_hex = header.index("HEX")
    i_ill = header.index("Illuminant")
    i_obs = header.index("Observer")

    out: list[Swatch] = []
    for row in reader:
        if not row or not row[0].strip().isdigit():
            continue
        ill, obs = row[i_ill].strip(), row[i_obs].strip()
        if ill != "D50" or obs != "2":
            print(f"warning: row {row[0]} is {ill}/{obs}, expected D50/2; "
                  f"skipping", file=sys.stderr)
            continue
        out.append(Swatch(
            cube=norm(row[i_cube]),
            name=norm(row[i_name]),
            index=int(row[0]),
            Lab=(float(row[iL]), float(row[ia]), float(row[ib])),
            hex=row[i_hex].strip(),
        ))
    return out


def dedupe(swatches: list[Swatch]) -> list[Swatch]:
    """Drop exact repeat scans (same cube, name, Lab) -- keep the first."""
    seen, out = set(), []
    for s in swatches:
        key = (s.cube.lower(), s.name.lower(), tuple(round(v, 4) for v in s.Lab))
        if key in seen:
            continue
        seen.add(key)
        out.append(s)
    return out


class Library:
    def __init__(self, swatches: list[Swatch]):
        self.swatches = dedupe(swatches)
        self.by_cube: dict[str, dict[str, Swatch]] = {}
        for s in self.swatches:
            self.by_cube.setdefault(s.cube, {})
            # If a cube has two different scans of the same color name,
            # keep the first and warn.
            k = s.name.lower()
            if k in self.by_cube[s.cube]:
                print(f"warning: {s.cube} has multiple '{s.name}' scans; "
                      f"using row {self.by_cube[s.cube][k].index}, "
                      f"ignoring row {s.index}", file=sys.stderr)
                continue
            self.by_cube[s.cube][k] = s

    def cubes(self) -> list[str]:
        return list(self.by_cube)

    def find_cube(self, query: str) -> str:
        q = query.lower()
        exact = [c for c in self.by_cube if c.lower() == q]
        if exact:
            return exact[0]
        partial = [c for c in self.by_cube if q in c.lower()]
        if len(partial) == 1:
            return partial[0]
        if not partial:
            raise KeyError(f"no cube matching '{query}'. "
                           f"Available: {', '.join(self.cubes())}")
        raise KeyError(f"'{query}' is ambiguous: {', '.join(partial)}")

    def get(self, cube: str, color_name: str) -> Swatch:
        cube = self.find_cube(cube)
        k = color_name.strip().lower()
        if k in self.by_cube[cube]:
            return self.by_cube[cube][k]
        raise KeyError(f"{cube} has no color '{color_name}'. "
                       f"Has: {', '.join(s.name for s in self.by_cube[cube].values())}")


# ----------------------------------------------------------------------------
# Color math
# ----------------------------------------------------------------------------

def lab_d50_to_xyz_d65(Lab: np.ndarray) -> np.ndarray:
    """Measured Lab (D50/2) -> XYZ (D65), 0-1 scale, CAT16 adaptation."""
    XYZ_d50 = colour.Lab_to_XYZ(Lab, illuminant=D50)
    return colour.chromatic_adaptation(
        XYZ_d50, colour.xy_to_XYZ(D50), colour.xy_to_XYZ(D65),
        method="Von Kries", transform="CAT16")


def xyz_to_cam16_ucs(XYZ_d65: np.ndarray) -> np.ndarray:
    """XYZ (D65, 0-1) -> CAM16-UCS (J', a', b') under the declared conditions."""
    spec = colour.XYZ_to_CAM16(XYZ_d65 * 100.0, XYZ_W_D65, CAM16_L_A,
                               CAM16_Y_B, CAM16_SURROUND)
    JMh = np.stack([spec.J, spec.M, spec.h], axis=-1)
    return colour.JMh_CAM16_to_CAM16UCS(JMh)


def simulate_cvd_xyz(XYZ_d65: np.ndarray, deficiency: str) -> np.ndarray:
    """
    Simulate a dichromat (Machado 2009, severity 1.0).  The Machado matrices
    act on LINEAR sRGB, so: XYZ(D65) -> linear sRGB -> matrix -> XYZ(D65).
    Out-of-gamut values (saturated yellows, greens) are NOT clipped; the
    transform is linear so negative components are fine.
    """
    rgb_lin = colour.XYZ_to_sRGB(XYZ_d65, illuminant=D65,
                                 apply_cctf_encoding=False)
    M = matrix_cvd_Machado2009(CVD_TYPES[deficiency], 1.0)
    rgb_cvd = rgb_lin @ M.T
    return colour.sRGB_to_XYZ(rgb_cvd, illuminant=D65,
                              apply_cctf_decoding=False)


def dist(a: np.ndarray, b: np.ndarray) -> float:
    """Euclidean distance in CAM16-UCS = the CAM16-UCS color difference."""
    return float(np.linalg.norm(a - b))


# ----------------------------------------------------------------------------
# Palette evaluation
# ----------------------------------------------------------------------------

@dataclass
class PairResult:
    a: Swatch
    b: Swatch
    normal: float
    protan: float
    deutan: float
    tritan: float


@dataclass
class PaletteResult:
    name: str
    swatches: list[Swatch]
    pairs: list[PairResult]

    def minimum(self, metric: str) -> PairResult:
        return min(self.pairs, key=lambda p: getattr(p, metric))

    def summary_row(self) -> dict:
        row = {"palette": self.name, "n_colors": len(self.swatches),
               "n_pairs": len(self.pairs)}
        for m in ("normal", "protan", "deutan", "tritan"):
            p = self.minimum(m)
            row[f"min_{m}"] = getattr(p, m)
            row[f"min_{m}_pair"] = f"{p.a.name}/{p.b.name}"
        d = sorted(p.normal for p in self.pairs)
        row["sum_normal"] = float(np.sum(d))
        row["mean_normal"] = float(np.mean(d))
        row["worst3_normal"] = float(np.mean(d[:3]))
        row["second_normal"] = d[1] if len(d) > 1 else d[0]
        return row


def evaluate(name: str, swatches: list[Swatch]) -> PaletteResult:
    Lab = np.array([s.Lab for s in swatches])
    XYZ = lab_d50_to_xyz_d65(Lab)
    ucs = {"normal": xyz_to_cam16_ucs(XYZ)}
    for k in CVD_TYPES:
        ucs[k] = xyz_to_cam16_ucs(simulate_cvd_xyz(XYZ, k))

    pairs = []
    for i, j in combinations(range(len(swatches)), 2):
        pairs.append(PairResult(
            a=swatches[i], b=swatches[j],
            normal=dist(ucs["normal"][i], ucs["normal"][j]),
            protan=dist(ucs["protan"][i], ucs["protan"][j]),
            deutan=dist(ucs["deutan"][i], ucs["deutan"][j]),
            tritan=dist(ucs["tritan"][i], ucs["tritan"][j]),
        ))
    pairs.sort(key=lambda p: p.normal)
    return PaletteResult(name, swatches, pairs)


# ----------------------------------------------------------------------------
# Palette specification parsing
# ----------------------------------------------------------------------------

def parse_palette_spec(spec: str, lib: Library) -> tuple[str, list[Swatch]]:
    """
    "NAME = CUBE: c1, c2, c3"           -> all colors from CUBE
    "NAME = c1@CUBE1, c2@CUBE2, ..."    -> cross-cube
    "NAME = CUBE: c1, c2@OTHER, c3"     -> mixed (default cube CUBE)
    """
    if "=" not in spec:
        raise ValueError(f"palette spec needs 'NAME = ...': {spec}")
    name, rest = (x.strip() for x in spec.split("=", 1))
    default_cube = None
    if ":" in rest:
        default_cube, rest = (x.strip() for x in rest.split(":", 1))
    swatches = []
    for tok in rest.split(","):
        tok = tok.strip()
        if not tok:
            continue
        if "@" in tok:
            cname, cube = (x.strip() for x in tok.split("@", 1))
        else:
            if default_cube is None:
                raise ValueError(f"'{tok}' has no cube; use 'color@cube' or "
                                 f"'CUBE: colors'")
            cname, cube = tok, default_cube
        swatches.append(lib.get(cube, cname))
    if len(swatches) < 2:
        raise ValueError(f"palette '{name}' needs at least two colors")
    return name, swatches


def auto_palettes(lib: Library) -> list[tuple[str, list[Swatch]]]:
    out = []
    for cube, cols in lib.by_cube.items():
        have = set(cols)
        if all(c.lower() in have for c in STANDARD_SCHEME):
            out.append((f"{cube} -- standard (W Y R O B G)",
                        [cols[c.lower()] for c in STANDARD_SCHEME]))
        if all(c.lower() in have for c in OP_SCHEME):
            out.append((f"{cube} -- OP (W K Y R B G)",
                        [cols[c.lower()] for c in OP_SCHEME]))
    return out


# ----------------------------------------------------------------------------
# Reporting
# ----------------------------------------------------------------------------

def fmt(x: float) -> str:
    return f"{x:6.1f}"


def print_palette(res: PaletteResult, verbose: bool) -> None:
    print(f"\n=== {res.name} ===")
    for s in res.swatches:
        L, a, b = s.Lab
        print(f"  {s.name:<12} L*{L:6.1f}  a*{a:7.1f}  b*{b:7.1f}   {s.hex}"
              f"   [{s.cube}, row {s.index}]")
    print()
    hdr = f"  {'pair':<22}{'normal':>8}{'protan':>8}{'deutan':>8}{'tritan':>8}"
    print(hdr)
    print("  " + "-" * (len(hdr) - 2))
    rows = res.pairs if verbose else res.pairs[:5]
    for p in rows:
        print(f"  {p.a.name + ' / ' + p.b.name:<22}{fmt(p.normal):>8}"
              f"{fmt(p.protan):>8}{fmt(p.deutan):>8}{fmt(p.tritan):>8}")
    if not verbose and len(res.pairs) > 5:
        print(f"  ... {len(res.pairs) - 5} more pairs (use --verbose)")
    print()
    s = res.summary_row()
    print(f"  CONTRAST RATING (min CAM16-UCS, normal observer): "
          f"{s['min_normal']:.1f}  [{s['min_normal_pair']}]")
    print(f"  worst pair protan {s['min_protan']:.1f} [{s['min_protan_pair']}]"
          f"   deutan {s['min_deutan']:.1f} [{s['min_deutan_pair']}]"
          f"   tritan {s['min_tritan']:.1f} [{s['min_tritan_pair']}]")


def print_leaderboard(results: list[PaletteResult]) -> None:
    print("\n" + "=" * 100)
    print("LEADERBOARD -- minimum pairwise CAM16-UCS distance across all "
          "pairs (higher is better)")
    print("=" * 100)
    hdr = (f"{'palette':<40}{'min':>7}{'2nd':>7}{'worst3':>8}{'mean':>7}"
           f"{'sum':>7}{'protan':>8}{'deutan':>8}{'tritan':>8}  bottleneck")
    print(hdr)
    print("-" * len(hdr))
    for r in sorted(results, key=lambda r: -r.summary_row()["min_normal"]):
        s = r.summary_row()
        print(f"{r.name:<40}{s['min_normal']:>7.1f}{s['second_normal']:>7.1f}"
              f"{s['worst3_normal']:>8.1f}{s['mean_normal']:>7.1f}"
              f"{s['sum_normal']:>7.0f}"
              f"{s['min_protan']:>8.1f}{s['min_deutan']:>8.1f}"
              f"{s['min_tritan']:>8.1f}  {s['min_normal_pair']}")


def write_csv(results: list[PaletteResult], out: Path) -> None:
    with out.open("w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["palette", "color_a", "cube_a", "row_a",
                    "color_b", "cube_b", "row_b",
                    "cam16ucs_normal", "cam16ucs_protan",
                    "cam16ucs_deutan", "cam16ucs_tritan"])
        for r in results:
            for p in r.pairs:
                w.writerow([r.name, p.a.name, p.a.cube, p.a.index,
                            p.b.name, p.b.cube, p.b.index,
                            f"{p.normal:.2f}",
                            f"{p.protan:.2f}", f"{p.deutan:.2f}",
                            f"{p.tritan:.2f}"])


def write_summary_csv(results: list[PaletteResult], out: Path) -> None:
    rows = [r.summary_row() for r in results]
    with out.open("w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0]))
        w.writeheader()
        for row in rows:
            w.writerow({k: (f"{v:.2f}" if isinstance(v, float) else v)
                        for k, v in row.items()})


def write_markdown(results: list[PaletteResult], out: Path,
                   source: Path) -> None:
    L = []
    L.append("# Cube palette contrast report\n")
    L.append(f"Source: `{source.name}` -- Nix Mini 3, M2, D50/2. "
             "All distances are CAM16-UCS (L_A=64 cd/m2, Y_b=20, average "
             "surround) after CAT16 adaptation D50->D65; CVD columns are the "
             "same metric after Machado 2009 simulation at severity 1.0.\n")
    L.append("Contrast rating = **minimum** pairwise distance across all "
             "pairs (all pairs are live on a scrambled cube).\n")
    L.append("## Leaderboard\n")
    L.append("| Palette | min | 2nd | worst-3 mean | mean | sum | "
             "protan | deutan | tritan | bottleneck pair |")
    L.append("|---|---:|---:|---:|---:|---:|---:|---:|---:|---|")
    for r in sorted(results, key=lambda r: -r.summary_row()["min_normal"]):
        s = r.summary_row()
        L.append(f"| {r.name} | {s['min_normal']:.1f} | {s['second_normal']:.1f} "
                 f"| {s['worst3_normal']:.1f} | {s['mean_normal']:.1f} "
                 f"| {s['sum_normal']:.0f} "
                 f"| {s['min_protan']:.1f} | {s['min_deutan']:.1f} "
                 f"| {s['min_tritan']:.1f} | {s['min_normal_pair']} |")
    for r in results:
        L.append(f"\n## {r.name}\n")
        L.append("| Color | L* | a* | b* | sRGB | source |")
        L.append("|---|---:|---:|---:|---|---|")
        for s in r.swatches:
            L.append(f"| {s.name} | {s.Lab[0]:.1f} | {s.Lab[1]:.1f} "
                     f"| {s.Lab[2]:.1f} | `{s.hex}` | {s.cube} row {s.index} |")
        L.append("")
        L.append("| Pair | normal | protan | deutan | tritan |")
        L.append("|---|---:|---:|---:|---:|")
        for p in r.pairs:
            L.append(f"| {p.a.name} / {p.b.name} | {p.normal:.1f} "
                     f"| {p.protan:.1f} | {p.deutan:.1f} | {p.tritan:.1f} |")
    out.write_text("\n".join(L) + "\n")


# ----------------------------------------------------------------------------
# CLI
# ----------------------------------------------------------------------------

def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("csv", type=Path, help="Nix Toolkit CSV export")
    ap.add_argument("--palette", "-p", action="append", default=[],
                    help='e.g. "GAN OP = GAN V100 Matte: White,Black,Yellow,'
                         'Red,Blue,Green" (repeatable)')
    ap.add_argument("--exclude", "-x", action="append", default=[],
                    metavar="CUBE",
                    help="drop this cube from the file (substring match, "
                         "repeatable)")
    ap.add_argument("--list", action="store_true",
                    help="list cubes and colors in the file, then exit")
    ap.add_argument("--no-auto", action="store_true",
                    help="do not auto-build standard/OP palettes per cube")
    ap.add_argument("--verbose", "-v", action="store_true",
                    help="print all pairs, not just the worst five")
    ap.add_argument("--out", type=Path, default=Path("."),
                    help="output directory for CSV/Markdown")
    args = ap.parse_args(argv)

    swatches = load_nix_csv(args.csv)
    for ex in args.exclude:
        before = len(swatches)
        swatches = [s for s in swatches if ex.lower() not in s.cube.lower()]
        if before == len(swatches):
            print(f"warning: --exclude '{ex}' matched nothing", file=sys.stderr)
    lib = Library(swatches)

    if args.list:
        for cube, cols in lib.by_cube.items():
            print(f"{cube}:")
            for s in cols.values():
                print(f"    {s.name:<36} L*{s.Lab[0]:6.1f} a*{s.Lab[1]:7.1f} "
                      f"b*{s.Lab[2]:7.1f}  {s.hex}  (row {s.index})")
        return 0

    specs = [] if args.no_auto else auto_palettes(lib)
    for spec in args.palette:
        specs.append(parse_palette_spec(spec, lib))
    if not specs:
        print("no palettes to evaluate; use --palette or drop --no-auto",
              file=sys.stderr)
        return 1

    results = [evaluate(n, sw) for n, sw in specs]
    for r in results:
        print_palette(r, args.verbose)
    print_leaderboard(results)

    args.out.mkdir(parents=True, exist_ok=True)
    write_csv(results, args.out / "cube_contrast_pairs.csv")
    write_summary_csv(results, args.out / "cube_contrast_summary.csv")
    write_markdown(results, args.out / "cube_contrast_report.md", args.csv)
    print(f"\nwrote {args.out / 'cube_contrast_pairs.csv'}, "
          f"{args.out / 'cube_contrast_summary.csv'}, "
          f"{args.out / 'cube_contrast_report.md'}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
