#!/usr/bin/env bash
# install.sh — Quality Gate stop hook installer for Claude Code (spec P2)
#
# Usage:
#   curl -fsSL https://your-qg-instance.vercel.app/install.sh | bash
#   curl -fsSL https://your-qg-instance.vercel.app/install.sh | bash -s -- --uninstall
#
# Non-interactive (CI, or any context with no terminal) — supply both up front:
#   QG_URL=… QG_API_KEY=… bash install.sh
#
# Environment variables:
#   QG_URL             deployment base URL (prompted for if unset)
#   QG_API_KEY         your 64-char hex key (prompted for if unset)
#   QG_PORTAL_URL      URL shown in the out-of-credits upsell (defaults to QG_URL)
#   QG_INSTALL_LOCAL=1 copy quality_gate_hook.py / qg_render.py from beside this
#                      script instead of downloading them, even when QG_URL is
#                      set. For development and the offline test suite; a normal
#                      install never sets it.
#
# What install does:
#   1. Creates ~/.claude and ~/.config/qualitygate (fresh-machine safe)
#   2. Downloads quality_gate_hook.py + qg_render.py to ~/.config/qualitygate/
#   3. Prompts for QG_URL, QG_API_KEY (QG_PORTAL_URL defaults to QG_URL), reading
#      from /dev/tty so a piped install cannot swallow its own source as the key
#   4. Writes them to ~/.config/qualitygate/config.json at 0600 (NOT to your
#      shell profile — the key never lands in a world-readable file)
#   5. Backs up ~/.claude/settings.json, then registers/updates the stop hook
#      (idempotent: re-running updates the existing entry in place rather
#      than adding a duplicate; any other hooks you already have are untouched)
#
# What --uninstall does:
#   Backs up settings.json, then removes ONLY the Quality Gate hook itself —
#   including when it shares a Stop entry with hooks of your own, which are left
#   untouched — and prints the backup path.
#
# Requirements: bash, curl, python3
#
# After install, restart Claude Code (or reload your shell) for the hook to
# take effect.

set -euo pipefail

# ── Constants ──────────────────────────────────────────────────────────────────

HOOK_DIR="$HOME/.config/qualitygate"
HOOK_PATH="$HOOK_DIR/hook.py"
RENDER_PATH="$HOOK_DIR/qg_render.py"
CONFIG_PATH="$HOOK_DIR/config.json"
CLAUDE_DIR="$HOME/.claude"
CLAUDE_SETTINGS="$CLAUDE_DIR/settings.json"

QG_VERSION="1.1.0"
QG_URL="${QG_URL:-}"
QG_API_KEY="${QG_API_KEY:-}"

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

info()  { echo -e "${GREEN}[qg-install]${NC} $*"; }
warn()  { echo -e "${YELLOW}[qg-install]${NC} $*"; }
error() { echo -e "${RED}[qg-install]${NC} $*" >&2; }

# ── --uninstall ────────────────────────────────────────────────────────────────

if [[ "${1:-}" == "--uninstall" ]]; then
    if [[ ! -f "$CLAUDE_SETTINGS" ]]; then
        info "No settings.json found at $CLAUDE_SETTINGS — nothing to uninstall."
        exit 0
    fi

    CLAUDE_SETTINGS="$CLAUDE_SETTINGS" python3 - <<'PYEOF'
import json, os, sys, time
from pathlib import Path

settings_path = Path(os.environ["CLAUDE_SETTINGS"])
raw = settings_path.read_text()

try:
    settings = json.loads(raw)
except json.JSONDecodeError:
    print("[qg-install] settings.json is malformed — cannot safely uninstall. Fix it manually.", file=sys.stderr)
    sys.exit(1)

stop_hooks = settings.get("hooks", {}).get("Stop", [])

def strip_qg_hooks(entry):
    """Remove only OUR hook objects from one Stop entry, leaving any others in
    place. Returns (remaining_hooks, removed_count).

    This used to filter at the ENTRY level, which silently deleted a user's own
    hook whenever it shared an entry with ours — real data loss in their config,
    invisible after the fact. The installer never creates that shape itself, but
    a hand-edit or another tool merging on `matcher` can."""
    hooks = entry.get("hooks", [])
    remaining = [
        h for h in hooks
        if not (isinstance(h, dict) and "qualitygate/hook.py" in h.get("command", ""))
    ]
    return remaining, len(hooks) - len(remaining)

