#!/usr/bin/env bash
# pre-write — gate for Write / Edit / MultiEdit / NotebookEdit tool calls.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
: "${CLAUDE_PLUGIN_ROOT:=$PLUGIN_ROOT}"
# shellcheck source=../lib/hook.sh
. "${PLUGIN_ROOT}/lib/hook.sh"
# shellcheck source=../lib/tools.sh
. "${PLUGIN_ROOT}/lib/tools.sh"
# shellcheck source=../lib/secrets.sh
. "${PLUGIN_ROOT}/lib/secrets.sh"
# shellcheck source=../lib/state.sh
. "${PLUGIN_ROOT}/lib/state.sh"
# shellcheck source=../lib/docs.sh
. "${PLUGIN_ROOT}/lib/docs.sh"

hook_input
tool_name="$(hook_field .tool_name)"
file_path=""
new_content=""
old_content=""

case "$tool_name" in
    Write)
        file_path="$(hook_field .tool_input.file_path)"
        new_content="$(hook_field .tool_input.content)"
        old_content="$(cat "$file_path" 2>/dev/null || true)"
        ;;
    Edit)
        file_path="$(hook_field .tool_input.file_path)"
        new_content="$(hook_field .tool_input.new_string)"
        old_content="$(hook_field .tool_input.old_string)"
        ;;
    MultiEdit)
        file_path="$(hook_field .tool_input.file_path)"
        new_content="$(hook_field '[.tool_input.edits[]?.new_string] | join("\n")')"
        old_content="$(hook_field '[.tool_input.edits[]?.old_string] | join("\n")')"
        ;;
    NotebookEdit)
        file_path="$(hook_field .tool_input.notebook_path)"
        new_content="$(hook_field .tool_input.new_source)"
        old_content="$(hook_field .tool_input.old_source)"
        ;;
    *) exit 0 ;;
esac
[ -n "$file_path" ] || exit 0

# External scanner first; unmistakable credential formats remain covered if it is unavailable.
if secret_finding="$(secret_in_content "$new_content" "$file_path")"; then
    hook_secret_deny "${secret_finding}. Use an environment variable or a secret manager instead."
    exit 0
fi

# Parse the deliberately small suppressions.yaml schema without adding a YAML dependency.
# Each emitted record pairs a literal marker with its language-specific POSIX ERE.
suppression_records="$(awk '
    /^  [[:alnum:]_]+:$/ { in_patterns = 0; count = 0; next }
    /^    patterns:$/ { in_patterns = 1; count = 0; next }
    in_patterns && /^      - / {
        value = $0
        sub(/^[[:space:]]*-[[:space:]]*/, "", value)
        gsub(/^\042|\042$/, "", value)
        patterns[++count] = value
        next
    }
    in_patterns && /^    reason_grep_E:/ {
        reason = $0
        sub(/^[[:space:]]*reason_grep_E:[[:space:]]*\047/, "", reason)
        sub(/\047[[:space:]]*$/, "", reason)
        for (i = 1; i <= count; i++) print patterns[i] "\t" reason
        delete patterns
        count = 0
        in_patterns = 0
    }
' "${PLUGIN_ROOT}/rules/policies/suppressions.yaml" 2>/dev/null || true)"

_suppression_count() {
    printf '%s' "$1" | grep -Fo -- "$2" | wc -l | tr -d ' '
}

_unjustified_suppression_count() {
    local content="$1" marker_text="$2" reason_regex="$3" line count=0
    while IFS= read -r line; do
        case "$line" in
            *"$marker_text"*)
                if ! printf '%s\n' "$line" | grep -Eq -- "$reason_regex"; then
                    count=$((count + 1))
                fi ;;
        esac
    done <<< "$content"
    printf '%s' "$count"
}

while IFS=$'\t' read -r marker reason_pattern; do
    [ -n "$marker" ] || continue
    new_marker_count="$(_suppression_count "$new_content" "$marker")"
    old_marker_count="$(_suppression_count "$old_content" "$marker")"
    [ "$new_marker_count" -gt "$old_marker_count" ] || continue

    new_unjustified="$(_unjustified_suppression_count "$new_content" "$marker" "$reason_pattern")"
    old_unjustified="$(_unjustified_suppression_count "$old_content" "$marker" "$reason_pattern")"
    if [ "$new_unjustified" -gt "$old_unjustified" ]; then
        hook_deny "Suppression comment '$marker' requires a justification on the same line."
        exit 0
    fi
