<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\ThemeVersion;
use App\Services\AuditLogger;
use App\Services\MediaStorage;
use App\Services\Settings;
use App\Support\ThemePalette;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
 * Backing controller for the admin appearance editor at `/admin/appearance`.
 *
 * Two surfaces:
 *   - Inertia page (`index`) — knob editor + version list + preset gallery.
 *   - JSON action endpoints — draft staging (live preview), save (new
 *     version row), apply, delete, preset lookup, JSON export/import.
 *
 * Save flow: knobs → `ThemePalette::derive` → new ThemeVersion row with
 * `is_active = false`. Apply flow: deactivate prior active row, then
 * activate the requested row, then `Settings::flushCache()` so the next
 * request reads the new active.
 *
 * Preset files ship at `resources/themes/presets/{key}.json` — slug-only
 * filename (`[a-z][a-z0-9-]*`) so user input can't traverse paths. The
 * route regex enforces the same shape; the controller re-validates as
 * defense-in-depth.
 */
class AppearanceController extends Controller
{
    /**
     * Marketing design-system skins selectable via the `skin` knob. The value
     * rides the `knobs` JSON verbatim (it NEVER enters `ThemePalette::derive`)
     * and surfaces as `data-skin` on `<html>`; `resources/css/skins/*.css`
     * scope every rule under `html[data-skin='{skin}'][data-chrome='marketing']`.
     * Allowlisted here so a malformed knob payload can't write an arbitrary
     * attribute value through the otherwise-loose `knobs => array` validation.
     */
    private const SKINS = ['default', 'geometry'];

    /**
     * Clamp the `skin` knob to {@see self::SKINS} when present, defaulting any
     * unrecognized value to 'default'. Leaves every other knob untouched, and
     * — crucially — does NOT add a `skin` key when the payload omits one: the
     * default skin is the absence of the knob (the blade falls back to
     * 'default'), so non-skin callers round-trip byte-identical knobs through
     * store/draft/apply/export/import.
     *
     * @param  array<string, mixed>  $knobs
     * @return array<string, mixed>
     */
    private function normalizeSkin(array $knobs): array
    {
        if (! array_key_exists('skin', $knobs)) {
            return $knobs;
        }

        $skin = is_string($knobs['skin']) ? $knobs['skin'] : 'default';
        $knobs['skin'] = in_array($skin, self::SKINS, true) ? $skin : 'default';

        return $knobs;
    }

    public function index(Request $request): Response
    {
        $active = ThemeVersion::query()->active()->first();
        if ($active === null) {
            // Fallback for installs that somehow don't have a seeded row —
            // surface a synthetic "active" so the UI still renders without
            // a 500. The Settings service has the same fallback for parity.
            $themeData = Settings::activeTheme();
            $activePayload = [
                'id' => null,
                'name' => $themeData['name'],
                'knobs' => $themeData['knobs'],
                'applied_at' => null,
                'assets' => $this->assetsPayload([]),
            ];
        } else {
            $activePayload = [
                'id' => $active->id,
                'name' => $active->name,
                'knobs' => is_array($active->knobs) ? $active->knobs : [],
                'applied_at' => $active->applied_at?->toIso8601String(),
                'assets' => $this->assetsPayload(is_array($active->assets) ? $active->assets : []),
            ];
        }

        $versions = ThemeVersion::query()
            ->with('creator:id,email')
            ->orderByDesc('id')
            ->limit(50)
            ->get(['id', 'name', 'knobs', 'is_active', 'created_at', 'applied_at', 'created_by'])
            ->map(fn (ThemeVersion $v) => [
                'id' => $v->id,
                'name' => $v->name,
                'knobs' => is_array($v->knobs) ? $v->knobs : [],
                'is_active' => (bool) $v->is_active,
                'created_at' => $v->created_at?->toIso8601String(),
                'applied_at' => $v->applied_at?->toIso8601String(),
                'creator_email' => $v->creator?->email,
            ])
            ->all();

        return Inertia::render('Admin/Appearance', [
            'active' => $activePayload,
            'versions' => $versions,
            'presets' => $this->loadPresets(),
            'draft' => $request->session()->get('appearance.draft'),
        ]);
    }

