<?php
declare(strict_types=1);

/**
 * Static and behavioural code inspection.
 *
 * `php -l` proves a file parses. It does not notice a view reading a variable
 * nobody passes, a class used without an import, a raw Markdown body echoed
 * into HTML, or an accent colour that disappears in dark mode. Every one of
 * those shipped at least once, and none of them fail loudly: the page still
 * answers 200 and simply renders something wrong.
 *
 * This walks the tree and reports them.
 *
 *   php bin/inspect.php                 text report, live checks included
 *   php bin/inspect.php --quick         skip anything needing the database
 *   php bin/inspect.php --format=md     markdown, for pasting into an issue
 *   php bin/inspect.php --format=json   machine readable
 *   php bin/inspect.php --report=FILE   also write the report to FILE
 *
 * Exit codes: 0 clean, 1 errors found, 2 only warnings.
 */

if (PHP_SAPI !== 'cli') {
    http_response_code(404);
    exit;
}

require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'bootstrap.php';
app_bootstrap();

use App\Theme\ThemeTokenService;

$root = dirname(__DIR__);
$options = [
    'quick' => in_array('--quick', $argv, true),
    'format' => 'text',
    'report' => '',
];
foreach ($argv as $argument) {
    if (str_starts_with($argument, '--format=')) $options['format'] = substr($argument, 9);
    if (str_starts_with($argument, '--report=')) $options['report'] = substr($argument, 9);
}

/** @var list<array{level:string,area:string,file:string,line:int,message:string}> $findings */
$findings = [];
$checksRun = [];

$report = static function (string $level, string $area, string $message, string $file = '', int $line = 0) use (&$findings): void {
    $findings[] = ['level' => $level, 'area' => $area, 'file' => $file, 'line' => $line, 'message' => $message];
};
$ran = static function (string $name, int $count) use (&$checksRun): void {
    $checksRun[] = ['name' => $name, 'scanned' => $count];
};

/** @return list<string> Paths relative to the project root. */
$collect = static function (string $directory, string $extension) use ($root): array {
    $base = $root . DIRECTORY_SEPARATOR . $directory;
    if (!is_dir($base)) return [];
    $found = [];
    $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
    foreach ($iterator as $file) {
        /** @var SplFileInfo $file */
        if (!$file->isFile() || strtolower($file->getExtension()) !== $extension) continue;
        $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1));
        if (str_starts_with($relative, 'dist/') || str_contains($relative, '/node_modules/')) continue;
        $found[] = $relative;
    }
    sort($found);
    return $found;
};

$sourceDirs = ['app', 'admin', 'public', 'bin', 'api', 'install', 'config', 'plugins', 'tests'];
$phpFiles = [];
foreach ($sourceDirs as $directory) {
    $phpFiles = array_merge($phpFiles, $collect($directory, 'php'));
}
$phpFiles = array_values(array_unique($phpFiles));

// ---------------------------------------------------------------- 1. syntax
foreach ($phpFiles as $relative) {
    $output = [];
    $code = 0;
    exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($root . '/' . $relative) . ' 2>&1', $output, $code);
    if ($code !== 0) {
        $report('error', 'syntax', trim(implode(' ', $output)), $relative);
    }
}
$ran('PHP syntax', count($phpFiles));

$jsFiles = $collect('public', 'js');
$nodeAvailable = false;
$probe = [];
exec('node --version 2>&1', $probe, $probeCode);
$nodeAvailable = $probeCode === 0;
if ($nodeAvailable) {
    foreach ($jsFiles as $relative) {
        $output = [];
        $code = 0;
        exec('node --check ' . escapeshellarg($root . '/' . $relative) . ' 2>&1', $output, $code);
        if ($code !== 0) {
            $report('error', 'syntax', trim(implode(' ', array_slice($output, 0, 3))), $relative);
        }
    }
    $ran('JavaScript syntax', count($jsFiles));
} else {
    $report('warning', 'environment', 'node is unavailable, so JavaScript files were not syntax-checked.');
}

