<?php

declare(strict_types=1);

require_once __DIR__ . '/DefinitionsPath.php';

final class MyronDefinitionsAuditService
{
    public function __construct(
        private readonly MyronDefinitionsPath $definitionsPath,
    ) {
    }

    /**
     * @return array{checks: list<string>, counts: array<string, int>}
     */
    public function audit(): array
    {
        $checks = [];

        $atomsPayload = $this->decodeJsonFile($this->definitionsPath->componentsAtomsPath(), 'component atoms definitions');
        $primitivesPayload = $this->decodeJsonFile($this->definitionsPath->componentsPrimitivesPath(), 'component primitives definitions');
        $catalog = $this->decodeJsonFile($this->definitionsPath->catalogReconciliationPath(), 'template catalog reconciliation');

        $atoms = $this->listObjects($atomsPayload['atoms'] ?? null, 'atoms');
        $primitives = $this->listObjects($primitivesPayload['primitives'] ?? null, 'primitives');
        $canonicalComponentIds = $this->indexRecordsById(array_merge($atoms, $primitives), 'component');
        $atomIds = $this->indexRecordsById($atoms, 'atom');
        $backlog = $this->loadBacklogIndex();
        $tokenIds = $this->loadTokenIdsIfAvailable();

        $this->assertComponentRecords($atoms, 'atom', $canonicalComponentIds, $atomIds, $tokenIds);
        $this->assertComponentRecords($primitives, 'primitive', $canonicalComponentIds, $atomIds, $tokenIds);
        $checks[] = 'component records use canonical paths, dependencies, and backlog-ready token references';

        $this->assertCatalogReconciliation($catalog, $canonicalComponentIds, $backlog);
        $checks[] = 'catalog reconciliation resolves every drift name to canonical component or backlog IDs';

        $templateIds = $this->assertTemplateReferences($canonicalComponentIds, $backlog);
        $checks[] = 'template sections normalize pending backlog references against real repo paths';

        $this->assertSiteTypeReferences($templateIds);
        $checks[] = 'site-type bundles resolve template and subtype references against the canonical catalog';

        return [
            'checks' => $checks,
            'counts' => [
                'atoms' => count($atoms),
                'primitives' => count($primitives),
                'backlogSpecs' => count($backlog),
                'templates' => count($templateIds),
                'siteTypes' => count($this->definitionsPath->siteTypePaths()),
                'catalogResolutions' => count($this->listObjects($catalog['resolutions'] ?? null, 'catalog resolutions')),
            ],
        ];
    }

    /**
     * @param list<array<string, mixed>> $records
     * @param array<string, true> $canonicalComponentIds
     * @param array<string, true> $atomIds
     * @param array<string, true> $tokenIds
     */
    private function assertComponentRecords(
        array $records,
        string $classification,
        array $canonicalComponentIds,
        array $atomIds,
        array $tokenIds,
    ): void {
        foreach ($records as $record) {
            $id = $this->requireString($record, 'id', $classification . ' component');
            if ($this->requireString($record, 'classification', 'component `' . $id . '`') !== $classification) {
                throw new RuntimeException('Component `' . $id . '` has the wrong classification.');
            }

            $implementationPaths = $record['implementationPath'] ?? null;
            if (!is_array($implementationPaths) || $implementationPaths === []) {
                throw new RuntimeException('Component `' . $id . '` must expose implementationPath as a non-empty list.');
            }
            foreach ($implementationPaths as $path) {
                $this->assertProjectFile((string) $path, 'Component `' . $id . '` implementation path');
            }

            $dependencies = $record['composedOf'] ?? [];
            if (!is_array($dependencies)) {
                throw new RuntimeException('Component `' . $id . '` must expose composedOf as a list.');
            }
            foreach ($dependencies as $dependency) {
                $dependencyId = trim((string) $dependency);
                if ($dependencyId === '' || !isset($canonicalComponentIds[$dependencyId])) {
                    throw new RuntimeException('Component `' . $id . '` references missing dependency `' . $dependencyId . '`.');
                }
            }

            $tokens = $record['tokenDependencies'] ?? [];
            if (!is_array($tokens)) {
                throw new RuntimeException('Component `' . $id . '` must expose tokenDependencies as a list.');
            }
            foreach ($tokens as $token) {
                $tokenName = trim((string) $token);
                if ($tokenName === '' || !str_starts_with($tokenName, '--myron-')) {
                    throw new RuntimeException('Component `' . $id . '` has an invalid token dependency `' . $tokenName . '`.');
                }
            }
        }

        foreach ($this->loadBacklogIndex() as $id => $spec) {
            $record = $spec['record'];
            $dependencies = $record['composedOf'] ?? [];
            if (!is_array($dependencies)) {
                throw new RuntimeException('Backlog spec `' . $id . '` must expose composedOf as a list.');
            }
            foreach ($dependencies as $dependency) {
                $dependencyId = trim((string) $dependency);
                if ($dependencyId === '' || !isset($atomIds[$dependencyId])) {
                    throw new RuntimeException('Backlog spec `' . $id . '` references missing atom `' . $dependencyId . '`.');
                }
            }

            $tokens = $record['tokenDependencies'] ?? [];
            if (!is_array($tokens)) {
                throw new RuntimeException('Backlog spec `' . $id . '` must expose tokenDependencies as a list.');
            }
            foreach ($tokens as $token) {
                $tokenName = trim((string) $token);
                if (!isset($tokenIds[$tokenName])) {
                    throw new RuntimeException('Backlog spec `' . $id . '` references token outside token-map.json: `' . $tokenName . '`.');
                }
            }
        }
    }

