#!/usr/bin/env python3
# udt-palette: generate every themed config from one palette and one role map.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
# Licensed under the GNU General Public License v2 only.
"""Render the palette into each consumer's own syntax.

Usage: udt-palette [--roles <file>] [--out <dir>]
       udt-palette --selftest

The point of this script is that a colour is written down once. palette/<scheme>
.conf holds the colours under the scheme's own names, palette/roles.conf says
what each is for, and everything else here is generated. Editing a generated
file is pointless: the next install.sh overwrites it.
"""

import re
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
PALETTE_DIR = REPO / "palette"

# The scheme selection is the user's, not the repo's, so it lives outside the
# working tree: switching scheme should not show up as a diff, and two machines
# sharing this repo can run different schemes. install.sh seeds it from
# palette/roles.conf.default on a first run and never overwrites it after.
SELECTOR = Path.home() / ".config" / "udt" / "roles.conf"
SELECTOR_SEED = PALETTE_DIR / "roles.conf.default"

# The Lua dashboard lives in its own repo; UDT only renders its colours.
# A sibling checkout, like waybar-theme-udt; skipped when it is not cloned.
CONKY_REPO = REPO.parent / "conky-theme-udt"
GRUB_REPO = REPO.parent / "grub-theme-udt"


def parse_conf(path):
    """Parse `key = value` lines into a dict, preserving order.

    [section] headers are read but not nested: roles are a flat namespace, and
    the sections exist to group the file for a human reader. A duplicate key is
    an error rather than a silent last-wins, because two roles quietly fighting
    is exactly the drift this file exists to prevent.
    """
    out = {}
    for lineno, raw in enumerate(path.read_text().splitlines(), 1):
        # A comment is a whole line starting with #, or a trailing "# " after
        # a value. Splitting on a bare "#" would eat every colour, since the
        # values are hex literals that start with one.
        line = raw.strip()
        if line.startswith("#"):
            continue
        line = re.split(r"\s#\s", line, maxsplit=1)[0].strip()
        if not line or line.startswith("["):
            continue
        if "=" not in line:
            raise ValueError(f"{path}:{lineno}: expected 'key = value', got {raw!r}")
        key, value = (p.strip() for p in line.split("=", 1))
        if key in out:
            raise ValueError(f"{path}:{lineno}: duplicate key {key!r}")
        out[key] = value
    return out


def resolve(roles, palette, roles_path):
    """Turn every role into (r, g, b, alpha). Unknown colour names are fatal.

    A role names a palette colour, optionally with an alpha percentage:
    `text/75`. Failing here is the whole safety story: a typo or a name the new
    scheme does not carry stops the build, rather than emitting a config with a
    black hole where a colour should be.
    """
    resolved = {}
    for role, spec in roles.items():
        if role in ("scheme", "snap"):
            continue
        # `name/75` is 75% alpha; `name*50` is the colour at 50% brightness.
        # shade exists because GTK's shade() has no palette name to point at:
        # half-brightness crust is darker than the darkest colour a scheme
        # ships, so it can only be computed.
        rest, _, alpha_s = spec.partition("/")
        name, _, shade_s = rest.partition("*")
        name = name.strip()
        if name not in palette:
            available = ", ".join(sorted(palette))
            raise ValueError(
                f"{roles_path}: role {role!r} wants colour {name!r}, which the "
                f"{roles['scheme']} palette does not define.\nAvailable: {available}")
        alpha = 100
        if alpha_s:
            alpha = int(alpha_s.strip())
            if not 0 <= alpha <= 100:
                raise ValueError(f"{roles_path}: role {role!r} alpha {alpha} out of 0-100")
        hexval = palette[name]
        r, g, b = (int(hexval[i:i + 2], 16) for i in (1, 3, 5))
        if shade_s:
            factor = int(shade_s.strip()) / 100
            if not 0 <= factor <= 2:
                raise ValueError(f"{roles_path}: role {role!r} shade out of 0-200")
            r, g, b = (min(255, round(c * factor)) for c in (r, g, b))
        resolved[role] = (r, g, b, alpha)
    return resolved


def hex6(c):
    r, g, b, _ = c
    return f"#{r:02x}{g:02x}{b:02x}"


def hex8(c):
    r, g, b, a = c
    return f"#{r:02x}{g:02x}{b:02x}{round(a * 255 / 100):02x}"


def rgb_array(c):
    """[r, g, b], which is how a .sublime-theme writes a colour."""
    r, g, b, _ = c
    return f"[{r}, {g}, {b}]"


def rgb_triple(c):
    """`r, g, b` with spaces, which is how GTK's CSS writes an rgba() colour.

    The rgba() helper below emits without spaces and wraps in rgb()/rgba(),
    neither of which matches what the upstream GTK theme contains, so the
    substitution is on the bare triple and the template keeps the rgba() call.
    """
    r, g, b, _ = c
    return f"{r}, {g}, {b}"


def rgba(c):
    r, g, b, a = c
    return f"rgb({r},{g},{b})" if a == 100 else f"rgba({r},{g},{b},{a / 100:.2f})"


BANNER = "Generated by udt-palette from palette/{scheme}.conf. Do not edit."