kept = []
removed = 0
for entry in stop_hooks:
    remaining, n = strip_qg_hooks(entry)
    removed += n
    if n == 0:
        kept.append(entry)                      # nothing of ours here — untouched
    elif remaining:
        pruned = dict(entry)
        pruned["hooks"] = remaining
        kept.append(pruned)                     # ours stripped, theirs preserved
    # else: the entry existed only to hold our hook — drop it

if removed == 0:
    print("[qg-install] No Quality Gate hook entry found — nothing to uninstall.")
    sys.exit(0)

backup_path = settings_path.parent / f"settings.json.qgbak.{int(time.time())}"
backup_path.write_text(raw)

settings.setdefault("hooks", {})["Stop"] = kept
with settings_path.open("w") as f:
    json.dump(settings, f, indent=2)

noun = "entry" if removed == 1 else "entries"
print(f"[qg-install] Removed {removed} Quality Gate hook {noun}.")
print(f"[qg-install] Backup saved to: {backup_path}")
PYEOF
    uninstall_rc=$?
    # Remove the 0600 credentials file too — the key should not linger after
    # uninstall. Best-effort; absence is fine.
    if [[ -f "$CONFIG_PATH" ]]; then
        rm -f "$CONFIG_PATH" && info "Removed credentials config: $CONFIG_PATH"
    fi
    exit $uninstall_rc
fi

# ── Prerequisites ──────────────────────────────────────────────────────────────

if ! command -v python3 &>/dev/null; then
    error "python3 is required but not found. Install Python 3.8+ and retry."
    exit 1
fi

# ── Step 1: directories (fresh-machine safe) ────────────────────────────────────

info "Quality Gate installer v$QG_VERSION"
info "Creating hook directory: $HOOK_DIR"
mkdir -p "$HOOK_DIR"
info "Creating Claude settings directory: $CLAUDE_DIR"
mkdir -p "$CLAUDE_DIR"

# ── Step 2: fetch/copy quality_gate_hook.py + qg_render.py ─────────────────────
# Both files travel together — the hook imports qg_render at runtime, so a
# partial fetch (hook without renderer) would break the report on first use.

fetch_or_copy() {
    local remote_name="$1" dest="$2"
    # QG_INSTALL_LOCAL=1 forces the copy-from-beside-script path even when
    # QG_URL is set. Real installs never set it; the test suite does, so it can
    # supply credentials via env (the supported non-interactive path) while
    # still running fully offline. Without this, "has a URL" and "should
    # download" are the same flag and the suite cannot test the env path.
    if [[ -n "$QG_URL" && "${QG_INSTALL_LOCAL:-}" != "1" ]]; then
        local url="${QG_URL%/}/${remote_name}"
        info "Downloading $remote_name from $url"
        if ! curl -fsSL "$url" -o "$dest"; then
            error "Failed to download $remote_name from $url"
            error "Check that QG_URL is correct and the service is reachable."
            exit 1
        fi
    else
        local script_dir
        # ${BASH_SOURCE[0]} is UNSET when the script is piped (`curl … | bash`),
        # and `set -u` turns that into a raw "unbound variable" crash instead of
        # the clean error below. Default it so a piped install with no QG_URL
        # gets the actionable message rather than a bash internal error.
        script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")"
        local src="$script_dir/$remote_name"
        if [[ -f "$src" ]]; then
            info "Copying $remote_name from $src (development install)"
            cp "$src" "$dest"
        else
            error "QG_URL not set and $remote_name not found beside this script."
            error "Set QG_URL=https://your-qg-instance.vercel.app and re-run."
            exit 1
        fi
    fi
}

fetch_or_copy "quality_gate_hook.py" "$HOOK_PATH"
fetch_or_copy "qg_render.py" "$RENDER_PATH"
chmod +x "$HOOK_PATH"
info "Hook installed: $HOOK_PATH"
info "Renderer installed: $RENDER_PATH"

# ── Step 3: prompt for credentials ──────────────────────────────────────────────

