<?php

namespace MintyPHP\Tests\Architecture;

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

/**
 * Contract: every class referenced via `$c->get(X::class)` inside a container
 * registrar MUST itself be bound via `$container->set(X::class, ...)` somewhere
 * (in the same registrar, in core/App/registerContainer.php, or in a module
 * container registrar) — or be on the framework-bootstrap allowlist.
 *
 * Catches the bug pattern from cd2c9c5: a registrar's factory closure pulls
 * a service via $c->get() but no matching ->set() exists, so the failure only
 * surfaces at runtime ("Service not bound: ...") when the affected page is
 * loaded — never inside unit tests, because unit tests mock the dependencies.
 *
 * Static-only: parses use-aliases + namespace + ::class references via regex.
 * No factory invocation — so the test does not require DB / session / HTTP.
 *
 * Detection is conservative: when an aggregator binding cannot be statically
 * located (string-literal keys, dynamic `::class` lookups), the entry is
 * skipped rather than reported as a false positive. The class-must-exist
 * gate via class_exists() avoids matching non-class identifiers.
 */
final class ContainerBindingResolutionContractTest extends TestCase
{
    use ProjectFileAssertionSupport;

    public function testEveryContainerGetTargetIsBoundSomewhere(): void
    {
        $registrarFiles = $this->collectRegistrarFiles();
        self::assertNotEmpty(
            $registrarFiles,
            'No registrar files discovered — the registrar-discovery glob is broken.'
        );

        $boundClasses = [];
        $referencedClasses = [];

        foreach ($registrarFiles as $file) {
            $content = $this->readProjectFile($file);
            $aliases = $this->parseUseAliases($content);
            $namespace = $this->parseNamespace($content);

            foreach ($this->matchClassRefs('->set\(', $content) as $ref) {
                $fqn = $this->resolveFqn($ref, $aliases, $namespace);
                if ($fqn !== null && (class_exists($fqn) || interface_exists($fqn))) {
                    $boundClasses[$fqn] = true;
                }
            }

            foreach ($this->matchClassRefs('->get\(', $content) as $ref) {
                $fqn = $this->resolveFqn($ref, $aliases, $namespace);
                if ($fqn === null) {
                    continue;
                }
                if (!class_exists($fqn) && !interface_exists($fqn)) {
                    continue;
                }
                if (!isset($referencedClasses[$fqn])) {
                    $referencedClasses[$fqn] = $file;
                }
            }
        }

        $allowlist = $this->frameworkBoundAllowlist();

        $missing = [];
        foreach ($referencedClasses as $class => $file) {
            if (isset($boundClasses[$class])) {
                continue;
            }
            if (in_array($class, $allowlist, true)) {
                continue;
            }
            $missing[] = sprintf('%s — referenced in %s', $class, $file);
        }
        sort($missing);

        self::assertSame(
            [],
            $missing,
            "Container ->get() targets without matching ->set() — these will fail with"
            . " 'Service not bound: ...' at runtime:\n  "
            . implode("\n  ", $missing)
        );
    }

    /**
     * Match either `->set(Class::class,` or `->get(Class::class)` based on the
     * provided opening pattern, and return the captured class identifier
     * strings (still potentially aliased — caller resolves to FQN).
     *
     * @return list<string>
     */
    private function matchClassRefs(string $callOpenPattern, string $content): array
    {
        $regex = '/' . $callOpenPattern . '\s*([\\\\A-Za-z_][\\\\A-Za-z0-9_]*)::class\s*[,)]/';
        if (!preg_match_all($regex, $content, $m)) {
            return [];
        }
        return $m[1];
    }

    /**
     * @return list<string>
     */
    private function collectRegistrarFiles(): array
    {
        $root = $this->projectRootPath();
        $files = ['core/App/registerContainer.php'];

        $coreGlob = glob($root . '/core/App/Container/Registrars/*Registrar.php');
        foreach ($coreGlob ?: [] as $f) {
            $files[] = str_replace($root . '/', '', $f);
        }

        $modulesDir = $root . '/modules';
        if (is_dir($modulesDir)) {
            $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($modulesDir, FilesystemIterator::SKIP_DOTS));
            foreach ($iterator as $file) {
                if (!$file->isFile() || $file->getExtension() !== 'php') {
                    continue;
                }
                if (!str_ends_with($file->getFilename(), 'ContainerRegistrar.php')) {
                    continue;
                }
                $files[] = str_replace($root . '/', '', $file->getPathname());
            }
        }

        sort($files);
        return array_values(array_unique($files));
    }

    /**
     * @return array<string, string>
     */
    private function parseUseAliases(string $content): array
    {
        $aliases = [];
        if (!preg_match_all('/^use\s+([^;]+);/m', $content, $m)) {
            return $aliases;
        }
        foreach ($m[1] as $useStmt) {
            $useStmt = trim($useStmt);
            // Skip group-uses for simplicity ("use A\{B, C}") — registrar files don't use them.
            if (str_contains($useStmt, '{')) {
                continue;
            }
            if (preg_match('/^(.+?)\s+as\s+(.+)$/i', $useStmt, $aliasM)) {
                $fqn = trim($aliasM[1]);
                $alias = trim($aliasM[2]);
            } else {
                $fqn = $useStmt;
                $lastBackslash = strrpos($fqn, '\\');
                $alias = $lastBackslash === false ? $fqn : substr($fqn, $lastBackslash + 1);
            }
            $aliases[$alias] = ltrim($fqn, '\\');
        }
        return $aliases;
    }

    private function parseNamespace(string $content): string
    {
        if (preg_match('/^namespace\s+([^;]+);/m', $content, $m)) {
            return trim($m[1]);
        }
        return '';
    }

    private function resolveFqn(string $ref, array $aliases, string $namespace): ?string
    {
        // FQN with leading backslash → strip and use directly
        if (str_starts_with($ref, '\\')) {
            return ltrim($ref, '\\');
        }

        // First segment is either an alias or a namespace prefix
        $parts = explode('\\', $ref);
        $first = $parts[0];

        if (isset($aliases[$first])) {
            $rest = array_slice($parts, 1);
            return $rest === [] ? $aliases[$first] : $aliases[$first] . '\\' . implode('\\', $rest);
        }

        // No alias match — try as same-namespace ref (rare in registrars)
        if ($namespace !== '') {
            return $namespace . '\\' . $ref;
        }

        return $ref;
    }

    /**
     * Classes bound by the MintyPHP framework or PHP-FPM bootstrap before
     * any project registrar runs. Each entry needs a one-line justification.
     *
     * Add to this list ONLY when you have verified that the class genuinely
     * cannot be discovered statically from registrars — never to silence a
     * legitimate "Service not bound" warning.
     *
     * @return list<string>
     */
    private function frameworkBoundAllowlist(): array
    {
        return [
            // App container itself — bound by registerContainer.php's `new AppContainer()`,
            // not via $container->set() (it IS the container).
            'MintyPHP\\App\\AppContainer',
            // Module registry — bound dynamically after module discovery.
            'MintyPHP\\App\\Module\\ModuleRegistry',
        ];
    }
}
