#!/usr/bin/env bash
# Requires bash 3.2+ (macOS ships with 3.2; mapfile/readarray require 4+).
# Uses while-read loops instead of mapfile for macOS compatibility.
# R6TEST-002: Anti-ghost-test / anti-over-mock guard.
#
# Detects two classes of ghost tests:
#
#   Class A — assertOk/assertStatus-only: test makes an HTTP request but asserts
#             only the status code, not any response data. Catches nothing when
#             the controller returns wrong data.
#
#   Class B — stub-only mocks: test uses shouldReceive() but has no ->once() /
#             ->times(N) call count assertion, making the mock a stub that passes
#             even if the method is never called.
#
# Usage: bash scripts/check-ghost-tests.sh [path]
#   path: optional, defaults to tests/ (relative to repo root)
#
# Exit codes:
#   0  no ghost tests found (or all found instances are annotated @ghost-ok)
#   1  ghost tests found (with diagnostics)
#
# Annotation: add // @ghost-ok on the it()/test() line to acknowledge a
# deliberate exception. Must be followed by a comment explaining why.
#
# See tests/CLAUDE.md § Anti-Ghost-Test / Anti-Over-Mock Policy for the
# full policy, rules, and exemptions.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")"
SEARCH_PATH="${1:-$REPO_ROOT/tests}"

if [ ! -d "$SEARCH_PATH" ]; then
    echo "ERROR: search path not found: $SEARCH_PATH" >&2
    exit 1
fi

GHOST_COUNT=0
# Use a plain string to accumulate findings (bash 3.2 lacks associative arrays in
# some modes; a newline-delimited string is the safest portable approach).
GHOST_REPORT=""

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

# Extract exactly ONE test block (from the 'it(' / 'test(' line through
# the matching closing brace). Tracks brace depth so it stops at the right '});'.
# Works for Pest-style closures.
# Args: $1=file, $2=start_line_number
extract_test_body() {
    local file="$1"
    local start="$2"
    # Use perl to extract the block: read from start_line, track { } depth,
    # stop when depth returns to 0 (the closing }); or ); ).
    # Do NOT use -0777 here: that slurps file as single string, breaking @lines.
    # Read line-by-line into array, then slice from $start.
    START="$start" perl -e '
        my @lines = ();
        while (<STDIN>) { push @lines, $_; }
        my $start = $ENV{START} - 1;  # convert 1-based to 0-based index
        my $depth = 0;
        my @out;
        for my $i ($start .. $#lines) {
            my $line = $lines[$i];
            push @out, $line;
            # Count braces (skip strings — good enough for test bodies)
            $depth += () = $line =~ /\{/g;
            $depth -= () = $line =~ /\}/g;
            last if $depth <= 0 && $i > $start;
        }
        print join("", @out);
    ' < "$file"
}