# The names the rofi themes reference. They are Catppuccin spellings because
# that is what the themes were written against, but each is filled from a role,
# so a scheme that names nothing "base" still produces a parseable palette.
ROFI_NAMES = [
    ("base", "bg"), ("mantle", "bg_alt"), ("crust", "bg_deep"),
    ("surface0", "surface"), ("surface1", "surface_alt"),
    ("surface2", "surface_high"),
    ("text", "fg"), ("subtext0", "fg_dim"), ("subtext1", "fg_bright"),
    ("overlay0", "fg_faint"), ("overlay1", "mid_low"), ("overlay2", "mid_high"),
    ("red", "critical"), ("green", "success"), ("yellow", "warning"),
    ("teal", "info"), ("blue", "border_active"), ("lavender", "accent"),
    # Not referenced by any rofi theme. Here because palette.rasi is also
    # what udt-accent forwards to quickshell, whose keybind reminder draws
    # the macropad's orange, purple and blue keycaps in them. "blue" above is
    # the active border, which is magenta under candy-night; termblue is the
    # terminal's blue, a real blue in every scheme but Dracula, which has none.
    ("peach", "peach"), ("mauve", "highlight"), ("termblue", "blue"),
    # The one translucent entry: the fullscreen overlays' ground.
    ("scrim", "scrim"),
]


def gen_rofi(res, scheme, palette):
    """rofi: the structural palette, under the names the themes reference.

    Every value comes from a role, not from the scheme's own colour names: the
    themes ask for @base and @text, and Tokyo Night calls those bg and fg, so
    emitting palette names outright left the themes referencing colours that
    did not exist and rofi refusing to parse the file.
    """
    rows = "\n".join(f"    {name + ':':11}{hex8(res[role])};"
                      for name, role in ROFI_NAMES)
    return (
        f"/*\n * {BANNER.format(scheme=scheme)}\n *\n"
        " * Structural colors only. The accent lives in accent.rasi.\n"
        " * Names are Catppuccin's; values come from the scheme's roles.\n */\n\n"
        f"* {{\n{rows}\n}}\n"
    )


def gen_waybar(res, scheme, palette):
    """waybar: one file holding the palette and the role layer.

    Both halves together because the stylesheet imports a single theme.css and
    references names from each: @lavender and @surface1 by Catppuccin name,
    @main-bg and @hover-bg by role.

    There are no per-module colours. The old bar alternated module backgrounds
    so it read as stripes, and carried a role per module to do it. The current
    one draws every pill on @main-bg, which left those ten roles with no
    consumer: emitting them anyway would be ten colours nothing reads, and the
    next reader would reasonably assume something did.

    The Catppuccin-named half is emitted from roles, exactly as gen_rofi does
    and for the same reason: styles/main.css and styles/global.css are edited
    in place, not generated, and ask for @surface1 and @text outright. Tokyo
    Night calls those surface_alt and fg, so emitting only the scheme's own
    palette names left every such reference dangling and GTK fell back to a
    default, which is how the bar's separators went bright.

    Role names keep their existing hyphenated spelling (main-bg, not main_bg):
    the stylesheets reference them and are not generated.
    """
    palette_rows = "\n".join(f"@define-color {k:<12}{v};"
                              for k, v in sorted(palette.items()))

    # The names the hand-edited stylesheets reference, all in ROFI_NAMES.
    compat_rows = "\n".join(
        f"@define-color {name:<12}{rgba(res[role])};"
        for name, role in ROFI_NAMES)

    def emit(role, css_name=None):
        c = res[role]
        return f"@define-color {(css_name or role.replace('_', '-')):<12}{rgba(c)};"

    ui = ["main_br", "main_bg", "main_fg", "hover_bg", "hover_fg", "outline"]
    states = ["warning", "critical"]

    return "\n".join([
        f"/* {BANNER.format(scheme=scheme)} */",
        "",
        palette_rows,
        "",
        "/* Catppuccin names, from roles: the hand-edited stylesheets use these. */",
        compat_rows,
        "",
        "/* br - border, bg - background, fg - foreground */",
        "",
        "/* main colors */",
        f"@define-color accent      {rgba(res['accent'])};",
        *(emit(r) for r in ui),
        "",
        "/* state colors */",
        *(emit(r) for r in states),
        "",
    ])


def gen_kitty(res, scheme, palette):
    """kitty: the 16 ANSI slots plus chrome.

    kitty has no include-with-override, so this is the whole colour section of
    the theme file rather than a fragment.
    """
    ansi = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
    lines = [f"# {BANNER.format(scheme=scheme)}", ""]
    for key, role in [("foreground", "fg"), ("background", "bg"),
                      ("selection_foreground", "selection_fg"),
                      ("selection_background", "selection_bg"),
                      ("cursor", "cursor"), ("cursor_text_color", "bg"),
                      ("url_color", "url"),
                      ("active_border_color", "border_active"),
                      ("inactive_border_color", "border_inactive"),
                      ("active_tab_foreground", "tab_active_fg"),
                      ("active_tab_background", "tab_active_bg"),
                      ("inactive_tab_foreground", "tab_inactive_fg"),
                      ("inactive_tab_background", "tab_inactive_bg"),
                      ("tab_bar_background", "tab_bar_bg"),
                      ("scrollbar_handle_color", "scrollbar_handle"),
                      ("scrollbar_track_color", "scrollbar_track"),
                      ("bell_border_color", "bell_border"),
                      ("mark1_foreground", "bg"), ("mark1_background", "mark1"),
                      ("mark2_foreground", "bg"), ("mark2_background", "mark2"),
                      ("mark3_foreground", "bg"), ("mark3_background", "mark3")]:
        lines.append(f"{key:<24}{hex6(res[role])}")
    lines.append("")
    for i, name in enumerate(ansi):
        lines.append(f"color{i:<3}{' ' * 16}{hex6(res[name])}")
    for i, name in enumerate(ansi):
        lines.append(f"color{i + 8:<3}{' ' * 16}{hex6(res['bright_' + name])}")
    return "\n".join(lines) + "\n"


