<?php

namespace App\Services;

use App\Http\Controllers\ScreenController;
use App\Models\Project;
use App\Models\Screen;
use App\Services\MediaStorage;
use App\Support\DevicePresetFamily;
use App\Support\LayoutMigrator;
use App\Support\LayoutResolver;
use App\Support\Templates;
use RuntimeException;
use ZipArchive;

/**
 * Shared kit-install / management surface.
 *
 * Single source of truth for everything that touches
 * `resources/templates/projects/{key}/` and `public/template-screens/{key}/`:
 *
 *   - `InstallTemplateCommand` (CLI) is now a thin wrapper that calls
 *     `installFromZip()` and renders the lint report.
 *   - `Admin\KitsController` (HTTP) calls every method here directly.
 *
 * Failure modes use sentinel message tags (ZIP_EXTRACTION_FAILED,
 * KIT_KEY_MISSING_OR_INVALID, KIT_ALREADY_EXISTS, LINT_FAILED) so the
 * caller can branch on the cause without parsing prose. The lint report
 * is carried on the RuntimeException via a separate getter.
 */
class KitInstallerService
{
    private const KIT_KEY_PATTERN = '/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/';

    private const UPDATEABLE_MARKETING_FIELDS = [
        'category',
        'tagline',
        'is_featured',
        'is_premium',
        'is_active',
        'sort_order',
    ];

    /**
     * Top-level kit JSON fields editable through the admin Edit modal.
     * `key` is intentionally excluded — changing it would require renaming
     * the kit dir + public preview dir + rewriting preview_path URLs, which
     * lives outside the marketing-edit happy path.
     */
    private const UPDATEABLE_TOP_LEVEL_FIELDS = [
        'name',
        'description',
    ];

    /**
     * Default sort_order applied to kits that don't carry one in their
     * marketing block. Higher sort_order values surface earlier in lists.
     */
    public const DEFAULT_SORT_ORDER = 100;

    /** @var array<int, array{level: string, where: string, message: string}>|null */
    private ?array $lastLintIssues = null;

    public function __construct(private readonly KitLinter $linter) {}

    /**
     * @return array<int, array<string, mixed>>
     */
    public function listInstalled(): array
    {
        $base = resource_path('templates/projects');
        if (! is_dir($base)) {
            return [];
        }

        $out = [];
        foreach ((array) glob($base.'/*', GLOB_ONLYDIR) as $dir) {
            $slug = basename($dir);
            if (! is_string($slug) || $slug === '' || $slug === '__MACOSX') {
                continue;
            }
            $entry = $this->readKitEntry($dir, $slug);
            if ($entry !== null) {
                $out[] = $entry;
            }
        }

        usort($out, function (array $a, array $b): int {
            $byOrder = ($b['sort_order'] ?? self::DEFAULT_SORT_ORDER) <=> ($a['sort_order'] ?? self::DEFAULT_SORT_ORDER);
            if ($byOrder !== 0) {
                return $byOrder;
            }

            return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
        });

        return $out;
    }

    /**
     * Return the on-disk `screens[]` block for a kit (the `screens` array
     * as authored — names + preview_paths + master/layouts). Returns null
     * if the kit dir or JSON is missing.
     *
     * @return array<int, array<string, mixed>>|null
     */
    public function screens(string $kitKey): ?array
    {
        if (! $this->validKey($kitKey)) {
            return null;
        }
        $dir = resource_path('templates/projects/'.$kitKey);
        $jsonPath = $dir.'/'.$kitKey.'.json';
        if (! is_dir($dir) || ! is_file($jsonPath)) {
            return null;
        }
        $raw = @file_get_contents($jsonPath);
        if ($raw === false) {
            return null;
        }
        $data = json_decode($raw, true);
        if (! is_array($data) || ! isset($data['screens']) || ! is_array($data['screens'])) {
            return null;
        }

        $out = [];
        foreach ($data['screens'] as $screen) {
            if (! is_array($screen)) {
                continue;
            }
            $out[] = [
                'name' => (string) ($screen['name'] ?? ''),
                'preview_path' => (string) ($screen['preview_path'] ?? ''),
            ];
        }

        return $out;
    }

