import type { FormFactor, Layout } from '@/types/layout';
import { useEffect, useRef, useState } from 'react';
import { editorTabId, reverbSocketId } from './realtime';
import { useEditor } from './store';

function readCookie(name: string): string | undefined {
    const match = document.cookie.match(
        new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`),
    );
    return match ? decodeURIComponent(match[1]!) : undefined;
}

export async function saveScreen(
    screenId: number,
    layout: Layout,
    factor?: FormFactor,
    baseVersion?: number | null,
): Promise<number | null> {
    const xsrf = readCookie('XSRF-TOKEN');
    const body: { layout: Layout; factor?: FormFactor; base_version?: number } = {
        layout,
    };
    // Multi-device pivot Phase 2: server applies this edit into the v4
    // envelope under `factor`. Single-factor projects don't need to send it
    // (server defaults to project.form_factor); multi-factor projects MUST
    // send it or edits target the wrong factor.
    if (factor) body.factor = factor;
    // Optimistic-concurrency baseline (stale-save guard). The version this tab
    // last loaded/saved; the server 409s when stored has moved past it (another
    // tab / editor saved in between) rather than clobbering. Omitted → server
    // skips the check (back-compat).
    if (typeof baseVersion === 'number') body.base_version = baseVersion;
    // X-Socket-ID lets the server's broadcast()->toOthers() exclude OUR Reverb
    // socket at the transport level (best-effort — null on the lock path /
    // before connect). X-Editor-Tab is the connection-independent backstop: the
    // server echoes it back as `origin_tab` so this tab ignores its OWN save
    // even before the socket connects (when toOthers can't exclude it). Without
    // it, a queued self-echo arriving post-connect forces a spurious reload.
    const socketId = reverbSocketId();
    const res = await fetch(`/screens/${screenId}`, {
        method: 'PATCH',
        credentials: 'same-origin',
        headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
            ...(xsrf ? { 'X-XSRF-TOKEN': xsrf } : {}),
            ...(socketId ? { 'X-Socket-ID': socketId } : {}),
            'X-Editor-Tab': editorTabId,
        },
        body: JSON.stringify(body),
    });
    if (!res.ok) {
        let message = `Save failed (${res.status})`;
        let holder: { id: number; name: string } | null = null;
        try {
            const body = await res.json();
            if (body?.message) message = body.message;
            if (body?.holder) holder = body.holder;
        } catch {
            /* ignore */
        }
        // A 403 from the lock-holder write guard (Team Workspaces P4 / Δ21)
        // means we were taken over. Tag it so the autosave loop can flip to
        // read-only + clear its queue instead of retrying a doomed save.
        if (res.status === 403) {
            throw new SaveLockError(message, holder);
        }
        // A 409 from the stale-save guard: another tab/editor saved past our
        // base version. Tag it so the autosave loop halts + the editor reloads
        // to the latest instead of clobbering it.
        if (res.status === 409) {
            throw new SaveConflictError(message);
        }
        // A 404 on a previously-authorized save = the active workspace was
        // switched in another tab (Δ8) — tenant mismatch on authorizeProject.
        // Tag it distinctly so the editor can surface a "reload" toast instead
        // of the lock-loss one.
        if (res.status === 404) {
            throw new SaveAuthError(message);
        }
        throw new Error(message);
    }
    // Success — return the new version so the autosave loop advances its base
    // (so OUR next save isn't seen as stale).
    try {
        const ok = await res.json();
        return typeof ok?.version === 'number' ? ok.version : null;
    } catch {
        return null;
    }
}

/**
 * A save was rejected by the optimistic-concurrency guard (409): another tab or
 * editor saved past our base version. The editor halts autosave + reloads to the
 * latest rather than overwriting newer work.
 */
export class SaveConflictError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'SaveConflictError';
    }
}

/**
 * A previously-authorized save now 404s — the active workspace was changed in
 * another tab (Δ8). Distinct from a lock-loss so the editor toasts "Workspace
 * changed in another tab — reload" rather than the take-over message.
 */
export class SaveAuthError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'SaveAuthError';
    }
}

/** Distinguishes a lock-loss 403 (taken over) from a generic save failure. */
export class SaveLockError extends Error {
    holder: { id: number; name: string } | null;

    constructor(message: string, holder: { id: number; name: string } | null) {
        super(message);
        this.name = 'SaveLockError';
        this.holder = holder;
    }
}

export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'readonly';

const DEBOUNCE_MS = 800;

export type AutoSaveOptions = {
    /** Lock-loss handler (Team Workspaces P4 / Δ21). Fired when a save 403s
     *  because the project lock was taken over — useEditLock flips read-only
     *  + toasts. The autosave loop has already cleared its queue by the time
     *  this runs. */
    onLockLost?: (holder: { id: number; name: string } | null) => void;
    /** Cross-tab workspace switch (Δ8). A previously-authorized save 404s
     *  because the active workspace flipped in another tab — surface a
     *  distinct "reload" toast, not the take-over one. */
    onWorkspaceChanged?: () => void;
    /** Stale-save conflict (409). Another tab/editor saved past our base
     *  version. The loop has already halted by the time this fires — the editor
     *  toasts + reloads to the latest. */
    onConflict?: () => void;
    /** When true, autosave is suppressed entirely (read-only viewer / not the
     *  lock holder). The status pins to 'readonly'. */
    readOnly?: boolean;
    /** Optimistic-concurrency baseline — the active screen's server `version`.
     *  Echoed back as `base_version`; advanced from each save response so OUR
     *  own writes aren't flagged stale. A server-driven reload re-seeds it. */
    version?: number;
};

export function useAutoSave(
    screenId: number,
    factor?: FormFactor,
    options?: AutoSaveOptions,
): SaveStatus {
    const [status, setStatus] = useState<SaveStatus>('idle');
    const statusRef = useRef(status);
    statusRef.current = status;

    // Keep the latest callbacks/flags in a ref so the effect doesn't re-run
    // (and tear down the subscription) on every render.
    const optsRef = useRef(options);
    optsRef.current = options;

    const readOnly = options?.readOnly === true;

    // Optimistic-concurrency base version. Seeded from the screen prop and
    // advanced from each save response so OUR own writes aren't flagged stale.
    // A server-driven reload re-seeds it from the (newer) prop; the post-save
    // ref value is always ≥ the prop, so the sync effect only ever moves it
    // forward (a quiet save that bumped the ref won't be reset by an unchanged
    // prop). Kept OUT of the effect deps — version churn must not tear down the
    // autosave subscription.
    const propVersion = options?.version;
    const versionRef = useRef<number | null>(
        typeof propVersion === 'number' ? propVersion : null,
    );
    useEffect(() => {
        if (typeof propVersion === 'number') versionRef.current = propVersion;
    }, [propVersion]);

    useEffect(() => {
        if (readOnly) {
            setStatus('readonly');
            return;
        }

        // Re-entering editable mode (took over the lock) — clear the pinned
        // 'readonly' status so the indicator doesn't keep showing "Read-only"
        // until the next save fires. Functional update avoids a stale closure.
        setStatus((s) => (s === 'readonly' ? 'idle' : s));

        let timer: ReturnType<typeof setTimeout> | null = null;
        let inFlight = false;
        let pending: Layout | null = null;
        let lockLost = false;

        const flush = async (layout: Layout): Promise<void> => {
            if (lockLost) return;
            if (inFlight) {
                pending = layout;
                return;
            }
            inFlight = true;
            setStatus('saving');
            try {
                const newVersion = await saveScreen(
                    screenId,
                    layout,
                    factor,
                    versionRef.current,
                );
                if (typeof newVersion === 'number') {
                    versionRef.current = newVersion;
                }
                if (!pending) setStatus('saved');
            } catch (err) {
                if (err instanceof SaveConflictError) {
                    // Stale write rejected — another tab/editor saved past our
                    // base. Halt like a lock-loss (clear queue + pending
                    // debounce so nothing re-fires the doomed save) and let the
                    // editor reload to the latest. Do NOT go 'readonly' — this
                    // isn't a lock change.
                    lockLost = true;
                    pending = null;
                    if (timer) {
                        clearTimeout(timer);
                        timer = null;
                    }
                    setStatus('error');
                    optsRef.current?.onConflict?.();
                    return;
                }
                if (err instanceof SaveLockError) {
                    // Taken over (Δ19a): clear the queued layout AND any pending
                    // debounce so nothing re-fires the doomed save, flip to
                    // read-only, and notify useEditLock.
                    lockLost = true;
                    pending = null;
                    if (timer) {
                        clearTimeout(timer);
                        timer = null;
                    }
                    setStatus('readonly');
                    optsRef.current?.onLockLost?.(err.holder);
                    return;
                }
                if (err instanceof SaveAuthError) {
                    // Workspace switched in another tab (Δ8). Stop retrying;
                    // surface a distinct toast. Don't go read-only — this is a
                    // tenant change, not a lock loss.
                    lockLost = true;
                    pending = null;
                    if (timer) {
                        clearTimeout(timer);
                        timer = null;
                    }
                    setStatus('error');
                    optsRef.current?.onWorkspaceChanged?.();
                    return;
                }
                setStatus('error');
            } finally {
                inFlight = false;
                if (pending && !lockLost) {
                    const next = pending;
                    pending = null;
                    void flush(next);
                }
            }
        };

        // Autosave triggers on `editSeq` — bumped ONLY by real user edits
        // (writeBackToBase), never by `replaceLayout` (server hydrate / reload /
        // factor + screen switch). Watching the `baseLayout` reference instead
        // would fire on every server re-hydrate, which under Reverb feeds a
        // reload→save→broadcast→reload loop and races the version guard. We
        // still SEND `baseLayout` (the pre-overlay source of truth) — only the
        // trigger changed.
        const unsub = useEditor.subscribe((state, prev) => {
            if (lockLost) return;
            if (state.editSeq === prev.editSeq) return;
            if (timer) clearTimeout(timer);
            timer = setTimeout(() => {
                timer = null;
                void flush(state.baseLayout);
            }, DEBOUNCE_MS);
        });

        return () => {
            unsub();
            if (timer && !lockLost) {
                clearTimeout(timer);
                timer = null;
                void saveScreen(
                    screenId,
                    useEditor.getState().baseLayout,
                    factor,
                ).catch(() => {
                    // no-op: component gone, nothing to show. A 403 on
                    // navigate-away (taken over mid-unmount) is silently
                    // dropped — acceptable (Δ19a).
                });
            }
        };
    }, [screenId, factor, readOnly]);

    return status;
}
