#!/usr/bin/env bash
# slopguard — anti-pattern detection and gate for agent-written code.
#
#   slopguard doctor [--install]   report tool status; install with --install
#   slopguard version              print version
#   slopguard help                 this message
#
# Requires: jq (or compatible). Other tool dependencies are installed by doctor.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MANIFEST="${PLUGIN_ROOT}/.claude-plugin/plugin.json"

# Normalize plugin root and data across runtimes.
# Grok injects GROK_PLUGIN_ROOT/GROK_PLUGIN_DATA; Claude/Codex inject CLAUDE_* directly.
# Seeding CLAUDE_* from GROK_* here keeps all downstream scripts host-agnostic.
[ -n "${GROK_PLUGIN_ROOT:-}" ] && : "${CLAUDE_PLUGIN_ROOT:=$GROK_PLUGIN_ROOT}"
: "${CLAUDE_PLUGIN_ROOT:=$PLUGIN_ROOT}"
[ -n "${GROK_PLUGIN_DATA:-}" ] && : "${CLAUDE_PLUGIN_DATA:=$GROK_PLUGIN_DATA}"
: "${CLAUDE_PLUGIN_DATA:=${XDG_DATA_HOME:-$HOME/.local/share}/claude/plugins/slop-guard}"
export CLAUDE_PLUGIN_ROOT CLAUDE_PLUGIN_DATA

# Read identity from the manifest so nothing here hard-codes the plugin name.
manifest_field() {
    sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$MANIFEST" 2>/dev/null | head -1
}
VERSION="$(manifest_field version)"; [ -n "$VERSION" ] || VERSION="unknown"

# --------------------------------------------------------------------------- #
# plumbing
# --------------------------------------------------------------------------- #

die() { printf 'slopguard: %s\n' "$1" >&2; exit "${2:-1}"; }
say() { printf '%s\n' "$1"; }

ensure_jq() {
    command -v jq >/dev/null 2>&1 && return 0
    local managed_jq
    managed_jq="$(bootstrap_jq)" || die "jq is missing and the pinned bootstrap failed"
    PATH="$(dirname "$managed_jq"):${PATH}"
    export PATH
}

# shellcheck source=../lib/tools.sh
. "${CLAUDE_PLUGIN_ROOT}/lib/tools.sh"
# shellcheck source=../lib/hook.sh
. "${CLAUDE_PLUGIN_ROOT}/lib/hook.sh"
# shellcheck source=../lib/state.sh
. "${CLAUDE_PLUGIN_ROOT}/lib/state.sh"
# shellcheck source=../lib/finding.sh
. "${CLAUDE_PLUGIN_ROOT}/lib/finding.sh"
# shellcheck source=../lib/detect.sh
. "${CLAUDE_PLUGIN_ROOT}/lib/detect.sh"
# shellcheck source=../lib/config.sh
. "${CLAUDE_PLUGIN_ROOT}/lib/config.sh"

# Fallback: tool_config_mode is defined in lib/tools.sh (ConfigMode slice).
# This stub fires only when that slice hasn't landed yet; the contract is identical.
# Unknown CONFIG_SOURCE values → overlay (safe: a typo must not disable the guard).
if ! declare -F tool_config_mode >/dev/null 2>&1; then
    tool_config_mode() {
        local _tcm_t="$1" _tcm_pd="${2:-${CLAUDE_PROJECT_DIR:-}}"
        local _tcm_cp; _tcm_cp="$(tool_config_path "$_tcm_t" "$_tcm_pd")"
        case "$(tool_config_source "$_tcm_cp")" in
            project) printf 'project' ;;
            *)
                case "${CLAUDE_PLUGIN_OPTION_CONFIG_SOURCE:-overlay}" in
                    full)         printf 'full' ;;
                    project-only) printf 'skip' ;;
                    *)            printf 'overlay' ;;
                esac
                ;;
        esac
    }
fi

# --------------------------------------------------------------------------- #
# session-start
# --------------------------------------------------------------------------- #

cmd_session_start() {
    "${PLUGIN_ROOT}/hooks/session-start"
}

# --------------------------------------------------------------------------- #
# policy hooks
# --------------------------------------------------------------------------- #

cmd_pre_bash() {
    "${PLUGIN_ROOT}/hooks/pre-bash"
}

cmd_pre_write() {
    "${PLUGIN_ROOT}/hooks/pre-write"
}

cmd_pre_read() {
    "${PLUGIN_ROOT}/hooks/pre-read"
}