// ------------------------------------------------ 2. classes used but unimported
// A global-namespace file resolves every class name against the global scope,
// so a missing `use` is a fatal that only fires when that branch runs.
$builtins = ['PDO', 'PDOException', 'PDOStatement', 'Throwable', 'Exception', 'Error', 'TypeError',
    'RuntimeException', 'InvalidArgumentException', 'LogicException', 'JsonException', 'DateTime',
    'DateTimeImmutable', 'DateTimeZone', 'DateInterval', 'Closure', 'Generator', 'ArrayObject',
    'SplFileInfo', 'SplObjectStorage', 'RecursiveIteratorIterator', 'RecursiveDirectoryIterator',
    'FilesystemIterator', 'DirectoryIterator', 'IteratorAggregate', 'Countable', 'JsonSerializable',
    'Stringable', 'ZipArchive', 'ReflectionClass', 'ReflectionMethod', 'SimpleXMLElement', 'DOMDocument',
    'NumberFormatter', 'IntlDateFormatter', 'Normalizer', 'Collator', 'CurlHandle', 'SessionHandler'];

$globalNamespaceFiles = 0;
foreach ($phpFiles as $relative) {
    $source = (string) file_get_contents($root . '/' . $relative);
    if (preg_match('/^\s*namespace\s+[A-Za-z_]/m', $source) === 1) {
        continue; // Namespaced files fall back to the global scope explicitly.
    }
    // Test scripts name classes inside assertion strings on purpose, and they
    // are not shipped; scanning them only produces noise.
    if (str_starts_with($relative, 'tests/')) {
        continue;
    }
    $globalNamespaceFiles++;

    // Names inside strings and comments are not code. Scanning them produced
    // more noise than findings, and a noisy report gets ignored.
    $code = (string) preg_replace('~/\*.*?\*/|//[^\n]*~s', '', $source);
    $code = (string) preg_replace('~([\'"])(?:\\\\.|(?!\1).)*\1~s', "''", $code);

    // A class declared in the same file needs no import.
    preg_match_all('/\b(?:class|interface|trait|enum)\s+([A-Za-z_]\w*)/', $code, $declaredMatches);
    $declared = $declaredMatches[1];

    $imported = [];
    preg_match_all('/^use\s+([A-Za-z0-9_\\\\]+)(?:\s+as\s+([A-Za-z0-9_]+))?\s*;/mi', $source, $useMatches, PREG_SET_ORDER);
    foreach ($useMatches as $row) {
        $alias = $row[2] ?? '';
        if ($alias !== '') {
            $imported[] = $alias;
            continue;
        }
        $parts = explode('\\', $row[1]);
        $imported[] = end($parts);
    }

    // A name followed by a backslash is a namespace prefix, not a class.
    $used = [];
    preg_match_all('/\bnew\s+([A-Z][A-Za-z0-9_]{2,})\s*\(/', $code, $m);
    $used = array_merge($used, $m[1]);
    preg_match_all('/(?<![\\\\$\w>])([A-Z][A-Za-z0-9_]{2,})::/', $code, $m);
    $used = array_merge($used, $m[1]);
    preg_match_all('/\bcatch\s*\(\s*([A-Z][A-Za-z0-9_]{2,})(?!\\\\)/', $code, $m);
    $used = array_merge($used, $m[1]);
    preg_match_all('/\binstanceof\s+([A-Z][A-Za-z0-9_]{2,})(?!\\\\)/', $code, $m);
    $used = array_merge($used, $m[1]);

    foreach (array_unique($used) as $class) {
        if (in_array($class, $imported, true) || in_array($class, $builtins, true)) continue;
        if (in_array($class, $declared, true)) continue;
        if (strtoupper($class) === $class) continue; // A constant, not a class.
        if (!class_exists($class) && !interface_exists($class)) {
            // Confirm it resolves under a namespace somewhere before reporting.
            $line = 0;
            foreach (explode("\n", $source) as $index => $text) {
                if (str_contains($text, $class)) { $line = $index + 1; break; }
            }
            $report('error', 'imports', $class . ' is used without a `use` import; this fatals when the branch runs.', $relative, $line);
        }
    }
}
$ran('Unimported classes', $globalNamespaceFiles);