    /**
     * List user-media assets bundled with the kit (everything under
     * `assets/` EXCEPT the `marketing/` subdir which carries preview PNGs
     * not user-facing assets). Non-recursive — kits ship a flat asset
     * directory.
     *
     * @return array<int, array{path: string, size_bytes: int, ext: string}>
     */
    public function assets(string $kitKey): array
    {
        if (! $this->validKey($kitKey)) {
            return [];
        }
        $assetsDir = resource_path('templates/projects/'.$kitKey.'/assets');
        if (! is_dir($assetsDir)) {
            return [];
        }

        $out = [];
        foreach ((array) scandir($assetsDir) as $entry) {
            if ($entry === '.' || $entry === '..' || $entry === false) {
                continue;
            }
            $full = $assetsDir.DIRECTORY_SEPARATOR.$entry;
            if (! is_file($full)) {
                continue;
            }
            $ext = strtolower((string) pathinfo($entry, PATHINFO_EXTENSION));
            if (! in_array($ext, ['png', 'jpg', 'jpeg', 'webp', 'svg'], true)) {
                continue;
            }
            $size = @filesize($full);
            $out[] = [
                'path' => $entry,
                'size_bytes' => $size === false ? 0 : (int) $size,
                'ext' => $ext,
            ];
        }

        usort($out, fn ($a, $b) => strcmp($a['path'], $b['path']));

        return $out;
    }

    /**
     * Absolute filesystem path to the kit's directory. Caller is
     * responsible for checking existence — service-level callers (download
     * handler) wrap the result.
     */
    public function kitDirPath(string $kitKey): ?string
    {
        if (! $this->validKey($kitKey)) {
            return null;
        }
        $dir = resource_path('templates/projects/'.$kitKey);

        return is_dir($dir) ? $dir : null;
    }

    /**
     * @return array<string, mixed>|null
     */
    public function get(string $kitKey): ?array
    {
        if (! $this->validKey($kitKey)) {
            return null;
        }
        $dir = resource_path('templates/projects/'.$kitKey);
        if (! is_dir($dir)) {
            return null;
        }

        return $this->readKitEntry($dir, $kitKey);
    }

    /**
     * Kit shape for the marketing landing teaser. Matches the `KitMeta`
     * contract on the /templates page so the same card component can render
     * either surface. Filters to active + `is_featured=true` kits only
     * (the landing teaser is a curated row, not a paginated grid — admins
     * gate it via the featured flag), caps at `$limit`.
     *
     * @return array<int, array{
     *   key: string,
     *   name: string,
     *   description: string,
     *   category: string,
     *   tagline: string,
     *   screen_count: int,
     *   is_featured: bool,
     *   is_premium: bool,
     *   screens: array<int, array{name: string, preview_path: string}>,
     * }>
     */
    public function featuredForLanding(int $limit = 3): array
    {
        $kits = collect($this->listInstalled())
            ->filter(fn ($k) => ($k['is_active'] ?? true) && ($k['is_featured'] ?? false))
            ->take($limit)
            ->values();

        return $kits->map(function (array $k): array {
            $screens = $this->screens($k['key']) ?? [];

            return [
                'key' => (string) $k['key'],
                'name' => (string) $k['name'],
                'description' => (string) ($k['description'] ?? ''),
                'category' => (string) ($k['category'] ?? ''),
                'tagline' => (string) ($k['tagline'] ?? ''),
                'screen_count' => (int) ($k['screen_count'] ?? count($screens)),
                'is_featured' => (bool) ($k['is_featured'] ?? false),
                'is_premium' => (bool) ($k['is_premium'] ?? false),
                'screens' => array_values(array_map(
                    fn (array $s) => [
                        'name' => (string) ($s['name'] ?? ''),
                        'preview_path' => (string) ($s['preview_path'] ?? ''),
                    ],
                    array_filter($screens, fn ($s) => is_array($s)),
                )),
            ];
        })->all();
    }

    /**
     * Distinct, non-empty, trimmed categories across every installed kit.
     * Drives the admin Edit-modal autocomplete + the /templates filter
     * chip strip. Sorted alpha-ascending case-insensitively so the
     * dropdown reads predictably.
     *
     * @return array<int, string>
     */
    public function categories(): array
    {
        $seen = [];
        foreach ($this->listInstalled() as $kit) {
            $cat = $kit['category'] ?? null;
            if (! is_string($cat)) {
                continue;
            }
            $cat = trim($cat);
            if ($cat === '') {
                continue;
            }
            $seen[$cat] = true;
        }
        $list = array_keys($seen);
        usort($list, fn ($a, $b) => strcasecmp($a, $b));

        return $list;
    }