# --------------------------------------------------------------------------- #
# post-write (fast and medium tiers)
# --------------------------------------------------------------------------- #

# _cmd_post_write_medium — reads hook JSON from stdin (already consumed by
# hook_input), runs dispatch_medium, and exits 2 when new error/blocker
# findings are present (asyncRewake).  Advisory mode uses additionalContext.
# Warn-only findings use additionalContext without exit 2.
_cmd_post_write_medium() {
    hook_input

    local tool_name; tool_name="$(hook_field .tool_name)"
    local file_path=""
    case "$tool_name" in
        Write|Edit|MultiEdit)  file_path="$(hook_field .tool_input.file_path)" ;;
        NotebookEdit)          file_path="$(hook_field .tool_input.notebook_path)" ;;
        *) exit 0 ;;
    esac

    [ -n "$file_path" ] || exit 0
    [ -f "$file_path" ] || exit 0

    local session_id; session_id="$(hook_field .session_id)"
    local agent_id;   agent_id="$(hook_field .agent_id)"
    [ -n "$session_id" ] || exit 0

    local enforcement="${CLAUDE_PLUGIN_OPTION_ENFORCEMENT_MODE:-balanced}"
    local project_dir="${CLAUDE_PROJECT_DIR:-.}"

    local findings_tmp; findings_tmp="$(mktemp "${TMPDIR:-/tmp}/.sg-med-findings.XXXXXX")"
    trap 'rm -f "${findings_tmp}"' EXIT INT TERM

    dispatch_medium "$file_path" "$session_id" "${agent_id:-}" "$project_dir" "$findings_tmp"

    # Debounced away, no tools apply, or all findings already fingerprinted.
    [ -s "$findings_tmp" ] || exit 0

    local findings_json total
    findings_json="$(jq -s '.' "$findings_tmp" 2>/dev/null || printf '[]')"
    total="$(printf '%s\n' "$findings_json" | jq 'length' 2>/dev/null || printf '0')"
    [ "$total" -gt 0 ] || exit 0

    # Apply 20-finding cap (Z4).
    local MAX_FINDINGS=20
    local capped; capped="$(printf '%s\n' "$findings_json" \
        | jq --argjson max "$MAX_FINDINGS" '.[0:$max]')"
    local overflow; overflow=$(( total > MAX_FINDINGS ? total - MAX_FINDINGS : 0 ))

    local n_blocker n_error n_warn n_info
    n_blocker="$(printf '%s\n' "$capped" | jq '[.[] | select(.severity=="blocker")] | length')"
    n_error="$(printf '%s\n' "$capped"   | jq '[.[] | select(.severity=="error")]   | length')"
    n_warn="$(printf '%s\n' "$capped"    | jq '[.[] | select(.severity=="warn")]    | length')"
    n_info="$(printf '%s\n' "$capped"    | jq '[.[] | select(.severity=="info")]    | length')"

    # Medium tier: only new findings ≥ error wake the agent (§11.3 criterion).
    local n_actionable; n_actionable=$(( n_blocker + n_error ))

    # Build §4.7 header.
    local header_parts=""
    [ "$n_blocker" -gt 0 ] && header_parts="${header_parts}${n_blocker} blocker$([ "$n_blocker" -ne 1 ] && printf 's'), "
    [ "$n_error"   -gt 0 ] && header_parts="${header_parts}${n_error} error$([ "$n_error" -ne 1 ] && printf 's'), "
    [ "$n_warn"    -gt 0 ] && header_parts="${header_parts}${n_warn} warning$([ "$n_warn" -ne 1 ] && printf 's'), "
    [ "$n_info"    -gt 0 ] && header_parts="${header_parts}${n_info} info, "
    header_parts="${header_parts%, }"
    local header="slopguard: ${header_parts} in changed code (medium tier)"

    # Build §4.7 body.
    local body_lines
    body_lines="$(printf '%s\n' "$capped" | jq -r '
        group_by(.file)[] |
        (.[0].file) as $f |
        ($f + "\n") +
        (map(
            "  " +
            (if .severity == "blocker" then "BLOCKER"
             elif .severity == "error" then "ERROR  "
             elif .severity == "warn"  then "WARN   "
             else "INFO   " end) + " " +
            (if .line > 0 then "L\(.line) " else "" end) +
            .ap_id + " " +
            (if (.cwe | length) > 0 then .cwe[0] + " " else "" end) +
            "[\(.tool):\(.tool_rule)] " +
            .message +
            (if .fix != "" then ". Fix: " + .fix else "" end)
        ) | join("\n"))
    ' 2>/dev/null || true)"

    # Details lines for error/blocker AP-ids.
    local unique_ap_ids details_lines=""
    unique_ap_ids="$(printf '%s\n' "$capped" | jq -r \
        '[.[] | select(.severity == "blocker" or .severity == "error") | .ap_id] | unique | .[]' \
        2>/dev/null | head -5 || true)"
    while IFS= read -r _ap; do
        [ -n "$_ap" ] || continue
        local _skill=""
        case "$_ap" in
            AP-PHP-*)              _skill="php-antipatterns" ;;
            AP-GO-*)               _skill="go-antipatterns" ;;
            AP-PY-*)               _skill="python-antipatterns" ;;
            AP-TS-*|AP-NODE-*)     _skill="ts-react-antipatterns" ;;
            AP-K8S-*|AP-TF-*|AP-DOCKER-*|AP-CI-*) _skill="iac-antipatterns" ;;
            AP-SQL-*)              _skill="sql-antipatterns" ;;
            *)                     _skill="${_ap%%-*}-antipatterns" ;;
        esac
        details_lines="${details_lines}Details: skills ${_skill}/reference/${_ap}.md\n"
    done <<< "$unique_ap_ids"

    local msg="${header}"$'\n'"${body_lines}"
    [ -n "$details_lines" ] && msg="${msg}"$'\n'"$(printf '%b' "${details_lines}")"
    [ "$overflow" -gt 0 ] \
        && msg="${msg}"$'\n'"+${overflow} more findings (run: slopguard scan ${file_path})"

    # Trim to 4000 chars (Z4).
    [ "${#msg}" -gt 4000 ] && msg="${msg:0:3990}..."

    # Enforce modes §4.6.
    case "$enforcement" in
        advisory)
            hook_context "$msg"
            ;;
        balanced|strict)
            if [ "$n_actionable" -gt 0 ]; then
                printf '%s\n' "$msg" >&2
                exit 2
            elif [ "$n_warn" -gt 0 ]; then
                hook_context "$msg"
            fi
            ;;
    esac

    exit 0
}