// ------------------------------------------------------- 3. theme view contracts
$viewContract = [
    'home' => ['translator', 'renderSection', 'localeContext', 'communityFeed', 'feedFilter',
        'currentUser', 'dbAvailable', 'searchQuery', 'submittedQuestionId', 'widgetsHtml'],
    'question' => ['translator', 'renderSection', 'localeContext', 'question', 'answers', 'answersMeta',
        'questionTags', 'comments', 'isFollowing', 'reportedItems', 'slug', 'currentUser', 'csrfToken',
        'expandQuestionComments', 'notice', 'site', 'postingRules', 'currentPath', 'renderAd', 'renderBetweenAnswers'],
    'topics' => ['translator', 'localeContext', 'site', 'currentUser', 'csrfToken', 'currentPath',
        'topics', 'topicsMeta', 'categories', 'languages', 'filters', 'mode', 'error', 'values', 'postingRules'],
    'topic' => ['translator', 'localeContext', 'site', 'currentUser', 'csrfToken', 'currentPath',
        'topic', 'comments', 'reportedItems', 'isFollowing', 'slug', 'notice', 'query', 'postingRules'],
    'taxonomy' => ['translator', 'site', 'currentUser', 'csrfToken', 'currentPath', 'type',
        'taxonomy', 'questions', 'topics', 'isFollowing'],
    'search' => ['translator', 'localeContext', 'site', 'results', 'options', 'dbAvailable'],
];
$superglobals = ['this', 'GLOBALS', '_SERVER', '_GET', '_POST', '_SESSION', '_COOKIE', '_FILES', '_ENV', '_REQUEST'];

$requiredInView = [
    'home' => [
        'communityFeed' => 'the feed will always be empty',
        'widgetsHtml' => 'the homepage_main widget region disappears',
        'renderSection' => 'the sidebar widget region disappears',
    ],
    'question' => [
        'csrfToken' => 'every form on the page fails',
        'answersMeta' => 'answer pagination and canonical self-reference break',
    ],
    'topic' => ['csrfToken' => 'reply, follow and report forms fail'],
    'topics' => ["'new'" => '/d/new renders no create form'],
];

$themeViews = 0;
$themesRoot = $root . '/public/themes';
foreach (is_dir($themesRoot) ? (scandir($themesRoot) ?: []) : [] as $theme) {
    if ($theme === '.' || $theme === '..' || !is_dir($themesRoot . '/' . $theme . '/views')) continue;
    foreach (scandir($themesRoot . '/' . $theme . '/views') ?: [] as $file) {
        if (!str_ends_with($file, '.php')) continue;
        $view = substr($file, 0, -4);
        $relative = 'public/themes/' . $theme . '/views/' . $file;
        $source = (string) file_get_contents($root . '/' . $relative);
        $themeViews++;

        $local = [];
        preg_match_all('/\$([A-Za-z_]\w*)\s*=(?!=)/', $source, $m);
        $local = array_merge($local, $m[1]);
        preg_match_all('/\bas\s+\$([A-Za-z_]\w*)(?:\s*=>\s*\$([A-Za-z_]\w*))?/', $source, $m);
        $local = array_merge($local, $m[1], array_filter($m[2]));
        preg_match_all('/\bfn\s*\([^)]*?\$([A-Za-z_]\w*)/', $source, $m);
        $local = array_merge($local, $m[1]);
        preg_match_all('/\bfunction\s*\([^)]*?\$([A-Za-z_]\w*)/', $source, $m);
        $local = array_merge($local, $m[1]);

        $allowed = array_merge($viewContract[$view] ?? [], $local, $superglobals);
        preg_match_all('/\$([A-Za-z_]\w*)/', $source, $m);
        foreach (array_unique($m[1]) as $name) {
            if (in_array($name, $allowed, true)) continue;
            $report('error', 'view-contract', '$' . $name . ' is never passed to the ' . $view . ' view; it silently reads as null.', $relative);
        }

        foreach ($requiredInView[$view] ?? [] as $needle => $consequence) {
            if (!str_contains($source, $needle)) {
                $report('error', 'view-contract', 'missing ' . $needle . ' - ' . $consequence . '.', $relative);
            }
        }

        if (preg_match('/<\?=\s*\$\w+\[.body.\]\s*\?>/', $source) === 1) {
            $report('error', 'escaping', 'a raw body is echoed; user content is Markdown and must go through quaeriva_rich_text() or be escaped.', $relative);
        }
        if (preg_match('~["\']/admin\b~', $source) === 1) {
            $report('error', 'portability', 'hard-codes /admin; the administrator path is configurable.', $relative);
        }
        if (preg_match('~href="/(?!/)[a-z]~', $source) === 1) {
            $report('error', 'portability', 'uses an absolute internal link; build links with the URL helpers.', $relative);
        }
    }
}
$ran('Theme view contracts', $themeViews);

