#!/usr/bin/env bash
# tags: [safety, automation]
# description: Notification hook. When a session needs the developer, pushes a one-line alert (ntfy.sh / Slack / macOS fallback) with surface·branch·cwd·reason + a `claude --resume <id>` hint, and records wait_state so /sessions shows which session is blocked. Debounced, fail-open.
#
# .claude/hooks/notify-attention.sh
#
# Wired on the Claude Code `Notification` event (fires on a permission prompt
# and on idle). The attention half of session mission control: instead of
# window-hopping to find which session is asking, you get one push and resume it.
#
# stdin (JSON): { session_id, cwd, transcript_path, notification_type, message, ... }
#
# What it does, in order:
#   1. Records wait_state {type, reason, since, since_epoch} in this session's
#      registry entry (<repo>/.claude/session/sessions/<id>.json) — the read
#      side (scripts/sessions-view.mjs + /sessions) surfaces it. session-drift-
#      check clears it on the next prompt.
#   2. Pushes a notification through the first configured channel:
#        ntfy.sh topic (NTFY_TOPIC)  ->  Slack webhook (SLACK_WEBHOOK_URL)
#      and falls back to a macOS osascript banner + bell if neither is set.
#   3. Debounces per session (NOTIFY_DEBOUNCE_SECS, default 45s) so repeated
#      idle pings don't spam the phone — wait_state is still refreshed.
#
# HARD LIMIT (verified, docs.claude.com): a permission dialog CANNOT be approved
# from mobile / web Remote Control. This hook only NOTIFIES; the permission
# ALLOWLIST is what actually cuts the prompts. See the runbook.
#
# Config: sourced from .claude/hooks/notify-attention.env if present (gitignored;
# holds the real ntfy topic / Slack URL — both are effectively secrets).
# Environment variables override the file. See notify-attention.env.example.
#
# Exit codes: 0 always (never blocks; fail-open on any error).

set -uo pipefail
PAYLOAD=$(cat 2>/dev/null || true)

# Surface detection — mirrors session-register.sh (Claude Desktop sets no
# TERM_PROGRAM, so it's identified by entrypoint / Anthropic bundle id).
detect_surface() {
  [[ -n "${CLAUDE_SURFACE:-}" ]] && { echo "$CLAUDE_SURFACE"; return; }
  case "${CLAUDE_CODE_ENTRYPOINT:-}" in
    *vscode*) echo vscode; return;; *cursor*) echo cursor; return;;
    *desktop*) echo desktop; return;; cli|*-cli|*cli) echo cli; return;;
  esac
  case "${__CFBundleIdentifier:-}" in
    *VSCode*|*VSCodium*) echo vscode; return;; *Cursor*) echo cursor; return;;
    com.anthropic.*|*[Cc]laude*) echo desktop; return;;
  esac
  case "${TERM_PROGRAM:-}" in
    vscode) echo vscode; return;; Apple_Terminal) echo terminal; return;;
    iTerm.app) echo iterm; return;; WezTerm) echo wezterm; return;;
    "") echo unknown; return;; *) echo "${TERM_PROGRAM}"; return;;
  esac
}

