#!/bin/sh
# Slim pre-commit hook (#3303).
#
# Goal: average run time < 2s. Only fast, staged-only checks live here.
# Heavier verification (clippy, openapi/SDK codegen drift) is handled by
# scripts/hooks/pre-push and CI.
#
# Install: git config core.hooksPath scripts/hooks
#
# Checks performed:
#   1. cargo fmt --check on staged *.rs files only (no full-workspace scan)
#   2. CHANGELOG guard: reject duplicate `## [Unreleased]` headings (#3395)
#   3. detect-secrets scan against .secrets.baseline (if installed)
#   4. openapi.sha256 baseline auto-sync when openapi.json is staged (#4690)

set -eu

# 1. Format check, staged Rust files only.
#
# `cargo fmt --check -- <paths>` silently ignores the path arguments and
# rescans the whole workspace, so the hook used to fail on pre-existing
# fmt drift in files the committer never touched. Invoke `rustfmt`
# directly so the path scope actually sticks. Edition is hardcoded to
# match `workspace.package.edition` in the root Cargo.toml.
STAGED_RS=$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs' || true)
if [ -n "$STAGED_RS" ]; then
    # shellcheck disable=SC2086 # intentional word splitting on file list
    if ! rustfmt --check --edition 2021 $STAGED_RS >/dev/null 2>&1; then
        echo "Error: staged Rust files are not rustfmt-clean."
        echo "Run: rustfmt --edition 2021 $STAGED_RS"
        echo "Then re-stage and commit again."
        exit 1
    fi
fi

# 2. CHANGELOG.md duplicate-Unreleased guard. Release tooling
# (`.github/workflows/release.yml` awk extractor and
# `xtask/src/changelog.rs`) silently picks the first match and drops
# subsequent blocks. See #3395.
if git diff --cached --name-only | grep -qx "CHANGELOG.md"; then
    UNRELEASED_COUNT=$(grep -c '^## \[Unreleased\]' CHANGELOG.md || true)
    if [ "$UNRELEASED_COUNT" -gt 1 ]; then
        echo "Error: CHANGELOG.md has $UNRELEASED_COUNT '## [Unreleased]' sections; expected exactly 1."
        echo "Release tooling will silently drop entries from all but the first. Merge them into one block."
        exit 1
    fi

    # 2b. (@username) attribution on staged additions to [Unreleased]. See
    # #3400. Only fails when the staged diff adds a bullet without `(@user)`
    # — never penalises historical entries. Soft-skip if python3 is missing.
    if command -v python3 >/dev/null 2>&1; then
        if ! python3 scripts/check-changelog-attribution.py --staged; then
            echo 'Error: staged CHANGELOG.md additions to [Unreleased] are missing'
            echo '       a `(@your-github-login)` suffix. Add it at the end of each bullet.'
            echo '       Audit current section: python3 scripts/check-changelog-attribution.py --all-unreleased'
            exit 1
        fi
    else
        echo "warning: python3 not available; skipping CHANGELOG attribution check." >&2
    fi
fi

# 3. Secret detection. Soft-fail if detect-secrets is not installed —
# the .pre-commit-config.yaml entry pins the canonical version, and CI
# is the authoritative gate. We don't want to wedge contributors who
# haven't installed it yet, but we do warn.
if [ -f .secrets.baseline ]; then
    if command -v detect-secrets >/dev/null 2>&1; then
        STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
        if [ -n "$STAGED" ]; then
            # shellcheck disable=SC2086
            if ! detect-secrets-hook --baseline .secrets.baseline $STAGED; then
                echo "Error: detect-secrets flagged a potential credential."
                echo "If this is a false positive, audit & update the baseline:"
                echo "  detect-secrets scan --baseline .secrets.baseline"
                echo "  detect-secrets audit .secrets.baseline"
                exit 1
            fi
        fi
    else
        echo "warning: detect-secrets not installed; skipping secret scan." >&2
        echo "  install with: pipx install detect-secrets" >&2
    fi
fi