    /**
     * @return array<string, mixed>
     *
     * @throws RuntimeException on failure (see message constants).
     */
    public function installFromZip(string $zipPath, bool $replace = false): array
    {
        if (! is_file($zipPath)) {
            throw new RuntimeException('ZIP_EXTRACTION_FAILED: file not found');
        }

        $staging = sys_get_temp_dir().'/appshot-tpl-install-'.uniqid();
        if (! @mkdir($staging, 0o755, true)) {
            throw new RuntimeException('ZIP_EXTRACTION_FAILED: cannot create staging dir');
        }

        try {
            $slug = $this->extractAndResolveKey($zipPath, $staging);

            $target = resource_path('templates/projects/'.$slug);
            if (is_dir($target) && ! $replace) {
                $err = new RuntimeException("KIT_ALREADY_EXISTS: {$slug}");
                throw $err;
            }

            $report = $this->linter->lint($slug, $staging);
            if (! empty($report['hasErrors'])) {
                $this->lastLintIssues = $this->flattenLintIssues($report);
                throw new RuntimeException('LINT_FAILED');
            }
            $this->lastLintIssues = $this->flattenLintIssues($report);

            $this->installFromStaging($staging.'/'.$slug, $target);
            $this->materializeMarketingPreviews($slug, $target);

            $entry = $this->readKitEntry($target, $slug);
            if ($entry === null) {
                throw new RuntimeException('ZIP_EXTRACTION_FAILED: post-install read failed');
            }

            return $entry;
        } finally {
            $this->removeRecursive($staging);
        }
    }

    /**
     * @param  array<string, mixed>  $patch
     * @return array<string, mixed>
     */
    public function updateMarketing(string $kitKey, array $patch): array
    {
        if (! $this->validKey($kitKey)) {
            throw new RuntimeException('KIT_KEY_MISSING_OR_INVALID');
        }

        $dir = resource_path('templates/projects/'.$kitKey);
        $jsonPath = $dir.'/'.$kitKey.'.json';
        if (! is_dir($dir) || ! is_file($jsonPath)) {
            throw new RuntimeException('KIT_NOT_FOUND');
        }

        $raw = @file_get_contents($jsonPath);
        if ($raw === false) {
            throw new RuntimeException('KIT_READ_FAILED');
        }
        $data = json_decode($raw, true);
        if (! is_array($data)) {
            throw new RuntimeException('KIT_JSON_INVALID');
        }

        foreach (self::UPDATEABLE_TOP_LEVEL_FIELDS as $field) {
            if (! array_key_exists($field, $patch)) {
                continue;
            }
            $value = $patch[$field];
            if (is_string($value)) {
                $value = trim($value);
            }
            $data[$field] = $value;
        }

        $marketing = is_array($data['marketing'] ?? null) ? $data['marketing'] : [];
        foreach (self::UPDATEABLE_MARKETING_FIELDS as $field) {
            if (! array_key_exists($field, $patch)) {
                continue;
            }
            $value = $patch[$field];
            // sort_order is stored as an int so the comparator in
            // listInstalled doesn't get confused by stringified numerics.
            if ($field === 'sort_order' && $value !== null) {
                $value = (int) $value;
            }
            // Trim free-form strings on save so "Productivity " and
            // "Productivity" don't end up as distinct category buckets
            // on /templates filter chips or in the admin autocomplete.
            if (($field === 'category' || $field === 'tagline') && is_string($value)) {
                $value = trim($value);
            }
            $marketing[$field] = $value;
        }
        $data['marketing'] = $marketing;

        $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($encoded === false) {
            throw new RuntimeException('KIT_WRITE_FAILED: json encode');
        }
        if (@file_put_contents($jsonPath, $encoded) === false) {
            throw new RuntimeException('KIT_WRITE_FAILED: file put');
        }

        $entry = $this->readKitEntry($dir, $kitKey);
        if ($entry === null) {
            throw new RuntimeException('KIT_WRITE_FAILED: post-write read');
        }

        return $entry;
    }

