#!/usr/bin/env bash
# gstack-settings-hook — manage Claude Code hooks in ~/.claude/settings.json
#
# Two shapes:
#
#   1. Legacy (SessionStart only — used by setup --team and gstack-uninstall):
#        gstack-settings-hook add <cmd>            # adds SessionStart hook
#        gstack-settings-hook remove <cmd>         # removes matching SessionStart hook
#
#   2. Schema-aware (plan-tune cathedral T3 — supports PreToolUse + PostToolUse):
#        gstack-settings-hook add-event --event <SessionStart|PreToolUse|PostToolUse> \
#          --command <cmd> --source <tag> [--matcher <regex>] [--timeout <s>]
#        gstack-settings-hook ensure-event --event ... --command ... --source ... [--matcher ...] [--timeout <s>]
#        gstack-settings-hook remove-source --source <tag>
#        gstack-settings-hook diff-event   --event ... --command ... --source ... [--matcher ...]
#        gstack-settings-hook rollback     # restore latest backup
#        gstack-settings-hook list-sources # show all gstack-tagged hook entries
#
# ensure-event is the update-in-place verb: same flags as add-event, but it
# first compares the REGISTERED payload for (event, matcher, source) against
# the requested one. Identical → no write, no backup ("unchanged"). Different
# → the single matching entry is replaced via one atomic tmp+rename, so a
# failed update can never leave zero or two registrations. This is what heals
# a stale absolute hook path (e.g. a deleted dev worktree) baked into
# settings.json by an earlier setup — presence-only dedup never re-pointed it.
#
# Every add-event/remove-source writes a backup to ~/.claude/settings.json.bak.<ts>
# before mutating (Codex correction — silent settings.json mutation is wrong);
# ensure-event backs up only when it actually mutates, so a no-op re-run of
# ./setup doesn't churn backup files.
#
# Dedup: legacy `add`/`remove` dedupe by the historical `gstack-session-update`
# substring. Schema-aware `add-event` dedupes by (event, matcher, _gstack_source) so
# multiple gstack registrations (plan-tune, ...) don't collide.
#
# Writes atomically: .tmp + rename to prevent corruption on crash/disk-full.
set -euo pipefail

ACTION="${1:-}"
SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}"

if [ -z "$ACTION" ]; then
  cat <<EOF >&2
Usage:
  gstack-settings-hook add <hook-command>             # legacy SessionStart add
  gstack-settings-hook remove <hook-command>          # legacy SessionStart remove
  gstack-settings-hook add-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
  gstack-settings-hook ensure-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
  gstack-settings-hook remove-source --source <tag>
  gstack-settings-hook diff-event --event <name> --command <cmd> --source <tag> [--matcher <re>] [--timeout <s>]
  gstack-settings-hook rollback
  gstack-settings-hook list-sources
EOF
  exit 1
fi

if ! command -v bun >/dev/null 2>&1; then
  echo "Error: bun is required but not installed." >&2
  exit 1
fi

backup_settings() {
  if [ -f "$SETTINGS_FILE" ]; then
    local ts
    ts=$(date +%Y%m%d-%H%M%S)
    cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$ts"
    echo "$SETTINGS_FILE.bak.$ts" > "$SETTINGS_FILE.bak-latest"
  fi
}

# --- legacy SessionStart add/remove (backwards compat) -----------------