# Catppuccin's 26 names, and the role that stands in for each when the scheme
# does not define that name itself.
NVIM_NAMES = [
    ("rosewater", "cursor"), ("flamingo", "cursor"), ("pink", "magenta"),
    ("mauve", "highlight"), ("red", "red"), ("maroon", "bright_red"),
    ("peach", "peach"), ("yellow", "yellow"), ("green", "green"),
    ("teal", "cyan"), ("sky", "bright_cyan"), ("sapphire", "bright_blue"),
    ("blue", "blue"), ("lavender", "accent"),
    ("text", "fg"), ("subtext1", "fg_bright"), ("subtext0", "fg_dim"),
    ("overlay2", "mid_high"), ("overlay1", "mid_low"), ("overlay0", "fg_faint"),
    ("surface2", "surface_high"), ("surface1", "surface_alt"),
    ("surface0", "surface"),
    ("base", "bg"), ("mantle", "bg_alt"), ("crust", "bg_deep"),
]


def gen_nvim(res, scheme, palette):
    """neovim: a colorscheme that is catppuccin/nvim with every colour replaced.

    catppuccin's highlight groups are written against its 26 palette names, so
    overriding all of them re-skins the whole editor without restating a single
    group. The Catppuccin schemes define those names and pass straight through;
    the rest are filled from roles, so a few near-duplicate hues (maroon, sky,
    sapphire, flamingo) collapse onto their neighbours there.
    """
    rows = "\n".join(
        f'  {name} = "{palette[name].lower() if name in palette else hex6(res[role])}",'
        for name, role in NVIM_NAMES)
    return (
        f"-- {BANNER.format(scheme=scheme)}\n"
        "-- Selected with `colorscheme udt`. Needs the catppuccin/nvim plugin.\n"
        f"local colors = {{\n{rows}\n}}\n\n"
        "require(\"catppuccin\").setup({\n"
        "  flavour = \"macchiato\",\n"
        "  color_overrides = { macchiato = colors },\n"
        "})\n"
        "vim.cmd.colorscheme(\"catppuccin-macchiato\")\n"
        "vim.g.colors_name = \"udt\"\n"
    )


def gen_hyprlock(res, scheme, palette):
    """hyprlock: the lock screen's scheme-level palette, as hyprlang variables.

    Only the values that move with the scheme. The accent border is a separate
    generated file, written by udt-accent, because it follows the wallpaper
    rather than the scheme; hyprlock.conf sources both.

    The panel alpha is a design constant, not a role: it matches the drawer's
    Qt.alpha(Theme.base, 0.72), so the lock panel and the drawer read as one
    surface.
    """
    def with_alpha(role, alpha):
        r, g, b, _ = res[role]
        return f"rgba({r},{g},{b},{alpha})"

    rows = [
        ("panel",    with_alpha("bg", 0.72)),
        ("input_bg", rgba(res["surface"])),
        ("text",     rgba(res["fg"])),
        ("subtext",  rgba(res["fg_dim"])),
        ("fail",     rgba(res["critical"])),
    ]
    body = "\n".join(f"${name} = {value}" for name, value in rows)

    return (
        f"# {BANNER.format(scheme=scheme)}\n"
        "#\n"
        "# hyprlock's static palette. The accent border is generated separately\n"
        "# by udt-accent, at ~/.cache/udt/hyprlock-border.conf.\n"
        f"{body}\n"
    )


def gen_obsidian(res, scheme, palette):
    """Obsidian: a CSS snippet overriding the theme variables.

    A snippet rather than a theme, because a theme replaces the user's choice
    outright while a snippet layers over whatever they have enabled. Obsidian
    loads snippets after the theme, so these win without the theme having to go.

    The variables are Obsidian's documented public API for this. Only colour is
    set: spacing and typography belong to whatever theme the vault uses.
    """
    def c(role):
        return hex6(res[role])

    return f"""/* {BANNER.format(scheme=scheme)}
 *
 * Enabled per vault in Appearance > CSS snippets. install.sh writes this into
 * every vault it finds and enables it without disturbing the snippets already
 * on, so a vault keeps its layout snippets and gains these colours.
 */

.theme-dark {{
  --background-primary:         {c('bg')};
  --background-primary-alt:     {c('bg_alt')};
  --background-secondary:       {c('bg_alt')};
  --background-secondary-alt:   {c('bg_deep')};
  --background-modifier-border: {c('border')};
  --background-modifier-hover:  {c('surface')};
  --background-modifier-error:  {c('critical')};
  --background-modifier-success:{c('success')};

  --text-normal:      {c('fg')};
  --text-muted:       {c('fg_dim')};
  --text-faint:       {c('fg_faint')};
  --text-error:       {c('critical')};
  --text-success:     {c('success')};
  --text-accent:      {c('accent')};
  --text-accent-hover:{c('accent_bright')};
  --text-on-accent:   {c('bg')};
  --text-selection:   {hex8((*res['accent'][:3], 30))};
  --text-highlight-bg:{hex8((*res['warning'][:3], 40))};

  --interactive-normal:   {c('surface')};
  --interactive-hover:    {c('surface_alt')};
  --interactive-accent:   {c('accent')};
  --interactive-accent-hover: {c('accent_bright')};

  --h1-color: {c('accent')};
  --h2-color: {c('accent')};
  --h3-color: {c('info')};
  --h4-color: {c('info')};
  --h5-color: {c('fg_dim')};
  --h6-color: {c('fg_dim')};

  --code-normal:     {c('warning')};
  --code-background: {c('bg_alt')};
  --blockquote-border-color: {c('accent')};
  --hr-color:        {c('border')};
  --checkbox-color:  {c('accent')};
  --tag-color:       {c('info')};
  --tag-background:  {hex8((*res['info'][:3], 20))};
}}
"""