// ----------------------------------------------------- 3b. theme manifests
// A theme whose manifest is rejected does not error: the site renders the
// default theme, which reads as "my theme did nothing" rather than as a fault.
$manifests = 0;
foreach (is_dir($themesRoot) ? (scandir($themesRoot) ?: []) : [] as $theme) {
    if ($theme === '.' || $theme === '..' || !is_dir($themesRoot . '/' . $theme)) continue;
    $manifestPath = $themesRoot . '/' . $theme . '/theme.json';
    $relative = 'public/themes/' . $theme . '/theme.json';
    if (!is_file($manifestPath)) {
        $report('error', 'theme-manifest', 'theme "' . $theme . '" has no theme.json, so none of its views load.', $relative);
        continue;
    }
    $manifests++;

    $raw = json_decode((string) file_get_contents($manifestPath), true);
    if (!is_array($raw)) {
        $report('error', 'theme-manifest', 'the manifest is not valid JSON, so the theme is ignored.', $relative);
        continue;
    }

    $declared = is_array($raw['directions'] ?? null) ? $raw['directions'] : [];
    $valid = array_values(array_filter(array_map(
        static fn ($d): string => strtolower(trim((string) $d)),
        $declared
    ), static fn (string $d): bool => in_array($d, ['rtl', 'ltr'], true)));
    if ($valid === []) {
        $report('error', 'theme-manifest', 'directions must be an array containing "rtl" and/or "ltr"; otherwise the whole manifest is discarded.', $relative);
    } elseif (!in_array('rtl', $valid, true)) {
        $report('warning', 'theme-manifest', 'the theme does not declare "rtl", so every Arabic page renders core views instead.', $relative);
    }

    $minCore = trim((string) ($raw['min_core'] ?? ''));
    if ($minCore !== '' && version_compare(app_version(), $minCore, '<')) {
        $report('error', 'theme-manifest', 'min_core "' . $minCore . '" is newer than this Core (' . app_version() . '), so the theme silently falls back to default. Note that a release candidate ranks below its final release.', $relative);
    }

    foreach (['css', 'js'] as $kind) {
        $entries = is_array($raw['assets'][$kind] ?? null) ? $raw['assets'][$kind] : [];
        foreach ($entries as $entry) {
            $asset = trim(str_replace('\\', '/', (string) $entry), '/');
            if ($asset === '' || str_contains($asset, '..') || preg_match('/^[A-Za-z0-9_\/-]+\.(?:css|js)$/', $asset) !== 1) {
                $report('error', 'theme-manifest', 'asset "' . $asset . '" fails validation and is dropped silently.', $relative);
                continue;
            }
            if (!is_file($themesRoot . '/' . $theme . '/' . $asset)) {
                $report('error', 'theme-manifest', 'asset "' . $asset . '" is declared but missing on disk, so it never loads.', $relative);
            }
        }
    }

    // A stylesheet that exists but is not declared is the usual reason a new
    // theme renders unstyled.
    foreach (glob($themesRoot . '/' . $theme . '/assets/css/*.css') ?: [] as $stylesheet) {
        $relativeAsset = 'assets/css/' . basename($stylesheet);
        $declaredCss = array_map(static fn ($v): string => trim((string) $v, '/'), (array) ($raw['assets']['css'] ?? []));
        if (!in_array($relativeAsset, $declaredCss, true)) {
            $report('warning', 'theme-manifest', $relativeAsset . ' exists but is not declared in assets.css, so it is never loaded.', $relative);
        }
    }
}
$ran('Theme manifests', $manifests);