    /**
     * Stage a knob draft in the session. Used by the live preview — the
     * editor POSTs the in-progress knobs here, then opens an iframe with
     * `?theme_preview=1` so `PreviewTheme` middleware swaps the rendered
     * colors. We compute `derived` server-side so the preview matches the
     * server's canonical `ThemePalette::derive` output (the TS twin must
     * stay byte-equivalent, but it's safer to round-trip through PHP).
     *
     * Validation is deliberately loose — `knobs` is a small associative
     * array of well-known keys, but its leaf shapes (accent OKLCH triple,
     * enums) are validated by `ThemePalette::derive`'s defensive defaults
     * rather than rules here. A bad knob shape produces a usable derived
     * map (defaults flow through), so invalid drafts degrade rather than
     * 422-ing the live preview.
     */
    public function storeDraft(Request $request): JsonResponse
    {
        $data = $request->validate([
            'knobs' => ['required', 'array'],
        ]);

        $knobs = $this->normalizeSkin($data['knobs']);
        $derived = ThemePalette::derive($knobs);

        $request->session()->put('appearance.draft', [
            'knobs' => $knobs,
            'derived' => $derived,
        ]);

        return response()->json([
            'ok' => true,
            'derived' => $derived,
        ]);
    }

    public function clearDraft(Request $request): HttpResponse
    {
        $request->session()->forget('appearance.draft');

        return response()->noContent();
    }

