#!/usr/bin/env bash
# check-ratchet-baseline.sh
#
# Enforces that the DS ratchet file counts can only shrink.
# Reads baseline from .v/ds-ratchet-baseline.json and compares to current counts.
#
# Usage:
#   bash scripts/codemods/check-ratchet-baseline.sh             # CI check (fails if counts grew)
#   bash scripts/codemods/check-ratchet-baseline.sh --update    # Update baseline after a migration
#   bash scripts/codemods/check-ratchet-baseline.sh --callsite-only  # Only the R9FE-103 callsite check
#
# Exit codes:
#   0 — counts are at or below baseline (or --update wrote new baseline)
#   1 — one or more counts INCREASED (regression) or baseline file is missing
#
# Environment overrides (for testing):
#   MUTATION_MIGRATED_FILES — space/newline-separated list of TSX files to check for ad-hoc
#                             mutations instead of the default hardcoded migrated-file list.
#                             Used by tests/Unit/Frontend/MutationRatchetCallsiteTest.php.

set -euo pipefail

BASELINE_FILE=".v/ds-ratchet-baseline.json"
UPDATE_MODE="${1:-}"

# R9FE-103: The 7 files that already import useMutationButton (the "migrated allowlist").
# These are NOT caught by the file-level mutation_ratchet_file_count because they DO import
# useMutationButton — but a new ad-hoc useState(false)+router.post could still sneak in.
# Override via MUTATION_MIGRATED_FILES env var for testing.
MUTATION_MIGRATED_DEFAULT=(
  "resources/js/Pages/ContentBriefs/Index.tsx"
  "resources/js/Pages/OpportunityMap/Index.tsx"
  "resources/js/Pages/Batch/Show.tsx"
  "resources/js/Pages/Sites/LinkOpportunities/Index.tsx"
  "resources/js/Pages/TrafficAlerts/Index.tsx"
  "resources/js/Components/Recommendations/BulkActionsBar.tsx"
  "resources/js/Components/Ai/DraftReviewPanel.tsx"
  # R9 wave migrations (added R11FE-001 — omitted from original list)
  "resources/js/Components/Portfolio/SiteComparisonTable.tsx"
  "resources/js/Pages/ContentIntelligence/Freshness/Index.tsx"
  "resources/js/Pages/ContentIntelligence/TopicClusters/Index.tsx"
  # R11 wave migrations
  "resources/js/Pages/Cannibalization/Show.tsx"
  # ADMFE-02-COMPLETION: admin pages migrated in this wave (all Admin/ pages now migrated)
  "resources/js/Components/admin/ImpersonationBanner.tsx"
  "resources/js/Pages/Admin/AiJobs/Index.tsx"
  "resources/js/Pages/Admin/AiTemplates/Index.tsx"
  "resources/js/Pages/Admin/AnalysisRuns/Index.tsx"
  "resources/js/Pages/Admin/BlogPosts/Index.tsx"
  "resources/js/Pages/Admin/BreachIncidents/Index.tsx"
  "resources/js/Pages/Admin/BreachIncidents/Show.tsx"
  "resources/js/Pages/Admin/Cache/Index.tsx"
  "resources/js/Pages/Admin/CannibalizationRuns/Index.tsx"
  "resources/js/Pages/Admin/ChangelogEntries/Edit.tsx"
  "resources/js/Pages/Admin/ChangelogEntries/Index.tsx"
  "resources/js/Pages/Admin/CircuitBreaker.tsx"
  "resources/js/Pages/Admin/DataHealth/Index.tsx"
  "resources/js/Pages/Admin/Dlq/Index.tsx"
  "resources/js/Pages/Admin/Dlq/Show.tsx"
  "resources/js/Pages/Admin/Dsar/Index.tsx"
  "resources/js/Pages/Admin/EmailSuppressions/Index.tsx"
  "resources/js/Pages/Admin/FeatureFlags/Index.tsx"
  "resources/js/Pages/Admin/FeatureFlags/Overrides.tsx"
  "resources/js/Pages/Admin/FeatureRequests/Index.tsx"
  "resources/js/Pages/Admin/FeedbackDashboard.tsx"
  "resources/js/Pages/Admin/FounderDashboard.tsx"
  "resources/js/Pages/Admin/GscConnections/Index.tsx"
  "resources/js/Pages/Admin/LimitPolicies/Index.tsx"
  "resources/js/Pages/Admin/Operations/Index.tsx"
  "resources/js/Pages/Admin/Recommendations/Index.tsx"
  "resources/js/Pages/Admin/ReportTemplates/Index.tsx"
  "resources/js/Pages/Admin/Sessions/Index.tsx"
  "resources/js/Pages/Admin/SharedReportLinks/Index.tsx"
  "resources/js/Pages/Admin/Sites/Index.tsx"
  "resources/js/Pages/Admin/Sites/Show.tsx"
  "resources/js/Pages/Admin/SocialAuth/Accounts.tsx"
  "resources/js/Pages/Admin/Subscribers/Index.tsx"
  "resources/js/Pages/Admin/System.tsx"
  "resources/js/Pages/Admin/Testimonials/Index.tsx"
  "resources/js/Pages/Admin/TrafficAlerts/Index.tsx"
  "resources/js/Pages/Admin/Users/Index.tsx"
  "resources/js/Pages/Admin/Users/Show.tsx"
  "resources/js/Pages/Admin/WebhookEndpoints/Index.tsx"
  # R16FE-001: Calendar/Index.tsx — per-row pending refs + onError toasts added.
  # Also imported useForm for an unrelated create-task sheet (the original blind spot
  # that R16FE-002 closed); now explicitly included so the main guard protects it.
  "resources/js/Pages/Calendar/Index.tsx"
)

