<?php

declare(strict_types=1);

/**
 * APO-186 Card 7 — Registry enrollment audit + drift guard substrate.
 *
 * Enforces the Surface enrollment contract from the Card 1 architecture log:
 * every interaction surface declares itself to the registry by construction.
 * Without this primitive, the registry is "the list of currently-registered
 * actions"; with this primitive, the registry becomes the canonical
 * inventory of every operator interaction in the system, with CI-enforced
 * drift detection.
 *
 * The audit performs a static-analysis grep of all `app/admin/routes/*.php`
 * files for `$action === '...'` branches (the established pattern for the
 * 88-action vocabulary) and cross-references each discovered verb against
 * the registered MyronAction instances. The set difference is the
 * "un-registered backlog" — the list of admin actions that have not yet
 * been migrated to the dual-mode service-layer + MyronAction pattern.
 *
 * Subsequent milestones (APO-187 Pass A, APO-191 Pass B, APO-192 Pass C)
 * progressively shrink this backlog to zero. After zero, ANY new admin
 * POST `$action === '...'` branch added without a corresponding registered
 * MyronAction fails the verifier — the drift guard.
 *
 * The audit is read-only. It does not modify admin routes; does not register
 * actions; does not move files. It writes one artifact:
 * app/tests/reports/ai-native-registry-backlog.json (Class 3; never ships).
 */
final class MyronRegistryAudit
{
    public function __construct(
        private readonly string $adminRoutesDir,
        private readonly MyronActionRegistry $registry,
        private readonly string $backlogReportPath,
    ) {
    }

    /**
     * Static-analysis discovery of admin POST $action branches across all
     * route files under $adminRoutesDir.
     *
     * @return list<array{routeFile: string, actionVerb: string, domainGroup: string, discoveredAt: string}>
     */
    public function discoverAdminActions(): array
    {
        $discoveries = [];
        if (!is_dir($this->adminRoutesDir)) {
            return $discoveries;
        }

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($this->adminRoutesDir, FilesystemIterator::SKIP_DOTS)
        );
        $now = $this->nowIso();
        foreach ($iterator as $file) {
            if (!$file instanceof SplFileInfo) {
                continue;
            }
            if ($file->getExtension() !== 'php') {
                continue;
            }
            $contents = (string) @file_get_contents($file->getPathname());
            if ($contents === '') {
                continue;
            }
            // Match $action === 'verb-name' or $action === "verb-name" tolerant of whitespace.
            if (preg_match_all(
                '/\\$action\\s*===\\s*[\'"]([a-z0-9][a-z0-9-]*)[\'"]/i',
                $contents,
                $matches
            ) !== false) {
                $relPath = $this->relativeRoutePath($file->getPathname());
                $domain  = $this->inferDomainGroup($relPath);
                foreach ((array) ($matches[1] ?? []) as $verb) {
                    $verb = (string) $verb;
                    if ($verb === '') {
                        continue;
                    }
                    $discoveries[] = [
                        'routeFile'    => $relPath,
                        'actionVerb'   => $verb,
                        'domainGroup'  => $domain,
                        'discoveredAt' => $now,
                    ];
                }
            }
        }