# Check whether a test body string contains a real data assertion
# (beyond a bare assertOk/assertStatus/assertRedirect/assertNoContent).
#
# Uses perl -0777 instead of grep: macOS /usr/bin/grep is ugrep, which parses
# patterns starting with '->' (e.g. '->once()') as option flags, even inside
# -e '...' argument strings. perl -0777 slurp mode is immune to this.
has_data_assertion() {
    local body="$1"
    printf '%s' "$body" | perl -0777 -e '
        $_ = <STDIN>;
        exit 0 if /assertInertia|assertJson|assertJsonPath|assertJsonFragment/;
        exit 0 if /assertDatabaseHas|assertDatabaseMissing|assertDatabaseCount/;
        exit 0 if /assertSee|assertSeeText|assertViewHas/;
        exit 0 if /assertSentTo|assertPushed|assertDispatched|assertSent\(/;
        exit 0 if /assertForbidden|assertUnauthorized|assertNotFound|assertCreated/;
        exit 0 if /assertJsonValidationErrors|assertSessionHas/;
        exit 0 if /->toBe\(|->toEqual\(|->toContain\(/;
        exit 0 if /->toHaveCount\(|->toHaveKey\(|->toMatchArray\(/;
        exit 0 if /->toBeNull\(|->toBeTrue\(|->toBeFalse\(|->toBeString\(/;
        exit 0 if /->toBeGreaterThan|->toBeLessThan|->toBeInstanceOf\(/;
        exit 0 if /->toBeEmpty\(|->not->|->and\(/;
        exit 0 if /->throws\(/;
        exit 0 if /->has\(|->component\(|->count\(/;
        exit 0 if /->assertStatus\([45][0-9]{2}|->assertSee\(|->assertDontSee\(/;
        exit 0 if /expect\(.*\)->to/;
        exit 1;
    '
}

# Check whether a test body uses only stub shouldReceive (no ->once/->times)
# Returns 0 (true) if it IS a stub-only mock (no call-count assertion)
# Returns 1 (false) if no shouldReceive, or if ->once()/->times() IS present
is_stub_only_mock() {
    local body="$1"
    # Quick check: has shouldReceive at all?
    printf '%s' "$body" | perl -0777 -e '$_ = <STDIN>; exit 0 if /shouldReceive/; exit 1;' || return 1
    # If it has ->once() / ->times() / ->twice() / ->never(), it's NOT stub-only
    if printf '%s' "$body" | perl -0777 -e '$_ = <STDIN>; exit 0 if /->once\(\)|->times\(|->twice\(\)|->never\(\)/; exit 1;'; then
        return 1
    fi
    # Has shouldReceive but no call-count assertion → stub-only
    return 0
}

# ---------------------------------------------------------------------------
# Main scan loop
# ---------------------------------------------------------------------------

# Collect all PHP test files using while-read (bash 3.2 compatible; no mapfile)
while IFS= read -r file; do
    # Read the file line by line looking for test function declarations
    line_num=0
    while IFS= read -r line; do
        line_num=$((line_num + 1))

        # Match Pest `it('...', function () {` and `test('...', function () {`
        if ! printf '%s\n' "$line" | perl -0777 -e '$_ = <STDIN>; exit 0 if /^(it|test)\s*\(/m; exit 1;'; then
            continue
        fi

        # Check for @ghost-ok annotation on this line
        if printf '%s\n' "$line" | perl -0777 -e '$_ = <STDIN>; exit 0 if /@ghost-ok/; exit 1;'; then
            continue
        fi

        body=$(extract_test_body "$file" "$line_num")

        # ── Class A: assertOk/assertStatus-only ──────────────────────────
        # Check if the test body contains any assertOk / assertStatus / assertNoContent /
        # assertRedirect / assertSuccessful (status-only assertions)
        has_status_assert=false
        if printf '%s' "$body" | perl -0777 -e '$_ = <STDIN>; exit 0 if /assertOk\(\)|assertStatus\(|assertNoContent|assertSuccessful|->assertRedirect\(/; exit 1;'; then
            has_status_assert=true
        fi

        if [ "$has_status_assert" = "true" ] && ! has_data_assertion "$body"; then
            # Status-only assertion without any data assertion
            GHOST_COUNT=$((GHOST_COUNT + 1))
            GHOST_REPORT="${GHOST_REPORT}CLASS-A|$file:$line_num|assertOk/assertStatus-only (no data assertion)
"
        fi

        # ── Class B: stub-only mock ───────────────────────────────────────
        if is_stub_only_mock "$body"; then
            # Only flag if there are also no other data assertions
            if ! has_data_assertion "$body"; then
                GHOST_COUNT=$((GHOST_COUNT + 1))
                GHOST_REPORT="${GHOST_REPORT}CLASS-B|$file:$line_num|shouldReceive() stub with no ->once()/->times() and no data assertion
"
            fi
        fi

    done < "$file"
done < <(find "$SEARCH_PATH" -name "*.php" | sort)

# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------

if [ "$GHOST_COUNT" -eq 0 ]; then
    echo "check-ghost-tests: PASS — no ghost tests found in $SEARCH_PATH"
    exit 0
fi

echo ""
echo "check-ghost-tests: FAIL — $GHOST_COUNT ghost test(s) found"
echo ""
echo "Ghost tests pass even when the code under test is broken."
echo "Each finding below requires at least ONE data assertion."
echo "See tests/CLAUDE.md § Anti-Ghost-Test / Anti-Over-Mock Policy."
echo ""

echo "$GHOST_REPORT" | while IFS='|' read -r class location reason; do
    [ -z "$class" ] && continue
    echo "  [$class] $location"
    echo "          $reason"
    echo ""
done

echo "To acknowledge a deliberate exception, add // @ghost-ok to the it()/test() line"
echo "and add a comment on the following line explaining why."
echo ""
exit 1