    /**
     * @param array<string, true> $canonicalComponentIds
     * @param array<string, array{record: array<string, mixed>, shortPath: string, projectPath: string}> $backlog
     */
    private function assertCatalogReconciliation(array $catalog, array $canonicalComponentIds, array $backlog): void
    {
        $summary = $catalog['summary'] ?? null;
        if (!is_array($summary)) {
            throw new RuntimeException('Catalog reconciliation summary is missing.');
        }

        $resolutions = $this->listObjects($catalog['resolutions'] ?? null, 'catalog resolutions');
        if ((int) ($summary['unresolvedCount'] ?? -1) !== 0) {
            throw new RuntimeException('Catalog reconciliation still reports unresolved drift names.');
        }
        if ((int) ($summary['resolvedCount'] ?? -1) !== count($resolutions)) {
            throw new RuntimeException('Catalog reconciliation resolvedCount does not match resolutions[].');
        }
        if ((int) ($summary['totalReferences'] ?? -1) !== count($resolutions)) {
            throw new RuntimeException('Catalog reconciliation totalReferences does not match resolutions[].');
        }

        foreach ($resolutions as $index => $resolution) {
            $type = $this->requireString($resolution, 'resolutionType', 'catalog resolution ' . $index);
            $resolvedTo = $resolution['resolvedTo'] ?? null;

            if ($type === 'backlog') {
                $id = trim((string) $resolvedTo);
                if ($id === '' || !isset($backlog[$id])) {
                    throw new RuntimeException('Catalog backlog resolution `' . $id . '` has no backlog spec.');
                }

                $notes = trim((string) ($resolution['notes'] ?? ''));
                if (!str_contains($notes, $backlog[$id]['shortPath'])) {
                    throw new RuntimeException('Catalog backlog resolution `' . $id . '` must cite `' . $backlog[$id]['shortPath'] . '`.');
                }
                continue;
            }

            $ids = is_array($resolvedTo) ? $resolvedTo : [$resolvedTo];
            if ($ids === []) {
                throw new RuntimeException('Catalog resolution ' . $index . ' must resolve to at least one component.');
            }

            foreach ($ids as $idValue) {
                $id = trim((string) $idValue);
                if ($id === '' || (!isset($canonicalComponentIds[$id]) && !isset($backlog[$id]))) {
                    throw new RuntimeException('Catalog resolution ' . $index . ' references unknown component or backlog spec `' . $id . '`.');
                }
            }
        }
    }

