<?php

namespace App\Services;

use App\Models\Project;
use App\Models\Screen;
use App\Support\LayoutMigrator;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

/**
 * Serializes a Project into kit JSON consumed by
 * {@see \App\Support\Templates::projectForKey()}:
 *
 *   { key, name, description?, form_factor,
 *     screens: [{ name, layout: Layout }] }
 *
 * Normalization rules:
 *   - Layer `id` fields are preserved inside layer bodies — opaque UUIDs
 *     re-rotated at apply time by {@see \App\Support\Templates::reIdLayout()}.
 *   - User-media `storagePath`s are rewritten to `assets/<filename>` and
 *     the bytes are bundled for inclusion in the kit ZIP.
 *   - Marketing preview images (one per screen, keyed by slugged screen name)
 *     are bundled at `assets/marketing/<slug>.<ext>`. Each screen's JSON entry
 *     gets a `preview_path` pointing at the relative bundle path.
 */
class TemplateExporter
{
    private const ACTIVE_DISK_ALIAS = 'public';

    /**
     * Whether to compress media bytes during `bundleAsset`. Set per-call from
     * the caller's `$compress` arg; read inside the recursive walk so the
     * recursive shape doesn't need to thread an extra parameter through every
     * layer-walk seam. New instance per request (controller construct-injects
     * via the DI container), so no cross-request leak.
     */
    private bool $compress = false;


    /**
     * @param  array<string, string>  $marketingPngs  Map of screen slug → raw image bytes (JPEG or PNG).
     * @param  bool  $compress  When true, recompress bundled media via GD:
     *                          opaque PNGs → JPG q=85 (extension flips), images
     *                          with transparency → PNG with max compression
     *                          (level 9). Marketing previews are not touched —
     *                          the client already downscales + re-encodes them.
     * @return array{
     *   json: array,
     *   assets: array<int, array{bundlePath: string, bytes: string}>
     * }
     */
    public function export(
        Project $project,
        string $key,
        string $name,
        ?string $description = null,
        ?string $category = null,
        ?string $tagline = null,
        array $marketingPngs = [],
        bool $compress = false,
    ): array {
        $this->compress = $compress;
        $screens = Screen::query()
            ->where('project_id', $project->id)
            ->orderBy('position')
            ->orderBy('id')
            ->get(['name', 'layouts']);

        $assetMap = [];
        $assetBytes = [];
        $marketingAssets = [];
        $bundledPreviewCount = 0;

        $outScreens = [];
        foreach ($screens as $screen) {
            $layout = LayoutMigrator::migrate($screen->layouts ?? []);
            $entry = [
                'name' => $screen->name,
                'layout' => $this->rewriteLayout($layout, $assetMap, $assetBytes),
            ];

            // Slug the screen name to match how the controller side keys the
            // uploaded preview map. Collisions take the first preview — the
            // editor UI prevents duplicate names so this is defensive.
            $slug = Str::slug((string) $screen->name);
            if ($slug !== '' && isset($marketingPngs[$slug]) && ! isset($marketingAssets[$slug])) {
                $bytes = (string) $marketingPngs[$slug];
                $ext = $this->sniffImageExtension($bytes);
                $marketingAssets[$slug] = ['bytes' => $bytes, 'ext' => $ext];
                $entry['preview_path'] = "assets/marketing/{$slug}.{$ext}";
                $bundledPreviewCount++;
            }

            $outScreens[] = $entry;
        }

        // Insertion order: key/name/description/form_factor/marketing/screens
        // so the JSON-on-disk reads top-down.
        $json = [
            'key' => $key,
            'name' => $name,
        ];
        if ($description !== null && $description !== '') {
            $json['description'] = $description;
        }
        // Always emit the legacy single-factor key — single-factor kits still
        // read it, and multi-factor kits use it as the canonical primary so
        // an older installer (KitInstallerService pre-v4) can still apply
        // the kit at the primary factor with its scaffolded layout.
        $json['form_factor'] = $project->form_factor;

        // Multi-factor projects also emit the full active set so the
        // v4-aware installer can align the kit's per-factor data with the
        // user's create-modal picks at apply time.
        $activeFormFactors = is_array($project->active_form_factors) && $project->active_form_factors !== []
            ? array_values($project->active_form_factors)
            : [$project->form_factor];
        if (count($activeFormFactors) > 1) {
            $json['active_form_factors'] = $activeFormFactors;
        }

        $hasMarketingMetadata = ($category !== null && $category !== '')
            || ($tagline !== null && $tagline !== '');
        if ($hasMarketingMetadata || $bundledPreviewCount > 0) {
            $json['marketing'] = [
                'category' => $category !== null && $category !== '' ? $category : 'Uncategorized',
                'tagline' => $tagline ?? '',
                'is_featured' => false,
                'is_premium' => false,
                'screen_count' => count($outScreens),
            ];
        }

        $json['screens'] = $outScreens;

        $assets = [];
        foreach ($assetBytes as $bundleName => $bytes) {
            $assets[] = ['bundlePath' => 'assets/'.$bundleName, 'bytes' => $bytes];
        }
        foreach ($marketingAssets as $slug => $entry) {
            $assets[] = [
                'bundlePath' => "assets/marketing/{$slug}.{$entry['ext']}",
                'bytes' => $entry['bytes'],
            ];
        }

        return ['json' => $json, 'assets' => $assets];
    }