case "$ACTION" in
  add)
    HOOK_CMD="${2:-}"
    if [ -z "$HOOK_CMD" ]; then
      echo "Usage: gstack-settings-hook add <hook-command>" >&2
      exit 1
    fi
    backup_settings
    GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun -e '
      const fs = require("fs");
      const settingsPath = process.env.GSTACK_SETTINGS_PATH;
      const hookCmd = process.env.GSTACK_HOOK_CMD;
      let settings = {};
      // An EXISTING file that does not parse must never be rewritten: the
      // old catch{} folded it to {} and the atomic write below replaced the
      // user permissions/env/other hooks with just ours. Refuse loudly.
      if (fs.existsSync(settingsPath)) {
        try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
        catch (e) {
          console.error("error: " + settingsPath + " exists but is not valid JSON (" +
            (e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
          process.exit(1);
        }
      }
      if (!settings.hooks) settings.hooks = {};
      if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
      const exists = settings.hooks.SessionStart.some(entry =>
        entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update"))
      );
      if (!exists) {
        settings.hooks.SessionStart.push({
          hooks: [{ type: "command", command: hookCmd }]
        });
      }
      const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
      fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
      fs.renameSync(tmp, settingsPath);
    '
    ;;

  remove)
    HOOK_CMD="${2:-}"
    if [ -z "$HOOK_CMD" ]; then
      echo "Usage: gstack-settings-hook remove <hook-command>" >&2
      exit 1
    fi
    [ -f "$SETTINGS_FILE" ] || exit 1
    backup_settings
    GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e '
      const fs = require("fs");
      const settingsPath = process.env.GSTACK_SETTINGS_PATH;
      let settings = {};
      try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); }
      if (settings.hooks && settings.hooks.SessionStart) {
        settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry =>
          !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update")))
        );
        if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
        if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
      }
      const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
      fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
      fs.renameSync(tmp, settingsPath);
    ' 2>/dev/null
    ;;

  add-event|diff-event|ensure-event)
    EVENT=""
    COMMAND=""
    SOURCE=""
    MATCHER=""
    TIMEOUT=""
    shift
    while [ $# -gt 0 ]; do
      case "$1" in
        --event)   EVENT="$2"; shift 2 ;;
        --command) COMMAND="$2"; shift 2 ;;
        --source)  SOURCE="$2"; shift 2 ;;
        --matcher) MATCHER="$2"; shift 2 ;;
        --timeout) TIMEOUT="$2"; shift 2 ;;
        *) echo "unknown flag: $1" >&2; exit 1 ;;
      esac
    done
    if [ -z "$EVENT" ] || [ -z "$COMMAND" ] || [ -z "$SOURCE" ]; then
      echo "add-event/ensure-event/diff-event require --event, --command, --source" >&2
      exit 1
    fi
    case "$EVENT" in
      SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification) ;;
      *) echo "invalid --event '$EVENT'; must be one of SessionStart|PreToolUse|PostToolUse|UserPromptSubmit|Stop|Notification" >&2; exit 1 ;;
    esac
    if [ "$ACTION" = "add-event" ]; then
      backup_settings
    fi
    DIFF_ONLY=""
    if [ "$ACTION" = "diff-event" ]; then DIFF_ONLY=1; fi
    ENSURE=""
    if [ "$ACTION" = "ensure-event" ]; then ENSURE=1; fi
    GSTACK_SETTINGS_PATH="$SETTINGS_FILE" \
    GSTACK_EVENT="$EVENT" \
    GSTACK_COMMAND="$COMMAND" \
    GSTACK_SOURCE="$SOURCE" \
    GSTACK_MATCHER="$MATCHER" \
    GSTACK_TIMEOUT="$TIMEOUT" \
    GSTACK_DIFF_ONLY="$DIFF_ONLY" \
    GSTACK_ENSURE="$ENSURE" \
    bun -e '
      const fs = require("fs");
      const settingsPath = process.env.GSTACK_SETTINGS_PATH;
      const event = process.env.GSTACK_EVENT;
      const cmd = process.env.GSTACK_COMMAND;
      const source = process.env.GSTACK_SOURCE;
      const matcher = process.env.GSTACK_MATCHER || "";
      const timeoutRaw = process.env.GSTACK_TIMEOUT || "";
      const diffOnly = process.env.GSTACK_DIFF_ONLY === "1";
      const ensure = process.env.GSTACK_ENSURE === "1";

      let settings = {};
      // An EXISTING file that does not parse must never be rewritten: the
      // old catch{} folded it to {} and the atomic write below replaced the
      // user permissions/env/other hooks with just ours. Refuse loudly.
      if (fs.existsSync(settingsPath)) {
        try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
        catch (e) {
          console.error("error: " + settingsPath + " exists but is not valid JSON (" +
            (e && e.message ? e.message : e) + "); refusing to rewrite it. Fix or move the file, then re-run.");
          process.exit(1);
        }
      }

      const before = JSON.stringify(settings, null, 2);

      if (!settings.hooks) settings.hooks = {};
      if (!settings.hooks[event]) settings.hooks[event] = [];

      // Identity key is (event, source): any existing entry carrying OUR
      // source tag for this event IS the entry to compare/update — a matcher
      // change must update it in place, never push a SECOND gstack entry
      // (the old key included the matcher, so a future matcher change would
      // have duplicated the registration). Untagged legacy entries are still
      // adopted when both matcher and command line up.
      const matchesEntry = (entry) => {
        if (entry._gstack_source === source) return true;
        const sameMatcher = (entry.matcher || "") === matcher;
        const sameCommand = entry.hooks && entry.hooks[0] && entry.hooks[0].command === cmd;
        return sameMatcher && sameCommand;
      };

      // Collect ALL matches, not just the first: pre-existing installs can
      // carry two entries with the same (event, _gstack_source) from the old
      // matcher-keyed dedup. `.find()` updated only the first and left the
      // stale twin running forever. Keep ONE canonical entry (the first),
      // remove the rest in the same atomic write.
      const matched = settings.hooks[event].filter(matchesEntry);
      let existing = matched.length > 0 ? matched[0] : undefined;
      let collapsed = 0;
      if (matched.length > 1) {
        const extras = new Set(matched.slice(1));
        settings.hooks[event] = settings.hooks[event].filter((e) => !extras.has(e));
        collapsed = matched.length - 1;
      }
      const hookEntry = { type: "command", command: cmd };
      if (timeoutRaw) {
        const n = Number(timeoutRaw);
        if (Number.isFinite(n) && n > 0) hookEntry.timeout = n;
      }

      if (existing) {
        existing.hooks = [hookEntry];
        existing._gstack_source = source;
        // Keep the matcher current too — under the (event, source) key the
        // matched entry may carry a stale matcher.
        if (matcher) existing.matcher = matcher;
        else delete existing.matcher;
      } else {
        const newEntry = { _gstack_source: source, hooks: [hookEntry] };
        if (matcher) newEntry.matcher = matcher;
        settings.hooks[event].push(newEntry);
      }

      const after = JSON.stringify(settings, null, 2);

      if (diffOnly) {
        console.log("--- BEFORE");
        console.log(before);
        console.log("--- AFTER");
        console.log(after);
        process.exit(0);
      }

      if (ensure && before === after) {
        // Registered payload already matches the canonical one — no write, no
        // backup, no churn. Re-running ./setup stays a true no-op.
        console.log("OK: " + event + " hook unchanged (source: " + source + ")");
        process.exit(0);
      }

      try {
        if (ensure && fs.existsSync(settingsPath)) {
          // Mirrors backup_settings (bash) — but only when a write actually
          // happens, so a no-op ensure-event never creates backup files.
          const d = new Date();
          const pad = (n) => String(n).padStart(2, "0");
          const ts = "" + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) +
            "-" + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds());
          fs.copyFileSync(settingsPath, settingsPath + ".bak." + ts);
          fs.writeFileSync(settingsPath + ".bak-latest", settingsPath + ".bak." + ts + "\n");
        }

        // Atomic tmp+rename: the settings file is either the old JSON (with
        // the old single registration) or the new JSON (with the replaced
        // one) — a failed update can never leave zero or two registrations.
        // Per-process tmp suffix: a fixed settings.json.tmp let two parallel
        // writers consume one another. (No apostrophes here: this JS lives
        // inside a bash single-quoted string.)
        const tmp = settingsPath + ".tmp." + process.pid;
        fs.writeFileSync(tmp, after + "\n");
        fs.renameSync(tmp, settingsPath);
      } catch (e) {
        // Explicit catch + exit 1: bun -e has been observed (1.3.13) to turn
        // an uncaught sync fs error into a SILENT exit 0, which would let a
        // failed update masquerade as success to the caller.
        console.error("error: could not update " + settingsPath + ": " + (e && e.message ? e.message : e));
        process.exit(1);
      }
      if (collapsed > 0) {
        console.error("collapsed " + collapsed + " duplicate (event, source) hook entr" + (collapsed === 1 ? "y" : "ies") + " for " + event + " (source: " + source + ")");
      }
      if (ensure && existing) {
        console.log("OK: " + event + " hook re-pointed (source: " + source + ")");
      } else {
        console.log("OK: " + event + " hook registered (source: " + source + ")");
      }
    '
    ;;

  remove-source)
    SOURCE=""
    shift
    while [ $# -gt 0 ]; do
      case "$1" in
        --source) SOURCE="$2"; shift 2 ;;
        *) echo "unknown flag: $1" >&2; exit 1 ;;
      esac
    done
    if [ -z "$SOURCE" ]; then
      echo "remove-source requires --source <tag>" >&2
      exit 1
    fi
    [ -f "$SETTINGS_FILE" ] || exit 0
    backup_settings
    GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" bun -e '
      const fs = require("fs");
      const settingsPath = process.env.GSTACK_SETTINGS_PATH;
      const source = process.env.GSTACK_SOURCE;
      let settings = {};
      try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); }
      if (!settings.hooks) { process.exit(0); }
      let removed = 0;
      for (const event of Object.keys(settings.hooks)) {
        const before = settings.hooks[event].length;
        settings.hooks[event] = settings.hooks[event].filter(entry => entry._gstack_source !== source);
        removed += before - settings.hooks[event].length;
        if (settings.hooks[event].length === 0) delete settings.hooks[event];
      }
      if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
      const tmp = settingsPath + ".tmp." + process.pid; // per-process: parallel writers must not share a tmp
      fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
      fs.renameSync(tmp, settingsPath);
      console.log("OK: removed " + removed + " hook entry/entries tagged source=" + source);
    '
    ;;

  rollback)
    if [ ! -f "$SETTINGS_FILE.bak-latest" ]; then
      echo "rollback: no backup pointer at $SETTINGS_FILE.bak-latest" >&2
      exit 1
    fi
    LATEST=$(cat "$SETTINGS_FILE.bak-latest")
    if [ ! -f "$LATEST" ]; then
      echo "rollback: pointer references missing backup $LATEST" >&2
      exit 1
    fi
    cp "$LATEST" "$SETTINGS_FILE"
    echo "OK: restored $SETTINGS_FILE from $LATEST"
    ;;

  list-sources)
    [ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; }
    GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun -e '
      const fs = require("fs");
      let settings = {};
      try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } catch { process.exit(0); }
      const hooks = settings.hooks || {};
      let any = false;
      for (const event of Object.keys(hooks)) {
        for (const entry of hooks[event]) {
          if (entry._gstack_source) {
            any = true;
            console.log(event + "\t" + entry._gstack_source + "\t" + (entry.matcher || "(no matcher)"));
          }
        }
      }
      if (!any) console.log("(no gstack-tagged hooks)");
    '
    ;;

  *)
    echo "Unknown action: $ACTION" >&2
    exit 1
    ;;
esac
