import ActionsBar from '@/Components/Admin/Appearance/ActionsBar';
import AssetsPanel, { type AssetsState } from '@/Components/Admin/Appearance/AssetsPanel';
import ContrastChecker from '@/Components/Admin/Appearance/ContrastChecker';
import KnobsPanel, { type PresetSummary } from '@/Components/Admin/Appearance/KnobsPanel';
import PresetGallery from '@/Components/Admin/Appearance/PresetGallery';
import PreviewPane from '@/Components/Admin/Appearance/PreviewPane';
import VersionsList from '@/Components/Admin/Appearance/VersionsList';
import { AdminPageHeader, adminLayout } from '@/Layouts/AdminLayout';
import { DEFAULT_KNOBS, type DerivedTheme, type ThemeKnobs } from '@/lib/themePalette';
import { Head, router } from '@inertiajs/react';
import { useEffect, useMemo, useRef, useState } from 'react';

type VersionRow = {
    id: number;
    name: string;
    knobs?: ThemeKnobs;
    is_active: boolean;
    created_at: string | null;
    applied_at: string | null;
    creator_email: string | null;
};

type Props = {
    active: {
        id: number | null;
        name: string;
        knobs: ThemeKnobs;
        applied_at: string | null;
        assets?: AssetsState;
    };
    versions: VersionRow[];
    presets: PresetSummary[];
    draft: { knobs: ThemeKnobs; derived: DerivedTheme } | null;
};

const EMPTY_ASSETS: AssetsState = {
    logo_light: null,
    logo_dark: null,
    favicon: null,
};

function formatRelative(iso: string | null): string {
    if (!iso) return 'never';
    const d = new Date(iso);
    if (Number.isNaN(d.getTime())) return iso;
    return d.toLocaleString();
}

// Backfill missing nested fields with DEFAULT_KNOBS values. Existing draft
// sessions or theme versions saved before the typography pivot (Sprint 4)
// don't carry `scale_h1` / `weight_body` / etc., so naive `.size` access
// would throw. Deep-merge ensures the UI always sees a complete shape.
function withKnobDefaults(input: Partial<ThemeKnobs> | undefined): ThemeKnobs {
    const k = (input ?? {}) as Partial<ThemeKnobs>;
    return {
        ...DEFAULT_KNOBS,
        ...k,
        // Skin default lives HERE, not in DEFAULT_KNOBS — the parity spec
        // pins DEFAULT_KNOBS to the fixture's default-case knobs (a pure
        // derive input; skin never enters derive). Presets carry no `skin`,
        // so preset-apply through this helper resets any prior pick.
        skin: k.skin ?? 'default',
        accent: { ...DEFAULT_KNOBS.accent, ...(k.accent ?? {}) },
        // Subheading falls back to the display family if missing — themes
        // saved before the third-family pivot only have sans + display.
        font_subheading: k.font_subheading ?? k.font_display ?? DEFAULT_KNOBS.font_subheading,
        scale_h1: { ...DEFAULT_KNOBS.scale_h1, ...(k.scale_h1 ?? {}) },
        scale_h2: { ...DEFAULT_KNOBS.scale_h2, ...(k.scale_h2 ?? {}) },
        scale_h3: { ...DEFAULT_KNOBS.scale_h3, ...(k.scale_h3 ?? {}) },
        scale_body: { ...DEFAULT_KNOBS.scale_body, ...(k.scale_body ?? {}) },
    };
}

