# Nomarchy theming engine.
#
# nomarchy.stateFile (theme-state.json, inside the consuming flake) is
# ingested at evaluation time — pure, because flake files are store
# paths — and exposed to every other module as `config.nomarchy.theme`.
#
# Theme changes are fully Home Manager managed: `nomarchy-theme-sync
# apply <theme>` writes the JSON and runs `home-manager switch`, baking
# everything (Hyprland, Waybar, Ghostty, btop, Stylix) into one
# read-only generation. No runtime patching, no partial states; theme
# history is generation history.
#
# The one runtime exception is the wallpaper (swww is imperative by
# nature): applied at session start, after every switch (hook below),
# and cycled instantly with `nomarchy-theme-sync bg next`.
{ config, lib, pkgs, ... }:

let
  cfg = config.nomarchy;

  # Fail-closed load: missing / empty / non-object get a short pointer at
  # the template + `nomarchy-theme-sync validate`, not a raw readFile stack
  # from deep inside a consumer (nightlight, hyprland, …). Field-level
  # checks below still run on the merged result.
  themeState = import ../theme-state-read.nix { inherit lib; } cfg.stateFile;

  # Defaults guarantee evaluation succeeds on a sparse or older state
  # file (e.g. one written before a schema field was added). The shipped
  # Boreal preset provides the color/ansi fallbacks; the path is
  # relative to this module, so it resolves inside Nomarchy's own flake
  # source even when consumed downstream.
  preset = builtins.fromJSON (builtins.readFile ../../themes/boreal.json);

  defaults = preset // {
    fonts = {
      mono = "JetBrainsMono Nerd Font";
      ui = "Inter";
      size = 11;
    };
    ui = {
      gapsIn = 5;
      gapsOut = 12;
      borderSize = 2;
      rounding = 10;
      iconSize = 36; # rofi launcher + menu icon size (px) for the generated theme
      activeOpacity = 1.0;
      inactiveOpacity = 0.95;
      terminalOpacity = 0.96;
      blur = true;
      shadow = true;
    };
    # Icon theme name; "" means "pick by mode" (resolved below). A preset
    # or the state file can name any theme in the shipped icon package.
    icons = "";
    # Window border colors. Values are palette keys (resolved against
    # `colors` below) or literal #rrggbb. Solid, not a gradient — every
    # legacy identity theme used a solid border (accent for most, the text
    # tone for kanagawa/summer-*). Each preset declares its own so a theme
    # switch always replaces it (deep_merge would otherwise leave it stuck).
    border = { active = "accent"; inactive = "overlay"; };

    # Non-appearance feature settings the menu/watchers write into this same
    # in-flake state. nomarchy.nightlight: `installed` (sticky — gates the unit,
    # so the first enable rebuilds) and `on` (instant runtime on/off).
    # settings.keyboard.devices: per-device layouts the new-keyboard watcher
    # remembers (device-name -> XKB layout), git-tracked instead of
    # ~/.local/state; they graduate into nomarchy.keyboard.devices on the next
    # rebuild. settings.monitors: per-output resolutions the Display menu
    # remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
    # name in hyprland.nix — the monitor twin of the keyboard graduation.
    # settings.displayProfile / displayProfileAuto: active named layout +
    # auto-switch on plug events (hyprland.nix / Display menu).
    # Defaulted so a sparse/older state file still evaluates; nomarchy.settings
    # exposes them.
    settings = {
      nightlight = { installed = false; on = true; };
      keyboard.devices = { };
      monitors = { };
      # Automatic timezone detection (nomarchy.system.autoTimezone): a system
      # service, but the flag lives here so both sides read one source — the
      # home side gates the Waybar-refresh watcher (timezone.nix) on it.
      autoTimezone = false;
      displayProfile = "";
      displayProfileAuto = false;
    };
  };

  parsed = lib.recursiveUpdate defaults themeState;

  # ── Friendly eval-time validation ───────────────────────────────────
  # The same schema nomarchy-theme-sync enforces before every write. A
  # HAND-edited state file (the one path that bypasses the tool) must
  # fail with the field, the problem, and the fix — not a Nix stack
  # trace deep in some consumer. Checks run on `parsed` (after the
  # defaults), so missing fields are fine; only wrong values throw.
  isHex = v: builtins.isString v && builtins.match "#[0-9a-fA-F]{6}" v != null;
  isNum = v: builtins.isInt v || builtins.isFloat v;
  colorRoles = [ "base" "mantle" "surface" "overlay" "text" "subtext"
                 "muted" "accent" "accentAlt" "good" "warn" "bad" ];
  got = v: "got: ${builtins.toJSON v}";
  problems = lib.concatLists [
    (map (k: "colors.${k} must be \"#RRGGBB\" (${got (parsed.colors.${k} or null)})")
      (builtins.filter (k: !isHex (parsed.colors.${k} or null)) colorRoles))
    (lib.optional (!(parsed.mode == "dark" || parsed.mode == "light"))
      "mode must be \"dark\" or \"light\" (${got parsed.mode})")
    (map (k: "ui.${k} must be a non-negative whole number (${got (parsed.ui.${k} or null)})")
      (builtins.filter
        (k: let v = parsed.ui.${k} or null; in !(builtins.isInt v && v >= 0))
        [ "gapsIn" "gapsOut" "borderSize" "rounding" "iconSize" ]))
    (map (k: "ui.${k} must be a number between 0 and 1 (${got (parsed.ui.${k} or null)})")
      (builtins.filter
        (k: let v = parsed.ui.${k} or null; in !(isNum v && v >= 0 && v <= 1))
        [ "activeOpacity" "inactiveOpacity" "terminalOpacity" ]))
    (map (k: "ui.${k} must be true or false (${got (parsed.ui.${k} or null)})")
      (builtins.filter (k: !builtins.isBool (parsed.ui.${k} or null))
        [ "blur" "shadow" ]))
    (map (k: "fonts.${k} must be a font-family string (${got (parsed.fonts.${k} or null)})")
      (builtins.filter (k: !builtins.isString (parsed.fonts.${k} or null))
        [ "mono" "ui" ]))
    (lib.optional (!(isNum (parsed.fonts.size or null) && parsed.fonts.size > 0))
      "fonts.size must be a positive number (${got (parsed.fonts.size or null)})")
    (lib.optional (!(builtins.isList parsed.ansi
                     && builtins.length parsed.ansi == 16
                     && lib.all isHex parsed.ansi))
      "ansi must be a list of exactly 16 \"#RRGGBB\" strings")
    (map (k: "border.${k} must be a palette role or \"#RRGGBB\" (${got (parsed.border.${k} or null)})")
      (builtins.filter
        (k: let v = parsed.border.${k} or null;
            in !(builtins.isString v && (builtins.elem v colorRoles || isHex v)))
        [ "active" "inactive" ]))
  ];
  checked =
    if problems == [ ] then parsed
    else throw ''

      Nomarchy: your theme-state.json is invalid:
      ${lib.concatMapStrings (p: "  ✖ ${p}\n") problems}
      Fix the named field(s) in the theme-state.json of your flake
      checkout (usually ~/.nomarchy/theme-state.json — the store copy at
      ${toString cfg.stateFile} is a snapshot of it). The tool prints
      the same report with per-field fixes: `nomarchy-theme-sync
      validate`. Re-applying any preset resets all appearance fields:
      `nomarchy-theme-sync apply boreal`.'';

  # A border value is a palette key (look it up in colors) unless it's
  # already a literal hex. Unknown roles are rejected by the field checks
  # above; resolve only runs on a validated state.
  resolveColor = v:
    if lib.hasPrefix "#" v then v
    else parsed.colors.${v} or (throw ''

      Nomarchy: border role "${v}" is not in the palette (colors.*).
      Use one of: ${lib.concatStringsSep ", " colorRoles}
      or a literal "#RRGGBB". Validate with: nomarchy-theme-sync validate'');
  border = {
    active = resolveColor checked.border.active;
    inactive = resolveColor checked.border.inactive;
  };

  # Resolve the icon theme once and expose it on nomarchy.theme so both
  # the GTK side (stylix.nix) and rofi (rofi.nix) agree. The shipped
  # package (papirus-icon-theme) carries the Dark/Light pair.
  iconTheme =
    if parsed.icons != "" then parsed.icons
    else if parsed.mode == "light" then "Papirus-Light"
    else "Papirus-Dark";
in
{
  config = {
    nomarchy.theme = checked // { inherit iconTheme border; };

    # Feature toggles the menu writes (settings.nightlight.enable, …), exposed
    # alongside the appearance state. Feature modules mkDefault-read from here
    # so a menu toggle lands in the flake instead of in ~/.local/state.
    nomarchy.settings = checked.settings;

    nomarchy.lib = {
      # "#7aa2f7" -> "rgb(7aa2f7)"          (Hyprland color syntax)
      rgb = c: "rgb(${lib.removePrefix "#" c})";
      # "#16161e" -> "rgba(16161eaa)"       (Hyprland color + hex alpha)
      rgba = c: alpha: "rgba(${lib.removePrefix "#" c}${alpha})";
    };

    home.packages = [ cfg.package ];

    # After a switch the new theme's wallpaper should show without
    # logging out. Best-effort: outside a graphical session swww isn't
    # running and this is a no-op.
    home.activation.nomarchyWallpaper = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
      run ${lib.getExe cfg.package} --quiet wallpaper || true
    '';
  };
}