cmd_post_write() {
    # Parse --tier=<value> flag; other args are silently ignored for
    # forward-compatibility with future tiers.
    local tier="fast"
    while [ $# -gt 0 ]; do
        case "$1" in
            --tier=*) tier="${1#--tier=}"; shift ;;
            --tier)   tier="${2:-fast}";   shift 2 ;;
            *)        shift ;;
        esac
    done
    case "$tier" in
        fast)
            # shellcheck source=../lib/diff.sh
            . "${CLAUDE_PLUGIN_ROOT}/lib/diff.sh"
            # shellcheck source=../lib/dispatch.sh
            . "${CLAUDE_PLUGIN_ROOT}/lib/dispatch.sh"
            # shellcheck source=../lib/ext.sh
            [ -f "${CLAUDE_PLUGIN_ROOT}/lib/ext.sh" ] \
                && . "${CLAUDE_PLUGIN_ROOT}/lib/ext.sh"
            "${PLUGIN_ROOT}/hooks/post-write"
            ;;
        medium)
            # shellcheck source=../lib/diff.sh
            . "${CLAUDE_PLUGIN_ROOT}/lib/diff.sh"
            # shellcheck source=../lib/dispatch.sh
            . "${CLAUDE_PLUGIN_ROOT}/lib/dispatch.sh"
            # shellcheck source=../lib/ext.sh
            [ -f "${CLAUDE_PLUGIN_ROOT}/lib/ext.sh" ] \
                && . "${CLAUDE_PLUGIN_ROOT}/lib/ext.sh"
            _cmd_post_write_medium
            ;;
        *)
            # Unknown tier: exit 0 (fail-open — future tiers are not errors).
            exit 0
            ;;
    esac
}

# --------------------------------------------------------------------------- #
# note-docs
# --------------------------------------------------------------------------- #