# ── R9FE-103: Callsite-level mutation guard for already-migrated files ────────
#
# Detects router.post/put/delete calls in migrated files that are NOT inside a
# useMutationButton callback. The file-level ratchet only checks for import presence;
# this check closes the gap by detecting ad-hoc hand-rolled loading-state patterns.
#
# Detection heuristics (either signals a violation):
#   1. onFinish: () => set<Name>( pattern — the hand-rolled onFinish that resets a
#      local boolean, the classic ad-hoc pattern (e.g. onFinish: () => setLoading(false))
#   2. Named loading-state setter near a router call — set<Loading|Processing|Submitting|...>
#      within 5 lines (WINDOW) of a router.post/put/delete call
#
# CORRECT patterns NOT flagged:
#   - router.post(..., opts)                   — bare opts passthrough
#   - router.post(..., { ...opts, key: val })  — opts spread
#   - onFinish: () => { opts.onFinish?.(); }   — forwarding opts.onFinish
#
# Arguments: one or more file paths to check
run_callsite_check() {
  local -a files=("$@")
  python3 - "${files[@]}" <<'PYEOF'
import sys
import re

# Files to check are passed as arguments (after the script path placeholder)
file_paths = sys.argv[1:]

WINDOW = 5  # Lines to look around each router.post/put/delete call

# Pattern 1: hand-rolled onFinish that resets a local setter (the classic ad-hoc pattern)
# Matches: onFinish: () => setX(false) or onFinish: () => setLoading(false)
# Does NOT match: onFinish: () => { opts.onFinish?.(); ... } — the { tells us it's block form
RE_AD_HOC_ONFINISH = re.compile(
    r'onFinish\s*:\s*\(\s*\)\s*=>\s*set[A-Za-z]'
)

# Pattern 2: named loading-state setter adjacent to a router call
# Matches known loading-state naming conventions: setLoading, setIsSubmitting, etc.
# NOTE: the alternation directly follows `set[A-Z]` — no greedy `[A-Za-z]*` that would
# compete with the alternation group (avoids backtracking ambiguity).
RE_NAMED_SETTER = re.compile(
    r'\bset(?:Loading|Processing|Submitting|Retrying|'
    r'Saving|Sending|Publishing|Converting|Updating|Deleting|Running|Refreshing|Fetching|'
    r'Is(?:Loading|Processing|Submitting|Retrying|Saving|Sending|Publishing|Converting|'
    r'Updating|Deleting|Running|Refreshing|Fetching))\s*'
    r'\(\s*(?:false|true)\s*\)',
    re.IGNORECASE,
)

all_violations = []
read_errors = []

# R9FE-103/CODEX-001: fail CLOSED on an empty target set — a ratchet asked to check
# nothing must not report PASS (a collapsed/empty override would otherwise pass silently).
if not file_paths:
    print('CALLSITE-FAIL: no migrated files to check — refusing to pass (fail-closed)')
    sys.exit(1)

for file_path in file_paths:
    try:
        with open(file_path) as f:
            lines = f.read().split('\n')
    except OSError as e:
        read_errors.append(f'{file_path}: {e}')
        continue

    for i, line in enumerate(lines):
        # Skip import lines, comments, and lines without a router mutation
        stripped = line.strip()
        if stripped.startswith('import') or stripped.startswith('//') or stripped.startswith('*'):
            continue
        if not any(m in line for m in ('router.post(', 'router.put(', 'router.patch(', 'router.delete(')):
            continue

        # Build a context window around this line, excluding full-line comments so a
        # historical note like `// used to call setLoading(false)` near a correct
        # useMutationButton call does not false-positive (R9FE-103/CODEX-003).
        win_start = max(0, i - WINDOW)
        win_end   = min(len(lines), i + WINDOW + 1)
        window    = '\n'.join(
            wl for wl in lines[win_start:win_end]
            if not (wl.lstrip().startswith('//') or wl.lstrip().startswith('*'))
        )

        # Heuristic 1: ad-hoc onFinish setter
        if RE_AD_HOC_ONFINISH.search(window):
            all_violations.append(
                f'{file_path}:{i + 1}: ad-hoc onFinish setter detected — '
                f'use useMutationButton instead of hand-rolled loading state'
            )
            continue  # don't double-count this line

        # Heuristic 2: named loading-state setter
        if RE_NAMED_SETTER.search(window):
            all_violations.append(
                f'{file_path}:{i + 1}: named loading-state setter near router mutation — '
                f'use useMutationButton instead of hand-rolled boolean flag'
            )

# R9FE-103/CODEX-001: fail CLOSED if any input was unreadable. A guard that cannot read
# its inputs must never report PASS, or a stale/typo'd path silently disables enforcement.
if read_errors:
    print('CALLSITE-FAIL: cannot read migrated file(s) — refusing to pass (fail-closed):')
    for e in read_errors:
        print(f'  {e}')
    sys.exit(1)

if all_violations:
    print('CALLSITE-FAIL: hand-rolled mutation loading state detected in migrated file(s):')
    for v in all_violations:
        print(f'  {v}')
    print()
    print('  Fix: replace the hand-rolled useState(false)+router.* pattern with useMutationButton:')
    print('    const { processing, onClick } = useMutationButton((opts) => router.post(url, data, opts));')
    sys.exit(1)

# All files clean
sys.exit(0)
PYEOF
}

