#!/usr/bin/env python3
"""Manage agent hook sets from one unified, variable-driven template.

This productises the two coding-agent-control-plane hook managers (manage-hooks.py for
Claude, manage-codex-hooks.py for Codex) into a single CLI driven by one
template file that can describe multiple agents and target kinds at once. Hook
targets are updated surgically: these commands add or remove just "our" hook
entries, identified by a `marker` substring carried in every rendered
command/url. Rendered-file targets are owned by a generated marker in their
rendered content and are replaced as a whole.

A template (JSON, or YAML by extension) looks like::

    {
      "name": "coding-agent-control-plane",
      "marker": "coding-agent-control-plane/collector.py",
      "variables": {"collector": "<abs path>", "url_base": "http://.../hook"},
      "targets": {
        "claude": {
          "path": "~/.claude/settings.json",
          "settings": { ...top-level non-hook keys to deep-merge... },
          "hooks": { "<Event>": [ <entry objects> ], ... }
        },
        "codex": {
          "path": "~/.codex/hooks.json",
          "hooks": { "<Event>": [ <entry objects> ], ... }
        },
        "pi": {
          "kind": "rendered-file",
          "path": "~/.omp/agent/hooks/post/team-collector.js",
          "source": "team-collector.js.tpl",
          "marker": "hooks-mgr-owned: coding-agent-control-plane/pi",
          "mode": "0600"
        }
      }
    }

`{{variable}}` tokens (optional surrounding spaces allowed) are substituted in
every string of hook targets. Rendered-file source templates additionally
support `{{json:key}}`, which emits a JSON-quoted string safe for JavaScript.
Defaults come from `variables`; `--var key=value` overrides them. Any unresolved
token after substitution is a hard error — we never write a half-rendered file.

A hook entry is "ours" iff any of its command/commandWindows/command_windows/url
fields contains the rendered marker (the COMMAND_KEYS idea from the Codex
original), so this single predicate works for both Claude and Codex.

Commands:
  merge    Deep-merge rendered hook targets, or replace rendered-file targets.
           Existing "our" hook entries are replaced rather than duplicated;
           rendered files are backed up before replacement.
  remove   Strip only "our" hook entries, or delete a rendered file only when
           its generated ownership marker is present.
  diff     Show, as a unified diff, exactly what `merge` would change. No writes.
  disable  Pick one hook event to remove now and mark disabled in its template.
           Rendered-file targets have no individually disableable events.
  enable   Pick one previously-disabled hook event to re-enable and re-add.
  render   Print the fully-rendered target to stdout.

`--template` is required for every command. `--target NAME` selects one target;
if omitted, the command acts on every target present in the template.
"""

from __future__ import annotations

import argparse
import copy
import difflib
import json
import os
import re
import shlex
import shutil
import sys
import tempfile
from pathlib import Path

# A hook command may carry the marker under any of these keys (Codex also has
# *Windows variants); the same predicate then works for Claude and Codex alike.
COMMAND_KEYS = ("command", "commandWindows", "command_windows")

# {{ key }} with optional surrounding whitespace.
_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}")
# JSON-safe tokens are useful in rendered JavaScript and other text files.
_JSON_VAR_RE = re.compile(r"\{\{\s*json:([A-Za-z0-9_.-]+)\s*\}\}")
# Any leftover {{...}} after substitution is treated as unresolved.
_ANY_TOKEN_RE = re.compile(r"\{\{\s*([^{}]+?)\s*\}\}")


# --------------------------------------------------------------------------- #
# Template loading + rendering
# --------------------------------------------------------------------------- #

def load_json_or_yaml(path: Path) -> dict:
    """Load a template by extension. JSON is stdlib; YAML lazily imports PyYAML
    so the JSON path stays dependency-free."""
    text = path.read_text()
    if path.suffix.lower() in (".yaml", ".yml"):
        try:
            import yaml  # lazy: only needed for YAML templates
        except ModuleNotFoundError:
            sys.exit("YAML template requires PyYAML: pip install -r requirements.txt")
        return yaml.safe_load(text) or {}
    return json.loads(text) if text.strip() else {}


def load_template(path: Path) -> dict:
    if not path.exists():
        sys.exit(f"template not found: {path}")
    tpl = load_json_or_yaml(path)
    if not isinstance(tpl, dict) or "targets" not in tpl:
        sys.exit(f"template missing 'targets': {path}")
    return tpl


