"use strict";

// ---- tiny helpers -------------------------------------------------------
const $ = (id) => document.getElementById(id);
const nonce = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);

// The server mints a per-session access token and passes it in this iframe's
// URL. Attach it to every data request so other local origins that guess the
// port cannot read state or drive mutations.
const ACCESS_KEY = new URLSearchParams(location.search).get("k") || "";
function withKey(path) {
    const u = new URL(path, location.origin);
    if (ACCESS_KEY) u.searchParams.set("k", ACCESS_KEY);
    return u.pathname + u.search;
}

async function api(path, body) {
    const res = await fetch(withKey(path), {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body || {}),
    });
    if (!res.ok) throw new Error((await res.text()) || res.statusText);
    const text = await res.text();
    return text ? JSON.parse(text) : {};
}

function toast(msg, ms = 2600) {
    const t = $("toast");
    t.textContent = msg;
    t.hidden = false;
    clearTimeout(toast._t);
    toast._t = setTimeout(() => (t.hidden = true), ms);
}

// Surface any otherwise-unhandled async-handler failure to the user instead of
// letting it become a silent unhandled rejection with no feedback.
if (typeof window !== "undefined") {
    window.addEventListener("unhandledrejection", (e) => {
        toast("Error: " + (e.reason?.message || e.reason || "something went wrong"));
    });
}

function clampSize(w, h, max = 2048) {
    w = Math.max(1, Math.round(w));
    h = Math.max(1, Math.round(h));
    const longer = Math.max(w, h);
    if (longer > max) {
        const s = max / longer;
        w = Math.max(1, Math.round(w * s));
        h = Math.max(1, Math.round(h * s));
    }
    return { w, h };
}

// ---- state --------------------------------------------------------------
let state = { name: "animation", width: 256, height: 256, loops: 0, hiddenFirst: false, frames: [] };
let assetNonce = nonce();

function defaultDelay() {
    const v = parseInt($("in-delay-all").value, 10);
    return Number.isFinite(v) && v >= 0 ? v : 120;
}

async function refreshState() {
    const res = await fetch(withKey("/state"));
    if (!res.ok) throw new Error((await res.text()) || res.statusText);
    state = await res.json();
    assetNonce = nonce();
    render();
}

// ---- rendering ----------------------------------------------------------
function render() {
    renderPreview();
    renderMeta();
    renderSettings();
    renderFrames();
    syncDrawStage();
}

function renderPreview() {
    const img = $("preview");
    const empty = $("empty-preview");
    const has = state.frames.length > 0;
    empty.hidden = has;
    if (has) {
        img.src = withKey(`/preview.png?n=${assetNonce}`);
        img.hidden = false;
        img.classList.toggle("pixelated", Math.max(state.width, state.height) < 96);
    } else {
        img.hidden = true;
    }
}

function renderMeta() {
    const n = state.frames.length;
    const hidden = state.hiddenFirst && n >= 2;
    const animated = hidden ? state.frames.slice(1) : state.frames;
    const totalMs = Math.round(
        animated.reduce((a, f) => a + (f.delayNum || 0) / (f.delayDen || 1000), 0) * 1000
    );
    $("meta-frames").textContent = `${n} frame${n === 1 ? "" : "s"}${hidden ? " · 1 static" : ""}`;
    $("meta-duration").textContent = `${(totalMs / 1000).toFixed(1)}s`;
    $("meta-loops").textContent = state.loops === 0 ? "loops ∞" : `loops ${state.loops}`;
    const busy = n === 0;
    $("btn-export").disabled = busy;
    $("btn-download").disabled = busy;
    $("btn-restart").disabled = busy;
    $("btn-share").disabled = busy;
}