    /**
     * Walk every layer in a Layout, rewriting storage paths to `assets/*`
     * and bundling the bytes. Layer `id` fields are preserved so the apply-
     * time re-ID can find clipShapeId / parentId pairings inside the kit.
     *
     * Handles both v3 single-factor layouts and v4 multi-factor envelopes
     * (the new shape introduced by the multi-device pivot). v4 envelopes
     * carry per-factor screenshot paths under `perFactor.{factor}.screen.*`
     * — those get walked alongside the shared body's image / background
     * storage paths.
     *
     * @param  array<string, string>  $assetMap
     * @param  array<string, string>  $assetBytes
     */
    private function rewriteLayout(array $layout, array &$assetMap, array &$assetBytes): array
    {
        $rewritten = [];
        $isV4 = ($layout['version'] ?? null) === LayoutMigrator::LATEST_V4_VERSION;

        foreach (($layout['layers'] ?? []) as $layer) {
            if (! is_array($layer)) {
                continue;
            }
            if ($isV4) {
                $rewritten[] = $this->rewriteLayerV4($layer, $assetMap, $assetBytes);
            } else {
                $rewritten[] = $this->rewriteLayer($layer, $assetMap, $assetBytes);
            }
        }

        $out = [
            'version' => $layout['version'] ?? LayoutMigrator::LATEST_VERSION,
            'canvas' => $layout['canvas'] ?? null,
            'layers' => $rewritten,
        ];
        if (array_key_exists('meta', $layout)) {
            $out['meta'] = $layout['meta'];
        }
        return $out;
    }

    /**
     * v4 envelope walker — rewrites shared body assets + every
     * `perFactor.{factor}.screen.*` path. Same `bundleAsset` mechanic as
     * the v3 path; just walks an extra level of indirection.
     *
     * @param  array<string, string>  $assetMap
     * @param  array<string, string>  $assetBytes
     */
    private function rewriteLayerV4(array $layer, array &$assetMap, array &$assetBytes): array
    {
        // Shared body — image.storagePath / background.image.storagePath
        // live at the top level even in v4 (per the linking principle).
        if (isset($layer['storagePath']) && is_string($layer['storagePath'])
            && (($layer['type'] ?? null) === 'image')
        ) {
            $layer['storagePath'] = $this->bundleAsset(
                $layer['storagePath'],
                $assetMap,
                $assetBytes,
            );
        }
        if (isset($layer['image']) && is_array($layer['image'])
            && isset($layer['image']['storagePath'])
            && is_string($layer['image']['storagePath'])
        ) {
            $layer['image']['storagePath'] = $this->bundleAsset(
                $layer['image']['storagePath'],
                $assetMap,
                $assetBytes,
            );
        }

        // Per-factor — frame.perFactor.{factor}.screen lives under each
        // factor entry, and so does the per-factor background image when
        // linkBgImage = false.
        if (isset($layer['perFactor']) && is_array($layer['perFactor'])) {
            foreach ($layer['perFactor'] as $factor => $perFactor) {
                if (! is_array($perFactor)) {
                    continue;
                }
                if (isset($perFactor['screen']) && is_array($perFactor['screen'])) {
                    $layer['perFactor'][$factor]['screen'] = $this->rewriteFrameScreen(
                        $perFactor['screen'],
                        $assetMap,
                        $assetBytes,
                    );
                }
                if (isset($perFactor['image']) && is_array($perFactor['image'])
                    && isset($perFactor['image']['storagePath'])
                    && is_string($perFactor['image']['storagePath'])
                ) {
                    $layer['perFactor'][$factor]['image']['storagePath'] = $this->bundleAsset(
                        $perFactor['image']['storagePath'],
                        $assetMap,
                        $assetBytes,
                    );
                }
            }
        }

        return $layer;
    }