// ---------------------------------------------------------- 4. escaping and SQL
$viewFiles = array_values(array_filter($phpFiles, static fn (string $p): bool =>
    str_starts_with($p, 'app/views/') || str_contains($p, '/themes/')));
foreach ($phpFiles as $relative) {
    $source = (string) file_get_contents($root . '/' . $relative);
    if (str_starts_with($relative, 'tests/')) continue;

    // String-built SQL is the one thing prepared statements cannot save you from.
    if (preg_match('/->(?:query|exec)\(\s*["\'][^"\']*\$(?!limit|pageSize|offset|extra|charset)/', $source, $m, PREG_OFFSET_CAPTURE) === 1) {
        $line = substr_count(substr($source, 0, $m[0][1]), "\n") + 1;
        $report('error', 'sql', 'a query interpolates a variable; use a prepared statement.', $relative, $line);
    }
}
$ran('SQL interpolation', count($phpFiles));

// ------------------------------------------------------------ 5. logical CSS
$cssFiles = array_merge($collect('public/assets/css', 'css'), $collect('public/themes', 'css'));
$cssFiles = array_values(array_unique($cssFiles));
$physical = '~(?:^|[;{}\s])(?:margin|padding|border)-(?:left|right)\s*:'
    . '|(?:^|[;{}\s])(?:left|right)\s*:'
    . '|text-align\s*:\s*(?:left|right)'
    . '|float\s*:\s*(?:left|right)'
    . '|border-(?:top|bottom)-(?:left|right)-radius~i';
foreach ($cssFiles as $relative) {
    $css = (string) file_get_contents($root . '/' . $relative);
    if (preg_match_all($physical, $css, $m) > 0) {
        $report('error', 'rtl', 'uses physical CSS properties (' . implode(', ', array_unique(array_map('trim', $m[0]))) . '); use logical properties.', $relative);
    }
}
$ran('Direction-neutral CSS', count($cssFiles));

// ------------------------------------------------------- 6. translation coverage
$languages = ['ar', 'en'];
$catalogs = [];
foreach ($languages as $locale) {
    $path = $root . '/resources/lang/' . $locale . '.php';
    $catalogs[$locale] = is_file($path) ? (array) require $path : [];
}
$flatten = static function (array $data, string $prefix = '') use (&$flatten): array {
    $flat = [];
    foreach ($data as $key => $value) {
        $composed = $prefix === '' ? (string) $key : $prefix . '.' . $key;
        if (is_array($value)) {
            $flat = array_merge($flat, $flatten($value, $composed));
            continue;
        }
        $flat[$composed] = (string) $value;
    }
    return $flat;
};
$flat = [];
foreach ($languages as $locale) {
    $flat[$locale] = $flatten($catalogs[$locale]);
}