cmd_note_docs() {
    # shellcheck source=../lib/docs.sh
    . "${CLAUDE_PLUGIN_ROOT}/lib/docs.sh"
    local raw; raw="$(cat)"
    local session_id agent_id library
    session_id="$(printf '%s' "$raw" | jq -r '.session_id // empty')"
    agent_id="$(printf '%s'  "$raw" | jq -r '.agent_id  // empty')"
    library="$(printf '%s'   "$raw" | jq -r \
        '.tool_input.libraryName // .tool_input.context7CompatibleLibraryID // .tool_input.query // empty')"
    # Missing session_id or library is a silent no-op — this hook must be invisible.
    if [ -z "$session_id" ] || [ -z "$library" ]; then
        exit 0
    fi
    docs_note "$session_id" "${agent_id:-}" "$library"
    # Record cross-session memory when the library matches a project framework version.
    # Silent no-op on any failure: missing profile, no framework match, jq error.
    local _ndd_profile; _ndd_profile="$(state_dir "$session_id" "${agent_id:-}")/profile.json"
    if [ -f "$_ndd_profile" ]; then
        local _ndd_fwv; _ndd_fwv="$(jq -r '.framework_versions // empty' \
            "$_ndd_profile" 2>/dev/null || true)"
        if [ -n "$_ndd_fwv" ]; then
            local _ndd_stacks; _ndd_stacks="${SLOPGUARD_STACKS_JSON:-${CLAUDE_PLUGIN_ROOT}/rules/stacks.json}"
            local _ndd_needle; _ndd_needle="$(printf '%s' "$library" | tr '[:upper:]' '[:lower:]')"
            local _ndd_pairs; _ndd_pairs="$(jq -r \
                --argjson fwv "$_ndd_fwv" \
                'to_entries
                 | map(select(.value.context7 != null and ($fwv[.key] != null)))
                 | map("\(.value.context7)|\($fwv[.key])")
                 | .[]' \
                "$_ndd_stacks" 2>/dev/null || true)"
            local _ndd_pair _ndd_query _ndd_ver _ndd_hay _ndd_hit
            while IFS= read -r _ndd_pair; do
                [ -n "$_ndd_pair" ] || continue
                _ndd_query="${_ndd_pair%%|*}"
                _ndd_ver="${_ndd_pair##*|}"
                _ndd_hay="$(printf '%s' "$_ndd_query" | tr '[:upper:]' '[:lower:]')"
                _ndd_hit=0
                case "$_ndd_hay" in *"$_ndd_needle"*) _ndd_hit=1 ;; esac
                [ "$_ndd_hit" -eq 0 ] && case "$_ndd_needle" in *"$_ndd_hay"*) _ndd_hit=1 ;; esac
                if [ "$_ndd_hit" -eq 1 ]; then
                    docs_remember "$_ndd_query" "$_ndd_ver" 2>/dev/null || true
                    break
                fi
            done <<< "$_ndd_pairs"
        fi
    fi
    exit 0
}

# --------------------------------------------------------------------------- #
# deps-check
# --------------------------------------------------------------------------- #

cmd_deps_check() {
    local deps_lib="${CLAUDE_PLUGIN_ROOT}/lib/deps.sh"
    [ -f "$deps_lib" ] || die "lib/deps.sh not found — run: slopguard doctor"
    # shellcheck source=../lib/deps.sh
    . "$deps_lib"
    deps_check_main "$@"
}

# --------------------------------------------------------------------------- #
# doctor
# --------------------------------------------------------------------------- #