# ── Dynamic discovery of migrated files (R16FE-002) ──────────────────────────
#
# Replaces (or augments) the hardcoded MUTATION_MIGRATED_DEFAULT list with a live
# grep-based discovery: every Pages + Components TSX/TS file that imports
# useMutationButton OR useForm is a "migrated" file subject to the callsite guard.
#
# This closes the structural blind spot where a file importing useForm for an unrelated
# form (e.g. createForm) was invisible to the ratchet despite also having bare
# router.patch/post/delete callsites (Calendar/Index.tsx was exactly this case).
#
# Non-test files only (exclude .test.tsx, .test.ts, .pack*.test.tsx).
# Existence-guarded: files must exist to be included in the scan (guards against stale paths).
# Deterministic: output is sorted.
discover_migrated_files() {
  # Scope to *.tsx only — useForm/useMutationButton are React hooks, pure .ts files
  # (non-component) should not import them. This also avoids including test setup files
  # (resources/js/test/setup.ts) that could theoretically re-export the hook.
  # Also exclude files in test/ directories (e.g. resources/js/test/) and .pack*.test. variants.
  grep -rln 'useMutationButton\|useForm' \
    resources/js/Pages resources/js/Components \
    --include='*.tsx' 2>/dev/null \
  | grep -v '\.test\.' \
  | grep -v '/test/' \
  | sort
}

# ── --list-dynamic-migrated mode (for testing and inspection) ─────────────────

if [ "$UPDATE_MODE" = "--list-dynamic-migrated" ]; then
  discover_migrated_files
  exit 0
fi

# ── --callsite-only mode (for targeted testing) ───────────────────────────────