$translationRefs = 0;
foreach ($phpFiles as $relative) {
    if (str_starts_with($relative, 'tests/')) continue;
    $source = (string) file_get_contents($root . '/' . $relative);
    // Only translator lookups. The settings repository shares the ->get()
    // shape, and its keys are settings, not translation strings.
    $keyPattern = "'([a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+)'";
    preg_match_all('/quaeriva_t\(\s*\$\w+\s*,\s*' . $keyPattern . '/i', $source, $m);
    preg_match_all('/\$translator->get\(\s*' . $keyPattern . '/i', $source, $m2);
    $found = array_merge($m[1] ?? [], $m2[1] ?? []);
    foreach (array_unique($found) as $key) {
        // A key ending in an underscore is a prefix being concatenated with a
        // variable suffix, not a key that exists on its own.
        if (str_ends_with($key, '_')) {
            continue;
        }
        $translationRefs++;
        foreach ($languages as $locale) {
            if (!array_key_exists($key, $flat[$locale])) {
                $report('error', 'i18n', $locale . ' is missing the key ' . $key . '; it renders raw to the user.', $relative);
            }
        }
    }
}
// `fallback.*` exists to prove the Arabic-only fallback path works, so its
// absence from the English catalogue is the point, not a gap.
$missingParity = array_filter(
    array_diff(array_keys($flat['ar']), array_keys($flat['en'])),
    static fn (string $key): bool => !str_starts_with($key, 'fallback.')
);
foreach ($missingParity as $key) {
    $report('warning', 'i18n', 'key ' . $key . ' exists in ar but not en.', 'resources/lang/en.php');
}
$ran('Translation keys', $translationRefs);

// ------------------------------------------------------------- 7. contrast
$darkSurfaces = [
    'default' => ['bg' => '#0F1117', 'surface' => '#171A21'],
    'cortex' => ['bg' => '#121212', 'surface' => '#171A21'],
    'hikaya' => ['bg' => '#191614', 'surface' => '#221E1B'],
    'nexus' => ['bg' => '#2D2D2D', 'surface' => '#171A21'],
    'orbit' => ['bg' => '#030303', 'surface' => '#1A1A1B'],
    'rawy' => ['bg' => '#0F1117', 'surface' => '#171A21'],
    'zenith' => ['bg' => '#0B0F19', 'surface' => '#111827'],
];
$mainCss = (string) @file_get_contents($root . '/public/assets/css/quaeriva.css');
$palettes = 0;
foreach ($darkSurfaces as $theme => $pair) {
    if (!is_dir($themesRoot . '/' . $theme)) continue;
    $palettes++;

    $package = $themesRoot . '/' . $theme . '/' . $theme . '.json';
    if (is_file($package)) {
        $colors = json_decode((string) file_get_contents($package), true)['tokens']['colors'] ?? [];
        foreach (['surface', 'bg'] as $key) {
            if (!isset($colors['text'], $colors[$key])) continue;
            $ratio = ThemeTokenService::contrastRatio($colors['text'], $colors[$key]);
            if ($ratio < 4.5) {
                $report('error', 'contrast', sprintf('light text on %s is %.2f:1 (needs 4.5).', $key, $ratio), 'public/themes/' . $theme . '/' . $theme . '.json');
            }
        }
    }

    foreach ([$pair['surface'], $pair['bg']] as $background) {
        $ratio = ThemeTokenService::contrastRatio('#F0F2F5', $background);
        if ($ratio < 4.5) {
            $report('error', 'contrast', sprintf('dark text on %s is %.2f:1 (needs 4.5).', $background, $ratio), 'public/assets/css/quaeriva.css');
        }
    }

    $selector = $theme === 'default'
        ? 'html[data-theme-mode="dark"]'
        : 'html[data-theme-mode="dark"] body.theme-' . $theme;
    $block = '';
    $offset = 0;
    while (($position = strpos($mainCss, $selector, $offset)) !== false) {
        $offset = $position + strlen($selector);
        $end = strpos($mainCss, '}', $position);
        if ($end === false) break;
        $candidate = substr($mainCss, $position, $end - $position);
        if (str_contains($candidate, '--color-primary')) { $block = $candidate; break; }
    }
    if ($block === '') {
        $report('error', 'contrast', $theme . ' has no dark-mode accent override; accents keep their light values and fall below 4.5:1.', 'public/assets/css/quaeriva.css');
        continue;
    }
    foreach (['--color-primary', '--color-secondary'] as $token) {
        if (preg_match('/' . preg_quote($token, '/') . '\s*:\s*(#[0-9A-Fa-f]{6})/', $block, $m) !== 1) continue;
        $ratio = ThemeTokenService::contrastRatio($m[1], $pair['surface']);
        if ($ratio < 4.5) {
            $report('error', 'contrast', sprintf('%s dark %s (%s) is %.2f:1 (needs 4.5).', $theme, $token, $m[1], $ratio), 'public/assets/css/quaeriva.css');
        }
    }
}
$ran('Contrast (light and dark)', $palettes);