def resolve_vars(template: dict, overrides: dict) -> dict:
    """Template variable defaults overlaid with --var overrides."""
    merged = dict(template.get("variables") or {})
    merged.update(overrides)
    return {k: str(v) for k, v in merged.items()}


def _subst_string(s: str, variables: dict) -> str:
    return _VAR_RE.sub(lambda m: variables.get(m.group(1), m.group(0)), s)


def substitute(obj, variables: dict):
    """Recursively replace {{key}} tokens in every string of a JSON-ish object."""
    if isinstance(obj, str):
        return _subst_string(obj, variables)
    if isinstance(obj, list):
        return [substitute(v, variables) for v in obj]
    if isinstance(obj, dict):
        return {k: substitute(v, variables) for k, v in obj.items()}
    return obj

def substitute_text(text: str, variables: dict) -> str:
    """Render variables in a text template, JSON-quoting `{{json:key}}`."""
    text = _JSON_VAR_RE.sub(
        lambda m: json.dumps(variables[m.group(1)], ensure_ascii=False)
        if m.group(1) in variables else m.group(0),
        text,
    )
    return _subst_string(text, variables)


def _collect_unresolved(obj, found: set):
    if isinstance(obj, str):
        for m in _ANY_TOKEN_RE.finditer(obj):
            token = m.group(1).strip()
            found.add(token.removeprefix("json:"))
    elif isinstance(obj, list):
        for v in obj:
            _collect_unresolved(v, found)
    elif isinstance(obj, dict):
        for v in obj.values():
            _collect_unresolved(v, found)


def render_target(template: dict, target: str, variables: dict,
                  template_dir: Path | None = None) -> dict:
    """Render one hook or rendered-file target.

    Relative rendered-file sources are resolved beside the unified template.
    All template tokens must resolve, and a rendered file must contain its
    non-empty ownership marker so `remove` can never delete an unrelated file.
    """
    targets = template.get("targets") or {}
    if target not in targets:
        sys.exit(f"target {target!r} not in template (have: "
                 f"{', '.join(targets) or 'none'})")
    spec = targets[target]
    kind = str(spec.get("kind", "hooks"))
    marker = _subst_string(
        str(spec.get("marker", template.get("marker", ""))), variables)
    path = _subst_string(str(spec.get("path", "")), variables)

    if kind == "hooks":
        rendered = {
            "kind": kind,
            "path": path,
            "marker": marker,
            "settings": substitute(spec.get("settings") or {}, variables),
            "hooks": substitute(spec.get("hooks") or {}, variables),
        }
    elif kind == "rendered-file":
        source_name = _subst_string(str(spec.get("source", "")), variables)
        if not source_name:
            sys.exit(f"rendered-file target {target!r} missing 'source'")
        source_path = Path(source_name).expanduser()
        if not source_path.is_absolute():
            source_path = (template_dir or Path.cwd()) / source_path
        if not source_path.is_file():
            sys.exit(f"rendered-file source not found: {source_path}")
        content = substitute_text(source_path.read_text(), variables)
        rendered = {
            "kind": kind,
            "path": path,
            "marker": marker,
            "content": content,
            "mode": str(spec.get("mode", "0600")),
        }
    else:
        sys.exit(f"unsupported target kind {kind!r} for {target!r}")

    unresolved: set = set()
    _collect_unresolved(rendered, unresolved)
    if unresolved:
        names = ", ".join(sorted(unresolved))
        sys.exit(f"unresolved template variable(s): {names} "
                 f"(supply with --var <name>=<value>)")
    if kind == "rendered-file" and (not marker or marker not in rendered["content"]):
        sys.exit(f"rendered-file target {target!r} must have a non-empty marker "
                 "present in its rendered content")
    return rendered


def selected_targets(template: dict, target: str | None) -> list:
    """The target names to act on: one if --target given, else all present."""
    names = list((template.get("targets") or {}).keys())
    if target:
        if target not in names:
            sys.exit(f"target {target!r} not in template (have: "
                     f"{', '.join(names) or 'none'})")
        return [target]
    return names


# --------------------------------------------------------------------------- #
# Live-file IO
# --------------------------------------------------------------------------- #