function renderSettings() {
    const n = state.frames.length;
    const hasFrames = n > 0;
    const w = $("in-width"),
        h = $("in-height"),
        l = $("in-loops");
    if (document.activeElement !== w) w.value = state.width;
    if (document.activeElement !== h) h.value = state.height;
    if (document.activeElement !== l) l.value = state.loops;
    w.disabled = hasFrames;
    h.disabled = hasFrames;
    $("dim-hint").style.display = hasFrames ? "block" : "none";

    const hf = $("in-hidden-first");
    hf.checked = !!state.hiddenFirst;
    hf.disabled = n < 2;
    $("hidden-first-label").classList.toggle("disabled", n < 2);
    $("hidden-first-hint").hidden = !(state.hiddenFirst && n >= 2);

    for (const id of ["btn-delay-all", "btn-fps", "btn-ops-all"]) $(id).disabled = !hasFrames;
}

function renderFrames() {
    const strip = $("frame-strip");
    const empty = $("empty-frames");
    strip.innerHTML = "";
    empty.hidden = state.frames.length > 0;
    const hidden = state.hiddenFirst && state.frames.length >= 2;
    state.frames.forEach((f, i) => {
        const isStatic = hidden && i === 0;
        const card = document.createElement("div");
        card.className = "frame-card" + (isStatic ? " is-static" : "");

        const thumbWrap = document.createElement("div");
        thumbWrap.className = "checker frame-thumb-wrap";
        const thumb = document.createElement("img");
        thumb.className = "frame-thumb";
        thumb.alt = `Frame ${i + 1}`;
        thumb.src = withKey(`/frame?id=${encodeURIComponent(f.id)}&n=${assetNonce}`);
        thumbWrap.appendChild(thumb);
        if (isStatic) {
            const badge = document.createElement("span");
            badge.className = "static-badge";
            badge.textContent = "STATIC";
            thumbWrap.appendChild(badge);
        }

        const body = document.createElement("div");
        body.className = "frame-body";

        const idx = document.createElement("div");
        idx.className = "frame-index";
        idx.textContent = isStatic ? `#${i + 1} · fallback` : `#${i + 1}`;

        // Inputs (declared first so the shared commit closure can read them).
        const numIn = numberInput(f.delayNum, 0, 65535, "Delay numerator");
        const denIn = numberInput(f.delayDen, 1, 65535, "Delay denominator");
        const disposeSel = selectEl(
            [[0, "None"], [1, "Background"], [2, "Previous"]],
            f.disposeOp,
            "Dispose op — what to do with the canvas after this frame"
        );
        const blendSel = selectEl(
            [[0, "Source"], [1, "Over"]],
            f.blendOp,
            "Blend op — how this frame is drawn onto the canvas"
        );
        const msHint = document.createElement("span");
        msHint.className = "ms-hint muted";

        const updateHint = () => {
            const num = parseInt(numIn.value, 10) || 0;
            const den = parseInt(denIn.value, 10) || 1000;
            const ms = Math.round((num / den) * 1000);
            const fps = num > 0 ? den / num : 0;
            const fpsTxt = fps ? ` · ${Number.isInteger(fps) ? fps : fps.toFixed(1)} fps` : "";
            msHint.textContent = `= ${ms} ms${fpsTxt}`;
        };
        updateHint();
        const commit = () =>
            api("/frames/props", {
                id: f.id,
                delayNum: parseInt(numIn.value, 10) || 0,
                delayDen: parseInt(denIn.value, 10) || 1000,
                disposeOp: parseInt(disposeSel.value, 10) || 0,
                blendOp: parseInt(blendSel.value, 10) || 0,
            }).catch((e) => toast("Error: " + e.message));

        numIn.addEventListener("input", updateHint);
        denIn.addEventListener("input", updateHint);
        numIn.addEventListener("change", commit);
        denIn.addEventListener("change", commit);
        disposeSel.addEventListener("change", commit);
        blendSel.addEventListener("change", commit);

        const delayRow = document.createElement("div");
        delayRow.className = "frame-field";
        delayRow.append(fieldLabel("delay"), numIn, slash(), denIn, msHint);

        const opsRow = document.createElement("div");
        opsRow.className = "frame-field";
        opsRow.append(fieldLabel("dispose"), disposeSel);
        const blendRow = document.createElement("div");
        blendRow.className = "frame-field";
        blendRow.append(fieldLabel("blend"), blendSel);

        const actions = document.createElement("div");
        actions.className = "frame-actions";
        actions.append(
            iconBtn("◀", "Move left", i === 0, () => api("/frames/move", { id: f.id, delta: -1 })),
            iconBtn("▶", "Move right", i === state.frames.length - 1, () =>
                api("/frames/move", { id: f.id, delta: 1 })
            ),
            iconBtn("⧉", "Duplicate", false, () => api("/frames/duplicate", { id: f.id })),
            iconBtn("✕", "Delete", false, () => api("/frames/delete", { id: f.id }), true)
        );

        if (isStatic) {
            [numIn, denIn, disposeSel, blendSel].forEach((el) => (el.disabled = true));
            msHint.textContent = "not animated";
        }

        body.append(idx, delayRow, opsRow, blendRow, actions);
        card.append(thumbWrap, body);
        strip.appendChild(card);
    });
}