cmd_doctor() {
    local do_install=0 do_all=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --install) do_install=1; shift ;;
            --all)     do_all=1; shift ;;
            -*)        die "unknown flag for doctor: $1" ;;
            *)         die "unexpected argument: $1" ;;
        esac
    done

    detect_platform >/dev/null || die "unsupported platform"

    local lock_path; lock_path="$(_tools_lock)" || die "CLAUDE_PLUGIN_ROOT is not set"
    [ -f "$lock_path" ] || die "lockfile missing: $lock_path"

    local tools_list
    tools_list="$(lock_tools)" || die "lockfile unreadable or malformed: $lock_path"
    [ -n "$tools_list" ] || die "lockfile contains no tools: $lock_path"

    # Scope: only the tools the project's stacks can use, unless --all.  A repo
    # without Go has nothing to gain from a Go linter, so doctor neither reports
    # nor installs one.
    local scope_label="all pinned tools"
    if [ "$do_all" -eq 0 ]; then
        config_resolve_stacks "${CLAUDE_PROJECT_DIR:-.}"
        local relevant_tools; relevant_tools=" $(tools_for_stacks "${SG_STACKS:-}") "
        local scoped_list=""
        while IFS= read -r tool; do
            case "$relevant_tools" in
                *" ${tool} "*) scoped_list="${scoped_list}${tool}"$'\n' ;;
            esac
        done <<< "$tools_list"
        tools_list="${scoped_list%$'\n'}"
        scope_label="stacks: ${SG_STACKS:-none} (use --all for every pinned tool)"
        if [ -z "$tools_list" ]; then
            say "slopguard doctor — plugin: ${CLAUDE_PLUGIN_ROOT}  data: ${CLAUDE_PLUGIN_DATA}"
            say "scope: ${scope_label}"
            say ""
            say "No pinned tool applies to this project's stacks."
            return 0
        fi
    fi

    say "slopguard doctor — plugin: ${CLAUDE_PLUGIN_ROOT}  data: ${CLAUDE_PLUGIN_DATA}"
    say "scope: ${scope_label}"
    say ""

    local fail_count=0

    while IFS= read -r tool; do
        [ -n "$tool" ] || continue
        local expected_version; expected_version="$(lock_version "$tool")"
        local refresh_attempted=0 refresh_failed=0
        if [ "$do_install" -eq 1 ] \
            && [ -n "$(lock_metadata "$tool" node_lock)" ] \
            && ! managed_metadata_matches "$tool"; then
            refresh_attempted=1
            say "  ${tool}  managed bundle metadata changed; reinstalling..."
            if install_tool "$tool"; then
                say "    → refreshed ${tool} ${expected_version}"
            else
                say "    → refresh FAILED"
                refresh_failed=1
            fi
        fi
        local resolved; resolved="$(resolve_tool "$tool")"
        local config_label; config_label="$(tool_config_path "$tool")"
        local config_mode_label; config_mode_label="$(tool_config_mode "$tool")"
        local fail_this="$refresh_failed"

        if [ -z "$resolved" ]; then
            fail_this=1
            say "  ${tool}  source: missing  lockfile: ${expected_version}  [NOT FOUND]  config: ${config_label}  mode: ${config_mode_label}"
            local path_bin doctor_bin
            doctor_bin="$(lock_bin "$tool")"
            if path_bin="$(command -v "$doctor_bin" 2>/dev/null)"; then
                local path_ver; path_ver="$(tool_version_output "$tool" "$path_bin" | head -1 || true)"
                if tool_version_matches "$tool" "$path_bin"; then
                    say "    PATH: ${path_bin} (${path_ver}) — ignored by tool_source=${CLAUDE_PLUGIN_OPTION_TOOL_SOURCE:-project-first}"
                else
                    say "    PATH: ${path_bin} (${path_ver}) — rejected: version mismatch (want ${expected_version})"
                fi
            fi
        else
            local source_label version_label match_label
            local plugin_pfx="${CLAUDE_PLUGIN_DATA:-__NO_PLUGIN_DATA__}/tools/"
            case "$resolved" in
                "${plugin_pfx}"*)
                    source_label="plugin" ;;
                */vendor/bin/*|*/node_modules/.bin/*|*/.venv/bin/*)
                    source_label="project" ;;
                *)
                    if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && \
                       [ "${resolved#${CLAUDE_PROJECT_DIR}/}" != "$resolved" ]; then
                        source_label="project"
                    else
                        source_label="PATH"
                    fi
                    ;;
            esac

            version_label="$(tool_version_output "$tool" "$resolved" | head -1 || true)"

            if tool_version_matches "$tool" "$resolved"; then
                match_label="ok"
            else
                match_label="version mismatch (want ${expected_version})"
                fail_this=1
            fi

            say "  ${tool}  source: ${source_label}  version: ${version_label}  [${match_label}]  config: ${config_label}  mode: ${config_mode_label}"
        fi

        if [ "$fail_this" -eq 1 ]; then
            fail_count=$((fail_count + 1))
            if [ "$do_install" -eq 1 ] && [ "$refresh_attempted" -eq 0 ]; then
                say "    → installing..."
                if install_tool "$tool"; then
                    local re_resolved; re_resolved="$(resolve_tool "$tool")"
                    if [ -n "$re_resolved" ] && tool_version_matches "$tool" "$re_resolved"; then
                        say "    → installed ${tool} ${expected_version}"
                        fail_count=$((fail_count - 1))
                    elif [ -n "$re_resolved" ]; then
                        say "    → installed ${tool} ${expected_version} but a higher-priority binary still wins — see above"
                    else
                        say "    → installed ${tool} ${expected_version} but the binary is not reachable in the current source mode"
                    fi
                else
                    say "    → install FAILED"
                fi
            elif [ -z "$resolved" ]; then
                say "    install:  slopguard doctor --install"
            fi
        fi
    done <<< "$tools_list"

    # Extensions section — always shown; informs the user about descriptor status.
    say ""
    local _ext_sh_doc; _ext_sh_doc="${SLOPGUARD_EXT_SH:-${CLAUDE_PLUGIN_ROOT}/lib/ext.sh}"
    if [ -f "$_ext_sh_doc" ]; then
        # shellcheck source=../lib/ext.sh
        . "$_ext_sh_doc"
        local _doc_proj; _doc_proj="${CLAUDE_PROJECT_DIR:-.}"
        local _doc_ext_dir; _doc_ext_dir="$(ext_dir "$_doc_proj")"
        say "Extensions (${_doc_ext_dir}):"
        if [ -d "$_doc_ext_dir" ]; then
            local _doc_ext_out; _doc_ext_out="$(ext_tools "$_doc_proj")"
            if [ -z "$_doc_ext_out" ]; then
                say "  (no tool descriptors)"
            else
                local _doc_obj _doc_name _doc_tier _doc_resolved _doc_status
                local _doc_label _doc_map_count _doc_map_file
                while IFS= read -r _doc_obj; do
                    [ -n "$_doc_obj" ] || continue
                    _doc_name="$(printf '%s' "$_doc_obj" | jq -r '.name // "?"')"
                    _doc_tier="$(printf '%s' "$_doc_obj" | jq -r '.tier // "?"')"
                    _doc_resolved="$(printf '%s' "$_doc_obj" | jq -r '.resolved // ""')"
                    _doc_status="$(printf '%s' "$_doc_obj" | jq -r '.status // "?"')"
                    case "$_doc_status" in
                        ok)
                            _doc_label="project: ${_doc_resolved#${_doc_proj}/}"
                            ;;
                        absent)
                            _doc_label="absent"
                            ;;
                        refused:*)
                            _doc_label="${_doc_status/refused:/refused: }"
                            ;;
                        *)
                            _doc_label="$_doc_status"
                            ;;
                    esac
                    _doc_map_count=0
                    _doc_map_file="${_doc_ext_dir}/mapping/${_doc_name}.yaml"
                    if [ -f "$_doc_map_file" ]; then
                        _doc_map_count="$(awk '
                            /^rules:/{in_rules=1;next}
                            in_rules && /^  [[:alnum:]]/{c++}
                            END{print c+0}
                        ' "$_doc_map_file")"
                    fi
                    say "  ${_doc_name}  tier: ${_doc_tier}  ${_doc_label}  overrides: ${_doc_map_count}"
                done <<< "$_doc_ext_out"
            fi
        else
            say "  (no .slopguard/ directory)"
        fi
    else
        say "Extensions:"
        say "  (lib/ext.sh not yet installed — extensions unavailable)"
    fi

    say ""
    if [ "$fail_count" -eq 0 ]; then
        say "All tools present and version-matched."
    else
        say "Some tools are missing or version-mismatched."
        if [ "$do_install" -eq 0 ]; then
            say "Run:  slopguard doctor --install"
        fi
        return 1
    fi
}

# --------------------------------------------------------------------------- #
# adopt-config
# --------------------------------------------------------------------------- #

# _adopt_dest <tool> <project_dir>
# Prints the primary project config path for <tool> — where adopt-config writes.
# Mirrors the first candidate checked in tool_config_path for each tool.
# Returns empty string for tools that have no single-file config.
_adopt_dest() {
    local _ad_tool="$1" _ad_dir="$2"
    case "$_ad_tool" in
        betterleaks)   printf '%s/.gitleaks.toml'    "$_ad_dir" ;;
        checkov)       printf '%s/.checkov.yaml'      "$_ad_dir" ;;
        eslint-stack)  printf '%s/eslint.config.mjs'  "$_ad_dir" ;;
        golangci-lint) printf '%s/.golangci.yml'      "$_ad_dir" ;;
        hadolint)      printf '%s/.hadolint.yaml'     "$_ad_dir" ;;
        jscpd)         printf '%s/.jscpd.json'        "$_ad_dir" ;;
        kube-linter)   printf '%s/.kube-linter.yaml'  "$_ad_dir" ;;
        phpstan)       printf '%s/phpstan.neon'       "$_ad_dir" ;;
        psalm)         printf '%s/psalm.xml'          "$_ad_dir" ;;
        ruff)          printf '%s/ruff.toml'          "$_ad_dir" ;;
        tflint)        printf '%s/.tflint.hcl'        "$_ad_dir" ;;
        zizmor)        printf '%s/zizmor.yml'         "$_ad_dir" ;;
        *)             printf '' ;;
    esac
}

# _adopt_ext_dir <project_dir>
# Prints the directory that holds project tool descriptors.
_adopt_ext_dir() {
    local _ae_lib="${SLOPGUARD_EXT_SH:-${CLAUDE_PLUGIN_ROOT}/lib/ext.sh}"
    if ! declare -F ext_dir >/dev/null 2>&1 && [ -f "$_ae_lib" ]; then
        # shellcheck disable=SC1090  # path is resolved above, not a literal
        . "$_ae_lib"
    fi
    if declare -F ext_dir >/dev/null 2>&1; then
        printf '%s/tools' "$(ext_dir "$1")"
    else
        printf '%s/.slopguard/tools' "$1"
    fi
}

# _adopt_descriptor_template <name>
# Prints the shipped descriptor template for <name>, or nothing when the plugin
# ships none.  Descriptors cover analyzers the plugin deliberately does not pin
# because they only work from the project's own environment.
_adopt_descriptor_template() {
    local _at_path="${CLAUDE_PLUGIN_ROOT}/configs/baseline/descriptors/${1}.yaml"
    [ -f "$_at_path" ] && printf '%s' "$_at_path"
}

cmd_adopt_config() {
    local project_dir="${CLAUDE_PROJECT_DIR:-.}"
    local tool="${1:-}"

    if [ -z "$tool" ]; then
        # List tools without a project config, with the baseline each would copy.
        local _ac_found=0
        while IFS= read -r _ac_t; do
            local _ac_path; _ac_path="$(tool_config_path "$_ac_t" "$project_dir")"
            local _ac_src; _ac_src="$(tool_config_source "$_ac_path")"
            [ "$_ac_src" = "project" ] && continue   # already owned by project
            [ "$_ac_path" = "none" ] && continue     # jq, shellcheck — no config concept
            [ -d "$_ac_path" ] && continue            # opengrep uses a rule directory
            _ac_found=1
            printf '  %-20s  %s\n' "$_ac_t" "$_ac_path"
        done <<< "$(lock_tools)"
        local _ac_desc_dir; _ac_desc_dir="$(_adopt_ext_dir "$project_dir")"
        local _ac_tpl _ac_tpl_name
        for _ac_tpl in "${CLAUDE_PLUGIN_ROOT}"/configs/baseline/descriptors/*.yaml; do
            [ -f "$_ac_tpl" ] || continue
            _ac_tpl_name="${_ac_tpl##*/}"; _ac_tpl_name="${_ac_tpl_name%.yaml}"
            [ -f "${_ac_desc_dir}/${_ac_tpl_name}.yaml" ] && continue
            _ac_found=1
            printf '  %-20s  %s  (project-local descriptor)\n' "$_ac_tpl_name" "$_ac_tpl"
        done
        if [ "$_ac_found" -eq 0 ]; then
            say "All tools have project configs — nothing to adopt."
        else
            say ""
            say "Run: slopguard adopt-config <tool>  to copy a baseline into your project."
        fi
        return 0
    fi

    # Descriptor templates are adopted into .slopguard/tools/, not as a config.
    local _desc_tpl; _desc_tpl="$(_adopt_descriptor_template "$tool")"
    if [ -n "$_desc_tpl" ]; then
        local _desc_dir; _desc_dir="$(_adopt_ext_dir "$project_dir")"
        local _desc_dest="${_desc_dir}/${tool}.yaml"
        [ -f "$_desc_dest" ] && die "descriptor already exists: ${_desc_dest}"
        mkdir -p "$_desc_dir" || die "failed to create ${_desc_dir}"
        cp "$_desc_tpl" "$_desc_dest" || die "failed to copy ${_desc_tpl} → ${_desc_dest}"
        say "${_desc_dest}"
        say "${tool}: resolved from the project's own environment — slopguard installs nothing for it."
        say "Confirm it resolves:  slopguard doctor"
        return 0
    fi

    # Validate the tool name against the lockfile.
    local _ac_known=0
    while IFS= read -r _ac_t; do
        [ "$_ac_t" = "$tool" ] && { _ac_known=1; break; }
    done <<< "$(lock_tools)"
    [ "$_ac_known" -eq 0 ] && die "unknown tool '${tool}' — try: slopguard adopt-config"

    # Resolve existing config for this tool.
    local cfg_path; cfg_path="$(tool_config_path "$tool" "$project_dir")"
    local cfg_src; cfg_src="$(tool_config_source "$cfg_path")"

    # Already has a project config — refuse.
    if [ "$cfg_src" = "project" ]; then
        die "project config already exists: ${cfg_path}"
    fi

    [ "$cfg_path" = "none" ] \
        && die "tool '${tool}' has no config file (no-config tool — binary only)"

    # opengrep uses a rule directory; can't adopt as a single file.
    [ -d "$cfg_path" ] \
        && die "tool '${tool}' uses a rule directory — copy ${cfg_path} into your project manually"

    [ -f "$cfg_path" ] || die "baseline config not found: ${cfg_path}"

    # Determine the primary destination in the project.
    local dest; dest="$(_adopt_dest "$tool" "$project_dir")"
    [ -n "$dest" ] || die "tool '${tool}' does not support adopt-config"

    # Never overwrite — refuse with the filename so the user knows what to check.
    if [ -f "$dest" ]; then
        die "file already exists: ${dest}\n  Run: slopguard doctor  to confirm which config is active"
    fi

    cp "$cfg_path" "$dest" || die "failed to copy ${cfg_path} → ${dest}"

    say "${dest}"
    say "${tool}: this file is now the project's config and is write-protected from the agent (ask required to edit it)."
}