def gen_typora(res, scheme, palette, template):
    """Typora: substitute the palette into the theme's :root block.

    Typora themes are one self-contained stylesheet, but this one routes all
    350 of its rules through variables in :root, so only that block is a
    template. Derived from the dracula theme already installed here.
    """
    out = template
    for role in TYPORA_ROLES:
        out = out.replace(f"@{role.upper()}@", hex6(res[role]))
    return out


# Exactly the placeholders templates/typora/udt.css.in carries.
TYPORA_ROLES = ["bg", "bg_deep", "surface_alt", "fg", "fg_dim", "fg_faint",
                "accent", "accent_bright", "critical", "warning", "success",
                "info", "highlight"]


# The roles GTK's stylesheets reference. Both a hex and a decimal-triple form
# of each is substituted, because the upstream theme writes colours both ways.
GTK_ROLES = ["fg", "bg", "bg_alt", "bg_deep", "surface", "surface_alt",
             "accent", "critical", "warning", "success", "highlight", "info",
             "blue", "peach"]

KVANTUM_ROLES = ["bg", "bg_alt", "surface", "surface_alt", "surface_high",
                 "fg", "fg_dim", "fg_faint", "mid_high", "accent",
                 "accent_dim", "accent_bright", "highlight", "critical"]


def gen_kvantum(res, scheme, palette, template):
    """Kvantum: substitute the palette into the widget theme.

    A Kvantum theme is a .kvconfig of colours and a .svg of widget artwork with
    colours baked into the paths. Only the palette colours are placeholders;
    the neutral greys in the SVG are shading and shadow, not theme colour, so
    they are left exactly as upstream drew them.

    Derived from the catppuccin-macchiato-lavender theme, which is what this
    desktop was already running by hand.
    """
    out = template
    for role in KVANTUM_ROLES:
        out = out.replace(f"@{role.upper()}@", hex6(res[role]))
    return out


def gen_kde(res, scheme, palette):
    """KDE Frameworks apps: a KColorScheme file, the same roles as Kvantum.

    Off Plasma, KColorSchemeManager ignores the Qt style's palette and falls
    back to BreezeLight unless kdeglobals names a scheme, which is how kcalc
    ended up white under a dark Kvantum theme. install.sh points kdeglobals at
    this file. Colours are r,g,b triplets; the accent is the scheme's own,
    since these apps read it once at startup.
    """
    def rgb(role):
        r, g, b, _ = res[role]
        return f"{r},{g},{b}"

    fg = {
        "ForegroundNormal": "fg", "ForegroundInactive": "fg_dim",
        "ForegroundActive": "accent", "ForegroundLink": "accent",
        "ForegroundVisited": "accent_bright", "ForegroundNegative": "critical",
        "ForegroundNeutral": "warning", "ForegroundPositive": "success",
        "DecorationFocus": "accent", "DecorationHover": "accent",
    }
    sets = {
        "Window": ("bg", "surface"),
        "View": ("bg_alt", "bg"),
        "Button": ("surface", "surface_alt"),
        "Header": ("bg", "surface"),
        "Tooltip": ("bg_alt", "surface"),
        "Complementary": ("bg_alt", "surface"),
        "Selection": ("accent", "accent_dim"),
    }
    out = [f"# {BANNER.format(scheme=scheme)}", "[General]",
           "ColorScheme=udt", "Name=udt", ""]
    for name, (bg, bg_alt) in sets.items():
        roles = dict(fg, BackgroundNormal=bg, BackgroundAlternate=bg_alt)
        if name == "Selection":
            # Text on a solid accent fill, so it takes the dark end.
            roles.update(ForegroundNormal="bg", ForegroundActive="bg",
                         ForegroundInactive="surface")
        out.append(f"[Colors:{name}]")
        out += [f"{k}={rgb(v)}" for k, v in sorted(roles.items())]
        out.append("")
    return "\n".join(out)


# Shades upstream computed from a palette colour rather than taking one. Each
# paints exactly one widget surface, so leaving them literal (the rule that is
# right for the neutral greys) left twelve lavender and Macchiato-tinted spots
# on every other scheme: the window close/min/max hover fills among them.
#
# The percentage is that shade's brightness relative to its base, measured off
# the Macchiato theme these were derived from. Reproducing the exact upstream
# value is not the goal; keeping the relationship is.
GTK_TINTS = [
    ("@ACCENT_93@", "accent", 93),
    ("@ACCENT_103@", "accent", 103),
    ("@ACCENT_109@", "accent", 109),
    ("@ACCENT_118@", "accent", 118),
    ("@CRITICAL_110@", "critical", 110),
    ("@CRITICAL_92@", "critical", 92),
    ("@CRITICAL_79@", "critical", 79),
    ("@WARNING_105@", "warning", 105),
    ("@WARNING_94@", "warning", 94),
    ("@WARNING_86@", "warning", 86),
    ("@SUCCESS_109@", "success", 109),
    ("@SUCCESS_81@", "success", 81),
]