    /**
     * How many of a kit's screens (in order) must be installed to yield at least
     * `$deviceTarget` DEVICE-bearing screens on `$primaryFactor`. Kit decks often
     * open or close with a no-device screen (feature wall, CTA); installing by raw
     * count would leave fewer device screens than the user has screenshots, and a
     * screenshot would be silently dropped. AI generation installs by this count
     * so every uploaded screenshot gets its own device screen. Capped at the
     * per-project screen max.
     */
    public function screensNeededForDeviceTarget(string $kitKey, string $primaryFactor, int $deviceTarget): int
    {
        $screens = $this->screensWithLayouts($kitKey) ?? [];
        if ($screens === [] || $deviceTarget < 1) {
            return 0;
        }

        $deviceSeen = 0;
        $count      = 0;
        foreach ($screens as $s) {
            $count++;
            $layout   = is_array($s['layout'] ?? null) ? $s['layout'] : [];
            $resolved = (($layout['version'] ?? null) === 4)
                ? LayoutResolver::resolveLayoutForFactorV4($layout, $primaryFactor)
                : $layout;
            foreach (($resolved['layers'] ?? []) as $l) {
                if (is_array($l) && ($l['type'] ?? '') === 'frame') {
                    $deviceSeen++;
                    break;
                }
            }
            if ($deviceSeen >= $deviceTarget) {
                break;
            }
        }

        return min($count, ScreenController::MAX_SCREENS_PER_PROJECT);
    }

    /**
     * Install a kit onto an EXISTING project (blank-mode AI generation).
     *
     * Deletes the project's current screens, stamps the kit's screens across
     * the project's active form factors, materializes kit assets, and updates
     * the project flags. Mirrors the kit-install block in ProjectController::store()
     * so both paths stay in sync.
     *
     * Callers are responsible for DB transaction wrapping.
     */
    public function applyToProject(Project $project, string $kitKey, ?int $maxScreens = null): void
    {
        $kit = $this->get($kitKey);
        if ($kit === null) {
            throw new RuntimeException("Kit '{$kitKey}' is not installed.");
        }

        $formFactor       = $project->form_factor;
        $activeFactors    = $project->active_form_factors ?? [$formFactor];
        $canvas           = Templates::defaultCanvasFor($formFactor);
        $step             = $canvas['w'] + 10; // match ProjectController VIEWPORT_GUTTER
        $cap              = min($maxScreens ?? ScreenController::MAX_SCREENS_PER_PROJECT, ScreenController::MAX_SCREENS_PER_PROJECT);
        // get() returns metadata only — load the full screens (with layouts) from the JSON directly.
        $kitScreens       = array_slice(
            $this->screensWithLayouts($kitKey) ?? [],
            0,
            $cap
        );

        // Delete-then-recreate must be atomic — a mid-loop failure otherwise
        // leaves the project with its screens deleted and only a partial kit.
        \Illuminate\Support\Facades\DB::transaction(function () use ($project, $kitScreens, $kitKey, $formFactor, $activeFactors, $step) {
            // Delete current screens (the blank scaffold).
            Screen::where('project_id', $project->id)->delete();

            foreach ($kitScreens as $i => $kitScreen) {
                Screen::create([
                    'project_id' => $project->id,
                    'position'   => $i,
                    'viewport_x' => $i * $step,
                    'viewport_y' => 0,
                    'name'       => $kitScreen['name'] ?? "Screen ".($i + 1),
                    'layouts'    => $this->stampScreenToActiveFactors(
                        $kitScreen['layout'],
                        $formFactor,
                        $activeFactors,
                        $i === 0,
                    ),
                ]);
            }

            $this->materializeKitAssets($project, $kitKey);

            $project->update([
                'is_blank'     => false,
                'template_key' => $kitKey,
            ]);
        });
    }

