<?php

namespace Tests\Feature;

use Tests\TestCase;

/**
 * Read the source and refuse an `exists:` rule that does not name a branch.
 *
 * Every other branch test in this suite needs a restored Postgres tenant and
 * skips without one — which means on an ordinary `php artisan test`, and in any
 * CI that has no client database, none of them run. This one reads files, so it
 * runs everywhere, always.
 *
 * It guards the one hole the global scope cannot cover on the write path.
 * `exists:` and `Rule::exists()` compile to a raw table query: no academy scope,
 * no branch scope, nothing. Point one at a branch-owned table and it accepts any
 * id in the academy — and the property feeding it is usually a plain public
 * Livewire property, which the browser sets. That is not a display leak; it
 * files a payment, an enrolment or a stock movement against another branch, and
 * unlike a leak it cannot be fixed by reloading the page.
 *
 * The rule enforced here: a validation rule on a branch-owned table must be
 * written as Rule::exists(...) with a branch constraint in the same statement.
 * The bare string form (`'exists:products,id'`) is banned outright for those
 * tables, because there is nowhere in it to put one.
 */
class BranchValidationRulesTest extends TestCase
{
    /** Tables whose rows belong to a branch, so an id alone is not enough. */
    private const BRANCH_OWNED = [
        'participants', 'training_groups', 'training_programs', 'enrollments',
        'facilities', 'invoices', 'payments', 'expenses', 'products',
        'warehouses', 'base_prices', 'pricing_rules', 'promotions', 'kits',
        'training_sessions', 'space_layouts', 'cash_sessions', 'stock_counts',
        'inventory_levels', 'purchase_orders', 'receipt_templates',
    ];

    /** @return string[] */
    private function sourceFiles(): array
    {
        $files = [];

        foreach (['app/Livewire', 'app/Http/Controllers', 'app/Domain'] as $dir) {
            $path = base_path($dir);

            if (! is_dir($path)) {
                continue;
            }

            $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));

            foreach ($it as $file) {
                if ($file->isFile() && $file->getExtension() === 'php') {
                    $files[] = $file->getPathname();
                }
            }
        }

        sort($files);

        return $files;
    }

    public function test_no_bare_exists_rule_targets_a_branch_owned_table(): void
    {
        $offenders = [];

        foreach ($this->sourceFiles() as $file) {
            $lines = file($file);

            foreach ($lines as $i => $line) {
                // Comments explain the rule; they are not the rule.
                $code = trim($line);
                if (str_starts_with($code, '//') || str_starts_with($code, '*')) {
                    continue;
                }

                foreach (self::BRANCH_OWNED as $table) {
                    if (preg_match('/[\'"]exists:'.$table.'\b/', $line)) {
                        $offenders[] = $this->relative($file).':'.($i + 1).' — '.trim($line);
                    }
                }
            }
        }

        $this->assertSame(
            [],
            $offenders,
            "A bare `exists:` rule on a branch-owned table accepts any id in the academy.\n"
                ."Use Rule::exists(...)->where('branch_id', \$this->getActiveBranchId()) instead:\n\n"
                .implode("\n", $offenders)
        );
    }

    public function test_every_rule_exists_on_a_branch_owned_table_constrains_the_branch(): void
    {
        $offenders = [];

        foreach ($this->sourceFiles() as $file) {
            $source = file_get_contents($file);
            $lines = explode("\n", $source);

            foreach (self::BRANCH_OWNED as $table) {
                $pattern = '/Rule::exists\(\s*[\'"]'.$table.'[\'"]/';

                foreach ($lines as $i => $line) {
                    if (! preg_match($pattern, $line)) {
                        continue;
                    }

                    // The constraint may be chained across several lines, and on
                    // a couple of screens it is applied to a $rule variable
                    // further down. A generous window is the right trade: a
                    // false negative here is a missed leak, a false positive is
                    // a broken build over nothing.
                    $window = implode("\n", array_slice($lines, max(0, $i - 12), 26));

                    if (stripos($window, 'branch') !== false) {
                        continue;
                    }

                    $offenders[] = $this->relative($file).':'.($i + 1).' — '.trim($line);
                }
            }
        }

        $this->assertSame(
            [],
            $offenders,
            "These Rule::exists() calls name a branch-owned table but no branch:\n\n"
                .implode("\n", $offenders)
        );
    }

    /**
     * A model that owns a branch must declare which kind it is, so that adding
     * one and forgetting the trait is a failing test rather than a silent leak.
     */
    public function test_every_model_with_a_branch_column_is_classified(): void
    {
        $unclassified = [];

        $models = glob(base_path('app/Domain/*/Models/*.php'));

        foreach ($models as $file) {
            $source = file_get_contents($file);

            // A branch_id inside $fillable is the model claiming the column
            // as its own. Anywhere else — a withPivot() on a role, a foreign key
            // named in a relation — is a reference to somebody else's branch and
            // says nothing about this model.
            if (! preg_match('/\$fillable\s*=\s*\[(.*?)\];/s', $source, $m)) {
                continue;
            }

            if (! str_contains($m[1], "'branch_id'")) {
                continue;
            }

            $classified = str_contains($source, 'BelongsToBranch')
                || str_contains($source, 'ScopedThroughBranch')
                // An explicit, reasoned exemption is a classification too — the
                // point of this test is that nobody arrives here by accident.
                || str_contains($source, 'BRANCH_SCOPE_EXEMPT')
                // Branch itself is the subject rather than the object.
                || str_contains($source, 'class Branch ');

            if (! $classified) {
                $unclassified[] = $this->relative($file);
            }
        }

        $this->assertSame(
            [],
            $unclassified,
            "These models carry branch_id but declare no branch scope — see docs/agent-rules/19:\n\n"
                .implode("\n", $unclassified)
        );
    }

    private function relative(string $path): string
    {
        return str_replace(base_path().'/', '', $path);
    }
}