def shade(c, pct):
    """Scale a colour's channels by pct, clamped to 0-255."""
    r, g, b, a = c
    return (min(255, round(r * pct / 100)),
            min(255, round(g * pct / 100)),
            min(255, round(b * pct / 100)), a)


def gen_gtk(res, scheme, palette, template):
    """GTK3/GTK4: substitute the palette into the upstream stylesheet.

    Derived from catppuccin/gtk v1.0.3, which is archived upstream. The whole
    file is substituted rather than just its @define-color block, because the
    8400 lines above that block hardcode palette colours too.

    Two forms per role: the hex, and the bare `r, g, b` triple that the theme
    writes inside rgba(). Neutral greys and upstream's derived tints are left
    exactly as drawn, the same rule the Kvantum SVG follows.
    """
    out = template
    # Tints first: @ACCENT_93@ starts with @ACCENT, so the plain role loop
    # below would otherwise replace that prefix and leave a stray "_93@".
    for placeholder, role, pct in GTK_TINTS:
        out = out.replace(placeholder, hex6(shade(res[role], pct)))
    for role in GTK_ROLES:
        # _RGB@ first: @FG@ is a prefix of @FG_RGB@, so the bare form would
        # otherwise eat the start of the triple placeholder and leave "_RGB@".
        out = out.replace(f"@{role.upper()}_RGB@", rgb_triple(res[role]))
        out = out.replace(f"@{role.upper()}@", hex6(res[role]))
    # Spelled for the upstream colour it replaces rather than for its role,
    # which keeps the template readable against the theme it came from.
    out = out.replace("@MAUVE@", hex6(res["highlight"]))
    return out


def gen_gtk_asset(res, scheme, palette, template):
    """One GTK widget asset. Only three palette colours appear in the SVGs."""
    out = template
    for role in ("bg", "bg_alt", "accent"):
        out = out.replace(f"@{role.upper()}@", hex6(res[role]))
    return out


def gen_homepage(res, scheme, palette):
    """gethomepage: a custom.css overriding its ten-step colour ramp.

    homepage themes itself with --color-50 (lightest) to --color-900 (darkest)
    as space-separated RGB triples, so the ramp is filled from the text scale
    at the light end and the surface scale at the dark end.

    The card backgrounds do NOT come from that ramp: in dark mode the service
    and bookmark cards carry `dark:bg-white/5`, a literal white, so redefining
    the variables alone leaves them a neutral grey. Upstream hits the same wall
    and hardcodes those selectors for .theme-white; this does the same for
    .theme-gray, which is the class settings.yaml's `color: gray` puts on the
    page. Change that setting and this file stops applying.
    """
    def triple(role):
        r, g, b, _ = res[role]
        return f"{r} {g} {b}"

    ramp = [("50", "fg"), ("100", "fg_bright"), ("200", "fg_dim"),
            ("300", "mid_high"), ("400", "mid_low"), ("500", "fg_faint"),
            ("600", "surface_high"), ("700", "surface_alt"), ("800", "surface"),
            ("900", "bg")]
    rows = "\n".join(f"  --color-{n}: {triple(role)};" for n, role in ramp)

    sr, sg, sb, _ = res["surface"]
    ar, ag, ab, _ = res["surface_alt"]
    br, bg_, bb, _ = res["bg"]
    accent = hex6(res["accent"])

    return f"""/* {BANNER.format(scheme=scheme)}
 *
 * Catppuccin-independent: every colour here comes from palette/roles-{scheme}
 * .conf, so this file follows the desktop rather than restating a palette.
 */

.theme-gray {{
{rows}

  --color-logo-start: {triple('accent')};
  --color-logo-stop:  {triple('info')};
}}

/* Card backgrounds, which the ramp does not reach. See the note above. */
.theme-gray .bg-theme-100\\/20:not([class^="backdrop-blur"]),
.theme-gray .dark\\:bg-white\\/5:not([class^="backdrop-blur"]) {{
  background-color: rgb({sr} {sg} {sb} / 55%);
}}

.theme-gray .bg-theme-100\\/20:hover:not([class^="backdrop-blur"]),
.theme-gray .dark\\:bg-white\\/5:hover:not([class^="backdrop-blur"]) {{
  background-color: rgb({ar} {ag} {ab} / 70%);
}}

.theme-gray .bg-theme-900\\/50:not([class^="backdrop-blur"]) {{
  background-color: rgb({br} {bg_} {bb} / 50%);
}}

/* Accent on secondary labels. Fixed, not wallpaper-tracking: this runs on a
 * server with no wallpaper to read. */
.theme-gray .text-theme-500 {{
  color: {accent};
}}
"""


def gen_sublime(res, scheme, palette, template):
    """Sublime Text: substitute the palette into the colour scheme.

    Scopes map onto the terminal roles rather than onto syntax roles of their
    own. A scheme already declares all sixteen ANSI slots, so a syntax theme
    costs no new roles and every shipped scheme satisfies it without touching
    nine role maps.

    @ACCENT@ is left alone for udt-accent: Sublime colour schemes do not
    cascade, so the accent cannot arrive as a second file and has to be
    substituted into this one.
    """
    out = template.replace("@SCHEME@", scheme)
    for role in ("bg", "bg_alt", "bg_deep", "surface", "surface_alt",
                 "fg", "fg_dim", "fg_faint",
                 "red", "green", "yellow", "blue", "magenta", "cyan",
                 "critical", "warning", "success", "info"):
        out = out.replace(f"@{role.upper()}@", hex6(res[role]))
    # The selection has to be translucent or it hides the text under it, and
    # selection_bg resolves opaque because the terminal wants it that way.
    out = out.replace("@SELECTION_BG@", hex8((*res["selection_bg"][:3], 35)))
    return out


