<?php

namespace MintyPHP\Tests\Architecture;

use FilesystemIterator;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * GR-SEC-001 extension: every page action that calls one of the
 * action-context aggregators (actionEditContext, actionCreateContext)
 * AND handles POST data MUST also call actionRequireCsrf() before it
 * accesses the request body.
 *
 * Closes plan-stage finding SEC-PLAN-004 from Run
 * 2026-04-25-action-context-helper. Replaces the obsolete
 * ActionContextHelperContractTest::testNoProductionCallSitesYet which became
 * stale once Step 2 introduced the first production caller (Departments-Edit
 * pilot, Run 2026-04-25-action-context-rollout-step2-departments).
 *
 * Order requirement: actionRequireCsrf() must appear BEFORE the first
 * POST-body indicator (bodyAll(), bodyInt(), body( etc.). It does NOT need to
 * appear before the aggregator call itself — the aggregator only resolves
 * model + capabilities for the GET-render and is safe to run before CSRF
 * verification, mirroring the canonical Departments-Edit ordering preserved by
 * Run 2026-04-25-action-context-rollout-step2-departments
 * (Verhaltens-Identitäts-Pflicht).
 *
 * Detection is conservative: when an aggregator call cannot be statically
 * located (e.g. wrapped in a heredoc or dynamic dispatch), the test fails
 * explicitly with "Cannot verify CSRF pairing — review manually" rather than
 * silently passing. Pattern reference: DetailDrawerFragmentContractTest.
 */
class ActionContextCsrfPairingContractTest extends TestCase
{
    use ProjectFileAssertionSupport;

    private const AGGREGATORS = [
        'actionEditContext',
        'actionCreateContext',
    ];

    /**
     * POST-handling presence indicators (does the file handle POST at all?).
     * Mirrors PostEndpointCsrfContractTest::handlesPostData.
     */
    private const POST_PRESENCE_INDICATORS = [
        "isMethod('POST')",
        'requestInput()->method()',
        'hasBody(',
        'bodyAll()',
        'bodyInt(',
        'bodyString(',
    ];

    /**
     * Body-access indicators. These must NOT happen before
     * actionRequireCsrf(). isMethod('POST')/method() are guards, not body
     * accesses, so they're excluded here — guards may legitimately come
     * before the CSRF call (in the very same line as actionRequireCsrf).
     */
    private const BODY_ACCESS_INDICATORS = [
        'hasBody(',
        'bodyAll()',
        'bodyInt(',
        'bodyString(',
    ];

    public function testActionsUsingAggregatorsHaveCsrfBeforeAggregator(): void
    {
        $root = $this->projectRootPath();

        $missing = [];
        $unverifiable = [];
        $orderViolations = [];

        foreach (['pages', 'modules'] as $dir) {
            $base = $root . '/' . $dir;
            if (!is_dir($base)) {
                continue;
            }

            $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
            /** @var \SplFileInfo $file */
            foreach ($iterator as $file) {
                if (!$file->isFile() || $file->getExtension() !== 'php') {
                    continue;
                }

                $content = (string) file_get_contents($file->getPathname());
                $aggregatorOffsets = $this->locateAggregatorCalls($content);
                if ($aggregatorOffsets === []) {
                    continue;
                }

                $relativePath = str_replace($root . '/', '', $file->getPathname());

                // Non-static-extractable aggregator pattern (heredoc, eval, …).
                if ($aggregatorOffsets === false) {
                    $unverifiable[] = $relativePath;
                    continue;
                }

                $isPostHandler = $this->hasAny($content, self::POST_PRESENCE_INDICATORS)
                    || preg_match('/->body\(/', $content) === 1;

                // GET-only allowlist: aggregator callers without any POST
                // indicator are render-only and don't need CSRF.
                if (!$isPostHandler) {
                    continue;
                }

                $csrfOffset = $this->locateActionRequireCsrf($content);
                if ($csrfOffset === null) {
                    $missing[] = $relativePath;
                    continue;
                }

                $bodyAccessOffset = $this->locateFirstBodyAccess($content);
                if ($bodyAccessOffset !== null && $csrfOffset >= $bodyAccessOffset) {
                    $orderViolations[] = sprintf(
                        '%s — actionRequireCsrf() at offset %d but POST body access at offset %d (CSRF must precede body access)',
                        $relativePath,
                        $csrfOffset,
                        $bodyAccessOffset
                    );
                }
            }
        }

        sort($missing);
        sort($unverifiable);
        sort($orderViolations);

        $messages = [];
        if ($missing !== []) {
            $messages[] = "Aggregator-callers handling POST without actionRequireCsrf():\n  " . implode("\n  ", $missing);
        }
        if ($orderViolations !== []) {
            $messages[] = "Aggregator-callers with actionRequireCsrf() AFTER body access:\n  " . implode("\n  ", $orderViolations);
        }
        if ($unverifiable !== []) {
            $messages[] = "Cannot verify CSRF pairing — review manually:\n  " . implode("\n  ", $unverifiable);
        }

        $this->assertSame(
            [],
            $messages,
            "GR-SEC-001 / aggregator pairing violations:\n" . implode("\n", $messages)
        );
    }

    /**
     * @return list<int>|false  list of byte offsets of aggregator calls;
     *                          false when at least one match is wrapped in a
     *                          heredoc/string literal and cannot be statically
     *                          verified.
     */
    private function locateAggregatorCalls(string $content): array|false
    {
        $offsets = [];
        foreach (self::AGGREGATORS as $name) {
            $needle = $name . '(';
            $pos = 0;
            while (($found = strpos($content, $needle, $pos)) !== false) {
                if ($this->isInsideStringLiteral($content, $found)) {
                    return false;
                }
                $offsets[] = $found;
                $pos = $found + strlen($needle);
            }
        }
        sort($offsets);
        return $offsets;
    }

    private function locateFirstBodyAccess(string $content): ?int
    {
        $earliest = null;
        foreach (self::BODY_ACCESS_INDICATORS as $needle) {
            $pos = strpos($content, $needle);
            if ($pos !== false && ($earliest === null || $pos < $earliest)) {
                $earliest = $pos;
            }
        }
        if (preg_match('/->body\(/', $content, $m, PREG_OFFSET_CAPTURE) === 1) {
            $pos = (int) $m[0][1];
            if ($earliest === null || $pos < $earliest) {
                $earliest = $pos;
            }
        }
        return $earliest;
    }

    private function locateActionRequireCsrf(string $content): ?int
    {
        $pos = strpos($content, 'actionRequireCsrf(');
        return $pos === false ? null : $pos;
    }

    /**
     * @param list<string> $needles
     */
    private function hasAny(string $content, array $needles): bool
    {
        foreach ($needles as $needle) {
            if (str_contains($content, $needle)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Heuristic: a match is considered inside a heredoc/string literal when
     * the immediately preceding non-whitespace character is a backtick or an
     * unescaped quote on the same line. Conservative — favours raising
     * "review manually" over silent pass.
     */
    private function isInsideStringLiteral(string $content, int $offset): bool
    {
        if ($offset <= 0) {
            return false;
        }
        // Walk back to start of line.
        $lineStart = strrpos(substr($content, 0, $offset), "\n");
        $lineStart = $lineStart === false ? 0 : $lineStart + 1;
        $prefix = substr($content, $lineStart, $offset - $lineStart);

        // Count unescaped single/double quotes on the line so far.
        $singles = 0;
        $doubles = 0;
        $len = strlen($prefix);
        for ($i = 0; $i < $len; $i++) {
            $ch = $prefix[$i];
            if ($ch === '\\') {
                $i++;
                continue;
            }
            if ($ch === "'") {
                $singles++;
            } elseif ($ch === '"') {
                $doubles++;
            }
        }
        // Odd counts → currently inside that quote type.
        if (($singles % 2) === 1 || ($doubles % 2) === 1) {
            return true;
        }
        // Heredoc detection — simplistic: if the line above ended with <<<TAG
        // and we haven't seen the closing tag yet, we'd be inside. Skip — the
        // test fails-loud on unverifiable cases.
        return false;
    }
}