# `curl … | bash` makes THIS SCRIPT the process's stdin, so a bare `read` eats
# the script's own remaining source instead of the user's typing — it silently
# captured a line of shell as the API key and still printed "Installation
# complete!". Every prompt therefore reads from the controlling terminal
# explicitly; with no terminal we refuse loudly rather than guess.

# Probe by actually OPENING /dev/tty, not with `[[ -r /dev/tty ]]`. The node
# exists and tests readable in contexts where opening it fails outright
# ("Device not configured"), which leaked a raw bash error to the user instead
# of the actionable message below. It still failed safe — but unreadably.
if { exec 3</dev/tty; } 2>/dev/null; then
    HAVE_TTY=1
    exec 3<&-
else
    HAVE_TTY=0
fi

no_tty_abort() {
    error "Cannot prompt for $1 — no terminal is attached."
    error "This happens when the installer is piped in a non-interactive context."
    error "Supply the values up front instead:"
    error "  curl -fsSL <your-qg-url>/install.sh -o /tmp/qg-install.sh"
    error "  QG_URL=<your-qg-url> QG_API_KEY=<your-key> bash /tmp/qg-install.sh"
    exit 1
}

echo ""
echo "Before you continue — what this installs, plainly:"
echo "  • A Stop hook that runs when Claude Code finishes a turn."
echo "  • It reads your session transcript to find Claude's last message."
echo "  • If that message looks like a claim of \"done\", the text of that message"
echo "    is sent to Quality Gate and reviewed by five AI providers"
echo "    (Anthropic, OpenAI, Perplexity, xAI, and z.ai — z.ai is China-based)."
echo "  • Ordinary turns are never sent and never cost you a credit."
echo "  Full terms: $QG_URL/terms"
echo ""
echo "Your Quality Gate deployment URL (e.g. https://my-qg.vercel.app):"
if [[ -n "$QG_URL" ]]; then
    echo "  Using: $QG_URL (from env)"
else
    [[ "$HAVE_TTY" == "1" ]] || no_tty_abort QG_URL
    read -rp "  QG_URL: " QG_URL < /dev/tty
fi
QG_URL="${QG_URL%/}"

echo ""
if [[ -n "$QG_API_KEY" ]]; then
    echo "Your Quality Gate API key: using QG_API_KEY (from env)"
else
    echo "Your Quality Gate API key (64-char hex from your subscription dashboard):"
    [[ "$HAVE_TTY" == "1" ]] || no_tty_abort QG_API_KEY
    read -rsp "  QG_API_KEY: " QG_API_KEY_INPUT < /dev/tty
    echo ""
    QG_API_KEY="${QG_API_KEY_INPUT}"
fi

if [[ -z "$QG_URL" || -z "$QG_API_KEY" ]]; then
    error "QG_URL and QG_API_KEY are both required."
    exit 1
fi

# Shape-check the key. Real keys are crypto.randomBytes(32).toString('hex')
# (lib/subscribers.ts:80) — exactly 64 hex chars. A key containing whitespace is
# never valid and is the signature of a prompt that captured something other
# than the user's typing, so refuse before writing it to disk.
if [[ "$QG_API_KEY" =~ [[:space:]] ]]; then
    error "That API key contains whitespace — never a valid key."
    error "Nothing was written. Re-run and paste the key from your dashboard."
    exit 1
fi
if [[ ! "$QG_API_KEY" =~ ^[0-9a-fA-F]{64}$ ]]; then
    warn "That key is not the usual 64-character hex shape — continuing anyway."
    warn "If reviews come back 401, re-run the installer and re-paste the key."
fi

QG_PORTAL_URL="${QG_PORTAL_URL:-$QG_URL}"

# ── Step 4: write the credentials config (0600, owner-only) ─────────────────────
# Credentials live in a 0600 JSON file the hook reads — NOT inlined into the
# settings.json hook command (that command is re-parsed by a shell every time
# the hook fires, so a key with shell metacharacters was a command-injection
# sink — QG review P1), and NOT written to a world-readable shell profile
# (plaintext-key concern). json.dump handles all escaping; values arrive via
# os.environ, never interpolated into the Python source. The hook reads this
# file (env vars still override), so credentials never touch a shell-parsed
# string again.
info "Writing credentials config: $CONFIG_PATH (0600)"