def gen_sublime_theme(res, scheme, palette, template):
    """Sublime Text: the UI chrome, as an override layered over Material Behave.

    A .sublime-theme writes colours as [r, g, b] arrays rather than hex, so this
    substitutes a different syntax from the colour scheme next door out of the
    same roles.

    The accent is the scheme's fixed one, not the wallpaper's: Sublime reads a
    theme once per window, so a moving accent would leave open windows
    disagreeing with new ones. Same reasoning as conky, Qt, GTK and waybar.
    """
    out = template.replace("@SCHEME@", scheme)
    for role in ("bg", "bg_alt", "bg_deep", "fg", "fg_dim", "fg_faint",
                 "accent"):
        out = out.replace(f"@{role.upper()}@", rgb_array(res[role]))
    return out


def gen_conky(res, scheme, palette, template):
    """conky: substitute into the dashboard template.

    The template lives in the conky-theme-udt repo, which owns the Lua
    dashboard; this only fills in the colours. `critical` is included for the
    dashboard's error overlay, which draws a caught Lua error on screen because
    conky otherwise reports one as a blank window.
    """
    out = template.replace("@SCHEME@", scheme)
    for role in ("heading", "label", "rule", "value", "highlight", "ok",
                 "body", "body_outline", "body_shade", "critical", "warning"):
        out = out.replace(f"@{role.upper()}@", hex6(res[role]))
    # own_window_colour takes '#AARRGGBB', so the window background needs the
    # shade colour without its leading '#' to sit after the alpha pair.
    out = out.replace("@BODY_SHADE_RAW@", hex6(res["body_shade"]).lstrip("#"))
    return out


def gen_grub(res, scheme, palette, template):
    """GRUB: substitute into the theme template in the grub-theme-udt repo.

    theme.txt has no include, so the colours are baked in. The accent is the
    scheme's fixed one: nothing at boot can read the wallpaper. The PNG slices
    that carry colour are drawn by that repo's build script from this output.
    """
    out = template.replace("@SCHEME@", scheme)
    for role in ("bg", "bg_alt", "bg_deep", "surface", "fg", "fg_dim",
                 "fg_faint", "accent"):
        out = out.replace(f"@{role.upper()}@", hex6(res[role]))
    return out


def gen_accent_py(res, scheme, palette, snap_names):
    """The accent table and Macchiato dict udt-accent carries for Firefox.

    Written as a Python fragment that udt-accent imports, so the accent snapping
    and the pywalfox palette both follow the scheme instead of hardcoding one.
    """
    # The snap candidates are declared per scheme, not derived: which hues make
    # good accents is a judgement about the palette (drop near-neutrals, drop
    # near-duplicate hues) that the colour values alone do not carry.
    candidates = {n: palette[n] for n in snap_names}
    rows = "\n".join(f'    {n!r}: {v!r},' for n, v in candidates.items())

    ansi = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
    normal = ", ".join(f'"{hex6(res[n])}"' for n in ansi)
    bright = ", ".join(f'"{hex6(res["bright_" + n])}"' for n in ansi)

    return (
        f"# {BANNER.format(scheme=scheme)}\n"
        '"""Generated colour tables. See palette/roles.conf."""\n\n'
        f"SCHEME = {scheme!r}\n\n"
        "# The candidate accents udt-accent snaps a wallpaper to.\n"
        f"ACCENTS = {{\n{rows}\n}}\n\n"
        f"FALLBACK = {accent_name(res, palette, candidates)!r}\n\n"
        "# Firefox, via pywalfox, which reads colors.json and nothing else.\n"
        "PALETTE = {\n"
        f'    "background": "{hex6(res["bg"])}",\n'
        f'    "foreground": "{hex6(res["fg"])}",\n'
        f'    "cursor": "{hex6(res["cursor"])}",\n'
        f"    \"colors\": [{normal},\n"
        f"               {bright}],\n"
        "}\n"
    )


def accent_name(res, palette, candidates):
    """Which candidate udt-accent falls back to when a wallpaper is too grey.

    The accent role's own colour, so the fallback matches what every consumer
    that does not track the wallpaper is already sitting on.
    """
    target = hex6(res["accent"])
    for name, hexval in candidates.items():
        if hexval == target:
            return name
    return next(iter(candidates))


def schemes():
    """Every scheme that ships both a palette and a role map."""
    return sorted(p.stem for p in PALETTE_DIR.glob("*.conf")
                  if p.stem != "roles" and not p.stem.startswith("roles-"))