    private function stampScreenToActiveFactors(
        array $layout,
        string $primaryFactor,
        array $activeFormFactors,
        bool $isFirstScreen = true,
    ): array {
        $isV4   = ($layout['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION;
        $others = array_values(array_filter(
            $activeFormFactors,
            fn (string $f) => $f !== $primaryFactor,
        ));

        if ($others === []) {
            if ($isV4) {
                return LayoutResolver::resolveLayoutForFactorV4($layout, $primaryFactor);
            }

            return $layout;
        }

        $envelope = $isV4
            ? $layout
            : LayoutMigrator::migrateToV4($layout, $primaryFactor);

        // Strip factors the kit ships that the user didn't pick.
        $canvasFactors = array_keys(is_array($envelope['canvas'] ?? null) ? $envelope['canvas'] : []);
        foreach ($canvasFactors as $factor) {
            if (! in_array($factor, $activeFormFactors, true)) {
                $envelope = LayoutResolver::removeFactorFromEnvelope($envelope, $factor);
            }
        }

        // Kit-authored factors land verbatim; non-authored ones get stamped.
        $canvasMap = is_array($envelope['canvas'] ?? null) ? $envelope['canvas'] : [];
        foreach ($others as $factor) {
            if (isset($canvasMap[$factor])) {
                continue;
            }
            $targetCanvas = Templates::defaultCanvasFor($factor);
            $envelope     = LayoutResolver::stampEnvelopeToFactor(
                $envelope,
                $primaryFactor,
                $factor,
                ['width' => $targetCanvas['w'], 'height' => $targetCanvas['h']],
            );
            $envelope = DevicePresetFamily::resolveFramesForFactor($envelope, $factor);
            if ($isFirstScreen && LayoutResolver::isMarketingFactor($factor)) {
                $layer     = Templates::marketingHeadlineLayer(null);
                $perFactor = ['anchors' => $layer['anchors'], 'fontSize' => $layer['fontSize']];
                unset($layer['anchors'], $layer['fontSize']);
                $layer['perFactor']  = [$factor => $perFactor];
                $envelope['layers'][] = $layer;
            }
        }

        return $envelope;
    }

    private function materializeKitAssets(Project $project, string $kitKey): void
    {
        $assetsDir = resource_path("templates/projects/{$kitKey}/assets");
        if (! is_dir($assetsDir)) {
            return;
        }

        $nameMap = [];
        MediaStorage::beginBatch('media.kit.install');
        try {
            foreach ((array) scandir($assetsDir) as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }
                $src = $assetsDir.DIRECTORY_SEPARATOR.$entry;
                if (! is_file($src)) {
                    continue;
                }
                $bytes = (string) file_get_contents($src);
                $dest  = MediaStorage::put($bytes, "projects/{$project->id}", $entry, [
                    'owner_type' => 'project',
                    'owner_id'   => $project->id,
                    'purpose'    => 'kit-asset',
                ]);
                $nameMap["assets/{$entry}"] = $dest;
            }
        } finally {
            MediaStorage::endBatch("Materialized kit '{$kitKey}' assets for project #{$project->id}");
        }

        if ($nameMap === []) {
            return;
        }

        Screen::query()
            ->where('project_id', $project->id)
            ->get()
            ->each(function (Screen $screen) use ($nameMap): void {
                $layout = $screen->layouts ?? [];
                if (! is_array($layout) || ! isset($layout['layers']) || ! is_array($layout['layers'])) {
                    return;
                }
                $changed = false;
                foreach ($layout['layers'] as &$layer) {
                    $changed = $this->rewriteLayerAssets($layer, $nameMap) || $changed;
                }
                unset($layer);
                if ($changed) {
                    $screen->layouts = $layout;
                    $screen->save();
                }
            });
    }

    /**
     * @param  array<string, string>  $nameMap
     */
    private function rewriteLayerAssets(array &$layer, array $nameMap): bool
    {
        $changed = $this->rewriteAssetSlots($layer, $nameMap);
        if (isset($layer['perFactor']) && is_array($layer['perFactor'])) {
            foreach ($layer['perFactor'] as &$entry) {
                if (is_array($entry)) {
                    $changed = $this->rewriteAssetSlots($entry, $nameMap) || $changed;
                }
            }
            unset($entry);
        }

        return $changed;
    }

    /**
     * @param  array<string, string>  $nameMap
     */
    private function rewriteAssetSlots(array &$slot, array $nameMap): bool
    {
        $changed = false;
        if (isset($slot['storagePath']) && is_string($slot['storagePath']) && isset($nameMap[$slot['storagePath']])) {
            $slot['storagePath'] = $nameMap[$slot['storagePath']];
            $changed = true;
        }
        if (isset($slot['image']['storagePath']) && is_string($slot['image']['storagePath']) && isset($nameMap[$slot['image']['storagePath']])) {
            $slot['image']['storagePath'] = $nameMap[$slot['image']['storagePath']];
            $changed = true;
        }
        if (isset($slot['screen']) && is_array($slot['screen'])) {
            if (isset($slot['screen']['storagePath']) && is_string($slot['screen']['storagePath']) && isset($nameMap[$slot['screen']['storagePath']])) {
                $slot['screen']['storagePath'] = $nameMap[$slot['screen']['storagePath']];
                $changed = true;
            }
            if (isset($slot['screen']['localePaths']) && is_array($slot['screen']['localePaths'])) {
                foreach ($slot['screen']['localePaths'] as $locale => $path) {
                    if (is_string($path) && isset($nameMap[$path])) {
                        $slot['screen']['localePaths'][$locale] = $nameMap[$path];
                        $changed = true;
                    }
                }
            }
        }

        return $changed;
    }

    public function delete(string $kitKey): void
    {
        if (! $this->validKey($kitKey)) {
            throw new RuntimeException('KIT_KEY_MISSING_OR_INVALID');
        }
        $this->removeRecursive(resource_path('templates/projects/'.$kitKey));
        $this->removeRecursive(public_path('template-screens/'.$kitKey));
    }

    /**
     * @param  array<int, string>  $keys
     * @return array<int, array<string, mixed>>
     */
    public function bulkAction(string $action, array $keys): array
    {
        $results = [];
        foreach ($keys as $key) {
            if (! is_string($key) || ! $this->validKey($key)) {
                $results[] = ['key' => is_string($key) ? $key : '', 'ok' => false, 'error' => 'KIT_KEY_MISSING_OR_INVALID'];
                continue;
            }
            try {
                if ($action === 'delete') {
                    $this->delete($key);
                } elseif ($action === 'activate') {
                    $this->updateMarketing($key, ['is_active' => true]);
                } elseif ($action === 'deactivate') {
                    $this->updateMarketing($key, ['is_active' => false]);
                } else {
                    $results[] = ['key' => $key, 'ok' => false, 'error' => 'UNKNOWN_ACTION'];
                    continue;
                }
                $results[] = ['key' => $key, 'ok' => true];
            } catch (\Throwable $e) {
                $results[] = ['key' => $key, 'ok' => false, 'error' => $e->getMessage()];
            }
        }

        return $results;
    }

    /**
     * Lint issues from the most recent install attempt. Carried out-of-band
     * since `installFromZip` throws on LINT_FAILED and we don't want to stuff
     * a structured payload into the exception message string.
     *
     * @return array<int, array{level: string, where: string, message: string}>|null
     */
    public function lastLintIssues(): ?array
    {
        return $this->lastLintIssues;
    }

    private function validKey(string $key): bool
    {
        return preg_match(self::KIT_KEY_PATTERN, $key) === 1;
    }

    /**
     * @return array<string, mixed>|null
     */
    /**
     * Return the full `screens[]` from the kit JSON, including the `layout` field.
     * Distinct from `screens()` which only returns name + preview_path for the browse UI.
     *
     * @return array<int, array{name: string, layout: array<string, mixed>}>|null
     */
    private function screensWithLayouts(string $kitKey): ?array
    {
        if (! $this->validKey($kitKey)) {
            return null;
        }
        $jsonPath = resource_path("templates/projects/{$kitKey}/{$kitKey}.json");
        if (! is_file($jsonPath)) {
            return null;
        }
        $raw = @file_get_contents($jsonPath);
        if ($raw === false) {
            return null;
        }
        $data = json_decode($raw, true);
        if (! is_array($data) || ! is_array($data['screens'] ?? null)) {
            return null;
        }

        return array_values(array_filter(
            $data['screens'],
            fn ($s) => is_array($s) && is_array($s['layout'] ?? null),
        ));
    }

    private function readKitEntry(string $dir, string $slug): ?array
    {
        $jsonPath = $dir.'/'.$slug.'.json';
        if (! is_file($jsonPath)) {
            return null;
        }
        $raw = @file_get_contents($jsonPath);
        if ($raw === false) {
            return null;
        }
        $data = json_decode($raw, true);
        if (! is_array($data)) {
            return null;
        }

        $marketing = is_array($data['marketing'] ?? null) ? $data['marketing'] : null;
        $screens = is_array($data['screens'] ?? null) ? $data['screens'] : [];
        $screenCount = is_int($marketing['screen_count'] ?? null)
            ? $marketing['screen_count']
            : count($screens);

        $mtime = @filemtime($jsonPath);
        $updatedAt = $mtime ? date(DATE_ATOM, $mtime) : null;

        $sortOrder = self::DEFAULT_SORT_ORDER;
        if ($marketing !== null && array_key_exists('sort_order', $marketing) && is_numeric($marketing['sort_order'])) {
            $sortOrder = (int) $marketing['sort_order'];
        }

        return [
            'key' => (string) ($data['key'] ?? $slug),
            'name' => (string) ($data['name'] ?? $slug),
            'description' => (string) ($data['description'] ?? ''),
            'category' => $marketing !== null ? (string) ($marketing['category'] ?? '') : '',
            'tagline' => $marketing !== null ? (string) ($marketing['tagline'] ?? '') : '',
            'screen_count' => $screenCount,
            'is_featured' => $marketing !== null ? (bool) ($marketing['is_featured'] ?? false) : false,
            'is_premium' => $marketing !== null ? (bool) ($marketing['is_premium'] ?? false) : false,
            'is_active' => $marketing !== null
                ? (array_key_exists('is_active', $marketing) ? (bool) $marketing['is_active'] : true)
                : true,
            'sort_order' => $sortOrder,
            'marketing_present' => $marketing !== null,
            'updated_at' => $updatedAt,
            'install_size_bytes' => $this->dirSizeBytes($dir),
        ];
    }

    /**
     * Recursive byte sum for a directory. Used to report kit footprint on
     * /admin/kits/{key}. Symlinks aren't expected inside kit dirs so a
     * straight scandir walk is fine.
     */
    private function dirSizeBytes(string $dir): int
    {
        if (! is_dir($dir)) {
            return 0;
        }
        $total = 0;
        foreach ((array) scandir($dir) as $entry) {
            if ($entry === '.' || $entry === '..' || $entry === false) {
                continue;
            }
            $full = $dir.DIRECTORY_SEPARATOR.$entry;
            if (is_dir($full)) {
                $total += $this->dirSizeBytes($full);
            } elseif (is_file($full)) {
                $size = @filesize($full);
                if ($size !== false) {
                    $total += (int) $size;
                }
            }
        }

        return $total;
    }

    private function extractAndResolveKey(string $zipPath, string $staging): string
    {
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new RuntimeException('ZIP_EXTRACTION_FAILED: cannot open archive');
        }
        // Zip Slip guard mirrors InstallTemplateCommand — refuse entries that
        // would escape staging via `..` or absolute paths.
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = (string) $zip->getNameIndex($i);
            if ($name === ''
                || str_starts_with($name, '/')
                || str_starts_with($name, '\\')
                || preg_match('#(^|/|\\\\)\.\.(/|\\\\|$)#', $name) === 1
            ) {
                $zip->close();
                throw new RuntimeException("ZIP_EXTRACTION_FAILED: unsafe path \"{$name}\"");
            }
        }
        if (! $zip->extractTo($staging)) {
            $zip->close();
            throw new RuntimeException('ZIP_EXTRACTION_FAILED: extractTo failed');
        }
        $zip->close();