# --------------------------------------------------------------------------- #
# help
# --------------------------------------------------------------------------- #

cmd_help() {
    cat <<USAGE
slopguard ${VERSION} — anti-pattern detection and gate for agent-written code.

  slopguard doctor             report tool status for the project's stacks
  slopguard doctor --install   install or update the tools those stacks need
  slopguard doctor --all       widen either command to every pinned tool
  slopguard adopt-config       list tools without a project config
  slopguard adopt-config <t>   copy the baseline config for <t> into the project
  slopguard note-docs          record a Context7 lookup (reads hook JSON on stdin)
  slopguard deps-check         check dependency freshness against registries
  slopguard version            print version
  slopguard help               this message

Plugin root: ${CLAUDE_PLUGIN_ROOT}
Plugin data: ${CLAUDE_PLUGIN_DATA}

Tool resolution order (tool_source=project-first):
  1. Project binary  — vendor/bin, node_modules/.bin, .venv/bin, go tool -n
  2. Plugin binary   — \${CLAUDE_PLUGIN_DATA}/tools/<name>/current/<name>
                       accepted only when version equals the lockfile
  3. PATH binary     — only when version equals the lockfile

See tools/tools.lock.json for pinned versions and sha256 hashes.
USAGE
}
# --------------------------------------------------------------------------- #
# stop-gate
# --------------------------------------------------------------------------- #