    /**
     * Rewrite media storage paths on a single layer.
     *
     * Storage references walked:
     *   - image.storagePath
     *   - background.image.storagePath
     *   - frame.screen.storagePath
     *   - frame.screen.localePaths.*
     *
     * @param  array<string, string>  $assetMap
     * @param  array<string, string>  $assetBytes
     */
    private function rewriteLayer(array $layer, array &$assetMap, array &$assetBytes): array
    {
        $type = $layer['type'] ?? null;

        if (isset($layer['storagePath']) && is_string($layer['storagePath']) && ($type === 'image' || $type === null)) {
            $layer['storagePath'] = $this->bundleAsset($layer['storagePath'], $assetMap, $assetBytes);
        }

        if (isset($layer['image']) && is_array($layer['image']) && isset($layer['image']['storagePath']) && is_string($layer['image']['storagePath'])) {
            $layer['image']['storagePath'] = $this->bundleAsset(
                $layer['image']['storagePath'],
                $assetMap,
                $assetBytes,
            );
        }

        if (isset($layer['screen']) && is_array($layer['screen'])) {
            $layer['screen'] = $this->rewriteFrameScreen($layer['screen'], $assetMap, $assetBytes);
        }

        return $layer;
    }

    /**
     * @param  array<string, string>  $assetMap
     * @param  array<string, string>  $assetBytes
     */
    private function rewriteFrameScreen(array $screen, array &$assetMap, array &$assetBytes): array
    {
        if (isset($screen['storagePath']) && is_string($screen['storagePath'])) {
            $screen['storagePath'] = $this->bundleAsset($screen['storagePath'], $assetMap, $assetBytes);
        }
        if (isset($screen['localePaths']) && is_array($screen['localePaths'])) {
            foreach ($screen['localePaths'] as $locale => $path) {
                if (is_string($path)) {
                    $screen['localePaths'][$locale] = $this->bundleAsset($path, $assetMap, $assetBytes);
                }
            }
        }
        // v4-compat: `screen.factorPaths` maps each form factor to a native-
        // resolution screenshot. Bundle each path so the kit JSON references
        // `assets/<file>` and the linter accepts it. Without this walk, kits
        // authored with per-factor frame screenshots ship factorPaths still
        // pointing at `projects/<id>/...` and lint rejects on install.
        if (isset($screen['factorPaths']) && is_array($screen['factorPaths'])) {
            foreach ($screen['factorPaths'] as $factor => $path) {
                if (is_string($path)) {
                    $screen['factorPaths'][$factor] = $this->bundleAsset($path, $assetMap, $assetBytes);
                }
            }
        }

        return $screen;
    }

    /**
     * Sniff PNG vs JPEG from the file signature. Don't trust the caller's
     * extension or mime — the upload validator already accepts both formats
     * but the bundle path needs the right extension so install-time
     * materialization + browser content-type detection both Just Work.
     */
    private function sniffImageExtension(string $bytes): string
    {
        if (strncmp($bytes, "\xFF\xD8\xFF", 3) === 0) {
            return 'jpg';
        }
        if (strncmp($bytes, "\x89PNG\r\n\x1A\n", 8) === 0) {
            return 'png';
        }

        return 'png';
    }

