<?php

namespace App\Services;

use App\Support\LayoutMigrator;
use App\Support\LayoutResolver;

/**
 * Validates project-template kit JSON in `resources/templates/projects/`.
 *
 * Kits ship by dropping `{key}/{key}.json` (and optionally `{key}/assets/*`)
 * into that directory, then landing via `Templates::projectForKey()`. A
 * broken kit manifests at project-creation time — this linter is the
 * earlier gate.
 *
 * Kit shape (single form factor per project):
 *
 *   { key, name, description?, form_factor,
 *     screens: [{ name, layout: Layout, preview_path? }] }
 *
 * Returns a structured report so both the artisan command and tests can
 * consume it. Issues are tagged `error` (must fix, blocks the ship) or
 * `warning` (suspicious, worth fixing).
 */
class KitLinter
{
    public const LAYER_TYPES = ['text', 'image', 'background', 'shape', 'frame', 'callout'];

    /**
     * Brand image-slot naming convention (enforced so AI fill routes brand assets
     * deterministically instead of guessing):
     *   • BRAND_LIKE — names that clearly intend a brand slot; these MUST be canonical.
     *   • BRAND_CANONICAL — the allowed canonical tokens ("App Icon" / "Wordmark").
     * An image whose name is brand-like but not canonical fails lint.
     */
    private const BRAND_LIKE_RE = '/wordmark|\blogo\b|app[\s_-]?icon|brand\s?mark/i';

    private const BRAND_CANONICAL_RE = '/app[\s_-]?icon|wordmark/i';

    public const SUPPORTED_FORM_FACTORS = [
        'iphone_portrait',
        'ipad_portrait',
        'android_phone_portrait',
        'android_10_tablet_portrait',
        'iphone_landscape',
        'ipad_landscape',
        'android_phone_landscape',
        'android_10_tablet_landscape',
        'playstore_feature_graphic',
    ];

    /**
     * @return array{
     *   kits: array<int, array{
     *     key: string,
     *     path: string,
     *     issues: array<int, array{level: string, where: string, message: string}>
     *   }>,
     *   missing: array<int, string>,
     *   hasErrors: bool
     * }
     */
    public function lint(?string $onlyKey = null, ?string $baseDir = null): array
    {
        $dir = $baseDir ?? resource_path('templates/projects');
        $kits = [];
        $missing = [];
        $hasErrors = false;

        if (! is_dir($dir)) {
            return ['kits' => [], 'missing' => [], 'hasErrors' => false];
        }

        $kitDirs = (array) glob($dir.'/*', GLOB_ONLYDIR);
        $foundKey = false;

        foreach ($kitDirs as $kitDir) {
            $slug = basename($kitDir);
            if ($onlyKey !== null && $slug !== $onlyKey) {
                continue;
            }
            $foundKey = $foundKey || $slug === $onlyKey;

            $file = $kitDir.'/'.$slug.'.json';
            if (! is_file($file)) {
                $kits[] = [
                    'key' => $slug,
                    'path' => $kitDir,
                    'issues' => [$this->err('file', "Missing {$slug}.json — kit dir must contain {slug}.json.")],
                ];
                $hasErrors = true;

                continue;
            }

            $issues = $this->lintFile($file, $kitDir);
            if ($this->anyError($issues)) {
                $hasErrors = true;
            }

            $kits[] = ['key' => $slug, 'path' => $file, 'issues' => $issues];
        }

        if ($onlyKey !== null && ! $foundKey) {
            $missing[] = $onlyKey;
        }

        return ['kits' => $kits, 'missing' => $missing, 'hasErrors' => $hasErrors];
    }