def load(path: Path) -> dict:
    if not path.exists():
        return {}
    text = path.read_text().strip()
    return json.loads(text) if text else {}


def dumps(obj: dict) -> str:
    # No sort_keys: preserve natural/insertion order so diffs show real changes
    # rather than spurious reordering.
    return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"


# --------------------------------------------------------------------------- #
# "Our" hook detection + the merge/strip/disable machinery
# --------------------------------------------------------------------------- #

def is_ours(hook: dict, marker: str) -> bool:
    """A hook entry is "ours" if its command fields (or http url) reference the
    rendered marker."""
    return any(marker in hook.get(key, "") for key in COMMAND_KEYS + ("url",))


def inject_secret(rendered: dict, secret: str) -> None:
    """Append `--secret <value>` to every "our" command in the rendered hooks,
    in place. Lets `merge`/`enable` install a server secret without editing the
    template file (which is loaded fresh each run, so the next merge without
    --secret cleanly drops it again)."""
    token = shlex.quote(secret)
    marker = rendered["marker"]
    for entries in (rendered.get("hooks") or {}).values():
        for entry in entries:
            for h in entry.get("hooks", []):
                for key in COMMAND_KEYS:
                    cmd = h.get(key, "")
                    if marker in cmd:
                        h[key] = f"{cmd} --secret {token}"


def _strip_our_entries(entries: list, marker: str) -> list:
    """Drop "our" hooks from one event's entry list; keep everything else."""
    kept = []
    for entry in entries:
        if "hooks" in entry:
            inner = [h for h in entry["hooks"] if not is_ours(h, marker)]
            if inner:
                kept.append({**entry, "hooks": inner})
            # Entry whose only hooks were ours is dropped entirely.
        else:
            kept.append(entry)
    return kept


def _drop_disabled(entries: list, marker: str) -> list:
    """Copy one event's entries, omitting disabled "our" hooks.

    The "disabled" flag is only a template concept and is stripped before a hook
    is written into the live file. Entries left with no hooks are dropped."""
    out = []
    for entry in entries:
        if "hooks" in entry:
            inner = []
            for h in entry["hooks"]:
                if is_ours(h, marker) and h.get("disabled"):
                    continue
                inner.append({k: v for k, v in h.items() if k != "disabled"})
            if inner:
                out.append({**entry, "hooks": inner})
        else:
            out.append(entry)
    return copy.deepcopy(out)


def merge_hooks(target_hooks: dict, source_hooks: dict, marker: str) -> dict:
    """Merge event -> [entries].

    Re-running is idempotent: any pre-existing "our" entries are removed first,
    then the source entries are appended, so other hooks on the same event
    survive. Source hooks flagged "disabled" are skipped."""
    result = copy.deepcopy(target_hooks)
    for event, entries in source_hooks.items():
        cleaned = _strip_our_entries(result.get(event, []), marker)
        merged = cleaned + _drop_disabled(entries, marker)
        if merged:
            result[event] = merged
        else:
            # event had only our (now-disabled) hook -> don't create empty key
            result.pop(event, None)
    return result


def deep_merge(dst: dict, src: dict, marker: str) -> dict:
    out = copy.deepcopy(dst)
    for key, value in src.items():
        if key == "hooks" and isinstance(value, dict):
            out["hooks"] = merge_hooks(out.get("hooks") or {}, value, marker)
        elif isinstance(value, dict) and isinstance(out.get(key), dict):
            out[key] = deep_merge(out[key], value, marker)
        else:
            out[key] = copy.deepcopy(value)
    return out


def merged_target(live: dict, rendered: dict) -> dict:
    """Deep-merge a rendered target's settings + hooks into a live file dict."""
    marker = rendered["marker"]
    src = dict(rendered.get("settings") or {})
    src["hooks"] = rendered.get("hooks") or {}
    return deep_merge(live, src, marker)


def strip_hooks(target: dict, marker: str) -> dict:
    out = copy.deepcopy(target)
    hooks = out.get("hooks")
    if not isinstance(hooks, dict):
        return out
    new_hooks = {}
    for event, entries in hooks.items():
        kept = _strip_our_entries(entries, marker)
        if kept:
            new_hooks[event] = kept
    if new_hooks:
        out["hooks"] = new_hooks
    else:
        out.pop("hooks", None)
    return out