def load(selector_path, scheme=None):
    """Read the selected scheme's palette and role map.

    The selector names a scheme; the scheme names two files. Keeping the role
    map per-scheme is what lets a palette use its own colour names: Nord has no
    `base` and Dracula no `surface0`, so one shared map could not satisfy both.
    """
    if scheme is None:
        scheme = parse_conf(selector_path).get("scheme")
    if not scheme:
        raise ValueError(f"{selector_path}: no 'scheme' line")

    palette_path = PALETTE_DIR / f"{scheme}.conf"
    roles_path = PALETTE_DIR / f"roles-{scheme}.conf"
    for needed in (palette_path, roles_path):
        if not needed.exists():
            raise ValueError(
                f"scheme {scheme!r} is missing {needed.name}. "
                f"Available: {', '.join(schemes())}")

    roles = parse_conf(roles_path)
    palette = parse_conf(palette_path)
    for name, value in palette.items():
        if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
            raise ValueError(f"{palette_path}: {name} = {value!r} is not #rrggbb")
    snap_names = roles.get("snap", "").split()
    if not snap_names:
        raise ValueError(f"{roles_path}: no [accents] snap list")
    for name in snap_names:
        if name not in palette:
            raise ValueError(
                f"{roles_path}: snap candidate {name!r} is not in the {scheme} palette")

    roles["scheme"] = scheme
    return scheme, palette, resolve(roles, palette, roles_path), snap_names


# What gets written where. Templates are read from the repo, everything else is
# generated whole.
TARGETS = [
    ("rofi/udt/palette.rasi", gen_rofi, None),
    ("templates/waybar/theme.css", gen_waybar, None),
    ("templates/terminal/kitty-theme.conf", gen_kitty, None),
    ("templates/hyprlock/hyprlock-palette.conf", gen_hyprlock, None),
    ("templates/nvim/udt.lua", gen_nvim, None),
    (str(CONKY_REPO / "conky.conf"), gen_conky, str(CONKY_REPO / "conky.conf.in")),
    (str(GRUB_REPO / "theme.txt"), gen_grub, str(GRUB_REPO / "theme.txt.in")),
    ("bin/udt_colors.py", gen_accent_py, None),
    ("templates/homepage/custom.css", gen_homepage, None),
    ("templates/obsidian/udt.css", gen_obsidian, None),
    ("templates/typora/udt.css", gen_typora, "templates/typora/udt.css.in"),
    ("templates/kvantum/theme.kvconfig", gen_kvantum, "templates/kvantum/theme.kvconfig.in"),
    ("templates/kvantum/theme.svg", gen_kvantum, "templates/kvantum/theme.svg.in"),
    ("templates/kde/udt.colors", gen_kde, None),
    ("templates/sublime/udt.sublime-color-scheme", gen_sublime,
     "templates/sublime/udt.sublime-color-scheme.in"),
    ("templates/sublime/udt.sublime-theme", gen_sublime_theme,
     "templates/sublime/udt.sublime-theme.in"),
    ("templates/gtk/gtk3.css", gen_gtk, "templates/gtk/gtk3.css.in"),
    ("templates/gtk/gtk4.css", gen_gtk, "templates/gtk/gtk4.css.in"),
    ("templates/gtk/index.theme", gen_gtk, "templates/gtk/index.theme.in"),
]

# The GTK widget assets, one target each. Discovered rather than listed: there
# are 67 of them and they arrive as a set from upstream, so a hand-written list
# would silently miss one added by a future theme refresh.
TARGETS += [
    (f"templates/gtk/assets/{p.name[:-3]}", gen_gtk_asset,
     f"templates/gtk/assets/{p.name}")
    for p in sorted((REPO / "templates" / "gtk" / "assets").glob("*.svg.in"))
]


def generate(roles_path, out_dir, scheme=None):
    scheme, palette, res, snap_names = load(roles_path, scheme=scheme)
    written = []
    for target, fn, template in TARGETS:
        # A sibling repo that is not cloned here, or has no template yet, is
        # not an error: there is nothing to theme.
        if Path(target).is_absolute() and not (template and Path(template).exists()):
            continue
        # A target outside this repo (the conky dashboard) is an absolute path,
        # and `out_dir / absolute` discards out_dir entirely. For the real
        # render that is what we want, since the file genuinely belongs at that
        # path. For a render aimed elsewhere it is not: selftest renders every
        # scheme to a temp dir to have rofi parse the result, and an escaping
        # absolute target rewrote the live file each time, silently leaving the
        # user's config set to whichever scheme was probed last.
        #
        # So honour an absolute target only for the default in-repo render, and
        # rebase it under out_dir whenever a caller asked for somewhere else.
        if Path(target).is_absolute() and out_dir != REPO:
            dest = out_dir / Path(target).relative_to("/")
        else:
            dest = out_dir / target
        if template:
            src = REPO / template
            if not src.exists():
                raise ValueError(f"missing template {src}")
            content = fn(res, scheme, palette, src.read_text())
        elif fn is gen_accent_py:
            content = fn(res, scheme, palette, snap_names)
        else:
            content = fn(res, scheme, palette)
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_text(content)
        written.append(target)
    return scheme, written


def check_rofi_parses(scheme):
    """Render `scheme` to a temp dir and have rofi parse every theme.

    The role checks below prove a scheme resolves, not that what it emits is
    valid. A palette missing a name the themes reference parses as a broken
    theme and every launcher stops opening, which is how exactly that bug
    shipped once. rofi needs no display for -dump-theme.
    """
    import shutil
    import subprocess
    import tempfile

    if not shutil.which("rofi"):
        return None

    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp)
        generate(None, out, scheme=scheme)
        udt = out / "rofi" / "udt"
        # The themes @import siblings, so they need the whole directory.
        for extra in (REPO / "rofi" / "udt").glob("*.rasi"):
            if not (udt / extra.name).exists():
                shutil.copy(extra, udt / extra.name)
        # accent.rasi is generated per wallpaper; seed it so themes resolve.
        (udt / "accent.rasi").write_text("* { accent: #ffffffff; }\n")

        for theme in sorted(udt.glob("*.rasi")):
            if theme.name in ("palette.rasi", "accent.rasi", "common.rasi"):
                continue
            proc = subprocess.run(
                ["rofi", "-no-config", "-theme", str(theme), "-dump-theme"],
                capture_output=True, text=True)
            if "Failed to parse" in proc.stderr:
                raise AssertionError(
                    f"{scheme}: rofi cannot parse {theme.name}\n{proc.stderr.strip()}")
    return True