    /**
     * @param array<string, true> $canonicalComponentIds
     * @param array<string, array{record: array<string, mixed>, shortPath: string, projectPath: string}> $backlog
     * @return array<string, true>
     */
    private function assertTemplateReferences(array $canonicalComponentIds, array $backlog): array
    {
        $templateIds = [];

        foreach ($this->definitionsPath->templatePaths() as $path) {
            $record = $this->decodeJsonFile($path, 'template definition');
            $templateId = $this->requireString($record, 'id', 'template definition');
            $templateIds[$templateId] = true;

            $expectedProjectPath = $this->definitionsPath->relativeToProject($path);
            if ($expectedProjectPath !== $this->definitionsPath->canonicalProjectSourcePath($this->definitionsPath->canonicalDevPrepReference($path))) {
                throw new RuntimeException('Template `' . $templateId . '` source path does not normalize to the canonical definitions root.');
            }

            $sections = $record['sections'] ?? null;
            if (!is_array($sections)) {
                throw new RuntimeException('Template `' . $templateId . '` must expose sections as a list.');
            }

            foreach ($sections as $index => $section) {
                if (!is_array($section)) {
                    throw new RuntimeException('Template `' . $templateId . '` contains a non-object section at index ' . $index . '.');
                }

                $componentId = $this->requireString($section, 'componentId', 'template `' . $templateId . '` section ' . $index);
                $status = trim((string) ($section['componentStatus'] ?? 'active'));

                if ($status === 'pending') {
                    $backlogRef = $this->requireString($section, 'backlogRef', 'template `' . $templateId . '` pending section `' . $componentId . '`');
                    if (!isset($backlog[$componentId])) {
                        throw new RuntimeException('Template `' . $templateId . '` pending section `' . $componentId . '` has no backlog spec.');
                    }
                    if ($this->definitionsPath->canonicalDevPrepReference($backlogRef) !== $backlog[$componentId]['shortPath']) {
                        throw new RuntimeException('Template `' . $templateId . '` pending section `' . $componentId . '` uses a non-canonical backlogRef.');
                    }
                    if ($this->definitionsPath->canonicalProjectSourcePath($backlogRef) !== $backlog[$componentId]['projectPath']) {
                        throw new RuntimeException('Template `' . $templateId . '` pending section `' . $componentId . '` does not resolve to the real backlog file.');
                    }
                    continue;
                }

                if (!isset($canonicalComponentIds[$componentId]) && isset($backlog[$componentId])) {
                    continue;
                }

                if (!isset($canonicalComponentIds[$componentId])) {
                    throw new RuntimeException('Template `' . $templateId . '` references unknown active component `' . $componentId . '`.');
                }
            }
        }

        return $templateIds;
    }

    /**
     * @param array<string, true> $templateIds
     */
    private function assertSiteTypeReferences(array $templateIds): void
    {
        $siteTypes = [];
        $records = [];

        foreach ($this->definitionsPath->siteTypePaths() as $path) {
            $record = $this->decodeJsonFile($path, 'site-type definition');
            $id = $this->requireString($record, 'id', 'site-type definition');
            $siteTypes[$id] = true;
            $records[$id] = $record;
        }

        foreach ($records as $id => $record) {
            foreach (['defaultTemplates', 'optionalTemplates'] as $field) {
                $templateList = $record[$field] ?? [];
                if (!is_array($templateList)) {
                    throw new RuntimeException('Site type `' . $id . '` must expose ' . $field . ' as a list.');
                }
                foreach ($templateList as $templateIdValue) {
                    $templateId = trim((string) $templateIdValue);
                    if ($templateId === '' || !isset($templateIds[$templateId])) {
                        throw new RuntimeException('Site type `' . $id . '` references unknown template `' . $templateId . '`.');
                    }
                }
            }

            foreach ($this->parentBundleIds($record) as $parentBundle) {
                if (!isset($siteTypes[$parentBundle])) {
                    throw new RuntimeException('Site type `' . $id . '` references unknown parent bundle `' . $parentBundle . '`.');
                }
            }
        }
    }

    /**
     * @param array<string, mixed> $record
     * @return list<string>
     */
    private function parentBundleIds(array $record): array
    {
        $parents = [];
        foreach (is_array($record['parentBundles'] ?? null) ? $record['parentBundles'] : [] as $parentBundle) {
            $normalized = trim((string) $parentBundle);
            if ($normalized !== '') {
                $parents[$normalized] = true;
            }
        }

        $legacyParent = $record['parentBundle'] ?? null;
        if (is_array($legacyParent)) {
            foreach ($legacyParent as $parentBundle) {
                $normalized = trim((string) $parentBundle);
                if ($normalized !== '') {
                    $parents[$normalized] = true;
                }
            }
        } else {
            $normalized = trim((string) $legacyParent);
            if ($normalized !== '') {
                $parents[$normalized] = true;
            }
        }

        return array_keys($parents);
    }