def count_our_hooks(obj: dict, marker: str) -> int:
    n = 0
    for entries in (obj.get("hooks") or {}).values():
        for entry in entries:
            n += sum(1 for h in entry.get("hooks", []) if is_ours(h, marker))
    return n


# --------------------------------------------------------------------------- #
# disable/enable helpers (operate on the raw template, not the rendered one)
# --------------------------------------------------------------------------- #

def is_ours_raw(hook: dict, marker: str, variables: dict) -> bool:
    """Like is_ours, but for a RAW (un-rendered) template hook: substitute the
    variables into the command/url fields first so the marker (which lives
    inside the {{collector}} value) is visible. disable/enable operate on the
    raw template file, so they need this variant."""
    rendered = substitute(hook, variables)
    return is_ours(rendered, marker)


def template_events(template: dict, target: str, marker: str, variables: dict,
                    disabled: bool) -> list:
    """Event names in one target whose "our" hook disabled-state matches
    `disabled`, in file order, de-duplicated. Operates on the raw template
    hooks, substituting variables so the marker is detectable."""
    out = []
    spec = (template.get("targets") or {}).get(target) or {}
    for event, entries in (spec.get("hooks") or {}).items():
        for entry in entries:
            for h in entry.get("hooks", []):
                if (is_ours_raw(h, marker, variables)
                        and bool(h.get("disabled", False)) == disabled):
                    if event not in out:
                        out.append(event)
    return out


def set_disabled(template: dict, target: str, event: str, marker: str,
                 variables: dict, value: bool):
    """Flip the "disabled" flag on the "our" hook(s) for one event in the raw
    template file (in place)."""
    spec = (template.get("targets") or {}).get(target) or {}
    for entry in (spec.get("hooks") or {}).get(event, []):
        for h in entry.get("hooks", []):
            if is_ours_raw(h, marker, variables):
                if value:
                    h["disabled"] = True
                else:
                    h.pop("disabled", None)


def remove_event_from_target(live: dict, event: str, marker: str):
    """Strip the "our" hook for a single event out of the live file."""
    hooks = live.get("hooks")
    if not isinstance(hooks, dict) or event not in hooks:
        return
    kept = _strip_our_entries(hooks[event], marker)
    if kept:
        hooks[event] = kept
    else:
        del hooks[event]
    if not hooks:
        live.pop("hooks", None)


def add_event_to_target(live: dict, rendered: dict, event: str):
    """Add the rendered "our" hook for a single event into the live file,
    replacing any existing "our" entry for that event."""
    marker = rendered["marker"]
    entries = _drop_disabled((rendered.get("hooks") or {}).get(event, []), marker)
    if not entries:
        return
    hooks = live.setdefault("hooks", {})
    cleaned = _strip_our_entries(hooks.get(event, []), marker)
    hooks[event] = cleaned + entries


# --------------------------------------------------------------------------- #
# diff + writes
# --------------------------------------------------------------------------- #

def print_diff(before: dict, after: dict, path: Path) -> bool:
    before_lines = dumps(before).splitlines(keepends=True)
    after_lines = dumps(after).splitlines(keepends=True)
    diff = list(difflib.unified_diff(
        before_lines, after_lines,
        fromfile=f"{path} (current)", tofile=f"{path} (after)"))
    if not diff:
        print(f"no changes ({path})")
        return False
    sys.stdout.writelines(diff)
    return True

def print_text_diff(before: str, after: str, path: Path) -> bool:
    diff = list(difflib.unified_diff(
        before.splitlines(keepends=True), after.splitlines(keepends=True),
        fromfile=f"{path} (current)", tofile=f"{path} (after)"))
    if not diff:
        print(f"no changes ({path})")
        return False
    sys.stdout.writelines(diff)
    return True


def write_target(path: Path, obj: dict):
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists():
        backup = path.with_name(path.name + ".bak")
        backup.write_text(path.read_text())
        print(f"backed up current file -> {backup}", file=sys.stderr)
    path.write_text(dumps(obj))
    print(f"wrote {path}", file=sys.stderr)

def rendered_file_mode(rendered: dict) -> int:
    try:
        mode = int(rendered.get("mode", "0600"), 8)
    except (TypeError, ValueError):
        sys.exit(f"invalid rendered-file mode: {rendered.get('mode')!r}")
    if mode < 0 or mode > 0o777 or mode & 0o077:
        sys.exit("rendered-file mode must not grant group or other permissions")
    return mode