def selftest():
    # Every shipped scheme must satisfy every role, or switching to it breaks
    # at install time. load() raises on any role the palette cannot supply.
    names = schemes()
    assert names, "no palettes found"

    seen = {}
    for scheme in names:
        _, _, res, snap = load(None, scheme=scheme)
        assert res["bg"] != res["fg"], f"{scheme}: bg and fg are the same colour"
        assert len(snap) >= 5, f"{scheme}: only {len(snap)} snap candidates"
        seen[scheme] = set(res)
        parsed = check_rofi_parses(scheme)
        note = "" if parsed else "  (rofi not installed, parse unchecked)"
        print(f"  {scheme}: {len(res)} roles, {len(snap)} accents{note}")

    # Every scheme must define the SAME roles: a generator asks for a role by
    # name, so one scheme missing it would fail only once that scheme was
    # selected, which is exactly the late failure this check exists to prevent.
    reference = seen[names[0]]
    for scheme, roles in seen.items():
        missing = reference - roles
        extra = roles - reference
        assert not missing, f"{scheme} is missing roles: {sorted(missing)}"
        assert not extra, f"{scheme} has roles no other scheme has: {sorted(extra)}"

    # The hyprlock palette must name every variable the lock config references.
    # A missing one renders a literal `$var`, and hyprlock then falls back to a
    # default colour with nothing to say so.
    _, _, res, _ = load(None, scheme=names[0])
    lock = gen_hyprlock(res, names[0], None)
    for var in ("panel", "input_bg", "text", "subtext", "fail"):
        assert f"${var} = " in lock, f"hyprlock palette is missing ${var}"

    # Every placeholder in the GTK templates must correspond to a role, or it
    # renders literally and GTK drops the declaration without an error. The
    # stylesheets are too large to eyeball, so this is the only thing standing
    # between a typo and a silently half-themed desktop.
    gtk_dir = REPO / "templates" / "gtk"
    known = {f"@{r.upper()}@" for r in GTK_ROLES}
    known |= {f"@{r.upper()}_RGB@" for r in GTK_ROLES}
    known |= {placeholder for placeholder, _, _ in GTK_TINTS}
    known.add("@MAUVE@")
    for tpl in sorted(gtk_dir.glob("*.in")) + sorted((gtk_dir / "assets").glob("*.in")):
        used = set(re.findall(r"@[A-Z][A-Z_]*@", tpl.read_text()))
        unknown = used - known
        assert not unknown, f"{tpl.name} uses unknown placeholders: {sorted(unknown)}"

    # Every name the rofi themes reference must be emitted, or rofi refuses to
    # parse the theme and every launcher on the desktop stops opening. This is
    # a real regression that shipped: gen_rofi used to emit the scheme's own
    # colour names, which only happened to match under Catppuccin.
    theme_dir = REPO / "rofi" / "udt"
    used = set()
    for theme in theme_dir.glob("*.rasi"):
        if theme.name in ("palette.rasi", "accent.rasi"):
            continue
        # Only @name in a colour-property value: @import is a directive and
        # @radius a dimension, neither of which this file defines.
        # Only @name in a colour-property value. @import is a directive and
        # border-radius a dimension, so neither is a colour this file owes.
        used |= set(re.findall(
            r"(?!border-radius)(?:[\w-]*color|background[\w-]*|border):\s*@(\w+)",
            theme.read_text()))
    emitted = {name for name, _ in ROFI_NAMES} | {"accent"}
    missing = used - emitted
    assert not missing, f"rofi themes reference undefined colours: {sorted(missing)}"

    # Alpha survives the round trip into each syntax.
    assert hex8((202, 211, 245, 75)) == "#cad3f5bf", hex8((202, 211, 245, 75))
    assert hex8((202, 211, 245, 100)) == "#cad3f5ff"
    assert rgba((202, 211, 245, 100)) == "rgb(202,211,245)"
    assert rgba((202, 211, 245, 75)) == "rgba(202,211,245,0.75)"

    # A role naming a colour the palette lacks must fail, not emit a hole.
    try:
        resolve({"scheme": "x", "bad": "nosuchcolour"}, {"base": "#000000"}, "probe")
    except ValueError as exc:
        assert "nosuchcolour" in str(exc)
    else:
        raise AssertionError("unknown colour did not raise")

    print("selftest OK")


def main(argv):
    if "--selftest" in argv:
        selftest()
        return 0

    roles_path = SELECTOR if SELECTOR.exists() else SELECTOR_SEED
    out_dir = REPO
    if "--roles" in argv:
        roles_path = Path(argv[argv.index("--roles") + 1]).expanduser()
    if "--out" in argv:
        out_dir = Path(argv[argv.index("--out") + 1]).expanduser()

    try:
        scheme, written = generate(roles_path, out_dir)
    except ValueError as exc:
        print(f"udt-palette: {exc}", file=sys.stderr)
        return 1

    print(f"{scheme}: {len(written)} files")
    for w in written:
        print(f"  {w}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
