#!/bin/bash
# Overwatch Universal Hook Adapter
# This script handles communication between AI tools and the Overwatch daemon.
# Usage: ./universal-hook.sh <source> <event> <default_json>

set -euo pipefail

IDE_SOURCE=$1
HOOK_EVENT=$2
DEFAULT_RESPONSE=${3:-"{}"}

# Auto-start daemon in background if not running (fire-and-forget)
try_auto_start() {
    local LOCK_DIR="$HOME/.overwatch/daemon.lock.d"
    if command -v overwatch >/dev/null 2>&1; then
        # Use mkdir as an atomic lock — only one process can succeed
        if mkdir "$LOCK_DIR" 2>/dev/null; then
            nohup overwatch start --hook >/dev/null 2>&1 &
        else
            # Lock exists — remove if stale (>30s). GNU stat (-c) before BSD (-f): on GNU, -f is filesystem mode and poisons the arithmetic (#699).
            local LOCK_MTIME
            LOCK_MTIME=$(stat -c %Y "$LOCK_DIR" 2>/dev/null || stat -f %m "$LOCK_DIR" 2>/dev/null || echo 0)
            # Non-numeric stat output falls back to 0 (reads as stale) instead of crashing under set -e.
            case $LOCK_MTIME in (''|*[!0-9]*) LOCK_MTIME=0 ;; esac
            local LOCK_AGE=$(( $(date +%s) - LOCK_MTIME ))
            if [ "$LOCK_AGE" -gt 30 ]; then
                rmdir "$LOCK_DIR" 2>/dev/null || true
            fi
        fi
    fi
}

# Read the Overwatch daemon port from the discovery file (falling back to the
# legacy name written by pre-rename builds), default to static port 17580. An
# empty/torn file makes `cat` succeed with no output, so the `|| cat` chain
# would not fall through; guard each step with [ -z ] explicitly. `|| true`
# keeps a missing file from tripping `set -e` on the assignment.
OVERWATCH_PORT=$(cat "$HOME/.overwatch/overwatch_port" 2>/dev/null || true)
[ -z "$OVERWATCH_PORT" ] && OVERWATCH_PORT=$(cat "$HOME/.overwatch/guardian_port" 2>/dev/null || true)
[ -z "$OVERWATCH_PORT" ] && OVERWATCH_PORT="17580"
ENDPOINT="http://127.0.0.1:${OVERWATCH_PORT}/hook/${IDE_SOURCE}/${HOOK_EVENT}"

# 2. Read payload from stdin (needed for dedup check and forwarding)
PAYLOAD=$(cat)

# 3. Cross-IDE dedup: skip if Cursor is firing a non-cursor hook.
#    Cursor picks up hooks from other IDEs (e.g. ~/.claude/settings.json) and fires them,
#    causing duplicate processing. Detect this via cursor_version in the payload.
#
#    Kiro is EXCLUDED from this check. Cursor reads ~/.cursor and ~/.claude
#    configs, never ~/.kiro/hooks/, so Kiro events cannot be Cursor cross-fire —
#    while the marker is a substring match on an agent-controlled payload, so a
#    tool argument like {"env":{"cursor_version":"1"}} would otherwise exit 0
#    without ever reaching the daemon. PreToolUse is Kiro's only enforcement
#    surface, so that is a total bypass, and the `echo "{}"` would additionally
#    land in the model's context (stdout on exit 0 is injected on Kiro).
if [ "$IDE_SOURCE" != "cursor" ] && [ "$IDE_SOURCE" != "kiro" ] && echo "$PAYLOAD" | grep -q '"cursor_version"'; then
    echo "{}"
    exit 0
fi

# 4. Health Check & Forward Payload
# We use a 1s timeout for the health check to avoid hanging if the daemon is dead.
if curl -s -f -m 1 "http://127.0.0.1:${OVERWATCH_PORT}/health" >/dev/null 2>&1; then
    # Forward payload to daemon and capture response. The X-Overwatch-Default-Response
    # header carries $3 (the per-IDE deny envelope) so the daemon can echo it
    # for GENUINE denies (policy / step-up) instead of reconstructing IDE shapes
    # (ramparts#671). 25s timeout: must exceed the daemon's step-up resolve
    # budget (stepUpHookBudgetMs, default 20s). If the POST itself fails (daemon
    # died mid-request, network), fail OPEN with an empty allow, not $3.
    RESPONSE=$(echo "$PAYLOAD" | curl -s -m 25 -X POST "$ENDPOINT" \
        -H "Content-Type: application/json" \
        -H "X-Overwatch-Default-Response: $DEFAULT_RESPONSE" \
        -d @- 2>/dev/null || echo "{}")
else
    # Daemon is unreachable — try to auto-start it and fail OPEN (allow) for
    # this invocation so the developer is not blocked while it spins up.
    try_auto_start
    RESPONSE="{}"
fi

# 5. Kiro: translate the verdict into its exit-code protocol.
#
# Kiro has no JSON response envelope. A command hook signals its verdict with
# the process EXIT CODE, and whatever it prints on stdout with exit 0 is
# injected into the agent's context (SessionStart / UserPromptSubmit) — so an
# allow must print NOTHING, not even "{}".
#
# Exit 2 is the ONLY code Kiro treats as a block, and per Kiro's exit-code
# reference it blocks PreToolUse ONLY; every other non-zero code is surfaced as a
# warning and the event proceeds. Deny therefore exits exactly 2 with the reason
# on stderr.
#
# On UserPromptSubmit exit 2 does NOT stop the prompt: Kiro hands the stderr to
# the model as context and submission continues (verified live 2026-08-13 — the
# model read our reason and declined on its own). We still exit 2 there, because
# that is what delivers the reason to the model, but it is advisory, not
# enforcement. Do not describe it as a block.
#
# Deny shapes: {"action":"deny"} from Cerberus's Kiro extractor, and
# {"decision":"block"} from the daemon's generic fallback / the installer-baked
# $3. Everything else — "{}" (fail-open, observational) or {"action":"allow"} —
# is an allow.
if [ "$IDE_SOURCE" = "kiro" ]; then
    if printf '%s' "$RESPONSE" | grep -Eq '"(action|decision)"[[:space:]]*:[[:space:]]*"(deny|block)"'; then
        REASON=$(printf '%s' "$RESPONSE" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
        [ -z "$REASON" ] && REASON="Blocked by Highflame Overwatch."
        printf '%s\n' "$REASON" >&2
        exit 2
    fi

    exit 0
fi

# 6. Pass-through: every other supported agent reads stdout JSON.
echo "$RESPONSE"