done <<< "$suppression_records"

base_name="${file_path##*/}"

# AP-AGENT-002 has two tiers, because they are two different acts.
#   gate=1 → ask. A required test gate is being weakened: a coverage threshold
#            drops or disappears, a test job loses its gate in CI, or the guard's
#            own configuration is edited (turning the guard down deletes the gate
#            as surely as deleting the job).
#   lint=1 → note. A linter or type-checker threshold or baseline is loosened.
#            Recorded in context, never prompted: a factory changes these daily
#            and a prompt per edit trains the human to approve without reading.
gate=0
gate_reason="A required test gate is being weakened. That is a human decision, not a build fix."
lint=0

# _gate_numbers <content> — every number that follows a coverage-gate keyword.
# Keyword list is deliberately unambiguous: each one exists only to express a
# minimum coverage percentage, so a bare "lines: 80" elsewhere cannot trip it.
_gate_numbers() {
    printf '%s' "$1" \
        | grep -oiE '(cov-fail-under|fail[_-]under|coveragethreshold|coverage[_-]threshold|minimum[_-]coverage|min[_-]coverage)[^0-9]{0,40}[0-9]+(\.[0-9]+)?' \
        | grep -oE '[0-9]+(\.[0-9]+)?$'
}

# _thresholds_lowered <old> <new> — true when a coverage gate went down or away.
_thresholds_lowered() {
    local old_list new_list old_count new_count
    old_list="$(_gate_numbers "$1")"
    [ -n "$old_list" ] || return 1
    new_list="$(_gate_numbers "$2")"
    old_count="$(printf '%s\n' "$old_list" | grep -c '[0-9]')"
    new_count="$(printf '%s\n' "$new_list" | grep -c '[0-9]' || true)"
    [ "$new_count" -lt "$old_count" ] && return 0
    paste <(printf '%s\n' "$old_list") <(printf '%s\n' "$new_list") \
        | awk -F'\t' 'NF == 2 && $2 + 0 < $1 + 0 { found = 1 } END { exit !found }'
}

case "$base_name" in
    .slopguard.json)
        gate=1
        gate_reason="This is slop-guard's own configuration — turning the guard down removes the gate. A human decides that." ;;
    phpstan*.neon*|psalm*.xml*|.golangci.*|eslint.config.*|.eslintrc*|ruff.toml|.ruff.toml|.tflint.hcl|.checkov.yaml|.kube-linter.yaml|.hadolint.yaml|zizmor.yml|.gitleaks.toml|.sqlfluff|.eslintcache|*.baseline.json)
        lint=1 ;;
    pyproject.toml)
        if [ "$new_content" != "$old_content" ] \
            && { printf '%s\n%s' "$old_content" "$new_content" \
                | grep -Eq '^\[tool\.(ruff|mypy|pyright)\]'; }; then
            lint=1
        fi ;;
    tsconfig*.json)
        if printf '%s' "$new_content" | grep -Eq '"strict"[[:space:]]*:[[:space:]]*false' \
            || { printf '%s' "$old_content" | grep -Eq '"strict"[[:space:]]*:[[:space:]]*true' \
                && ! printf '%s' "$new_content" | grep -Eq '"strict"[[:space:]]*:[[:space:]]*true'; }; then
            lint=1
        fi ;;