        // De-duplicate exact (routeFile, actionVerb) pairs — the same verb
        // can appear multiple times in one file (e.g. validation + dispatch
        // branches); count it once.
        $unique = [];
        $seen   = [];
        foreach ($discoveries as $d) {
            $key = $d['routeFile'] . '::' . $d['actionVerb'];
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $unique[]   = $d;
        }
        // Stable ordering by route path then verb for deterministic output.
        usort($unique, static function (array $a, array $b): int {
            $cmp = strcmp($a['routeFile'], $b['routeFile']);
            return $cmp !== 0 ? $cmp : strcmp($a['actionVerb'], $b['actionVerb']);
        });
        return $unique;
    }

    /**
     * The set of admin actions NOT yet covered by a registered MyronAction.
     * Cross-reference: each registered action's metadata declares (domain, verb).
     * For a discovered action to count as "registered", the registry must
     * contain a MyronAction whose metadata.domain matches the discovered
     * domainGroup AND whose metadata.verb matches the discovered actionVerb
     * (after normalising the discovered verb — see verbsMatch()).
     *
     * @return list<array{routeFile: string, actionVerb: string, domainGroup: string, discoveredAt: string}>
     */
    public function discoverUnregisteredActions(): array
    {
        $discovered = $this->discoverAdminActions();
        if ($discovered === []) {
            return [];
        }

        // Collect every (domain, verb) tuple that a registered action claims —
        // both the canonical (domain, verb) AND each declared adminAlias under
        // the same domain.
        //
        // APO-196 substrate refinement: adminAliases also match cross-domain
        // by verb-name alone. The action's `domain` is its canonical API
        // domain (where the URI lives — e.g. `content` for files), while the
        // admin route's `domainGroup` is the admin IA category (e.g. `design`
        // when uploads happen from the footer route). The architecture log
        // §8 documents the alias as "this action covers this admin verb
        // wherever it appears". So an admin verb is "covered" if EITHER:
        //   (a) it matches by (domain, verb) — same-domain canonical, OR
        //   (b) it matches by adminAliases verb-name regardless of domain.
        $registeredKeys = [];      // domain::verb
        $aliasVerbs     = [];      // verb-name (cross-domain)
        foreach ($this->registry->all() as $action) {
            $meta = $action->metadata();
            $registeredKeys[$this->normaliseKey($meta->domain, $meta->verb)] = true;
            foreach ($meta->adminAliases as $alias) {
                $aliasNormalised = strtolower(trim((string) $alias));
                $registeredKeys[$this->normaliseKey($meta->domain, $aliasNormalised)] = true;
                $aliasVerbs[$aliasNormalised] = true;
            }
        }

        $unregistered = [];
        foreach ($discovered as $d) {
            $key = $this->normaliseKey($d['domainGroup'], $d['actionVerb']);
            $verbNormalised = strtolower(trim($d['actionVerb']));
            if (isset($registeredKeys[$key]) || isset($aliasVerbs[$verbNormalised])) {
                continue;
            }
            $unregistered[] = $d;
        }
        return $unregistered;
    }

    /**
     * Generate the canonical backlog manifest at $backlogReportPath. Returns
     * the manifest payload that was written.
     *
     * @return array<string, mixed>
     */
    public function generateBacklog(): array
    {
        $discovered   = $this->discoverAdminActions();
        $unregistered = $this->discoverUnregisteredActions();

        $manifest = [
            'version'             => '1',
            'generatedAt'         => $this->nowIso(),
            'totalAdminActions'   => count($discovered),
            'registeredActions'   => count($discovered) - count($unregistered),
            'unregisteredActions' => count($unregistered),
            'backlog'             => $unregistered,
        ];

        $dir = dirname($this->backlogReportPath);
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        file_put_contents(
            $this->backlogReportPath,
            json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
        );
        return $manifest;
    }

    /**
     * Drift-guard predicate. Returns true when the un-registered count is at
     * or below the previously-recorded baseline (things only get better,
     * never worse). Returns false on drift — a new admin POST branch was
     * added without a corresponding registered MyronAction.
     */
    public function isWithinDriftBaseline(int $previousBaseline): bool
    {
        return count($this->discoverUnregisteredActions()) <= $previousBaseline;
    }

    /**
     * APO-196 Card 7 — Drift-guard CI escalation. Throws if backlog > slack.
     *
     * After APO-196 closeout (backlog reached zero), this method becomes the
     * permanent CI invariant. Any new admin POST `$action === '...'` branch
     * added without a corresponding registered MyronAction makes this throw.
     *
     * Used by:
     *   - the Pass C verifier to assert backlog=0 at closeout time
     *   - `.github/workflows/registry-drift.yml` (APO-198) to fail CI on drift
     *
     * @param int $allowedSlack permitted backlog count; defaults to 0
     * @throws RuntimeException when un-registered count exceeds $allowedSlack
     * @return array{registered: int, unregistered: int}
     */
    public function assertNoDrift(int $allowedSlack = 0): array
    {
        $unregistered = $this->discoverUnregisteredActions();
        if (count($unregistered) > $allowedSlack) {
            $sample  = array_slice($unregistered, 0, 10);
            $details = '';
            foreach ($sample as $entry) {
                $details .= sprintf(
                    "\n  - %s :: %s",
                    (string) ($entry['routeFile'] ?? ''),
                    (string) ($entry['actionVerb'] ?? '')
                );
            }
            throw new RuntimeException(sprintf(
                'Registry drift detected: %d un-registered admin verb(s) exceed allowed slack of %d.%s',
                count($unregistered),
                $allowedSlack,
                $details
            ));
        }
        $total = $this->discoverAdminActions();
        return [
            'registered'   => count($total) - count($unregistered),
            'unregistered' => count($unregistered),
        ];
    }

    private function nowIso(): string
    {
        return date('Y-m-d\TH:i:s\Z');
    }

    private function relativeRoutePath(string $absolute): string
    {
        // Always emit POSIX-style slashes
        $abs = str_replace('\\', '/', $absolute);
        $base = str_replace('\\', '/', dirname(__DIR__, 2)); // app/
        if (str_starts_with($abs, $base . '/')) {
            return substr($abs, strlen($base) + 1);
        }
        return $abs;
    }

    private function inferDomainGroup(string $relPath): string
    {
        // app/admin/routes/{domain}/...
        $parts = explode('/', $relPath);
        $idx = array_search('routes', $parts, true);
        if ($idx !== false && isset($parts[$idx + 1])) {
            return (string) $parts[$idx + 1];
        }
        return 'unknown';
    }

    private function normaliseKey(string $domain, string $verb): string
    {
        return strtolower(trim($domain)) . '::' . strtolower(trim($verb));
    }
}