def backup_rendered_file(path: Path, mode: int) -> Path:
    backup = path.with_name(path.name + ".bak")
    shutil.copyfile(path, backup)
    os.chmod(backup, mode)
    print(f"backed up current file -> {backup}", file=sys.stderr)
    return backup


def write_rendered_file(path: Path, content: str, mode: int):
    """Atomically replace a rendered file with private permissions."""
    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    if path.exists():
        backup_rendered_file(path, mode)
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(
                mode="w", encoding="utf-8", dir=path.parent,
                prefix=f".{path.name}.", delete=False) as handle:
            temporary = Path(handle.name)
            handle.write(content)
        os.chmod(temporary, mode)
        os.replace(temporary, path)
    finally:
        if temporary is not None and temporary.exists():
            temporary.unlink()
    print(f"wrote {path}", file=sys.stderr)


def write_template(path: Path, obj: dict):
    """Persist the raw template back to disk (JSON only — disable/enable edits)."""
    if path.suffix.lower() in (".yaml", ".yml"):
        try:
            import yaml  # lazy: only needed for YAML templates
        except ModuleNotFoundError:
            sys.exit("YAML template requires PyYAML: pip install -r requirements.txt")
        path.write_text(yaml.safe_dump(obj, sort_keys=False, allow_unicode=True))
    else:
        path.write_text(dumps(obj))
    print(f"updated {path}", file=sys.stderr)


# --------------------------------------------------------------------------- #
# Target/path resolution for the CLI
# --------------------------------------------------------------------------- #

def target_path(rendered: dict, override: Path | None) -> Path:
    if override is not None:
        return override
    return Path(rendered["path"]).expanduser()


def each_target(template: dict, args):
    """Yield (name, rendered_target, live_path) for every selected target,
    applying --var overrides and rendering. Validates --path usage."""
    variables = resolve_vars(template, args.var)
    names = selected_targets(template, args.target)
    if args.path is not None and len(names) > 1:
        sys.exit("--path can only be used when exactly one target is selected "
                 "(pass --target NAME)")
    for name in names:
        rendered = render_target(template, name, variables,
                                 template_dir=args.template.parent)
        yield name, rendered, target_path(rendered, args.path)


# --------------------------------------------------------------------------- #
# arrow-key picker (ported verbatim from the originals)
# --------------------------------------------------------------------------- #

def pick(labels: list, title: str):
    """Arrow-key picker. Returns the chosen index, or None if cancelled.
    Falls back to a numbered prompt when stdin/stderr isn't a TTY."""
    if not labels:
        return None
    if not (sys.stdin.isatty() and sys.stderr.isatty()):
        for i, lbl in enumerate(labels):
            print(f"  [{i}] {lbl}", file=sys.stderr)
        try:
            raw = input(f"{title} (number, blank to cancel): ").strip()
        except EOFError:
            return None
        if not raw.isdigit() or not (0 <= int(raw) < len(labels)):
            return None
        return int(raw)

    import termios
    import tty

    fd = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    idx = 0
    n = len(labels)

    def draw(first=False):
        if not first:
            sys.stderr.write(f"\x1b[{n}A")  # move cursor up n lines
        for i, lbl in enumerate(labels):
            marker = "\x1b[7m> " if i == idx else "  "
            reset = "\x1b[0m" if i == idx else ""
            sys.stderr.write(f"\x1b[2K{marker}{lbl}{reset}\r\n")
        sys.stderr.flush()

    sys.stderr.write(title + "\r\n")
    sys.stderr.write("(up/down or j/k to move, Enter to select, q to cancel)\r\n")
    try:
        tty.setraw(fd)
        draw(first=True)
        while True:
            ch = sys.stdin.read(1)
            if ch == "\x1b":
                if sys.stdin.read(1) == "[":
                    arrow = sys.stdin.read(1)
                    if arrow == "A":
                        idx = (idx - 1) % n
                    elif arrow == "B":
                        idx = (idx + 1) % n
                    draw()
            elif ch in ("\r", "\n"):
                return idx
            elif ch == "k":
                idx = (idx - 1) % n
                draw()
            elif ch == "j":
                idx = (idx + 1) % n
                draw()
            elif ch in ("q", "\x03"):  # q or Ctrl-C
                return None
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)
        sys.stderr.write("\r\n")
        sys.stderr.flush()