    /**
     * @param  array<string, string>  $assetMap
     * @param  array<string, string>  $assetBytes
     */
    private function bundleAsset(string $storagePath, array &$assetMap, array &$assetBytes): string
    {
        if (! str_starts_with($storagePath, 'projects/')) {
            return $storagePath;
        }
        if (isset($assetMap[$storagePath])) {
            return $assetMap[$storagePath];
        }

        $disk = Storage::disk(self::ACTIVE_DISK_ALIAS);
        if (! $disk->exists($storagePath)) {
            return $storagePath;
        }

        $basename = basename($storagePath);
        $stem = pathinfo($basename, PATHINFO_FILENAME);
        $ext = strtolower(pathinfo($basename, PATHINFO_EXTENSION));
        $bytes = (string) $disk->get($storagePath);

        // Compression happens BEFORE candidate-name dedup so PNGs that flip
        // to JPG land under their new extension. The asset-map source key
        // stays the original `projects/<id>/<file>.png` — that's the path
        // layers point at; the bundled output is `assets/<file>.jpg` after
        // the rewrite. Layer-side path rewriting uses `$assetMap[source]`
        // which already carries the new extension via the candidate name.
        if ($this->compress && in_array($ext, ['png', 'jpg', 'jpeg'], true)) {
            $result = $this->compressImageBytes($bytes, $ext);
            $bytes = $result['bytes'];
            $ext = $result['ext'];
        }

        $candidate = $stem.($ext !== '' ? '.'.$ext : '');
        $n = 1;
        while (isset($assetBytes[$candidate])) {
            $candidate = $stem.'-'.$n.($ext !== '' ? '.'.$ext : '');
            $n++;
        }

        $assetBytes[$candidate] = $bytes;
        $assetMap[$storagePath] = 'assets/'.$candidate;

        return $assetMap[$storagePath];
    }

    /**
     * Recompress media bytes for kit bundling.
     *
     * Opaque PNGs become JPGs (q=85) — typically 80%+ smaller for full-
     * canvas screenshots. PNGs with transparency get re-encoded at PNG
     * compression level 9 (5-15% smaller, no visual change). JPGs and any
     * format GD fails to load pass through untouched. The returned `ext` is
     * the FINAL file extension (`'png'` or `'jpg'`) so bundleAsset can write
     * the candidate filename + asset path with the right suffix.
     *
     * Alpha detection reads the PNG IHDR color-type byte at offset 25 (and
     * the first 1 KB for the `tRNS` chunk when the PNG is palette-indexed) —
     * many orders of magnitude faster than scanning every pixel via GD.
     *
     * @return array{bytes: string, ext: string}
     */
    private function compressImageBytes(string $bytes, string $sourceExt): array
    {
        if (! function_exists('imagecreatefromstring')) {
            return ['bytes' => $bytes, 'ext' => $sourceExt];
        }
        if ($sourceExt === 'jpg' || $sourceExt === 'jpeg') {
            // Already DCT-compressed; re-encoding would only re-lossy-it.
            return ['bytes' => $bytes, 'ext' => 'jpg'];
        }

        $hasAlpha = $this->pngHasAlpha($bytes);
        $img = @imagecreatefromstring($bytes);
        if ($img === false) {
            return ['bytes' => $bytes, 'ext' => $sourceExt];
        }

        if ($hasAlpha) {
            imagealphablending($img, false);
            imagesavealpha($img, true);
            ob_start();
            imagepng($img, null, 9);
            $out = (string) ob_get_clean();
            imagedestroy($img);
            return ['bytes' => $out, 'ext' => 'png'];
        }

        // Opaque → JPG. Composite onto an opaque white canvas first; the
        // source might still carry an alpha channel even when no pixels
        // actually use it (false-positive opacity test), and JPG would
        // otherwise interpret the alpha byte as part of the chroma.
        $w = imagesx($img);
        $h = imagesy($img);
        $canvas = imagecreatetruecolor($w, $h);
        $white = imagecolorallocate($canvas, 255, 255, 255);
        imagefilledrectangle($canvas, 0, 0, $w, $h, $white);
        imagecopy($canvas, $img, 0, 0, 0, 0, $w, $h);
        ob_start();
        imagejpeg($canvas, null, 85);
        $out = (string) ob_get_clean();
        imagedestroy($img);
        imagedestroy($canvas);
        return ['bytes' => $out, 'ext' => 'jpg'];
    }

    /**
     * Fast alpha detection by parsing the PNG IHDR + first chunks. Returns
     * false for non-PNG inputs (caller already filtered JPGs out, but this
     * stays defensive).
     */
    private function pngHasAlpha(string $bytes): bool
    {
        if (strncmp($bytes, "\x89PNG\r\n\x1A\n", 8) !== 0) {
            return false;
        }
        if (strlen($bytes) < 26) {
            return false;
        }
        $colorType = ord($bytes[25]);
        // 4 = grayscale + alpha, 6 = RGBA.
        if ($colorType === 4 || $colorType === 6) {
            return true;
        }
        // Palette PNGs (color type 3) declare transparency via a tRNS chunk;
        // chunk names appear in the first few hundred bytes of any palette PNG.
        if ($colorType === 3) {
            $head = substr($bytes, 0, 4096);
            return strpos($head, 'tRNS') !== false;
        }
        return false;
    }
}