// ------------------------------------------------------- 8. documentation links
$docFiles = $collect('docs', 'md');
$docFiles = array_values(array_filter($docFiles, static fn (string $p): bool =>
    !str_starts_with($p, 'docs/archive/') && !str_starts_with($p, 'docs/superpowers/')));
foreach (array_merge($docFiles, ['README.md']) as $relative) {
    $path = $root . '/' . $relative;
    if (!is_file($path)) continue;
    $body = (string) file_get_contents($path);
    if (str_starts_with($relative, 'docs/') && strlen($body) < 900) {
        $report('warning', 'docs', 'is a stub (' . strlen($body) . ' bytes).', $relative);
    }
    preg_match_all('/\]\(([^)]+)\)/', $body, $m);
    foreach ($m[1] as $link) {
        if (preg_match('~^(https?:|#|mailto:)~i', $link) === 1) continue;
        $target = explode('#', $link)[0];
        if ($target === '') continue;
        if (!file_exists($root . '/' . dirname($relative) . '/' . $target)) {
            $report('error', 'docs', 'links to a missing target: ' . $link, $relative);
        }
    }
}
$ran('Documentation links', count($docFiles) + 1);

// ------------------------------------------------------ 9. live render (needs DB)
if (!$options['quick']) {
    try {
        $pdo = App\Database\Connection::connect(app_config('database'));
        $settings = new App\Settings\SettingsRepository($pdo);
        $originalTheme = (string) $settings->get('theme.active', 'default');

        require_once $root . '/public/index.php';

        $questionSlug = (string) $pdo->query("SELECT slug FROM questions WHERE status='published' AND slug<>'' LIMIT 1")->fetchColumn();
        $topicSlug = (string) $pdo->query("SELECT slug FROM topics WHERE status='published' AND slug<>'' LIMIT 1")->fetchColumn();
        $categorySlug = (string) $pdo->query("SELECT slug FROM categories WHERE slug<>'' LIMIT 1")->fetchColumn();

        $pages = ['home' => '/', 'topics' => '/d', 'search' => '/search?q=a'];
        if ($questionSlug !== '') $pages['question'] = '/q/' . rawurlencode($questionSlug);
        if ($topicSlug !== '') $pages['topic'] = '/d/' . rawurlencode($topicSlug);
        if ($categorySlug !== '') $pages['category'] = '/c/' . rawurlencode($categorySlug);

        $renders = 0;
        foreach (scandir($themesRoot) ?: [] as $theme) {
            if ($theme === '.' || $theme === '..' || !is_dir($themesRoot . '/' . $theme)) continue;
            $settings->put('theme.active', $theme, 'string');
            foreach (['ar', 'en'] as $locale) {
                foreach ($pages as $name => $uri) {
                    $_COOKIE['quaeriva_locale'] = $locale;
                    $parts = parse_url($uri);
                    $query = [];
                    if (isset($parts['query'])) parse_str($parts['query'], $query);
                    $_GET = $query;
                    $renders++;

                    $warnings = [];
                    set_error_handler(static function (int $number, string $text) use (&$warnings): bool {
                        $warnings[] = $text;
                        return true;
                    });
                    try {
                        $response = quaeriva_dispatch(['REQUEST_METHOD' => 'GET', 'REQUEST_URI' => $uri, 'HTTP_ACCEPT_LANGUAGE' => $locale], $query, []);
                    } catch (Throwable $throwable) {
                        restore_error_handler();
                        $report('error', 'render', $theme . '/' . $locale . '/' . $name . ' fatals: ' . $throwable->getMessage());
                        continue;
                    }
                    restore_error_handler();

                    foreach (array_unique($warnings) as $warning) {
                        $report('error', 'render', $theme . '/' . $locale . '/' . $name . ' emits a PHP warning: ' . $warning);
                    }
                    $status = (int) ($response['status'] ?? 0);
                    if ($status !== 200) {
                        $report('warning', 'render', $theme . '/' . $locale . '/' . $name . ' answered ' . $status . '.');
                    }
                    if (preg_match('/^\s*(?:PHP )?(?:Warning|Fatal error|Parse error|Notice):/mi', (string) ($response['body'] ?? '')) === 1) {
                        $report('error', 'render', $theme . '/' . $locale . '/' . $name . ' rendered a PHP error into the page.');
                    }
                }
            }
        }
        $settings->put('theme.active', $originalTheme, 'string');
        $ran('Live renders', $renders);
    } catch (Throwable $throwable) {
        $report('warning', 'environment', 'live render checks skipped: ' . $throwable->getMessage());
    }
} else {
    $report('warning', 'environment', 'live render checks skipped (--quick).');
}