# --------------------------------------------------------------------------- #
# Commands
# --------------------------------------------------------------------------- #

def cmd_merge(args):
    template = load_template(args.template)
    for name, rendered, path in each_target(template, args):
        if args.secret:
            inject_secret(rendered, args.secret)
        if rendered["kind"] == "rendered-file":
            before = path.read_text() if path.exists() else ""
            after = rendered["content"]
            if args.dry_run:
                print(f"# target: {name} -> {path}")
                print_text_diff(before, after, path)
                continue
            write_rendered_file(path, after, rendered_file_mode(rendered))
            print(f"[{name}] rendered file installed", file=sys.stderr)
            continue
        live = load(path)
        merged = merged_target(live, rendered)
        if args.dry_run:
            print(f"# target: {name} -> {path}")
            print_diff(live, merged, path)
            continue
        write_target(path, merged)
        print(f"[{name}] hooks now installed: "
              f"{count_our_hooks(merged, rendered['marker'])}", file=sys.stderr)


def cmd_remove(args):
    template = load_template(args.template)
    for name, rendered, path in each_target(template, args):
        marker = rendered["marker"]
        if rendered["kind"] == "rendered-file":
            if not path.exists():
                print(f"[{name}] rendered file not present; nothing to remove",
                      file=sys.stderr)
                continue
            if marker not in path.read_text():
                print(f"[{name}] refusing to remove unowned file: {path}",
                      file=sys.stderr)
                continue
            backup_rendered_file(path, rendered_file_mode(rendered))
            path.unlink()
            print(f"[{name}] removed rendered file", file=sys.stderr)
            continue
        live = load(path)
        removed = count_our_hooks(live, marker)
        if removed == 0:
            print(f"[{name}] no hooks present; nothing to remove", file=sys.stderr)
            continue
        stripped = strip_hooks(live, marker)
        write_target(path, stripped)
        print(f"[{name}] removed {removed} hook(s)", file=sys.stderr)


def cmd_diff(args):
    template = load_template(args.template)
    for name, rendered, path in each_target(template, args):
        if args.secret:
            inject_secret(rendered, args.secret)
        print(f"# target: {name} -> {path}")
        if rendered["kind"] == "rendered-file":
            before = path.read_text() if path.exists() else ""
            print_text_diff(before, rendered["content"], path)
            continue
        live = load(path)
        merged = merged_target(live, rendered)
        print_diff(live, merged, path)


def cmd_render(args):
    template = load_template(args.template)
    for name, rendered, path in each_target(template, args):
        if args.secret:
            inject_secret(rendered, args.secret)
        print(f"# target: {name} -> {path}")
        if rendered["kind"] == "rendered-file":
            sys.stdout.write(rendered["content"])
            if rendered["content"] and not rendered["content"].endswith("\n"):
                sys.stdout.write("\n")
            continue
        out = dict(rendered["settings"])
        out["hooks"] = rendered["hooks"]
        sys.stdout.write(dumps(out))


def _disable_enable_labels(template: dict, args, disabled: bool):
    """Build the (label, target, event) options across the selected targets.
    Labels read '<target> · <Event>' when more than one target or event is in
    play, else just the event name."""
    variables = resolve_vars(template, args.var)
    names = selected_targets(template, args.target)
    if args.path is not None and len(names) > 1:
        sys.exit("--path can only be used when exactly one target is selected "
                 "(pass --target NAME)")
    options = []  # list of (target, event)
    for name in names:
        spec = (template.get("targets") or {}).get(name) or {}
        marker = _subst_string(
            str(spec.get("marker", template.get("marker", ""))), variables)
        for event in template_events(template, name, marker, variables,
                                     disabled=disabled):
            options.append((name, event))
    multi = len(names) > 1 or len(options) > 1
    labels = [f"{t} · {e}" if multi else e for (t, e) in options]
    return options, labels, variables