    /**
     * Persist a new theme version. NEW rows always start `is_active = false`
     * — admin has to explicitly call `apply` to swap. This split lets admins
     * stash drafts as named versions for later A/B without disturbing the
     * live theme. `derived` is computed once here and cached on the row so
     * the Blade `<style>` injection doesn't recompute on every page render.
     */
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:191'],
            'knobs' => ['required', 'array'],
            'mode_overrides' => ['nullable', 'array'],
            'assets' => ['nullable', 'array'],
        ]);

        $knobs = $this->normalizeSkin($data['knobs']);
        $derived = ThemePalette::derive($knobs);

        // Contrast guard. AA failures block the save unless the admin opts
        // into `force=1`. UI catches the 422, surfaces the failed pairs, and
        // re-POSTs with `force` on confirm. Default-protects everyone; admins
        // with deliberate brand reasons can override.
        $themeValidator = app(\App\Services\Theme\Validator::class);
        $vResult = $themeValidator->validate($knobs, $derived);
        if (! $vResult['ok'] && ! $request->boolean('force')) {
            return response()->json([
                'error' => 'contrast_failures',
                'failures' => $vResult['contrast_failures'],
                'message' => 'This theme fails WCAG AA contrast checks. Re-submit with `force=1` to save anyway.',
            ], 422);
        }

        /** @var \App\Models\User $user */
        $user = $request->user();

        // Carry forward assets from the currently-active version when the
        // caller didn't pass any. The frontend's "Save and apply" flow only
        // sends knobs; without this, every save would reset uploaded logos /
        // favicon / OG image, since the AssetsPanel persists them onto the
        // active row but the new row defaults to empty.
        $assetsPayload = $data['assets'] ?? null;
        if ($assetsPayload === null) {
            $activeRow = ThemeVersion::query()->active()->first();
            $assetsPayload = is_array($activeRow?->assets) ? $activeRow->assets : [];
        }

        $version = ThemeVersion::query()->create([
            'name' => trim($data['name']),
            'knobs' => $knobs,
            'derived' => $derived,
            'mode_overrides' => $data['mode_overrides'] ?? [],
            'assets' => $assetsPayload,
            'is_active' => false,
            'created_by' => $user->id,
            'applied_at' => null,
        ]);

        AuditLogger::record(
            'theme.created',
            $version,
            "Saved theme '{$version->name}'",
            ['version_id' => $version->id],
        );

        return response()->json([
            'id' => $version->id,
            'name' => $version->name,
        ]);
    }

    /**
     * Swap the active theme. Two-step transaction:
     *   1. Deactivate the current active row(s) — there's only ever one,
     *      but `where is_active=true` is the safe shape if the invariant
     *      ever drifts.
     *   2. Activate the requested row + stamp `applied_at`.
     *
     * Order matters: if we activated first then deactivated, the `where
     * is_active=true` deactivate would catch the row we just turned on
     * (because `update` on `is_active=true` matches the just-updated row
     * within the same transaction in MySQL row-locking semantics). Doing
     * deactivate-first guarantees a clean swap.
     *
     * `Settings::flushCache()` outside the transaction so the static memo
     * is dropped only after the DB write commits — a transaction rollback
     * would otherwise leave stale memo'd state pointing at the still-
     * persisted old active row.
     */
    public function apply(Request $request, ThemeVersion $version): RedirectResponse|JsonResponse
    {
        // Re-run the contrast guard on apply. A version may have been saved
        // pre-validator (legacy row) or with a `force=1` override; applying
        // it to live deserves a second chance to surface the warning. The
        // admin can re-trigger with `force=1` on the apply request to push
        // through.
        $derived = is_array($version->derived) && $version->derived !== []
            ? $version->derived
            : ThemePalette::derive(is_array($version->knobs) ? $version->knobs : []);
        $vResult = app(\App\Services\Theme\Validator::class)
            ->validate(is_array($version->knobs) ? $version->knobs : [], $derived);
        if (! $vResult['ok'] && ! $request->boolean('force')) {
            if ($request->wantsJson() && ! $request->header('X-Inertia')) {
                return response()->json([
                    'error' => 'contrast_failures',
                    'failures' => $vResult['contrast_failures'],
                    'message' => 'Theme fails WCAG AA contrast checks. Re-apply with `force=1` to override.',
                ], 422);
            }

            return back()->with('error', 'Theme fails WCAG AA contrast checks: '
                .implode(', ', $vResult['contrast_failures'])
                .'. Re-apply with `force=1` to override.');
        }

        DB::transaction(function () use ($version) {
            ThemeVersion::query()
                ->where('is_active', true)
                ->where('id', '!=', $version->id)
                ->update(['is_active' => false]);

            $version->forceFill([
                'is_active' => true,
                'applied_at' => now(),
            ])->save();
        });

        Settings::flushCache();

        AuditLogger::record(
            'theme.applied',
            $version,
            "Applied theme '{$version->name}'",
            ['version_id' => $version->id],
        );

        // ActionsBar's save-and-apply calls this via plain fetch (Accept:
        // application/json, no X-Inertia) and full-reloads after — answering
        // its PATCH with a 302 makes Chromium method-preserve the redirect
        // into a PATCH on the target route → 405 console noise. JSON for
        // plain XHR; redirect-back for the Inertia versions-list path.
        if ($request->wantsJson() && ! $request->header('X-Inertia')) {
            return response()->json(['ok' => true, 'id' => $version->id]);
        }

        return back()->with('success', "Applied theme '{$version->name}'.");
    }

    /**
     * Delete a stored theme version. Two refusals:
     *   - Active theme can't be deleted (would orphan the live render).
     *   - The seeded baseline row is undeletable so a fresh-start
     *     "reset to default" path always exists. We identify it as the
     *     lowest-id row — id=1 is the seed on every install regardless of
     *     what it was named (the seed flipped from "Default" → "Anthropic"
     *     in 2026-05; keying on min(id) is rename-proof).
     */
    public function destroy(Request $request, ThemeVersion $version): RedirectResponse|JsonResponse
    {
        // Dual-consumed: VersionsList deletes via Inertia `router.delete` (needs
        // a redirect so the list re-renders — a raw JSON body would be surfaced
        // verbatim in the browser), while the JSON API / feature tests hit it
        // via plain XHR. Mirror apply(): JSON for `wantsJson() && !X-Inertia`,
        // redirect-back for the Inertia path.
        $jsonWanted = $request->wantsJson() && ! $request->header('X-Inertia');

        if ($version->is_active) {
            $msg = 'Cannot delete the active theme. Apply a different theme first.';

            return $jsonWanted
                ? response()->json(['message' => $msg], 422)
                : back()->with('error', $msg);
        }

        $seedBaselineId = ThemeVersion::query()->min('id');

        if ($seedBaselineId !== null && (int) $version->id === (int) $seedBaselineId) {
            $msg = 'The default theme is the system fallback and cannot be deleted.';

            return $jsonWanted
                ? response()->json(['message' => $msg], 422)
                : back()->with('error', $msg);
        }

        $name = $version->name;
        $id = $version->id;

        $version->delete();

        AuditLogger::record(
            'theme.deleted',
            null,
            "Deleted theme '{$name}'",
            ['version_id' => $id, 'name' => $name],
        );

        return $jsonWanted
            ? response()->json(['ok' => true])
            : back()->with('success', "Deleted theme '{$name}'.");
    }

    /**
     * Return a preset's knob payload by slug. Slug shape is enforced by the
     * route regex AND re-checked here — the file lookup is a path
     * concatenation, so a defense-in-depth re-validation is cheap insurance.
     */
    public function preset(string $key): JsonResponse
    {
        if (! preg_match('/^[a-z][a-z0-9-]*$/', $key)) {
            abort(404);
        }

        $path = resource_path("themes/presets/{$key}.json");
        if (! File::exists($path)) {
            abort(404);
        }

        $raw = File::get($path);
        $payload = json_decode($raw, true);
        if (! is_array($payload) || ! isset($payload['name'], $payload['knobs']) || ! is_array($payload['knobs'])) {
            abort(404);
        }

        return response()->json([
            'key' => $key,
            'name' => (string) $payload['name'],
            'description' => isset($payload['description']) ? (string) $payload['description'] : null,
            'knobs' => $payload['knobs'],
        ]);
    }

    /**
     * Stream a theme version as a downloadable JSON file. `schema_version`
     * lets future versions migrate older exports cleanly. `derived` is
     * deliberately omitted — it's recomputable from `knobs`, and including
     * it would inflate exports + risk drift between PHP and TS twins.
     *
     * Brand assets (logo / favicon / OG image) are NOT included. The stored
     * values are storage paths like `theme-assets/9/favicon-XYZ.png` which
     * are install-specific — on a different install, the path resolves to
     * nothing. Cross-install transfers would need the file bytes too,
     * which would either bloat the JSON (base64) or require a ZIP bundle.
     * Skipping them keeps the export single-file + lossless for everything
     * that's actually portable. Admin re-uploads brand assets on the
     * target install via /admin/appearance.
     */
    public function exportJson(Request $request, ThemeVersion $version): StreamedResponse
    {
        $payload = [
            'name' => $version->name,
            'knobs' => is_array($version->knobs) ? $version->knobs : [],
            'mode_overrides' => is_array($version->mode_overrides) ? $version->mode_overrides : [],
            'exported_at' => now()->toIso8601String(),
            'schema_version' => 1,
        ];

        $slug = Str::slug($version->name) ?: "theme-{$version->id}";
        $filename = "appshot-theme-{$slug}.json";
        $body = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        return response()->streamDownload(
            function () use ($body) {
                echo $body;
            },
            $filename,
            [
                'Content-Type' => 'application/json',
                'Content-Disposition' => "attachment; filename=\"{$filename}\"",
            ],
        );
    }

    /**
     * Import a previously-exported theme JSON. The uploaded file is bounded
     * at 64 KB — knobs are tiny, mode_overrides + assets shouldn't push past
     * a few KB, so 64 is a generous ceiling that still rejects accidental
     * large uploads.
     *
     * Imports always land as a NEW (inactive) version with " (imported)"
     * appended — admin must explicitly apply afterward. Schema-version
     * mismatch is tolerated for now (only v1 exists); future schema bumps
     * can branch on the field.
     */
    public function importJson(Request $request): RedirectResponse
    {
        $request->validate([
            'file' => [
                'required',
                'file',
                'max:64', // KB
                'mimetypes:application/json,text/plain',
            ],
        ]);

        $contents = $request->file('file')->get();
        $payload = json_decode($contents, true);

        if (! is_array($payload)) {
            return back()->with('error', 'Could not parse theme file as JSON.');
        }

        $validator = Validator::make($payload, [
            'name' => ['required', 'string', 'max:191'],
            'knobs' => ['required', 'array'],
            'mode_overrides' => ['nullable', 'array'],
            'schema_version' => ['nullable', 'integer'],
        ]);

        if ($validator->fails()) {
            return back()->with('error', 'Theme file is malformed: '.$validator->errors()->first());
        }

        $valid = $validator->validated();
        $knobs = $this->normalizeSkin($valid['knobs']);
        $derived = ThemePalette::derive($knobs);

        /** @var \App\Models\User $user */
        $user = $request->user();

        $name = trim($valid['name']).' (imported)';

        // Brand assets are NOT round-tripped — see exportJson docblock. The
        // imported version starts with an empty asset set; admin re-uploads
        // logo / favicon / OG image after applying if they want them.
        $version = ThemeVersion::query()->create([
            'name' => $name,
            'knobs' => $knobs,
            'derived' => $derived,
            'mode_overrides' => $valid['mode_overrides'] ?? [],
            'assets' => [],
            'is_active' => false,
            'created_by' => $user->id,
            'applied_at' => null,
        ]);

        AuditLogger::record(
            'theme.imported',
            $version,
            "Imported theme '{$version->name}'",
            ['version_id' => $version->id],
        );

        return back()->with('success', "Imported theme '{$version->name}' (v{$version->id}).");
    }

    /**
     * Enumerate `resources/themes/presets/*.json`. Returns a stable order
     * (alphabetical by slug) so the UI gallery doesn't reshuffle between
     * page loads. Malformed preset files are silently skipped — better to
     * hide a broken preset than 500 the appearance page.
     *
     * @return array<int, array{key: string, name: string, description: ?string, knobs: array<string, mixed>}>
     */
    private function loadPresets(): array
    {
        $dir = resource_path('themes/presets');
        if (! File::isDirectory($dir)) {
            return [];
        }

        $files = File::files($dir);
        $presets = [];
        foreach ($files as $file) {
            if (strtolower($file->getExtension()) !== 'json') {
                continue;
            }
            $key = $file->getBasename('.json');
            if (! preg_match('/^[a-z][a-z0-9-]*$/', $key)) {
                continue;
            }
            $payload = json_decode($file->getContents(), true);
            if (! is_array($payload) || ! isset($payload['name'], $payload['knobs']) || ! is_array($payload['knobs'])) {
                continue;
            }
            $presets[] = [
                'key' => $key,
                'name' => (string) $payload['name'],
                'description' => isset($payload['description']) ? (string) $payload['description'] : null,
                'knobs' => $payload['knobs'],
            ];
        }

        usort($presets, fn (array $a, array $b) => strcmp($a['key'], $b['key']));

        return $presets;
    }

    /**
     * Resolve a `theme_versions.assets` map (slot → storage path) into the
     * shape the UI consumes: every known slot keyed to a public URL or null.
     * Missing slots come back as null so the front-end can branch on
     * "uploaded?" without an existence check per slot.
     *
     * @param  array<string, mixed>  $stored
     * @return array<string, ?string>
     */
    private function assetsPayload(array $stored): array
    {
        $slots = ['logo_light', 'logo_dark', 'favicon'];
        $out = [];
        foreach ($slots as $slot) {
            $path = $stored[$slot] ?? null;
            $out[$slot] = is_string($path) && $path !== '' ? MediaStorage::url($path) : null;
        }

        return $out;
    }

    /**
     * Multipart asset upload — logo (light/dark), favicon, OG image. Each slot
     * has its own mime + size cap (`AssetUploader::SLOTS`); validation lives
     * in the service so the slot rules are co-located with the writer. Caller
     * receives the new URL inline so the UI can swap thumbnails without a
     * second roundtrip.
     */
    public function uploadAsset(Request $request, ThemeVersion $version): JsonResponse
    {
        $request->validate([
            'slot' => ['required', 'string', 'in:logo_light,logo_dark,favicon'],
            'file' => ['required', 'file'],
        ]);

        try {
            $result = app(\App\Services\Theme\AssetUploader::class)
                ->upload($version, $request->input('slot'), $request->file('file'));
        } catch (\InvalidArgumentException $e) {
            return response()->json(['error' => $e->getMessage()], 422);
        }

        AuditLogger::record(
            'theme.asset.uploaded',
            $version,
            "Uploaded {$result['slot']}",
            $result,
        );

        return response()->json($result, 201);
    }

    /**
     * Clear an asset slot. Slot whitelist is enforced both by the route regex
     * and here as defense-in-depth — if the regex ever drifts, the controller
     * still won't punt arbitrary keys at the service.
     */
    public function deleteAsset(Request $request, ThemeVersion $version, string $slot): JsonResponse
    {
        if (! in_array($slot, ['logo_light', 'logo_dark', 'favicon'], true)) {
            abort(404);
        }

        app(\App\Services\Theme\AssetUploader::class)->delete($version, $slot);

        AuditLogger::record(
            'theme.asset.deleted',
            $version,
            "Deleted {$slot}",
            ['slot' => $slot],
        );

        return response()->json([], 204);
    }

    /**
     * One-click revert from an audit log entry. Only meaningful for
     * `theme.applied` rows — those are the ones we can roll back. "Revert"
     * means: re-activate the theme that was active immediately BEFORE this
     * entry. We find that by looking up the most-recent prior `theme.applied`
     * audit row.
     *
     * Edge cases:
     *   - First-ever applied row: no prior to revert to → friendly error.
     *   - Prior version has since been deleted → friendly error (the audit
     *     row pinned the id but the row may have been pruned).
     */
    public function revertFromAudit(Request $request, AuditLog $auditLog): RedirectResponse
    {
        if ($auditLog->action !== 'theme.applied') {
            abort(422, 'Revert only applies to theme.applied entries.');
        }

        $prior = AuditLog::query()
            ->where('action', 'theme.applied')
            ->where('id', '<', $auditLog->id)
            ->orderByDesc('id')
            ->first();

        if ($prior === null) {
            return redirect()->back()->with('error', 'No prior applied theme to revert to.');
        }

        $version = ThemeVersion::query()->find($prior->target_id);
        if ($version === null) {
            return redirect()->back()->with('error', 'Prior theme version no longer exists.');
        }

        DB::transaction(function () use ($version) {
            ThemeVersion::query()
                ->where('is_active', true)
                ->where('id', '!=', $version->id)
                ->update(['is_active' => false]);

            $version->forceFill([
                'is_active' => true,
                'applied_at' => now(),
            ])->save();
        });

        Settings::flushCache();

        AuditLogger::record(
            'theme.reverted',
            $version,
            "Reverted to '{$version->name}' (audit #{$auditLog->id})",
            [
                'reverted_audit_id' => $auditLog->id,
                'version_id' => $version->id,
            ],
        );

        return redirect()->back()->with('success', "Reverted to '{$version->name}'.");
    }
}