CONFIG_PATH="$CONFIG_PATH" QG_URL="$QG_URL" QG_API_KEY="$QG_API_KEY" QG_PORTAL_URL="$QG_PORTAL_URL" python3 - <<'PYEOF'
import json, os
from pathlib import Path

path = Path(os.environ["CONFIG_PATH"])
path.parent.mkdir(parents=True, exist_ok=True)
# Remove any pre-existing file first, then create with 0600 from the very first
# byte (O_CREAT mode only applies on creation) — the key is never briefly
# world-readable in a write-then-chmod window.
if path.exists():
    path.unlink()
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
    json.dump(
        {
            "QG_URL": os.environ["QG_URL"],
            "QG_API_KEY": os.environ["QG_API_KEY"],
            "QG_PORTAL_URL": os.environ["QG_PORTAL_URL"],
        },
        f,
        indent=2,
    )
path.chmod(0o600)
print(f"[qg-install] Credentials written to {path} (0600)")
PYEOF

# ── Step 5: back up + patch ~/.claude/settings.json (idempotent) ──────────────

info "Patching Claude Code settings: $CLAUDE_SETTINGS"

CLAUDE_SETTINGS="$CLAUDE_SETTINGS" HOOK_PATH="$HOOK_PATH" python3 - <<'PYEOF'
import json, os, sys, time
from pathlib import Path

# Values arrive via os.environ, never interpolated into this source text.
# NOTE: no credentials here — the command is just `python3 <hook>`; the hook
# reads them from the 0600 config.json. This is what closes the third
# injection sink (the command string is shell-parsed on every hook fire).
settings_path = Path(os.environ["CLAUDE_SETTINGS"])
hook_path = os.environ["HOOK_PATH"]

# Always back up a PRE-EXISTING settings.json (valid or malformed) before any
# write — a single backup point taken up front, so a malformed file is never
# read twice / backed up twice.
if settings_path.exists():
    raw = settings_path.read_text()
    backup_path = settings_path.parent / f"settings.json.qgbak.{int(time.time())}"
    backup_path.write_text(raw)
    try:
        settings = json.loads(raw)
    except json.JSONDecodeError:
        print(f"[qg-install] settings.json is malformed. Backed up to {backup_path}. Starting fresh.", file=sys.stderr)
        settings = {}
else:
    settings_path.parent.mkdir(parents=True, exist_ok=True)
    settings = {}

hook_entry = {
    "matcher": "",
    "hooks": [
        {
            "type": "command",
            "command": f"python3 {hook_path}"
        }
    ]
}

hooks = settings.setdefault("hooks", {})
stop_hooks = hooks.setdefault("Stop", [])

def is_qg_entry(entry):
    return any("qualitygate/hook.py" in h.get("command", "") for h in entry.get("hooks", []) if isinstance(h, dict))

# Idempotent: update an existing QG entry IN PLACE rather than appending a
# duplicate. Any other (non-QG) hooks in the list are left untouched.
existing_idx = next((i for i, e in enumerate(stop_hooks) if is_qg_entry(e)), None)
if existing_idx is not None:
    stop_hooks[existing_idx] = hook_entry
    print(f"[qg-install] Updated existing Quality Gate hook entry in {settings_path}")
else:
    stop_hooks.append(hook_entry)
    print(f"[qg-install] Registered Quality Gate hook in {settings_path}")

with settings_path.open("w") as f:
    json.dump(settings, f, indent=2)
PYEOF

# ── Done ─────────────────────────────────────────────────────────────────────

echo ""
info "Installation complete!"
echo ""
echo "  Version:    $QG_VERSION"
echo "  Hook:       $HOOK_PATH"
echo "  Renderer:   $RENDER_PATH"
echo "  QG_URL:     $QG_URL"
echo "  API key:    ${QG_API_KEY:0:8}...${QG_API_KEY: -8}"
echo "  Config:     $CONFIG_PATH (0600)"
echo ""
echo "Restart Claude Code for the hook to take effect."
echo ""
echo "To verify: ask Claude to claim something is 'done' and watch for the"
echo "[quality-gate] verdict line in the tool output."
echo ""
echo "To disable temporarily: export QG_DISABLED=1"
echo "To soft-mode (log only, don't block): export QG_SOFT_MODE=1"
echo "To uninstall: curl -fsSL $QG_URL/install.sh | bash -s -- --uninstall"