// ------------------------------------------------------------------- output
$errors = array_values(array_filter($findings, static fn (array $f): bool => $f['level'] === 'error'));
$warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['level'] === 'warning'));

$byArea = [];
foreach ($findings as $finding) {
    $byArea[$finding['area']][] = $finding;
}
ksort($byArea);

$generatedAt = date('Y-m-d H:i:s');

if ($options['format'] === 'json') {
    $text = json_encode([
        'generated_at' => $generatedAt,
        'version' => app_version(),
        'checks' => $checksRun,
        'errors' => count($errors),
        'warnings' => count($warnings),
        'findings' => $findings,
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
} else {
    $markdown = $options['format'] === 'md';
    $lines = [];
    $lines[] = ($markdown ? '# ' : '') . 'Quaeriva code inspection';
    $lines[] = '';
    $lines[] = 'Version: ' . app_version() . '   Generated: ' . $generatedAt;
    $lines[] = '';
    $lines[] = ($markdown ? '## ' : '') . 'Checks run';
    $lines[] = '';
    if ($markdown) {
        $lines[] = '| Check | Scanned |';
        $lines[] = '|---|---|';
    }
    foreach ($checksRun as $check) {
        $lines[] = $markdown
            ? '| ' . $check['name'] . ' | ' . $check['scanned'] . ' |'
            : sprintf('  %-28s %d', $check['name'], $check['scanned']);
    }
    $lines[] = '';
    $lines[] = ($markdown ? '## ' : '') . sprintf('Result: %d error(s), %d warning(s)', count($errors), count($warnings));
    $lines[] = '';

    if ($findings === []) {
        $lines[] = 'No problems found.';
    }
    foreach ($byArea as $area => $items) {
        $lines[] = ($markdown ? '### ' : '') . strtoupper($area) . ' (' . count($items) . ')';
        $lines[] = '';
        foreach ($items as $item) {
            $location = $item['file'] !== '' ? $item['file'] . ($item['line'] > 0 ? ':' . $item['line'] : '') : '';
            $prefix = strtoupper($item['level']);
            $lines[] = $markdown
                ? '- **' . $prefix . '** ' . ($location !== '' ? '`' . $location . '` — ' : '') . $item['message']
                : sprintf('  [%s] %s%s', $prefix, $location !== '' ? $location . ': ' : '', $item['message']);
        }
        $lines[] = '';
    }
    $text = implode("\n", $lines) . "\n";
}

echo $text;

if ($options['report'] !== '') {
    $target = $options['report'];
    if (!preg_match('~^(?:[A-Za-z]:[\\\\/]|/)~', $target)) {
        $target = $root . DIRECTORY_SEPARATOR . $target;
    }
    $directory = dirname($target);
    if (!is_dir($directory)) {
        @mkdir($directory, 0755, true);
    }
    file_put_contents($target, $text);
    fwrite(STDERR, 'Report written to ' . $target . PHP_EOL);
}

exit($errors !== [] ? 1 : ($warnings !== [] ? 2 : 0));
