#!/usr/bin/env python3
# udt-accent: pick a Catppuccin Macchiato accent matching a wallpaper.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
# Licensed under the GNU General Public License v2 only.
"""Extract a wallpaper's signature color and snap it to a Macchiato accent.

Usage: udt-accent <wallpaper-path>
       udt-accent --selftest

The extraction deliberately calls pywal's colorz backend directly rather than
running `wal -i`, because `wal -i` rewrites the whole ~/.cache/wal directory
including the terminal's ANSI colors. See the design spec for why that matters.
"""

import math
import os
import sys
import tempfile
from pathlib import Path

# The nine candidate accents. rosewater and flamingo are excluded as
# near-neutral tints that capture saturated inputs; maroon, sapphire and
# lavender are excluded as near-duplicate hues of red, sky and mauve.
ACCENTS = {
    "pink":   "#f5bde6",
    "mauve":  "#c6a0f6",
    "red":    "#ed8796",
    "peach":  "#f5a97f",
    "yellow": "#eed49f",
    "green":  "#a6da95",
    "teal":   "#8bd5ca",
    "sky":    "#91d7e3",
    "blue":   "#8aadf4",
}

FALLBACK = "mauve"
MIN_CHROMA = 10.0

OUTPUT = Path.home() / ".cache" / "wal" / "udt-accent.rasi"


def _to_lab(hexval):
    """Convert #rrggbb to CIELAB. sRGB D65, the standard conversion."""
    r, g, b = (int(hexval[i:i + 2], 16) / 255 for i in (1, 3, 5))

    def linear(c):
        return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

    r, g, b = linear(r), linear(g), linear(b)

    x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
    y = (0.2126 * r + 0.7152 * g + 0.0722 * b)
    z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883

    def f(t):
        return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116

    fx, fy, fz = f(x), f(y), f(z)
    return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))


def _hue(hexval):
    """Perceptual hue angle in radians."""
    _, a, b = _to_lab(hexval)
    return math.atan2(b, a)


def _chroma(hexval):
    """Distance from the neutral axis. Near-greys sit close to zero."""
    _, a, b = _to_lab(hexval)
    return math.hypot(a, b)


def snap(hexval):
    """Return the name of the nearest candidate accent by perceptual hue."""
    if _chroma(hexval) < MIN_CHROMA:
        return FALLBACK

    target = _hue(hexval)

    def distance(name):
        delta = abs(_hue(ACCENTS[name]) - target)
        return min(delta, 2 * math.pi - delta)  # hue is circular

    return min(ACCENTS, key=distance)


def signature_color(image):
    """Extract the image's most chromatic mid-tone color.

    Calls the colorz backend directly. It returns a list and writes nothing,
    which is what keeps the pywal cache (and so the terminal) untouched.
    """
    from pywal.backends import colorz

    colors = colorz.get(str(image), 16)
    # Slot 0 trends near-black and the upper slots near-white; the signature
    # color of an image lives in the middle.
    return max(colors[1:7], key=_chroma)


def write_accent(name):
    """Write the accent rasi file atomically."""
    hexval = ACCENTS[name]
    content = (
        "/* Generated by udt-accent. Do not edit. */\n"
        f"* {{ accent: {hexval}ff; }}\n"
    )

    OUTPUT.parent.mkdir(parents=True, exist_ok=True)
    # Write-then-rename: a rofi launch during a wallpaper change must never
    # read a half-written file.
    fd, tmp = tempfile.mkstemp(dir=str(OUTPUT.parent), suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as handle:
            handle.write(content)
        os.replace(tmp, OUTPUT)
    except BaseException:
        if os.path.exists(tmp):
            os.unlink(tmp)
        raise


def main(image):
    try:
        name = snap(signature_color(image))
    except Exception as exc:
        # A broken image must still leave a working theme.
        print(f"udt-accent: {exc}, falling back to {FALLBACK}", file=sys.stderr)
        name = FALLBACK

    write_accent(name)
    print(f"{name} {ACCENTS[name]}")


def selftest():
    # Every accent must snap to itself, or the metric is not self-consistent.
    for name, hexval in ACCENTS.items():
        got = snap(hexval)
        assert got == name, f"{name} ({hexval}) snapped to {got}"

    # Representative real-world inputs.
    assert snap("#ff8800") == "peach", snap("#ff8800")
    assert snap("#00cc44") == "green", snap("#00cc44")

    # A near-grey has an unstable hue angle and must take the fallback.
    assert snap("#888888") == FALLBACK, snap("#888888")

    print("selftest OK")


if __name__ == "__main__":
    if len(sys.argv) == 2 and sys.argv[1] == "--selftest":
        selftest()
    elif len(sys.argv) == 2:
        main(sys.argv[1])
    else:
        print(__doc__, file=sys.stderr)
        sys.exit(2)