# 4. openapi.sha256 baseline auto-sync (#4690).
#
# When openapi.json is in the staged diff, recompute its sha256 and
# auto-stage the matching xtask/baselines/openapi.sha256 line so the
# bump commit is internally consistent. Without this, every version
# bump (or any direct openapi.json edit) lands a baseline mismatch
# that the openapi-drift CI gate rejects, forcing a follow-up fixup
# commit (PR #4695 was such a fixup). cargo xtask schema-check gen
# is too slow for a sub-2s pre-commit, so this hook does the same
# single-line update via plain shasum.
#
# `shasum -a 256` is used (vs. sha256sum) because it ships by default
# on both macOS and Linux (perl-provided), so contributors don't need
# to install GNU coreutils.
if git diff --cached --name-only | grep -qx "openapi.json"; then
    BASELINE_FILE="xtask/baselines/openapi.sha256"
    if [ -f openapi.json ] && [ -f "$BASELINE_FILE" ]; then
        if command -v shasum >/dev/null 2>&1; then
            EXPECTED=$(shasum -a 256 openapi.json | awk '{print $1}')
            RECORDED=$(awk '{print $1}' "$BASELINE_FILE")
            if [ "$EXPECTED" != "$RECORDED" ]; then
                printf '%s  openapi.json\n' "$EXPECTED" > "$BASELINE_FILE"
                git add "$BASELINE_FILE"
                echo "info: auto-staged refreshed openapi.sha256 baseline (#4690)" >&2
            fi
        else
            echo "warning: shasum not available; skipping openapi baseline sync." >&2
            echo "  CI openapi-drift gate may reject this commit." >&2
        fi
    fi
fi

# 5. Sidecar-first policy: block in-process channel adapters not on
# the allowlist. Checks ADDED *and MODIFIED* staged files under
# crates/librefang-channels/src/ — so a stub file + a follow-up commit
# that adds the impl can't slip past the added-only window. New
# channels must be sidecar adapters. This hook is fast feedback only;
# `cargo xtask channel-policy` runs the same check tree-wide in CI on
# every PR and is the authoritative gate (an unset core.hooksPath or
# --no-verify can't bypass that). Known, accepted limitation: a
# macro-generated impl, or a new adapter impl added inside an
# already-allowlisted file, is not detected — this is a policy
# ratchet, not a security boundary.
ALLOWLIST="crates/librefang-channels/src/channels-allowlist.txt"
if [ -f "$ALLOWLIST" ]; then
    STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR -- \
        'crates/librefang-channels/src/*.rs' \
        'crates/librefang-channels/src/*/*.rs' 2>/dev/null || true)
    VIOLATIONS=""
    for f in $STAGED_FILES; do
        case "$f" in
            crates/librefang-channels/src/*/*.rs)
                base=$(basename "$(dirname "$f")") ;;
            crates/librefang-channels/src/*.rs)
                base=$(basename "$f" .rs) ;;
            *) continue ;;
        esac
        if git show ":$f" 2>/dev/null | grep -q 'ChannelAdapter for'; then
            if ! grep -qx "$base" "$ALLOWLIST"; then
                VIOLATIONS="$VIOLATIONS $f"
            fi
        fi
    done
    if [ -n "$VIOLATIONS" ]; then
        echo "Error: in-process channel adapter(s) not allowed:" >&2
        for v in $VIOLATIONS; do echo "  - $v" >&2; done
        echo "" >&2
        echo "LibreFang is sidecar-first. Ship a new channel as an" >&2
        echo "out-of-process sidecar adapter (Python or any language):" >&2
        echo "  examples/sidecar-channel-python/adapter.py (template) + the librefang.sidecar SDK" >&2
        echo "Grandfathering an in-process adapter needs explicit" >&2
        echo "maintainer approval: add the basename to $ALLOWLIST in a" >&2
        echo "separate, reviewed commit." >&2
        exit 1
    fi

    # Defense-in-depth (soft warn only): a new in-process registration
    # in channel_bridge.rs also implies a new adapter, but that file
    # is edited often for *existing* channels and feature names differ
    # from module basenames, so a hard match would false-positive.
    # The file check above is the authoritative gate.
    if git diff --cached --name-only \
        | grep -qx "crates/librefang-api/src/channel_bridge.rs"; then
        if git diff --cached -U0 -- \
            crates/librefang-api/src/channel_bridge.rs 2>/dev/null \
            | grep -qE '^\+.*Adapter::new'; then
            echo "note: channel_bridge.rs adds an *Adapter::new call." >&2
            echo "  If this wires a NEW in-process channel, it must" >&2
            echo "  instead be a sidecar adapter (see the policy above)." >&2
        fi
    fi
fi