function numberInput(value, min, max, title) {
    const el = document.createElement("input");
    el.type = "number";
    el.className = "num-in";
    el.min = String(min);
    el.max = String(max);
    el.step = "1";
    el.value = value;
    el.title = title;
    return el;
}

function selectEl(options, value, title) {
    const s = document.createElement("select");
    s.className = "frame-select";
    s.title = title;
    for (const [val, text] of options) {
        const o = document.createElement("option");
        o.value = String(val);
        o.textContent = text;
        s.appendChild(o);
    }
    s.value = String(value);
    return s;
}

function fieldLabel(text) {
    const s = document.createElement("span");
    s.className = "frame-label muted";
    s.textContent = text;
    return s;
}

function slash() {
    const s = document.createElement("span");
    s.className = "slash muted";
    s.textContent = "/";
    return s;
}

function iconBtn(label, title, disabled, onClick, danger) {
    const b = document.createElement("button");
    b.className = "icon-btn" + (danger ? " danger" : "");
    b.textContent = label;
    b.title = title;
    b.setAttribute("aria-label", title);
    b.disabled = !!disabled;
    b.addEventListener("click", async () => {
        try {
            await onClick();
        } catch (e) {
            toast("Error: " + e.message);
        }
    });
    return b;
}

// ---- settings handlers --------------------------------------------------
async function commitLoops() {
    await api("/settings", { loops: parseInt($("in-loops").value, 10) || 0 });
}
async function commitDim() {
    if (state.frames.length > 0) return;
    const w = parseInt($("in-width").value, 10);
    const h = parseInt($("in-height").value, 10);
    const { w: cw, h: ch } = clampSize(w || state.width, h || state.height);
    await api("/settings", { width: cw, height: ch });
}
function debounce(fn, ms) {
    let t = null;
    return () => {
        clearTimeout(t);
        t = setTimeout(fn, ms);
    };
}
// Commit on `input` (fires for stepper buttons and arrow keys in every engine,
// including the host WebKit view where `change` is unreliable for steppers) as
// well as `change` (final blur/Enter). The `input` path is debounced so holding
// an arrow or typing a value doesn't spam the server.
const commitDimSoon = debounce(commitDim, 300);
const commitLoopsSoon = debounce(commitLoops, 300);
for (const id of ["in-width", "in-height"]) {
    $(id).addEventListener("input", commitDimSoon);
    $(id).addEventListener("change", commitDim);
}
$("in-loops").addEventListener("input", commitLoopsSoon);
$("in-loops").addEventListener("change", commitLoops);
$("in-hidden-first").addEventListener("change", async (e) => {
    await api("/settings", { hiddenFirst: e.target.checked });
});
$("btn-delay-all").addEventListener("click", async () => {
    const ms = defaultDelay();
    await api("/frames/props-all", { delayMs: ms });
    toast(`All delays set to ${ms} ms`);
});
$("btn-fps").addEventListener("click", async () => {
    const fps = Math.max(1, Math.min(120, parseInt($("in-fps").value, 10) || 12));
    await api("/frames/props-all", { fps });
    toast(`All frames set to ${fps} fps`);
});
$("btn-ops-all").addEventListener("click", async () => {
    await api("/frames/props-all", {
        disposeOp: parseInt($("in-dispose-all").value, 10) || 0,
        blendOp: parseInt($("in-blend-all").value, 10) || 0,
    });
    toast("Applied dispose & blend to all frames");
});
$("btn-clear").addEventListener("click", async () => {
    if (!state.frames.length) return;
    if (!confirm("Remove all frames?")) return;
    await api("/frames/clear", {});
});