function Appearance({ active, versions, presets, draft }: Props) {
    const initialKnobs: ThemeKnobs = withKnobDefaults(draft?.knobs ?? active.knobs);
    const [knobs, setKnobs] = useState<ThemeKnobs>(initialKnobs);
    const [presetsOpen, setPresetsOpen] = useState(false);
    const [previewVersion, setPreviewVersion] = useState(0);
    const [activeAssets, setActiveAssets] = useState<AssetsState>(active.assets ?? EMPTY_ASSETS);

    const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
    const firstRenderRef = useRef(true);
    // ActionsBar flips this true before it starts a save → reload sequence.
    // beforeunload reads it and skips the "Leave site?" prompt so admin's
    // explicit publish doesn't get a confusing "unsaved changes" dialog
    // mid-flight. Reset to false on mount since a fresh page load has no
    // save in progress.
    const savingRef = useRef(false);

    useEffect(() => {
        // Skip the initial render — the server already has the draft (or active)
        // we hydrated from. We only POST when the user actually moves something.
        if (firstRenderRef.current) {
            firstRenderRef.current = false;
            return;
        }
        if (debounceRef.current) {
            clearTimeout(debounceRef.current);
        }
        debounceRef.current = setTimeout(() => {
            // Plain fetch (not Inertia) — endpoint returns JSON, not a full
            // page response, and we just need the session-side-effect.
            const csrf =
                document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
                    ?.content ?? '';
            fetch('/admin/appearance/draft', {
                method: 'POST',
                credentials: 'same-origin',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': csrf,
                    Accept: 'application/json',
                },
                body: JSON.stringify({ knobs }),
            })
                .then((r) => {
                    if (r.ok) setPreviewVersion((v) => v + 1);
                })
                .catch(() => {
                    /* network blip — preview will be stale but the editor stays usable */
                });
        }, 220);
        return () => {
            if (debounceRef.current) clearTimeout(debounceRef.current);
        };
    }, [knobs]);

    const triggerReset = () => {
        setKnobs(DEFAULT_KNOBS);
    };

    const onApplyPreset = (next: ThemeKnobs) => {
        // Presets carry knob+palette only — never a skin. Fold through
        // withKnobDefaults so applying a preset REPLACES the current skin with
        // the default (skin:'default'), instead of leaving a stale prior pick
        // behind. (Skin and preset are orthogonal axes: preset = app+base
        // palette, skin = marketing design system.)
        setKnobs(withKnobDefaults(next));
    };

    // Compare current knobs against the active theme's saved knobs — if
    // they diverge, admin has unsaved changes. Used to gate the
    // beforeunload prompt and the Discard CTA.
    const initialActiveKnobsJson = useMemo(
        () => JSON.stringify(withKnobDefaults(active.knobs)),
        [active.knobs],
    );
    const isDirty = useMemo(
        () => JSON.stringify(knobs) !== initialActiveKnobsJson,
        [knobs, initialActiveKnobsJson],
    );

    // beforeunload: native browser "Leave site?" prompt when admin tries to
    // refresh / close / nav externally with unsaved knobs. Modern Chrome /
    // Safari ignore the custom message and show their own generic copy, but
    // the prompt itself still fires.
    //
    // When admin confirms leave (or refreshes), sendBeacon fires a DELETE
    // to /admin/appearance/draft — clears the session draft so the next
    // page load (post-refresh or anywhere else in admin) renders against
    // the saved active theme, not the in-flight preview. Matches the
    // requested semantic: "page refresh cancels unsaved changes".
    useEffect(() => {
        if (!isDirty) return;

        const handler = (e: BeforeUnloadEvent) => {
            // Suppress the prompt during an in-flight save — admin clicked
            // Publish, that's an intentional reload, no warning needed.
            if (savingRef.current) return;

            // fetch + keepalive survives the unload (modern replacement for
            // sendBeacon when you need a non-POST method or custom headers).
            // The server-side route is DELETE, so sendBeacon — which is
            // POST-only — would require a separate clear-endpoint.
            const csrf =
                document
                    .querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
                    ?.content ?? '';
            fetch('/admin/appearance/draft', {
                method: 'DELETE',
                credentials: 'same-origin',
                headers: {
                    Accept: 'application/json',
                    'X-CSRF-TOKEN': csrf,
                    'X-Requested-With': 'XMLHttpRequest',
                },
                keepalive: true,
            });

            e.preventDefault();
            // Older browsers (Firefox <44) require returnValue assignment.
            e.returnValue = '';
        };

        window.addEventListener('beforeunload', handler);
        return () => window.removeEventListener('beforeunload', handler);
    }, [isDirty]);

    // Inertia SPA navigation: same intent, different event. Without this
    // hook, clicking a sidebar link silently strands the preview draft —
    // beforeunload only fires on full unload (refresh / external nav).
    useEffect(() => {
        if (!isDirty) return;

        const remove = router.on('before', (e) => {
            // Skip the in-page Inertia reloads we trigger ourselves (after
            // save / apply) — those carry data that's already in sync.
            const visit = e.detail.visit;
            const isOwn = visit.url.pathname === '/admin/appearance';
            if (isOwn) return;
            if (
                !window.confirm(
                    'You have unsaved theme changes. Leave this page and discard them?',
                )
            ) {
                e.preventDefault();
                return;
            }
            // Confirmed leave — clear the server-side draft so other admin
            // pages don't keep rendering against the unsaved preview.
            const csrf =
                document
                    .querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
                    ?.content ?? '';
            fetch('/admin/appearance/draft', {
                method: 'DELETE',
                credentials: 'same-origin',
                headers: {
                    Accept: 'application/json',
                    'X-CSRF-TOKEN': csrf,
                    'X-Requested-With': 'XMLHttpRequest',
                },
                // keepalive lets the request finish even after navigation.
                keepalive: true,
            });
        });
        return () => remove();
    }, [isDirty]);

    // Explicit "Discard changes" — resets knobs to the active theme + tells
    // the server to drop the draft so the preview reverts immediately.
    const discardChanges = () => {
        if (
            !window.confirm(
                'Discard unsaved theme changes and revert to the active version?',
            )
        ) {
            return;
        }
        setKnobs(withKnobDefaults(active.knobs));
        const csrf =
            document
                .querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
                ?.content ?? '';
        fetch('/admin/appearance/draft', {
            method: 'DELETE',
            credentials: 'same-origin',
            headers: {
                Accept: 'application/json',
                'X-CSRF-TOKEN': csrf,
                'X-Requested-With': 'XMLHttpRequest',
            },
        });
    };

    return (
        <>
            <Head title="Appearance" />
            <AdminPageHeader
                title="Appearance · Theme management"
                description={`Active theme: ${active.name} · Applied ${formatRelative(active.applied_at)}`}
            />
            <div
                data-testid="appearance-page"
                className="flex flex-col gap-4 lg:flex-row lg:items-start"
            >
                {/* Left rail — Brand assets first (logo / favicon are the
                    landings most admins touch on first visit), then theme
                    knobs (colors / fonts / radii). */}
                <div className="flex w-full shrink-0 flex-col gap-4 lg:w-[320px]">
                    <AssetsPanel
                        versionId={active.id}
                        initialAssets={activeAssets}
                        onChange={(next) => {
                            setActiveAssets(next);
                            // Bump the iframe-reload tick so the preview
                            // re-fetches and picks up the new logo /
                            // favicon. The knobs path bumps via the
                            // /draft POST in the knobs effect above; the
                            // assets path writes to the server directly
                            // (it's not draft-staged), so the server side
                            // is already current when onChange fires —
                            // just need to nudge the iframe to reload.
                            setPreviewVersion((v) => v + 1);
                        }}
                    />
                    <KnobsPanel
                        knobs={knobs}
                        onChange={setKnobs}
                        onReset={triggerReset}
                        presets={presets}
                        onOpenPresets={() => setPresetsOpen(true)}
                    />
                </div>

                {/* Center — Preview. Explicit height anchors the inner
                    flex chain (PreviewPane → ResizableFrame → iframe all use
                    h-full); without it, flex-1/h-full collapse to a sliver
                    and the iframe renders ~30px tall. */}
                <div className="flex min-h-[640px] min-w-0 flex-1 flex-col lg:h-[calc(100vh-9rem)] lg:min-h-0">
                    <PreviewPane knobs={knobs} previewVersion={previewVersion} />
                </div>

                {/* Right rail — Actions + diagnostics */}
                <div className="flex w-full shrink-0 flex-col gap-4 lg:w-[280px]">
                    {isDirty && (
                        <div
                            className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-[12px]"
                            data-testid="appearance-dirty-banner"
                        >
                            <span className="mt-0.5 text-warning">●</span>
                            <div className="flex-1">
                                <p className="font-medium text-foreground">
                                    Unsaved theme changes
                                </p>
                                <p className="mt-0.5 text-[11px] text-muted-foreground">
                                    Leaving or refreshing without publishing
                                    will discard the preview.
                                </p>
                                <button
                                    type="button"
                                    onClick={discardChanges}
                                    className="mt-1.5 text-[11px] font-medium text-destructive hover:underline"
                                    data-testid="appearance-discard-changes"
                                >
                                    Discard changes
                                </button>
                            </div>
                        </div>
                    )}
                    <ActionsBar
                        knobs={knobs}
                        // Same defaulting as the client knobs state — themes saved
                        // before a knob existed (e.g. `skin`) would otherwise
                        // stringify-diverge and read as dirty on first load.
                        activeKnobs={withKnobDefaults(active.knobs)}
                        activeId={active.id}
                        onSaveStart={() => {
                            savingRef.current = true;
                        }}
                    />
                    <VersionsList
                        versions={versions}
                        activeId={active.id}
                        onRestore={(next) => setKnobs(next)}
                    />
                    <ContrastChecker knobs={knobs} />
                </div>
            </div>

            <PresetGallery
                presets={presets}
                open={presetsOpen}
                onClose={() => setPresetsOpen(false)}
                onApplyPreset={onApplyPreset}
            />
        </>
    );
}

Appearance.layout = adminLayout;

export default Appearance;