{
  # ── Config: machine-level first, per-repo override, env wins last ───────────
  REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "")"
  # shellcheck disable=SC1090
  MCFG="${HOME:-}/.bewith/notify.env"                       # machine-level (menu-bar Settings writes this)
  [[ -n "${HOME:-}" && -f "$MCFG" ]] && { set -a; . "$MCFG" 2>/dev/null || true; set +a; }
  CFG="${REPO_ROOT:+$REPO_ROOT/.claude/hooks/notify-attention.env}"  # per-repo override
  [[ -n "$CFG" && -f "$CFG" ]] && { set -a; . "$CFG" 2>/dev/null || true; set +a; }

  NOTIFY_ENABLED="${NOTIFY_ENABLED:-true}"
  [[ "$NOTIFY_ENABLED" == "true" ]] || exit 0
  NTFY_SERVER="${NTFY_SERVER:-https://ntfy.sh}"
  NTFY_TOPIC="${NTFY_TOPIC:-}"
  NTFY_PRIORITY="${NTFY_PRIORITY:-default}"
  SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
  NOTIFY_BANNER="${NOTIFY_BANNER:-${NOTIFY_OSASCRIPT:-auto}}"  # auto=fallback only · always=also with remote · off=never (NOTIFY_OSASCRIPT kept for back-compat)
  NOTIFY_NOTIFIER="${NOTIFY_NOTIFIER:-auto}"     # which local notifier: auto=terminal-notifier if installed, else osascript · terminal-notifier · osascript
  NOTIFY_SOUND="${NOTIFY_SOUND:-true}"           # play a sound with the local banner
  NOTIFY_SOUND_NAME="${NOTIFY_SOUND_NAME:-Glass}" # macOS system sound
  NOTIFY_SOUND_FILE="${NOTIFY_SOUND_FILE:-}"     # custom audio file (afplay) — overrides NOTIFY_SOUND_NAME; record your own here
  # Banner icon (terminal-notifier only). Default to the Claude mark bundled next to this hook.
  _HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")"
  NOTIFY_ICON="${NOTIFY_ICON:-}"  # -appIcon opt-in -- macOS 26 ignores it for unsigned helpers, so the logo rides -contentImage instead
  NOTIFY_CONTENT_IMAGE="${NOTIFY_CONTENT_IMAGE:-${_HOOK_DIR:+${_HOOK_DIR%/hooks}/assets/claude-symbol.png}}"  # -contentImage -- the Claude logo, on the right
  DEBOUNCE="${NOTIFY_DEBOUNCE_SECS:-45}"

  # ── Parse the Notification payload ──────────────────────────────────────────
  # Tab-delimited so a multi-word message/path stays in one field.
  IFS=$'\t' read -r SID NTYPE MSG CWD < <(printf '%s' "$PAYLOAD" | python3 -c '
import json,sys
try: d=json.load(sys.stdin)
except Exception: d={}
def one(*keys):
    for k in keys:
        v=d.get(k)
        if v: return " ".join(str(v).split())   # collapse internal whitespace/newlines
    return ""
sid=(d.get("session_id") or "")[:64]
cwd=" ".join(str(d.get("cwd") or "").split())   # a path keeps its spaces; only newlines collapse
print("\t".join([sid or "-", one("notification_type","type") or "attention",
                 (one("message","title") or "needs attention")[:200], cwd]))
' 2>/dev/null || echo "-")
  [[ "$SID" == "-" || -z "$SID" ]] && exit 0
  SAFE_SID=$(printf '%s' "$SID" | tr -c 'A-Za-z0-9_.-' '_')

  NOW=$(date +%s); NOW_ISO=$(date -u +%FT%TZ)
  [[ -z "$CWD" ]] && CWD="${REPO_ROOT:-$PWD}"
  BRANCH="DETACHED"; SURFACE="$(detect_surface)"
  if [[ -n "$REPO_ROOT" ]]; then
    BRANCH=$(git -C "$REPO_ROOT" symbolic-ref --short HEAD 2>/dev/null || echo "DETACHED")
  fi

  # ── Write wait_state; decide push vs debounce (one python pass) ─────────────
  DECISION="PUSH"
  if [[ -n "$REPO_ROOT" ]]; then
    # Registry lives in the MAIN repo (shared across this repo's worktrees) — see session-register.sh.
    GCD="$(git -C "$REPO_ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "")"
    REGISTRY_ROOT="$REPO_ROOT"; [[ "$GCD" == /* && -d "$(dirname "$GCD")" ]] && REGISTRY_ROOT="$(dirname "$GCD")"
    DIR="${REGISTRY_ROOT}/.claude/session/sessions"
    ENTRY="${DIR}/${SAFE_SID}.json"
    mkdir -p "$DIR" 2>/dev/null || true
    DECISION=$(
      SID="$SID" NTYPE="$NTYPE" MSG="$MSG" NOW="$NOW" NOW_ISO="$NOW_ISO" \
      BRANCH="$BRANCH" SURFACE="$SURFACE" CWD="$CWD" REPO_ROOT="$REPO_ROOT" \
      ENTRY="$ENTRY" DEBOUNCE="$DEBOUNCE" python3 -c '
import json,os
e=os.environ; p=e["ENTRY"]; now=int(e["NOW"]); deb=int(e["DEBOUNCE"])
try: d=json.load(open(p))
except Exception:
    d={"session_id":e["SID"],"surface":e["SURFACE"],"cwd":e["CWD"],
       "repo_root":e["REPO_ROOT"],"branch":e["BRANCH"],
       "started_at":e["NOW_ISO"],"started_epoch":now,
       "last_heartbeat":e["NOW_ISO"],"last_heartbeat_epoch":now,"files_touched":[]}
prev=d.get("wait_state") or {}
last_push=int(prev.get("last_push_epoch",0) or 0)
same=prev.get("type")==e["NTYPE"]
push = (not same) or (now-last_push >= deb)
blob=(e["NTYPE"]+" "+e["MSG"]).lower()
kind="permission" if ("permission" in blob or "approve" in blob or "allow" in blob) else "input"
d["wait_state"]={"type":e["NTYPE"],"kind":kind,"reason":e["MSG"],"since":e["NOW_ISO"],
                 "since_epoch":now,"last_push_epoch":(now if push else last_push)}
d["status"]="waiting"; d["status_epoch"]=now
json.dump(d,open(p,"w"),indent=2)
print("PUSH" if push else "SKIP")
' 2>/dev/null || echo "PUSH")
  fi
  [[ "$DECISION" == "SKIP" ]] && exit 0

  # ── Compose + push ──────────────────────────────────────────────────────────
  DIRNAME=$(basename "$CWD" 2>/dev/null || echo "$CWD")
  TITLE="Claude needs you · ${SURFACE} · ${BRANCH}"
  BODY="${MSG}
${DIRNAME} (${BRANCH})
claude --resume ${SID}"

  sent=0
  if [[ -n "$NTFY_TOPIC" ]]; then
    curl -s --max-time 5 \
      -H "Title: ${TITLE}" \
      -H "Priority: ${NTFY_PRIORITY}" \
      -H "Tags: bell,computer" \
      -d "${BODY}" \
      "${NTFY_SERVER%/}/${NTFY_TOPIC}" >/dev/null 2>&1 && sent=1
  fi
  if [[ -n "$SLACK_WEBHOOK_URL" ]]; then
    SLACK_TEXT=$(SID="$SID" T="$TITLE" B="$BODY" python3 -c '
import json,os
print(json.dumps({"text":os.environ["T"]+"\n"+os.environ["B"]}))' 2>/dev/null || echo '{"text":"Claude needs you"}')
    curl -s --max-time 5 -H 'Content-Type: application/json' -d "$SLACK_TEXT" "$SLACK_WEBHOOK_URL" >/dev/null 2>&1 && sent=1
  fi

  if [[ "$NOTIFY_BANNER" == "always" || ( "$NOTIFY_BANNER" == "auto" && "$sent" -eq 0 ) ]]; then
    if [[ "$(uname)" == "Darwin" ]]; then
      # Sound: a custom file plays via afplay (async) regardless; otherwise a
      # named system sound goes to the notifier itself.
      use_file=0
      if [[ "$NOTIFY_SOUND" == "true" && -n "$NOTIFY_SOUND_FILE" && -f "$NOTIFY_SOUND_FILE" ]]; then
        ( afplay "$NOTIFY_SOUND_FILE" >/dev/null 2>&1 & ); use_file=1
      fi
      # Prefer terminal-notifier (clickable banner + custom icon); fall back to
      # osascript only when it is not installed. On macOS 26+ terminal-notifier needs
      # notification permission (System Settings) or it fails SILENTLY (exits 0, shows
      # nothing) -- if banners do not appear, grant permission or set NOTIFY_NOTIFIER=osascript.
      use_tn=0
      if command -v terminal-notifier >/dev/null 2>&1 && [[ "$NOTIFY_NOTIFIER" != "osascript" ]]; then
        use_tn=1
      fi
      if [[ "$use_tn" -eq 1 ]]; then
        # Clicking focuses the session's VSCode window — osascript can't do this.
        tn=(terminal-notifier -title "Claude needs you" -subtitle "${SURFACE} · ${DIRNAME}" -message "${MSG}")
        [[ -n "$REPO_ROOT" ]] && tn+=(-execute "open -a 'Visual Studio Code' '${REPO_ROOT}'")
        [[ -n "$NOTIFY_ICON" && -f "$NOTIFY_ICON" ]] && tn+=(-appIcon "$NOTIFY_ICON")
        [[ -n "$NOTIFY_CONTENT_IMAGE" && -f "$NOTIFY_CONTENT_IMAGE" ]] && tn+=(-contentImage "$NOTIFY_CONTENT_IMAGE")
        [[ "$NOTIFY_SOUND" == "true" && "$use_file" -eq 0 ]] && tn+=(-sound "${NOTIFY_SOUND_NAME}")
        "${tn[@]}" >/dev/null 2>&1 || true
      elif command -v osascript >/dev/null 2>&1; then
        sound_clause=""
        [[ "$NOTIFY_SOUND" == "true" && "$use_file" -eq 0 ]] && sound_clause=" sound name \"${NOTIFY_SOUND_NAME}\""
        osascript -e "display notification \"${MSG} — ${DIRNAME} (${BRANCH})\" with title \"Claude needs you\" subtitle \"${SURFACE}\"${sound_clause}" >/dev/null 2>&1 || true
      fi
    fi
  fi
} 2>/dev/null || true

exit 0
