# Pure theme-state.json loader — fail closed with a short, actionable
# message instead of a raw `readFile` / `fromJSON` stack buried in a
# consumer. Used by modules/home/theme.nix (required path), the NixOS
# stateFile consumers, and lib.mkFlake (early gate).
#
# Field-level schema checks stay in theme.nix (post-defaults). This file
# only gates *existence* and *JSON-shape* so the first failure the user
# sees points at the state file, not at nightlight.nix.
{ lib }:

path:

let
  pathStr = toString path;

  tip = ''
    Fix:
      • Missing file → copy the template next to your flake.nix:
          templates/downstream/theme-state.json
        or regenerate from a preset:
          nomarchy-theme-sync apply boreal
      • Bad syntax / wrong shape → edit theme-state.json (trailing commas
        are the usual culprit) or reset with `apply` as above.
      • Field-level schema (colors, ui, border, …):
          nomarchy-theme-sync validate
        Eval-time field checks live in modules/home/theme.nix.'';

  missingMsg = ''

    Nomarchy: theme-state.json is missing:
      ${pathStr}

    This file is required (appearance + menu settings). Add it to your
    flake checkout so evaluation stays pure.
    ${tip}'';

  emptyMsg = ''

    Nomarchy: theme-state.json is empty:
      ${pathStr}
    ${tip}'';

  notObjectMsg = ''

    Nomarchy: theme-state.json must be a JSON object `{ ... }`:
      ${pathStr}
    ${tip}'';

  # lib.trim / lib.strings.trim — strip leading/trailing whitespace so an
  # all-whitespace file counts as empty and a BOM-less `{` is recognized.
  strip = s:
    let
      # Prefer lib.trim when present (nixpkgs ≥ 23.11); fall back so a
      # very old pin still gates missing/non-object.
      trim =
        if lib ? trim then lib.trim
        else if lib.strings ? trim then lib.strings.trim
        else (x: x);
    in trim s;

in
if !(builtins.pathExists path) then
  throw missingMsg
else
  let
    raw = builtins.readFile path;
    stripped = strip raw;
  in
  if stripped == "" then
    throw emptyMsg
  # Reject non-objects before fromJSON where we can (null / array / string
  # literals). Subtle syntax errors still surface from fromJSON itself —
  # those messages already include line/column; the path tip above is the
  # part a raw stack used to bury.
  else if builtins.match "[[:space:]]*\\{.*" stripped == null then
    throw notObjectMsg
  else
    let
      value = builtins.fromJSON raw;
    in
    if !(builtins.isAttrs value) then
      throw notObjectMsg
    else
      value