// ---- export / download --------------------------------------------------
$("btn-export").addEventListener("click", async () => {
    try {
        const r = await api("/export", {});
        $("saved-path").hidden = false;
        $("saved-path").textContent = "Saved: " + r.path;
        toast("Exported " + r.name);
    } catch (e) {
        toast("Export failed: " + e.message);
    }
});
$("btn-download").addEventListener("click", async () => {
    const res = await fetch(withKey(`/preview.png?n=${nonce()}`));
    const blob = await res.blob();
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = (state.name || "animation").replace(/[^\w.-]+/g, "_") + ".png";
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 4000);
});
$("btn-restart").addEventListener("click", () => {
    // Re-assigning an identical src is a no-op, so bump the nonce to force a
    // fresh fetch and restart the animation from the first frame.
    assetNonce = nonce();
    renderPreview();
});

// ---- send to phone ------------------------------------------------------
let shareCountdown = null;

$("btn-share").addEventListener("click", async () => {
    const btn = $("btn-share");
    btn.disabled = true;
    try {
        const info = await api("/share/start", {});
        openSharePanel(info);
    } catch (e) {
        toast("Couldn't start sharing: " + e.message);
    } finally {
        btn.disabled = state.frames.length === 0;
    }
});

$("btn-share-stop").addEventListener("click", stopSharing);