    /**
     * @return array<string, array{record: array<string, mixed>, shortPath: string, projectPath: string}>
     */
    private function loadBacklogIndex(): array
    {
        $backlog = [];

        foreach ($this->definitionsPath->backlogPaths() as $path) {
            $record = $this->decodeJsonFile($path, 'backlog spec');
            $id = $this->requireString($record, 'id', 'backlog spec');
            $shortPath = $this->definitionsPath->canonicalDevPrepReference($path);
            $projectPath = $this->definitionsPath->canonicalProjectSourcePath($path);
            $this->assertProjectFile($projectPath, 'Backlog spec `' . $id . '` project path');

            $backlog[$id] = [
                'record' => $record,
                'shortPath' => $shortPath,
                'projectPath' => $projectPath,
            ];
        }

        return $backlog;
    }

    /**
     * @param list<array<string, mixed>> $records
     * @return array<string, true>
     */
    private function indexRecordsById(array $records, string $label): array
    {
        $index = [];
        foreach ($records as $record) {
            $id = $this->requireString($record, 'id', $label);
            if (isset($index[$id])) {
                throw new RuntimeException('Duplicate ' . $label . ' id `' . $id . '`.');
            }
            $index[$id] = true;
        }

        return $index;
    }

    /**
     * @return array<string, true>
     */
    private function loadTokenIds(array $tokenMap): array
    {
        $ids = [];
        foreach (['mappings', 'specialMappings'] as $field) {
            $records = $tokenMap[$field] ?? [];
            if (!is_array($records)) {
                continue;
            }
            foreach ($records as $record) {
                if (!is_array($record)) {
                    continue;
                }
                $cssVar = trim((string) ($record['cssVar'] ?? ''));
                if ($cssVar !== '') {
                    $ids[$cssVar] = true;
                }
            }
        }

        return $ids;
    }

    /**
     * @return array<string, true>
     */
    private function loadTokenIdsIfAvailable(): array
    {
        $tokenMapPath = $this->definitionsPath->projectRoot()
            . DIRECTORY_SEPARATOR
            . 'app/data/runtime-definitions/token-map.json';

        if (!is_file($tokenMapPath)) {
            return [];
        }

        return $this->loadTokenIds($this->decodeJsonFile($tokenMapPath, 'token map'));
    }

    private function assertProjectFile(string $path, string $label): void
    {
        $normalized = str_replace('\\', '/', trim($path));
        if ($normalized === '' || str_contains($normalized, '../') || str_contains($normalized, '/..')) {
            throw new RuntimeException($label . ' is not a canonical project-relative path.');
        }

        $absolute = $this->definitionsPath->projectRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, ltrim($normalized, '/'));
        if (!is_file($absolute)) {
            throw new RuntimeException($label . ' is missing at `' . $normalized . '`.');
        }
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function listObjects(mixed $value, string $label): array
    {
        if (!is_array($value)) {
            throw new RuntimeException('Expected ' . $label . ' to be a list.');
        }

        $objects = [];
        foreach ($value as $index => $record) {
            if (!is_array($record)) {
                throw new RuntimeException('Expected ' . $label . '[' . $index . '] to be an object.');
            }
            $objects[] = $record;
        }

        return $objects;
    }

    private function requireString(array $record, string $field, string $label): string
    {
        $value = trim((string) ($record[$field] ?? ''));
        if ($value === '') {
            throw new RuntimeException($label . ' is missing `' . $field . '`.');
        }

        return $value;
    }

    /**
     * @return array<string, mixed>
     */
    private function decodeJsonFile(string $path, string $label): array
    {
        $raw = file_get_contents($path);
        if (!is_string($raw)) {
            throw new RuntimeException('Failed to read ' . $label . ' at `' . $path . '`.');
        }

        $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        if (!is_array($decoded)) {
            throw new RuntimeException('Expected ' . $label . ' at `' . $path . '` to decode into an object.');
        }

        return $decoded;
    }
}