if [ "$UPDATE_MODE" = "--callsite-only" ]; then
  if [ -n "${MUTATION_MIGRATED_FILES:-}" ]; then
    # Test override: whitespace-separated list of file paths. Split into an array so
    # EVERY listed file is checked individually — a single combined arg would collapse
    # multiple paths into one unreadable path and fail OPEN (R9FE-103/CODEX-001).
    read -r -a _mmf_arr <<< "${MUTATION_MIGRATED_FILES}"
    run_callsite_check "${_mmf_arr[@]}"
    EXIT=$?
  else
    # R16FE-002: use dynamic discovery instead of the hardcoded default list.
    # This catches files that import useForm for unrelated purposes while still
    # containing bare router mutation callsites (the Calendar/Index.tsx blind spot).
    mapfile -t _dynamic_arr < <(discover_migrated_files)
    if [ ${#_dynamic_arr[@]} -eq 0 ]; then
      echo "CALLSITE-FAIL: dynamic discovery found no migrated files — refusing to pass (fail-closed)"
      exit 1
    fi
    run_callsite_check "${_dynamic_arr[@]}"
    EXIT=$?
  fi
  if [ "$EXIT" -eq 0 ]; then
    echo "CALLSITE-PASS: all migrated files clean"
  fi
  exit "$EXIT"
fi

if [ ! -f "$BASELINE_FILE" ]; then
  echo "ERROR: $BASELINE_FILE not found. Run from the project root."
  exit 1
fi

# ── Count current violations ──────────────────────────────────────────────────

# Icon-size violations: h-N w-N pairs (symmetric) in JS files
ICON_COUNT=$(rg 'h-[1-9][0-9]?(?:\.[05])? w-[1-9][0-9]?(?:\.[05])?' \
  resources/js/Components resources/js/Pages resources/js/Layouts resources/js/hooks \
  --glob '*.tsx' --glob '*.ts' \
  -c 2>/dev/null | awk -F: '{sum+=$2} END {print sum}' || echo 0)

# Palette ratchet file count: entries in eslint.config.js TEMPORARY legacy allowlist
# Counts quoted tsx files between the marker comment and the "rules:" line
PALETTE_COUNT=$(python3 - <<'PYEOF'
import re, sys
try:
    with open('eslint.config.js', 'r') as f:
        content = f.read()
    start = content.find('Semantic-token guard — TEMPORARY legacy allowlist')
    end = content.find('"no-restricted-syntax": "off",', start)
    section = content[start:end] if start != -1 and end != -1 else ''
    files = re.findall(r'"resources/js/[^"]+\.tsx"', section)
    print(len(files))
except Exception as e:
    print(0, file=sys.stderr)
    print(0)
PYEOF
)

# R8FE-006: Mutation ratchet file count: Pages + Components files using router.post/put/patch/delete
# without useMutationButton or useForm imported. Count only non-test tsx/ts files.
# R11FE-001: added router.patch to the alternation (was previously excluded — blind spot).
MUTATION_COUNT=$(
  grep -rln 'router\.\(post\|put\|patch\|delete\)' \
    resources/js/Pages resources/js/Components \
    --include='*.tsx' --include='*.ts' 2>/dev/null \
  | grep -v '\.test\.' \
  | xargs grep -lL 'useMutationButton\|useForm' 2>/dev/null \
  | wc -l \
  | tr -d ' ' \
  || echo 0
)

# ── Read baseline values ──────────────────────────────────────────────────────

BASELINE_ICON=$(python3 -c "import json; d=json.load(open('$BASELINE_FILE')); print(d['icon_size_violations']['baseline'])")
BASELINE_PALETTE=$(python3 -c "import json; d=json.load(open('$BASELINE_FILE')); print(d['palette_ratchet_file_count']['baseline'])")
BASELINE_MUTATION=$(python3 -c "import json; d=json.load(open('$BASELINE_FILE')); print(d.get('mutation_ratchet_file_count', {}).get('baseline', 9999))")

# ── Update mode ───────────────────────────────────────────────────────────────

if [ "$UPDATE_MODE" = "--update" ]; then
  python3 - <<PYEOF
import json, datetime

with open('$BASELINE_FILE', 'r') as f:
    d = json.load(f)

d['_updated'] = datetime.date.today().isoformat()
d['icon_size_violations']['baseline'] = $ICON_COUNT
d['palette_ratchet_file_count']['baseline'] = $PALETTE_COUNT
if 'mutation_ratchet_file_count' not in d:
    d['mutation_ratchet_file_count'] = {
        '_description': 'Pages+Components files using router.post/put/patch/delete without useMutationButton or useForm',
        'wave': 'R8FE-006'
    }
d['mutation_ratchet_file_count']['baseline'] = $MUTATION_COUNT

with open('$BASELINE_FILE', 'w') as f:
    json.dump(d, f, indent=2)
    f.write('\n')

print(f"Updated baselines: icon={$ICON_COUNT} (was {$BASELINE_ICON}), palette_files={$PALETTE_COUNT} (was {$BASELINE_PALETTE}), mutation_files={$MUTATION_COUNT} (was {$BASELINE_MUTATION})")
PYEOF
  exit 0
fi

# ── Check mode ────────────────────────────────────────────────────────────────

FAIL=0

echo "DS ratchet baseline check"
echo "========================="

# Icon size
if [ "$ICON_COUNT" -gt "$BASELINE_ICON" ]; then
  echo "FAIL: icon_size_violations=$ICON_COUNT > baseline=$BASELINE_ICON (INCREASED by $((ICON_COUNT - BASELINE_ICON)))"
  echo "      New raw h-N w-N pairs were added to ratcheted files. Migrate them to size-N."
  echo "      Run: DRY_RUN=1 npx tsx scripts/codemods/icon-size-to-size-n.ts"
  FAIL=1
else
  DELTA=$((BASELINE_ICON - ICON_COUNT))
  echo "PASS: icon_size_violations=$ICON_COUNT <= baseline=$BASELINE_ICON (delta: -${DELTA})"
fi

# Palette ratchet
if [ "$PALETTE_COUNT" -gt "$BASELINE_PALETTE" ]; then
  echo "FAIL: palette_ratchet_file_count=$PALETTE_COUNT > baseline=$BASELINE_PALETTE (INCREASED by $((PALETTE_COUNT - BASELINE_PALETTE)))"
  echo "      A new file was added to the palette ratchet. Migrate it instead of adding it."
  FAIL=1
else
  DELTA=$((BASELINE_PALETTE - PALETTE_COUNT))
  echo "PASS: palette_ratchet_file_count=$PALETTE_COUNT <= baseline=$BASELINE_PALETTE (delta: -${DELTA})"
fi

# Mutation ratchet (R8FE-006)
if [ "$MUTATION_COUNT" -gt "$BASELINE_MUTATION" ]; then
  echo "FAIL: mutation_ratchet_file_count=$MUTATION_COUNT > baseline=$BASELINE_MUTATION (INCREASED by $((MUTATION_COUNT - BASELINE_MUTATION)))"
  echo "      A file was added/modified that uses router.post/put/patch/delete without useMutationButton or useForm."
  echo "      Migrate it: const { processing, onClick } = useMutationButton((opts) => router.post(url, data, opts));"
  FAIL=1
else
  DELTA=$((BASELINE_MUTATION - MUTATION_COUNT))
  echo "PASS: mutation_ratchet_file_count=$MUTATION_COUNT <= baseline=$BASELINE_MUTATION (delta: -${DELTA})"
fi

# R9FE-103 / R16FE-002: Callsite-level guard on already-migrated files (closes the file-level gap)
# The main gate uses the curated MUTATION_MIGRATED_DEFAULT list (now including Calendar/Index.tsx
# — the R16FE-002 blind-spot file). Dynamic discovery via discover_migrated_files() is available
# in --callsite-only mode for PHP tests and future full-sweep validation; applying it in the main
# gate without fixing the newly-visible pre-existing violations would block the ratchet. The curated
# list grows one file per wave; dynamic discovery catches NEW violations introduced in already-listed files.
MIGRATED_FILES_TO_CHECK=("${MUTATION_MIGRATED_DEFAULT[@]}")
EXISTING_MIGRATED=()
for f in "${MIGRATED_FILES_TO_CHECK[@]}"; do
  [ -f "$f" ] && EXISTING_MIGRATED+=("$f")
done

if [ ${#EXISTING_MIGRATED[@]} -gt 0 ]; then
  # Capture output and exit code independently — avoids the fragile || CALLSITE_EXIT=$?
  # pattern where removing the || would let set -e kill the script before FAIL=1 is set.
  CALLSITE_OUTPUT=$(run_callsite_check "${EXISTING_MIGRATED[@]}" 2>&1; echo "EXIT_CODE:$?")
  CALLSITE_EXIT=$(echo "$CALLSITE_OUTPUT" | grep '^EXIT_CODE:' | cut -d: -f2)
  CALLSITE_OUTPUT=$(echo "$CALLSITE_OUTPUT" | grep -v '^EXIT_CODE:' || true)
  CALLSITE_EXIT=${CALLSITE_EXIT:-0}
  if [ "$CALLSITE_EXIT" -ne 0 ]; then
    echo "FAIL: $CALLSITE_OUTPUT"
    FAIL=1
  else
    echo "PASS: mutation_callsite_guard — all ${#EXISTING_MIGRATED[@]} migrated file(s) clean (R9FE-103/R16FE-002)"
  fi
else
  echo "SKIP: mutation_callsite_guard — no migrated files found (R9FE-103)"
fi

echo ""
if [ "$FAIL" -ne 0 ]; then
  echo "STATUS: FAIL — ratchet regression(s) detected"
  exit 1
else
  echo "STATUS: PASS"
  exit 0
fi