function openSharePanel(info) {
    $("share-panel").hidden = false;
    // Cache-bust the QR so a new token's code is fetched each time.
    $("share-qr-img").src = withKey(`/share/qr.png?ts=${Date.now()}`);
    const link = $("share-url");
    link.href = info.url;
    link.textContent = info.url.replace(/^https?:\/\//, "");
    startShareCountdown(info.expiresAt);
}

function startShareCountdown(expiresAt) {
    clearInterval(shareCountdown);
    const tick = () => {
        const ms = expiresAt - Date.now();
        if (ms <= 0) {
            clearInterval(shareCountdown);
            $("share-panel").hidden = true;
            toast("Phone link expired");
            api("/share/stop", {}).catch(() => {});
            return;
        }
        const m = Math.floor(ms / 60000);
        const s = Math.floor((ms % 60000) / 1000);
        $("share-expiry").textContent = `Expires in ${m}:${String(s).padStart(2, "0")}`;
    };
    tick();
    shareCountdown = setInterval(tick, 1000);
}

async function stopSharing() {
    clearInterval(shareCountdown);
    $("share-panel").hidden = true;
    try {
        await api("/share/stop", {});
    } catch (_) {
        /* server may have already expired the share */
    }
}

// ---- upload -------------------------------------------------------------
$("btn-upload").addEventListener("click", () => $("file-input").click());
$("file-input").addEventListener("change", async (e) => {
    const files = [...e.target.files];
    e.target.value = "";
    if (files.length) await addFiles(files);
});

async function addFiles(files) {
    try {
        toast(`Adding ${files.length} image${files.length === 1 ? "" : "s"}…`);
        if (state.frames.length === 0) {
            const first = await createImageBitmap(files[0]);
            const { w, h } = clampSize(first.width, first.height);
            first.close?.();
            await api("/settings", { width: w, height: h });
            await refreshState();
        }
        for (const f of files) await addImageFile(f);
        toast("Frames added");
    } catch (e) {
        toast("Upload error: " + e.message);
    }
}

async function addImageFile(file) {
    const bmp = await createImageBitmap(file);
    const c = document.createElement("canvas");
    c.width = state.width;
    c.height = state.height;
    try {
        drawContain(c.getContext("2d"), bmp, state.width, state.height);
    } finally {
        // Release the decoded bitmap right after the synchronous draw so a large
        // batch doesn't retain native image memory until GC.
        bmp.close?.();
    }
    const blob = await new Promise((r) => c.toBlob(r, "image/png"));
    await postFrame(blob, defaultDelay());
}

function drawContain(ctx, img, W, H) {
    const s = Math.min(W / img.width, H / img.height);
    const dw = img.width * s;
    const dh = img.height * s;
    ctx.clearRect(0, 0, W, H);
    ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
}

async function postFrame(blob, delayMs) {
    const res = await fetch(withKey(`/frames?delayMs=${delayMs || 0}`), { method: "POST", body: blob });
    if (!res.ok) throw new Error(await res.text());
}

// ---- drawing ------------------------------------------------------------
const drawCanvas = $("draw-canvas");
const dctx = drawCanvas.getContext("2d");
let drawTool = "pen";
let drawing = false;
let lastPt = null;

$("btn-toggle-draw").addEventListener("click", () => {
    const panel = $("draw-panel");
    panel.hidden = !panel.hidden;
    if (!panel.hidden) initDrawCanvas();
});
$("btn-cancel-draw").addEventListener("click", () => ($("draw-panel").hidden = true));
$("tool-pen").addEventListener("click", () => setTool("pen"));
$("tool-eraser").addEventListener("click", () => setTool("eraser"));
function setTool(t) {
    drawTool = t;
    $("tool-pen").classList.toggle("active", t === "pen");
    $("tool-eraser").classList.toggle("active", t === "eraser");
    $("tool-pen").setAttribute("aria-pressed", String(t === "pen"));
    $("tool-eraser").setAttribute("aria-pressed", String(t === "eraser"));
}
$("btn-clear-draw").addEventListener("click", () => {
    dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
});
$("btn-fill").addEventListener("click", () => {
    dctx.save();
    dctx.globalCompositeOperation = "source-over";
    dctx.fillStyle = $("draw-color").value;
    dctx.fillRect(0, 0, drawCanvas.width, drawCanvas.height);
    dctx.restore();
});
$("opt-onion").addEventListener("change", syncOnion);
$("opt-from-last").addEventListener("change", initDrawCanvas);

function syncDrawStage() {
    // Size the drawing surface to the project dimensions and scale for display.
    if (drawCanvas.width !== state.width || drawCanvas.height !== state.height) {
        drawCanvas.width = state.width;
        drawCanvas.height = state.height;
    }
    const longer = Math.max(state.width, state.height) || 1;
    const scale = Math.min(360, Math.max(200, longer)) / longer;
    const dispW = Math.max(1, Math.round(state.width * scale));
    // Drive the display size from the width plus the intrinsic aspect ratio and
    // let height follow, so a narrow side panel (CSS max-width) shrinks the
    // surface proportionally instead of stretching a fixed height.
    const ratio = `${state.width} / ${state.height}`;
    drawCanvas.style.width = dispW + "px";
    drawCanvas.style.height = "auto";
    drawCanvas.style.aspectRatio = ratio;
    drawCanvas.style.imageRendering = scale > 1.4 ? "pixelated" : "auto";
    const onion = $("onion-img");
    onion.style.width = dispW + "px";
    onion.style.height = "auto";
    onion.style.aspectRatio = ratio;
    if (!$("draw-panel").hidden) syncOnion();
}

function lastFrame() {
    return state.frames.length ? state.frames[state.frames.length - 1] : null;
}

function syncOnion() {
    const onion = $("onion-img");
    const lf = lastFrame();
    if ($("opt-onion").checked && lf) {
        onion.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`);
        onion.hidden = false;
    } else {
        onion.hidden = true;
    }
}

function initDrawCanvas() {
    syncDrawStage();
    dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height);
    const lf = lastFrame();
    if ($("opt-from-last").checked && lf) {
        const img = new Image();
        img.onload = () => dctx.drawImage(img, 0, 0, drawCanvas.width, drawCanvas.height);
        img.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`);
    }
    syncOnion();
}