cmd_stop_gate() {
    # Source lib/stop.sh when present (Etap 4 — fail-open before it ships).
    [ -f "${CLAUDE_PLUGIN_ROOT}/lib/stop.sh" ] && . "${CLAUDE_PLUGIN_ROOT}/lib/stop.sh"
    # Delegate to hooks/stop; exit 0 if not yet deployed.
    if [ -f "${PLUGIN_ROOT}/hooks/stop" ]; then
        exec "${PLUGIN_ROOT}/hooks/stop" "$@"
    fi
    # hooks/stop not yet deployed: fail-open.
    exit 0
}

# --------------------------------------------------------------------------- #

case "${1:-help}" in
    session-start|pre-bash|pre-write|pre-read|post-write|doctor|note-docs|deps-check|stop-gate|adopt-config) ensure_jq ;;
esac

case "${1:-help}" in
    session-start)     shift; cmd_session_start "$@" ;;
    pre-bash)          shift; cmd_pre_bash "$@" ;;
    pre-write)         shift; cmd_pre_write "$@" ;;
    pre-read)          shift; cmd_pre_read "$@" ;;
    post-write)        shift; cmd_post_write "$@" ;;
    stop-gate)         shift; cmd_stop_gate "$@" ;;
    doctor)            shift; cmd_doctor "$@" ;;
    adopt-config)      shift; cmd_adopt_config "$@" ;;
    note-docs)         shift; cmd_note_docs "$@" ;;
    deps-check)        shift; cmd_deps_check "$@" ;;
    version|--version) say "slopguard ${VERSION}" ;;
    help|--help|-h)    cmd_help ;;
    *)                 die "unknown command '${1:-}' — try: slopguard help" ;;
esac