esac
case "$file_path" in
    */.github/workflows/*|*/.gitlab-ci.yml|.github/workflows/*|.gitlab-ci.yml)
        # A test or coverage step that disappears, or one that is told to stop
        # failing the build. Lint and security steps are not gated here: those
        # are the edits a factory makes legitimately, and their loss is a note.
        for keyword in test coverage; do
            if printf '%s' "$old_content" | grep -qi -- "$keyword" \
                && ! printf '%s' "$new_content" | grep -qi -- "$keyword"; then
                gate=1
                break
            fi
        done
        if [ "$gate" -eq 0 ] \
            && printf '%s' "$new_content" | grep -qiE 'continue-on-error:[[:space:]]*true|allow_failure:[[:space:]]*true|if:[[:space:]]*false' \
            && ! printf '%s' "$old_content" | grep -qiE 'continue-on-error:[[:space:]]*true|allow_failure:[[:space:]]*true|if:[[:space:]]*false'; then
            gate=1
        fi
        ;;
    .slopguard/tools/*.yaml|*/.slopguard/tools/*.yaml|\
    .slopguard/mapping/*.yaml|*/.slopguard/mapping/*.yaml)
        gate=1
        gate_reason="This is slop-guard's own configuration — turning the guard down removes the gate. A human decides that."
        ;;
esac
if [ "$gate" -eq 0 ] && _thresholds_lowered "$old_content" "$new_content"; then
    gate=1
fi
if [ "$gate" -eq 1 ]; then
    hook_ask "$gate_reason"
    exit 0
fi
if [ "$lint" -eq 1 ]; then
    hook_context "Linter or type-checker configuration changed (AP-AGENT-002). Thresholds and baselines are recorded, not gated — fix the findings rather than widening the ignore list."
    exit 0
fi


session_id="$(hook_field .session_id)"
agent_id="$(hook_field .agent_id)"
language=""
case "$base_name" in
    *.php) language="php" ;;
    *.go) language="go" ;;
    *.py) language="python" ;;
    *.js|*.jsx|*.mjs|*.cjs|*.ts|*.tsx) language="js-ts" ;;
    *.tf|*.hcl|*.yaml|*.yml|Dockerfile) language="iac" ;;
esac
if [ -n "$session_id" ] && [ -n "$language" ]; then
    context_dir="$(state_dir "$session_id" "${agent_id:-}")"
    mkdir -p "$context_dir"
    if mkdir "${context_dir}/context-${language}.sent" 2>/dev/null; then
        case "$language" in
            php) blockers="SQL injection, unsafe deserialization, mass assignment, and env() outside config" ;;
            go) blockers="unchecked errors, command or SQL injection, and unsafe filesystem boundaries" ;;
            python) blockers="eval/exec, shell=True, unsafe deserialization, and raw SQL" ;;
            js-ts) blockers="eval, unsafe HTML injection, unbounded request bodies, and unhandled promises" ;;
            iac) blockers="public exposure, unpinned images/actions, broad IAM, and embedded secrets" ;;
        esac
        _fw_sentence=""
        if [ "${CLAUDE_PLUGIN_OPTION_REQUIRE_DOCS_LOOKUP:-true}" != "false" ] \
                && [ -f "${context_dir}/profile.json" ]; then
            _fwv_json="$(jq -r '.framework_versions // empty' \
                "${context_dir}/profile.json" 2>/dev/null || true)"
            if [ -n "$_fwv_json" ]; then
                _stacks_file="${SLOPGUARD_STACKS_JSON:-${PLUGIN_ROOT}/rules/stacks.json}"
                _fw_match="$(jq -r \
                    --arg fn "$base_name" \
                    --argjson fwv "$_fwv_json" \
                    'to_entries
                     | map(select(
                         .value.context7 != null
                         and ($fwv[.key] != null)
                         and (
                           (.value.globs // [])
                           | map(ltrimstr("**/") | gsub("\\."; "\\.") | gsub("\\*"; ".*") | "^" + . + "$")
                           | map($fn | test(.))
                           | any
                         )
                       ))
                     | if length > 0 then (.[0] | "\(.key)|\($fwv[.key])") else empty end' \
                    "$_stacks_file" 2>/dev/null || true)"
                if [ -n "$_fw_match" ]; then
                    _fw_name="${_fw_match%%|*}"
                    _fw_ver="${_fw_match##*|}"
                    _fw_query="$(jq -r --arg k "$_fw_name" '.[$k].context7 // empty' \
                        "$_stacks_file" 2>/dev/null || true)"
                    _fw_suppress=0
                    if [ -n "$_fw_query" ]; then
                        if docs_seen "$session_id" "${agent_id:-}" "$_fw_query" 2>/dev/null; then
                            _fw_suppress=1
                        elif docs_recall "$_fw_query" "$_fw_ver" 2>/dev/null; then
                            _fw_suppress=1
                        fi
                    fi
                    if [ "$_fw_suppress" -eq 0 ]; then
                        _fw_sentence=" Project runs ${_fw_name} ${_fw_ver} — consult mcp__context7__resolve-library-id then get-library-docs for version-specific conventions before restructuring code."
                    fi
                fi
            fi
        fi
        hook_context "Slop Guard first ${language} edit: blocker checks cover ${blockers}.${_fw_sentence}"
    fi
fi

exit 0