        $kitDirs = array_filter(
            (array) glob($staging.'/*', GLOB_ONLYDIR),
            static fn ($d) => basename($d) !== '__MACOSX',
        );
        if (count($kitDirs) !== 1) {
            throw new RuntimeException('KIT_KEY_MISSING_OR_INVALID: ZIP must contain exactly one top-level kit directory');
        }
        $slug = basename(reset($kitDirs));
        if (! $this->validKey($slug)) {
            throw new RuntimeException("KIT_KEY_MISSING_OR_INVALID: invalid slug \"{$slug}\"");
        }
        if (! is_file($staging.'/'.$slug.'/'.$slug.'.json')) {
            throw new RuntimeException("KIT_KEY_MISSING_OR_INVALID: expected {$slug}/{$slug}.json");
        }

        return $slug;
    }

    private function installFromStaging(string $source, string $target): void
    {
        if (is_dir($target)) {
            $this->removeRecursive($target);
        }
        if (! is_dir(dirname($target))) {
            @mkdir(dirname($target), 0o755, true);
        }
        $this->copyRecursive($source, $target);
    }

    /**
     * Lift kit-bundled marketing previews out to `public/template-screens/{key}/`
     * and rewrite `screens[].preview_path` from the portable
     * `assets/marketing/<file>.<ext>` to a cache-busted
     * `/template-screens/{key}/<stem>.<hash>.<ext>` form. Accepts both PNG
     * (legacy kits) and JPEG (current exporter default — JPEGs are ~10× smaller
     * since marketing previews always render on a background). No-op when the
     * kit ships no marketing previews.
     *
     * The destination dir is wiped before writing so stale hashed files
     * from a previous install don't accumulate on `--replace`. Cache busting
     * uses the first 8 chars of md5(file) — re-uploading the same file
     * keeps the hash stable; modifying it produces a new filename + breaks
     * downstream browser caches without a manual purge.
     */
    private function materializeMarketingPreviews(string $slug, string $kitDir): void
    {
        $marketingDir = $kitDir.DIRECTORY_SEPARATOR.'assets'.DIRECTORY_SEPARATOR.'marketing';
        if (! is_dir($marketingDir)) {
            return;
        }

        $publicDir = public_path('template-screens'.DIRECTORY_SEPARATOR.$slug);
        // Clear the destination first so replace-installs don't accumulate
        // stale hashed filenames. We re-create the dir below before writes.
        $this->removeRecursive($publicDir);
        if (! @mkdir($publicDir, 0o755, true) && ! is_dir($publicDir)) {
            return;
        }

        $allowedExts = ['png', 'jpg', 'jpeg'];
        $renameMap = [];
        foreach ((array) scandir($marketingDir) as $entry) {
            if ($entry === '.' || $entry === '..' || $entry === false) {
                continue;
            }
            $src = $marketingDir.DIRECTORY_SEPARATOR.$entry;
            if (! is_file($src)) {
                continue;
            }
            $ext = strtolower((string) pathinfo($entry, PATHINFO_EXTENSION));
            if (! in_array($ext, $allowedExts, true)) {
                continue;
            }
            $hashFull = @md5_file($src);
            $hash = is_string($hashFull) ? substr($hashFull, 0, 8) : substr(md5($entry.uniqid()), 0, 8);
            $stem = pathinfo($entry, PATHINFO_FILENAME);
            $hashed = $stem.'.'.$hash.'.'.$ext;
            $dst = $publicDir.DIRECTORY_SEPARATOR.$hashed;
            if (@copy($src, $dst)) {
                $renameMap[$entry] = $hashed;
            }
        }

        if ($renameMap === []) {
            return;
        }

        $jsonPath = $kitDir.DIRECTORY_SEPARATOR.$slug.'.json';
        $raw = @file_get_contents($jsonPath);
        if ($raw === false) {
            return;
        }
        $data = json_decode($raw, true);
        if (! is_array($data) || ! isset($data['screens']) || ! is_array($data['screens'])) {
            return;
        }

        $changed = false;
        foreach ($data['screens'] as $i => $screen) {
            if (! is_array($screen)) {
                continue;
            }
            $path = $screen['preview_path'] ?? null;
            if (is_string($path) && str_starts_with($path, 'assets/marketing/')) {
                $basename = basename($path);
                $hashedName = $renameMap[$basename] ?? null;
                if ($hashedName === null) {
                    continue;
                }
                $data['screens'][$i]['preview_path'] = "/template-screens/{$slug}/{$hashedName}";
                $changed = true;
            }
        }

        if (! $changed) {
            return;
        }

        $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($encoded !== false) {
            @file_put_contents($jsonPath, $encoded);
        }
    }

    /**
     * @param  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}  $report
     * @return array<int, array{level: string, where: string, message: string}>
     */
    private function flattenLintIssues(array $report): array
    {
        $out = [];
        foreach ($report['kits'] as $kit) {
            foreach ($kit['issues'] as $issue) {
                $out[] = $issue;
            }
        }

        return $out;
    }

    private function copyRecursive(string $src, string $dst): void
    {
        if (is_file($src)) {
            copy($src, $dst);

            return;
        }
        if (! is_dir($dst)) {
            @mkdir($dst, 0o755, true);
        }
        foreach ((array) scandir($src) as $entry) {
            if ($entry === '.' || $entry === '..' || $entry === false) {
                continue;
            }
            $this->copyRecursive($src.DIRECTORY_SEPARATOR.$entry, $dst.DIRECTORY_SEPARATOR.$entry);
        }
    }

    private function removeRecursive(string $path): void
    {
        if (is_file($path) || is_link($path)) {
            @unlink($path);

            return;
        }
        if (! is_dir($path)) {
            return;
        }
        foreach ((array) scandir($path) as $entry) {
            if ($entry === '.' || $entry === '..' || $entry === false) {
                continue;
            }
            $this->removeRecursive($path.DIRECTORY_SEPARATOR.$entry);
        }
        @rmdir($path);
    }
}