    /** @return array<int, array{level: string, where: string, message: string}> */
    private function lintFile(string $file, string $kitDir): array
    {
        $issues = [];
        $raw = file_get_contents($file);
        if ($raw === false) {
            return [$this->err('file', 'Could not read file.')];
        }

        $data = json_decode($raw, true);
        if (! is_array($data)) {
            return [$this->err('file', 'JSON decode failed: '.json_last_error_msg())];
        }

        $slug = basename($kitDir);

        if (! isset($data['key']) || ! is_string($data['key'])) {
            $issues[] = $this->err('key', 'Missing or non-string `key`.');
        } elseif (! preg_match('/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/', $data['key'])) {
            $issues[] = $this->err('key', "Invalid key slug: \"{$data['key']}\".");
        } elseif ($data['key'] !== $slug) {
            $issues[] = $this->err(
                'key',
                "Key \"{$data['key']}\" doesn't match kit directory name \"{$slug}\".",
            );
        }

        if (! isset($data['name']) || ! is_string($data['name']) || trim($data['name']) === '') {
            $issues[] = $this->err('name', 'Missing or empty `name`.');
        }

        if (isset($data['description']) && ! is_string($data['description'])) {
            $issues[] = $this->err('description', '`description` must be a string.');
        }

        if (! isset($data['form_factor']) || ! is_string($data['form_factor'])) {
            $issues[] = $this->err('form_factor', 'Missing or non-string `form_factor`.');
        } elseif (! in_array($data['form_factor'], self::SUPPORTED_FORM_FACTORS, true)) {
            $issues[] = $this->err('form_factor', "Unknown form factor \"{$data['form_factor']}\".");
        }

        // Multi-device v4 kits also emit `active_form_factors` — validate
        // it carries known factors and includes the primary `form_factor`.
        $activeFactors = [];
        if (array_key_exists('active_form_factors', $data)) {
            if (! is_array($data['active_form_factors']) || $data['active_form_factors'] === []) {
                $issues[] = $this->err(
                    'active_form_factors',
                    '`active_form_factors` must be a non-empty array.',
                );
            } else {
                foreach ($data['active_form_factors'] as $k => $factor) {
                    if (! is_string($factor) || ! in_array($factor, self::SUPPORTED_FORM_FACTORS, true)) {
                        $issues[] = $this->err(
                            "active_form_factors[{$k}]",
                            'Unknown form factor "'.(is_string($factor) ? $factor : '(non-string)').'".',
                        );

                        continue;
                    }
                    $activeFactors[] = $factor;
                }
                if (
                    isset($data['form_factor'])
                    && is_string($data['form_factor'])
                    && $activeFactors !== []
                    && ! in_array($data['form_factor'], $activeFactors, true)
                ) {
                    $issues[] = $this->err(
                        'active_form_factors',
                        '`active_form_factors` must include the primary `form_factor`.',
                    );
                }
            }
        }

        if (! isset($data['screens']) || ! is_array($data['screens']) || $data['screens'] === []) {
            $issues[] = $this->err('screens', 'Missing or empty `screens` array.');

            return $issues;
        }

        $assetsDir = $kitDir.DIRECTORY_SEPARATOR.'assets';
        $availableAssets = $this->listAssets($assetsDir);
        $referencedAssets = [];

        foreach ($data['screens'] as $i => $screen) {
            $where = "screens[{$i}]";
            if (! is_array($screen)) {
                $issues[] = $this->err($where, 'Screen is not an object.');

                continue;
            }
            if (! isset($screen['name']) || ! is_string($screen['name']) || trim($screen['name']) === '') {
                $issues[] = $this->err("{$where}.name", 'Missing or empty screen name.');
            }

            // Reject legacy envelope / per-factor record shapes outright —
            // multi-factor architecture is gone, kit JSON must carry a single
            // `layout` per screen.
            if (isset($screen['master']) || isset($screen['overrides'])) {
                $issues[] = $this->err(
                    "{$where}",
                    'Legacy v3 envelope shape (`master`/`overrides`) is no longer supported. Re-export the kit from the editor to produce `screens[].layout`.',
                );

                continue;
            }
            if (isset($screen['layouts'])) {
                $issues[] = $this->err(
                    "{$where}",
                    'Legacy per-factor `layouts` record is no longer supported. Re-export the kit from the editor to produce `screens[].layout`.',
                );

                continue;
            }

            if (! isset($screen['layout']) || ! is_array($screen['layout'])) {
                $issues[] = $this->err("{$where}.layout", 'Missing or non-object `layout`.');

                continue;
            }

            $layout = $screen['layout'];
            if (($layout['version'] ?? null) === 1) {
                $issues[] = $this->warn(
                    "{$where}.layout",
                    'Layout declares `version: 1`. Auto-migrated to v'
                        .LayoutMigrator::LATEST_VERSION
                        .' for linting; re-export this kit from the editor to persist v'
                        .LayoutMigrator::LATEST_VERSION.' on disk.',
                );
            }

            // v4 multi-device envelope: validate canvas-per-factor + project
            // each factor down to v3 and run the v3 lint path. This catches
            // per-factor frame.devicePresetId + perFactor.{factor}.screen.*
            // asset references that the single-canvas v3 linter doesn't see.
            if (($layout['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION) {
                $issues = array_merge(
                    $issues,
                    $this->lintV4Layout(
                        $layout,
                        "{$where}.layout",
                        $availableAssets,
                        $referencedAssets,
                        $activeFactors,
                        is_string($data['form_factor'] ?? null) ? $data['form_factor'] : null,
                    ),
                );

                continue;
            }

            $migrated = LayoutMigrator::migrate($layout);

            $issues = array_merge(
                $issues,
                $this->lintLayout($migrated, "{$where}.layout", $availableAssets, $referencedAssets),
            );

            $issues = array_merge(
                $issues,
                $this->lintCalloutBindings($migrated, "{$where}.layout"),
            );
        }

        foreach (array_diff(array_keys($availableAssets), array_keys($referencedAssets)) as $orphan) {
            $issues[] = $this->warn("assets/{$orphan}", 'Asset is not referenced by any layer.');
        }

        return $issues;
    }

    /**
     * Callout binding validation — every CalloutLayer's `sourceLayerId` must
     * resolve to a non-callout layer present in the same Layout. Recursive
     * callouts (callout sourcing another callout) are rejected.
     *
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintCalloutBindings(array $layout, string $whereBase): array
    {
        $issues = [];
        $layers = is_array($layout['layers'] ?? null) ? $layout['layers'] : [];

        $idToType = [];
        foreach ($layers as $layer) {
            if (! is_array($layer)) {
                continue;
            }
            $id = $layer['id'] ?? null;
            $type = $layer['type'] ?? null;
            if (is_string($id) && $id !== '' && is_string($type)) {
                $idToType[$id] = $type;
            }
        }

        foreach ($layers as $j => $layer) {
            if (! is_array($layer) || ($layer['type'] ?? null) !== 'callout') {
                continue;
            }
            $where = "{$whereBase}.layers[{$j}]";
            $idLabel = is_string($layer['id'] ?? null) ? $layer['id'] : '(no-id)';
            $sourceId = $layer['sourceLayerId'] ?? null;

            if (! is_string($sourceId) || $sourceId === '') {
                $issues[] = $this->err(
                    $where,
                    "Callout layer {$idLabel} is missing `sourceLayerId` — every callout must bind to a source layer.",
                );

                continue;
            }
            if (! isset($idToType[$sourceId])) {
                $issues[] = $this->err(
                    $where,
                    "Callout layer {$idLabel} references missing source '{$sourceId}'.",
                );

                continue;
            }
            if ($idToType[$sourceId] === 'callout') {
                $issues[] = $this->err(
                    $where,
                    "Callout layer {$idLabel} sources from another callout ({$sourceId}) — recursive callouts are not supported.",
                );
            }
        }

        return $issues;
    }

    /**
     * v4 multi-device envelope: validates the envelope shape (canvas keyed
     * by factor; layers carrying optional perFactor entries) and then
     * projects each factor down to v3 via {@see LayoutResolver::resolveLayoutForFactorV4}
     * so the existing v3 lint path covers per-factor frame.devicePresetId,
     * perFactor.{factor}.screen.storagePath asset bundling, etc.
     *
     * @param  array<string, bool>  $available
     * @param  array<string, bool>  $referenced
     * @param  array<int, string>   $activeFactors   Factors declared at the kit's top-level `active_form_factors`.
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintV4Layout(
        array $layout,
        string $where,
        array $available,
        array &$referenced,
        array $activeFactors,
        ?string $primaryFactor,
    ): array {
        $issues = [];

        $canvasByFactor = $layout['canvas'] ?? null;
        if (! is_array($canvasByFactor) || $canvasByFactor === []) {
            $issues[] = $this->err(
                "{$where}.canvas",
                'v4 layout requires `canvas` keyed by form factor.',
            );

            return $issues;
        }

        if (! isset($layout['layers']) || ! is_array($layout['layers'])) {
            $issues[] = $this->err("{$where}.layers", 'Layers is not an array.');

            return $issues;
        }

        // Canvas factor whitelist + dims sanity. We don't require every
        // active factor to have a canvas entry — the controller side
        // scaffolds missing factors at apply time using project defaults.
        foreach ($canvasByFactor as $factor => $dims) {
            if (! is_string($factor) || ! in_array($factor, self::SUPPORTED_FORM_FACTORS, true)) {
                $issues[] = $this->err(
                    "{$where}.canvas",
                    'Unknown canvas factor "'.(is_string($factor) ? $factor : '(non-string)').'".',
                );

                continue;
            }
            if (
                ! is_array($dims)
                || ! is_numeric($dims['width'] ?? null)
                || ! is_numeric($dims['height'] ?? null)
            ) {
                $issues[] = $this->err(
                    "{$where}.canvas.{$factor}",
                    'Canvas missing numeric `width`/`height`.',
                );
            }
        }

        // Project per-factor to v3 and reuse the existing v3 lint paths.
        // Factors we lint: every canvas key. Falls back to the active set
        // or the primary factor if the kit somehow ships layers without
        // canvas data (defensive — TemplateExporter always emits canvas).
        $factorsToLint = array_values(array_keys($canvasByFactor));
        if ($factorsToLint === []) {
            $factorsToLint = $activeFactors;
        }
        if ($factorsToLint === [] && $primaryFactor !== null) {
            $factorsToLint = [$primaryFactor];
        }

        foreach ($factorsToLint as $factor) {
            $projected = LayoutResolver::resolveLayoutForFactorV4($layout, $factor);
            $issues = array_merge(
                $issues,
                $this->lintLayout(
                    $projected,
                    "{$where}[{$factor}]",
                    $available,
                    $referenced,
                ),
            );
            $issues = array_merge(
                $issues,
                $this->lintCalloutBindings($projected, "{$where}[{$factor}]"),
            );
        }

        return $issues;
    }

    /**
     * @param  array<string, bool>  $available
     * @param  array<string, bool>  $referenced
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintLayout(
        mixed $layout,
        string $where,
        array $available,
        array &$referenced,
    ): array {
        $issues = [];
        if (! is_array($layout)) {
            return [$this->err($where, 'Layout is not an object.')];
        }

        $canvas = $layout['canvas'] ?? null;
        if (! is_array($canvas)
            || ! is_numeric($canvas['width'] ?? null)
            || ! is_numeric($canvas['height'] ?? null)
        ) {
            $issues[] = $this->err("{$where}.canvas", 'Canvas missing numeric `width`/`height`.');
        }

        $layers = $layout['layers'] ?? null;
        if (! is_array($layers)) {
            $issues[] = $this->err("{$where}.layers", 'Layers is not an array.');

            return $issues;
        }

        foreach ($layers as $j => $layer) {
            $issues = array_merge(
                $issues,
                $this->lintLayer($layer, "{$where}.layers[{$j}]", $available, $referenced),
            );
        }

        return $issues;
    }

    /**
     * Layer ids are required — they're used by clipShapeId / parentId
     * references and by the apply-time re-ID rotation.
     *
     * @param  array<string, bool>  $available
     * @param  array<string, bool>  $referenced
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintLayer(
        mixed $layer,
        string $where,
        array $available,
        array &$referenced,
    ): array {
        $issues = [];
        if (! is_array($layer)) {
            return [$this->err($where, 'Layer is not an object.')];
        }

        if (! isset($layer['id']) || ! is_string($layer['id']) || $layer['id'] === '') {
            $issues[] = $this->err(
                "{$where}.id",
                'Layer is missing a non-empty string `id`.',
            );
        }

        $type = $layer['type'] ?? null;
        if (! is_string($type)) {
            $issues[] = $this->err("{$where}.type", 'Missing or non-string `type`.');

            return $issues;
        }
        if (! in_array($type, self::LAYER_TYPES, true)) {
            $issues[] = $this->err("{$where}.type", "Unknown layer type \"{$type}\".");
        }

        if ($type === 'image' && isset($layer['storagePath'])) {
            $issues = array_merge(
                $issues,
                $this->lintStoragePath(
                    $layer['storagePath'],
                    "{$where}.storagePath",
                    $available,
                    $referenced,
                ),
            );
        }

        // Strict brand-slot naming: a brand-like image name must be canonical so
        // AI fill can route it (app icon vs wordmark) without guessing.
        if ($type === 'image') {
            $name = is_string($layer['name'] ?? null) ? $layer['name'] : '';
            if (preg_match(self::BRAND_LIKE_RE, $name) && ! preg_match(self::BRAND_CANONICAL_RE, $name)) {
                $issues[] = $this->err(
                    "{$where}.name",
                    "Brand image layer \"{$name}\" must use a canonical name so AI fill can route it: "
                    .'include "App Icon" (square app icon) or "Wordmark" (brand lettering). '
                    .'If it is a content image, remove the brand word from its name.',
                );
            }
        }

        if ($type === 'background' && isset($layer['image']['storagePath'])) {
            $issues = array_merge(
                $issues,
                $this->lintStoragePath(
                    $layer['image']['storagePath'],
                    "{$where}.image.storagePath",
                    $available,
                    $referenced,
                ),
            );
        }

        if ($type === 'frame') {
            if (! is_numeric($layer['devicePresetId'] ?? null)) {
                $issues[] = $this->err("{$where}.devicePresetId", 'Frame layer missing numeric `devicePresetId`.');
            }
            $issues = array_merge(
                $issues,
                $this->lintFrameTransform($layer, $where),
            );
            $issues = array_merge(
                $issues,
                $this->lintRealFrameFields($layer, $where),
            );
            if (isset($layer['screen']) && is_array($layer['screen'])) {
                $issues = array_merge(
                    $issues,
                    $this->lintFrameScreen(
                        $layer['screen'],
                        "{$where}.screen",
                        $available,
                        $referenced,
                    ),
                );
            }
        }

        return $issues;
    }

    /**
     * Validate the real-frame opt-in fields. `realFrameEnabled` must be
     * boolean when present; `realFrameColor` must be a kebab-case string
     * slug when present.
     *
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintRealFrameFields(array $layer, string $where): array
    {
        $issues = [];
        if (array_key_exists('realFrameEnabled', $layer)) {
            if (! is_bool($layer['realFrameEnabled'])) {
                $issues[] = $this->err(
                    "{$where}.realFrameEnabled",
                    '`realFrameEnabled` must be a boolean.',
                );
            }
        }
        if (array_key_exists('realFrameColor', $layer)) {
            $color = $layer['realFrameColor'];
            if (! is_string($color) || $color === '') {
                $issues[] = $this->err(
                    "{$where}.realFrameColor",
                    '`realFrameColor` must be a non-empty string slug.',
                );
            } elseif (! preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $color)) {
                $issues[] = $this->err(
                    "{$where}.realFrameColor",
                    '`realFrameColor` must be a kebab-case slug (lowercase letters, digits, hyphens) — e.g. "natural-titanium".',
                );
            }
        }
        if (
            array_key_exists('realFrameEnabled', $layer)
            && $layer['realFrameEnabled'] === true
            && ! array_key_exists('realFrameColor', $layer)
        ) {
            $issues[] = $this->warn(
                "{$where}.realFrameColor",
                'Frame opts into the photorealistic renderer (`realFrameEnabled: true`) but no `realFrameColor` is set — renderer will fall back to the device preset\'s first color option.',
            );
        }

        return $issues;
    }

    /**
     * Validate the FrameTransform mixin (flipX/Y boolean, skewX/Y/tiltX/Y in
     * the -45..45 degree range).
     *
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintFrameTransform(array $layer, string $where): array
    {
        $issues = [];
        foreach (['flipX', 'flipY'] as $key) {
            if (array_key_exists($key, $layer) && ! is_bool($layer[$key])) {
                $issues[] = $this->err("{$where}.{$key}", "`{$key}` must be a boolean.");
            }
        }
        foreach (['skewX', 'skewY', 'tiltX', 'tiltY'] as $key) {
            if (! array_key_exists($key, $layer)) {
                continue;
            }
            $val = $layer[$key];
            if (! is_numeric($val)) {
                $issues[] = $this->err("{$where}.{$key}", "`{$key}` must be numeric.");

                continue;
            }
            if ($val < -45 || $val > 45) {
                $issues[] = $this->err(
                    "{$where}.{$key}",
                    "`{$key}` out of range — must be between -45 and 45 degrees.",
                );
            }
        }

        return $issues;
    }

    /**
     * Validate `frame.screen`. `storagePath` is the only required field;
     * `fit`/`offsetX/Y`/`zoom`/`localePaths` are optional with bounded
     * ranges + type checks.
     *
     * @param  array<string, bool>  $available
     * @param  array<string, bool>  $referenced
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintFrameScreen(array $screen, string $where, array $available, array &$referenced): array
    {
        $issues = [];

        if (! isset($screen['storagePath'])) {
            $issues[] = $this->err("{$where}.storagePath", 'Frame screen missing `storagePath`.');
        } else {
            $issues = array_merge(
                $issues,
                $this->lintStoragePath(
                    $screen['storagePath'],
                    "{$where}.storagePath",
                    $available,
                    $referenced,
                ),
            );
        }

        if (isset($screen['localePaths']) && is_array($screen['localePaths'])) {
            foreach ($screen['localePaths'] as $locale => $path) {
                $issues = array_merge(
                    $issues,
                    $this->lintStoragePath(
                        $path,
                        "{$where}.localePaths.{$locale}",
                        $available,
                        $referenced,
                    ),
                );
            }
        }

        // v4-compat: kits + projects can ship `screen.factorPaths` —
        // per-factor native-resolution screenshots that swap in via the
        // renderer when the active screen flips factors. Walk them as
        // referenced so the orphan-asset warning doesn't fire for the
        // 7 non-iPhone variants of each kit's frame screenshots.
        if (isset($screen['factorPaths']) && is_array($screen['factorPaths'])) {
            foreach ($screen['factorPaths'] as $factor => $path) {
                if (! is_string($factor) || ! in_array($factor, self::SUPPORTED_FORM_FACTORS, true)) {
                    $issues[] = $this->err(
                        "{$where}.factorPaths",
                        'Unknown form factor "'.(is_string($factor) ? $factor : '(non-string)').'".',
                    );

                    continue;
                }
                $issues = array_merge(
                    $issues,
                    $this->lintStoragePath(
                        $path,
                        "{$where}.factorPaths.{$factor}",
                        $available,
                        $referenced,
                    ),
                );
            }
        }

        if (array_key_exists('fit', $screen)) {
            $fit = $screen['fit'];
            if ($fit !== 'cover' && $fit !== 'contain' && $fit !== 'fill') {
                $issues[] = $this->err(
                    "{$where}.fit",
                    '`fit` must be one of: cover, contain, fill.',
                );
            }
        }

        foreach (['offsetX', 'offsetY'] as $key) {
            if (! array_key_exists($key, $screen)) {
                continue;
            }
            $val = $screen[$key];
            if (! is_numeric($val)) {
                $issues[] = $this->err("{$where}.{$key}", "`{$key}` must be numeric.");

                continue;
            }
            if ($val < -1 || $val > 1) {
                $issues[] = $this->err(
                    "{$where}.{$key}",
                    "`{$key}` out of range — must be a fraction between -1 and 1.",
                );
            }
        }

        if (array_key_exists('zoom', $screen)) {
            $val = $screen['zoom'];
            if (! is_numeric($val)) {
                $issues[] = $this->err("{$where}.zoom", '`zoom` must be numeric.');
            } elseif ($val < 0 || $val > 10) {
                $issues[] = $this->err(
                    "{$where}.zoom",
                    '`zoom` out of range — must be between 0 and 10.',
                );
            }
        }

        return $issues;
    }

    /**
     * @param  array<string, bool>  $available
     * @param  array<string, bool>  $referenced
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function lintStoragePath(mixed $path, string $where, array $available, array &$referenced): array
    {
        if (! is_string($path) || $path === '') {
            return [$this->err($where, '`storagePath` must be a non-empty string.')];
        }
        if (preg_match('#^https?://#i', $path)) {
            return [];
        }
        if (! str_starts_with($path, 'assets/')) {
            return [$this->err(
                $where,
                "\"{$path}\" — kit storagePaths must be `assets/<file>` or an http(s) URL.",
            )];
        }

        $name = substr($path, strlen('assets/'));
        $referenced[$name] = true;
        if (! isset($available[$name])) {
            return [$this->err(
                $where,
                "\"{$path}\" has no matching file in the kit's `assets/` directory.",
            )];
        }

        return [];
    }

    /** @return array<string, bool> */
    private function listAssets(string $dir): array
    {
        if (! is_dir($dir)) {
            return [];
        }
        $out = [];
        foreach ((array) scandir($dir) as $entry) {
            if ($entry === '.' || $entry === '..' || $entry === false) {
                continue;
            }
            if (is_file($dir.DIRECTORY_SEPARATOR.$entry)) {
                $out[$entry] = true;
            }
        }

        return $out;
    }

    /** @param array<int, array{level: string, where: string, message: string}> $issues */
    private function anyError(array $issues): bool
    {
        foreach ($issues as $i) {
            if ($i['level'] === 'error') {
                return true;
            }
        }

        return false;
    }

    /** @return array{level: string, where: string, message: string} */
    private function err(string $where, string $message): array
    {
        return ['level' => 'error', 'where' => $where, 'message' => $message];
    }

    /** @return array{level: string, where: string, message: string} */
    private function warn(string $where, string $message): array
    {
        return ['level' => 'warning', 'where' => $where, 'message' => $message];
    }
}