def cmd_disable(args):
    template = load_template(args.template)
    options, labels, variables = _disable_enable_labels(template, args, disabled=False)
    if not options:
        print("no enabled hooks to disable", file=sys.stderr)
        return
    choice = pick(labels, "Select a hook event to DISABLE:")
    if choice is None:
        print("cancelled", file=sys.stderr)
        return
    target, event = options[choice]
    rendered = render_target(template, target, variables,
                             template_dir=args.template.parent)
    marker = rendered["marker"]
    path = target_path(rendered, args.path)

    # 1. Mark disabled in the template file so a fresh `merge` won't re-add it.
    set_disabled(template, target, event, marker, variables, True)
    write_template(args.template, template)

    # 2. Remove it from the live file now.
    live = load(path)
    if count_our_hooks(live, marker):  # only back up if there's something
        remove_event_from_target(live, event, marker)
        write_target(path, live)
    print(f"[{target}] disabled hook for event: {event}", file=sys.stderr)


def cmd_enable(args):
    template = load_template(args.template)
    options, labels, variables = _disable_enable_labels(template, args, disabled=True)
    if not options:
        print("no disabled hooks to enable", file=sys.stderr)
        return
    choice = pick(labels, "Select a hook event to ENABLE:")
    if choice is None:
        print("cancelled", file=sys.stderr)
        return
    target, event = options[choice]
    marker = _subst_string(str(template.get("marker", "")), variables)

    # 1. Clear the flag in the template file.
    set_disabled(template, target, event, marker, variables, False)
    write_template(args.template, template)

    # 2. Add it back into the live file now. Render AFTER writing the template,
    #    then inject the secret, so the secret is never persisted to disk.
    rendered = render_target(template, target, variables,
                             template_dir=args.template.parent)
    if args.secret:
        inject_secret(rendered, args.secret)
    path = target_path(rendered, args.path)
    live = load(path)
    add_event_to_target(live, rendered, event)
    write_target(path, live)
    print(f"[{target}] enabled hook for event: {event}", file=sys.stderr)


# --------------------------------------------------------------------------- #
# CLI plumbing
# --------------------------------------------------------------------------- #

def _parse_var(value: str) -> tuple:
    if "=" not in value:
        raise argparse.ArgumentTypeError(
            f"--var expects key=value, got {value!r}")
    key, val = value.split("=", 1)
    key = key.strip()
    if not key:
        raise argparse.ArgumentTypeError(f"--var has empty key: {value!r}")
    return key, val


class _VarAction(argparse.Action):
    """Collect repeated --var key=value into a dict."""
    def __call__(self, parser, namespace, values, option_string=None):
        d = getattr(namespace, self.dest) or {}
        k, v = values
        d[k] = v
        setattr(namespace, self.dest, d)


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)

    def common(sp):
        sp.add_argument("--template", type=Path, required=True,
                        help="unified hook template (JSON, or YAML by extension)")
        sp.add_argument("--target", default=None,
                        help="act on one named target; default: every target")
        sp.add_argument("--var", action=_VarAction, type=_parse_var,
                        default={}, metavar="KEY=VALUE",
                        help="override a template variable (repeatable)")
        sp.add_argument("--path", type=Path, default=None,
                        help="override the live file path (single target only)")

    def secret_arg(sp):
        sp.add_argument("--secret", default=None,
                        help="legacy: append --secret <value> to every "
                             "installed command so it authenticates to a "
                             "secret-guarded sink")

    m = sub.add_parser("merge", help="install rendered targets into live files")
    common(m)
    secret_arg(m)
    m.add_argument("--dry-run", action="store_true",
                   help="show the diff instead of writing")
    m.set_defaults(func=cmd_merge)

    r = sub.add_parser("remove", help="remove owned hooks or rendered files")
    common(r)
    r.set_defaults(func=cmd_remove)

    d = sub.add_parser("diff", help="show what merge would change")
    common(d)
    secret_arg(d)
    d.set_defaults(func=cmd_diff)

    dis = sub.add_parser("disable",
                         help="pick an event to remove now and mark disabled")
    common(dis)
    dis.set_defaults(func=cmd_disable)

    en = sub.add_parser("enable",
                        help="pick a disabled event to re-enable and re-add")
    common(en)
    secret_arg(en)
    en.set_defaults(func=cmd_enable)

    rn = sub.add_parser("render",
                        help="print the fully-rendered target to stdout")
    common(rn)
    secret_arg(rn)
    rn.set_defaults(func=cmd_render)

    return p


def main(argv=None):
    args = build_parser().parse_args(argv)
    args.func(args)


if __name__ == "__main__":
    main()