function canvasPoint(e) {
    const rect = drawCanvas.getBoundingClientRect();
    return {
        x: (e.clientX - rect.left) * (drawCanvas.width / rect.width),
        y: (e.clientY - rect.top) * (drawCanvas.height / rect.height),
    };
}
function strokeTo(pt) {
    dctx.globalCompositeOperation = drawTool === "eraser" ? "destination-out" : "source-over";
    dctx.strokeStyle = $("draw-color").value;
    dctx.fillStyle = $("draw-color").value;
    dctx.lineWidth = parseInt($("draw-size").value, 10) || 6;
    dctx.lineCap = "round";
    dctx.lineJoin = "round";
    if (lastPt) {
        dctx.beginPath();
        dctx.moveTo(lastPt.x, lastPt.y);
        dctx.lineTo(pt.x, pt.y);
        dctx.stroke();
    } else {
        dctx.beginPath();
        dctx.arc(pt.x, pt.y, dctx.lineWidth / 2, 0, Math.PI * 2);
        dctx.fill();
    }
    lastPt = pt;
}
drawCanvas.addEventListener("pointerdown", (e) => {
    drawing = true;
    lastPt = null;
    drawCanvas.setPointerCapture(e.pointerId);
    strokeTo(canvasPoint(e));
});
drawCanvas.addEventListener("pointermove", (e) => {
    if (drawing) strokeTo(canvasPoint(e));
});
function endStroke() {
    drawing = false;
    lastPt = null;
}
drawCanvas.addEventListener("pointerup", endStroke);
drawCanvas.addEventListener("pointerleave", endStroke);
drawCanvas.addEventListener("pointercancel", endStroke);

$("btn-add-drawing").addEventListener("click", async () => {
    const blob = await new Promise((r) => drawCanvas.toBlob(r, "image/png"));
    await postFrame(blob, defaultDelay());
    toast("Frame added");
    if ($("opt-from-last").checked) {
        // Pull the just-added frame into state before re-seeding the canvas, so
        // "start from last frame" copies it instead of the previous frame (or a
        // blank canvas on the first add), which the async SSE update may not
        // have delivered yet.
        await refreshState();
        initDrawCanvas();
    }
});

// ---- live updates -------------------------------------------------------
let eventSource = null;
function connectEvents() {
    try {
        if (eventSource) eventSource.close();
        eventSource = new EventSource(withKey("/events"));
        eventSource.onmessage = () => refreshState();
    } catch (_) {
        eventSource = null; /* SSE unavailable; manual reload still works */
    }
}

$("btn-reload").addEventListener("click", async () => {
    const btn = $("btn-reload");
    const glyph = $("reload-glyph");
    btn.disabled = true;
    glyph.classList.add("spinning");
    try {
        // Recover a dropped live-update stream, then pull the latest state and
        // rebuild the preview + thumbnails against a fresh cache-busting nonce.
        if (!eventSource || eventSource.readyState === 2) connectEvents();
        await refreshState();
        toast("Reloaded");
    } catch (e) {
        toast("Reload failed: " + e.message);
    } finally {
        glyph.classList.remove("spinning");
        btn.disabled = false;
    }
});

connectEvents();
refreshState();
