import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import axios from "axios";
import { notifyError } from "../shared/toast";
import { pagesOf, pageMeta, richParts } from "@/shared/formWalk";

// THE FORM BUILDER — the questions as they will be asked, edited in place.
//
// Shaped like Google Forms deliberately, and for the reason the owner gave: *"better have similar
// ui/ux control as google form did … so the user can easily adapted"*. Organizations arrive here
// with forms they already run there; a builder that works the way theirs does is one they do not
// have to be taught. They sent fifteen screenshots of their own form, and this is built to them:
// one card per question, a floating item toolbar down the right, a section tab, the type on the
// card, per-option routing, and a footer of duplicate / delete / Required.
//
// NO separate preview — the owner again: *"because google don't have preview, it combines the edit
// section and preview together"*. A preview is only needed when editing does not look like the
// thing.
// GOOGLE'S OWN MENU, group for group and in its order — somebody moving across finds each type
// where they expect it. The last group is ours: Google asks for a number, an email or a phone by
// putting validation on a short answer; naming them is fewer steps and the same result.
const KINDS = [
    ["text", "Short answer", "▁"], ["long_text", "Paragraph", "¶"],
    ["choice", "Multiple choice", "◉"], ["checkbox", "Checkboxes", "☑"], ["dropdown", "Drop-down", "⌄"],
    ["file", "File upload", "⇧"],
    ["scale", "Linear scale", "⇹"], ["rating", "Rating", "☆"],
    ["grid_choice", "Multiple-choice grid", "⠿"], ["grid_checkbox", "Tick box grid", "▦"],
    ["date", "Date", "📅"], ["time", "Time", "🕐"],
    ["number", "Number", "#"], ["email", "Email", "@"], ["phone", "Phone", "☎"],
];
const KIND_LABEL = Object.fromEntries(KINDS.map(([v, l]) => [v, l]));
const CHOICE_KINDS = ["choice", "checkbox", "dropdown"];
const GRID_KINDS = ["grid_choice", "grid_checkbox"];
const DISPLAY_KINDS = ["note", "image", "video"];
const MARK = { choice: "○", checkbox: "☐", dropdown: "▾" };

// RESPONSE VALIDATION, in Google's own words — a type, then the conditions that type offers.
const RULE_OPS = {
    number: [["gt", "Greater than"], ["gte", "Greater than or equal to"], ["lt", "Less than"],
        ["lte", "Less than or equal to"], ["eq", "Equal to"], ["neq", "Not equal to"],
        ["between", "Between"], ["not_between", "Not between"], ["is_number", "Is number"],
        ["whole_number", "Whole number"]],
    text: [["contains", "Contains"], ["not_contains", "Doesn't contain"], ["email", "Email"], ["url", "URL"]],
    length: [["max_chars", "Maximum character count"], ["min_chars", "Minimum character count"]],
    regex: [["matches", "Matches"], ["not_matches", "Doesn't match"], ["contains", "Contains"],
        ["not_contains", "Doesn't contain"]],
    checkbox: [["at_least", "Select at least"], ["at_most", "Select at most"], ["exactly", "Select exactly"]],
};
const RULE_TYPES = [["number", "Number"], ["text", "Text"], ["length", "Length"], ["regex", "Regular expression"]];
const NEEDS_NOTHING = ["is_number", "whole_number", "email", "url"];

export function nameFrom(label) {
    return String(label || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64);
}

export const blankField = (page = 1) => ({ name: "", label: "", kind: "text", required: false, options: [], page });

const label = { fontSize: 11, color: "var(--muted)" };
const card = {
    border: "1px solid var(--line)", borderRadius: 10, padding: 16, marginBottom: 12,
    background: "var(--card, #fff)", position: "relative",
};
const focused = { ...card, borderLeft: "5px solid var(--brand)" };
const optionOf = o => (typeof o === "string" ? { label: o } : (o || { label: "" }));

// A label with its marks taken off, for the places a label is read as one line rather than shown.
const plainLabel = text => String(text || "")
    .replace(/\*\*|__|\*/g, "")
    .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
    .trim();

// The four marks, as elements. The card that is NOT being edited IS the preview, so a label that
// shows its own asterisks is the preview lying about what the customer will read.
function Marked({ text }) {
    return <>{richParts(text).map((p, i) => {
        if (p.bold) return <b key={i}>{p.text}</b>;
        if (p.italic) return <i key={i}>{p.text}</i>;
        if (p.underline) return <u key={i}>{p.text}</u>;
        if (p.href) return <a key={i} href={p.href} target="_blank" rel="noreferrer">{p.text}</a>;

        return <span key={i}>{p.text}</span>;
    })}</>;
}

// The ⋮ menus. Closes on the next click anywhere, which is what everybody expects of one.
function Menu({ items }) {
    const [open, setOpen] = useState(false);
    const box = useRef(null);

    useEffect(() => {
        if (!open) return;
        const shut = e => { if (!box.current?.contains(e.target)) setOpen(false); };
        document.addEventListener("mousedown", shut);

        return () => document.removeEventListener("mousedown", shut);
    }, [open]);

    return (
        <span ref={box} style={{ position: "relative" }}>
            <button type="button" className="pt-btn pt-btn-sm" title="More" onClick={() => setOpen(o => !o)}>⋮</button>
            {open && (
                <div style={{
                    position: "absolute", right: 0, top: "100%", zIndex: 30, minWidth: 210,
                    background: "var(--card, #fff)", border: "1px solid var(--line)", borderRadius: 8,
                    boxShadow: "0 8px 24px rgba(0,0,0,.12)", padding: 4,
                }}>
                    {items.filter(Boolean).map(([text, run], i) => (
                        <button key={i} type="button" onClick={() => { setOpen(false); run(); }}
                            style={{
                                display: "block", width: "100%", textAlign: "left", padding: "8px 10px",
                                border: "none", background: "none", cursor: "pointer", fontSize: 12.5,
                                color: "var(--ink)", borderRadius: 6,
                            }}>{text}</button>
                    ))}
                </div>
            )}
        </span>
    );
}

// B / I / U / link / clear — the four marks the schema carries, written into the text as marks
// rather than as markup. Applies to the selection, like a word processor's.
function RichBar({ forId, value, onChange }) {
    const wrap = (before, after = before) => {
        const el = document.getElementById(forId);
        if (!el) return;
        const [from, to] = [el.selectionStart ?? 0, el.selectionEnd ?? 0];
        const picked = value.slice(from, to) || "text";
        onChange(value.slice(0, from) + before + picked + after + value.slice(to));
    };
    const btn = { border: "none", background: "none", cursor: "pointer", color: "var(--muted)", padding: "2px 7px", fontSize: 13 };

    return (
        <div style={{ display: "flex", gap: 2, margin: "4px 0 2px" }}>
            <button type="button" style={{ ...btn, fontWeight: 800 }} title="Bold" onClick={() => wrap("**")}>B</button>
            <button type="button" style={{ ...btn, fontStyle: "italic" }} title="Italic" onClick={() => wrap("*")}>I</button>
            <button type="button" style={{ ...btn, textDecoration: "underline" }} title="Underline" onClick={() => wrap("__")}>U</button>
            <button type="button" style={btn} title="Link" onClick={() => wrap("[", "](https://)")}>🔗</button>
            <button type="button" style={btn} title="Clear formatting"
                onClick={() => onChange(value.replace(/\*\*|__|\*/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"))}>✕̶</button>
        </div>
    );
}

// One question, as it will be asked — and, when it is the one being edited, its controls.
// DRAG TO REORDER, done properly (Arfu: "the drag drop function is not smooth enough").
//
// The browser's own drag-and-drop was the first attempt and it is coarse: no gap opening where the
// card will land, a drop that only counts over another card's middle, a ghost image of the whole
// card, and no scrolling when you reach the edge of the dialog. This is the sortable everybody
// actually means — pointer events, the list reordering live underneath, and a light preview
// following the cursor.
//
// The CARDS REORDER AS YOU MOVE rather than on release, so what you see is what you will get; the
// card being dragged stays in the list as the gap, which is why nothing has to be measured twice.
function useSortable(count, onMove) {
    const nodes = useRef(new Map());
    const [drag, setDrag] = useState(null);
    const state = useRef(null);
    // NOT called `move`: the pointermove handler below is a local `move`, and inside that effect
    // the local shadows this ref — `move.current` then reads a function that has no `.current`,
    // which is exactly the throw that made every drag look like it would not drop. (18/09/2026)
    const onMoveRef = useRef(onMove);
    onMoveRef.current = onMove;
    const size = useRef(count);
    size.current = count;

    // Where the pointer is, and what to draw following it.
    const [ghost, setGhost] = useState(null);

    useEffect(() => {
        if (!drag) return;

        const scroller = () => {
            let el = nodes.current.get(state.current?.at);
            while (el && el !== document.body) {
                const style = getComputedStyle(el);
                if (/(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight + 4) return el;
                el = el.parentElement;
            }

            return null;
        };

        const move = (e) => {
            const me = state.current;
            if (!me) return;
            setGhost({ x: e.clientX - me.grabX, y: e.clientY - me.grabY });

            // WHERE IT WOULD LAND: the first card whose middle is below the pointer.
            // IN LIST ORDER. A Map iterates by insertion, and a card that was re-rendered is
            // re-inserted at the end — so without this sort "the first card below the pointer" is
            // whichever one happened to render last.
            let to = null;
            for (const index of [...nodes.current.keys()].sort((a, b) => a - b)) {
                const node = nodes.current.get(index);
                if (!node) continue;
                const box = node.getBoundingClientRect();
                if (e.clientY < box.top + box.height / 2) { to = index; break; }
            }
            if (to === null) to = size.current - 1;
            if (to !== me.at) {
                onMoveRef.current(me.at, to);
                me.at = to;
                // AND THE TINT MOVES WITH IT. `drag.index` was where the card STARTED, so once the
                // list had reordered underneath, the card wearing the dashed outline was whichever
                // one had inherited that position — not the one in your hand. (Arfu, 18/09/2026)
                setDrag(d => (d && d.index !== to ? { ...d, index: to } : d));
            }

            // Scroll when the pointer reaches the edge of whatever is scrolling — a long form is
            // otherwise a drag you have to let go of half way.
            const box = scroller();
            if (box) {
                const area = box.getBoundingClientRect();
                if (e.clientY < area.top + 70) box.scrollTop -= 14;
                else if (e.clientY > area.bottom - 70) box.scrollTop += 14;
            }
        };
        const up = () => { setDrag(null); setGhost(null); state.current = null; };

        window.addEventListener("pointermove", move);
        window.addEventListener("pointerup", up);
        window.addEventListener("pointercancel", up);

        return () => {
            window.removeEventListener("pointermove", move);
            window.removeEventListener("pointerup", up);
            window.removeEventListener("pointercancel", up);
        };
    }, [drag]);

    const start = (index, label) => (e) => {
        // Never from inside something you type in or press: selecting a word in a label must not
        // pick the card up.
        if (e.button !== 0 || e.target.closest("input, textarea, select, button, label, a")) return;
        const box = nodes.current.get(index)?.getBoundingClientRect();
        if (!box) return;
        state.current = { at: index, grabX: e.clientX - box.left, grabY: e.clientY - box.top };
        setGhost({ x: box.left, y: box.top });
        setDrag({ index, label, width: box.width });
        e.preventDefault();
    };

    const hold = (index) => (node) => {
        if (node) nodes.current.set(index, node);
        else nodes.current.delete(index);
    };

    // WHAT FOLLOWS THE CURSOR. Tinted, not white-on-white: a pale card with a hairline border over
    // a page of pale cards reads as a rendering glitch rather than as something being carried.
    // (Arfu: "quite strange when drag dropping … how about use diff. bg color", 18/09/2026.)
    // Narrower than the card too, and the label's marks are stripped — a floating **asterisk** is
    // the one place the raw text is nobody's business.
    const preview = drag && ghost ? (
        <div style={{
            position: "fixed", left: ghost.x, top: ghost.y, width: Math.min(drag.width, 460), zIndex: 80,
            pointerEvents: "none", background: "var(--brand)", color: "#fff",
            borderRadius: 10, padding: "10px 14px", boxShadow: "0 16px 36px rgba(0,0,0,.28)",
            transform: "rotate(-1deg) scale(.98)", fontSize: 13, fontWeight: 600,
            whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
            display: "flex", alignItems: "center", gap: 10,
        }}>
            <span style={{ opacity: .75, letterSpacing: "2px" }}>⠿</span>
            {plainLabel(drag.label) || "Untitled question"}
        </div>
    ) : null;

    return { start, hold, dragging: drag?.index ?? null, preview };
}

// The grip at the top of a card. The WHOLE card drags (see the wrapper below) — this is the thing
// that says so, and it is a comfortable target in its own right rather than six pixels of dots.
function Grip() {
    return (
        <div title="Drag to reorder"
            style={{ textAlign: "center", color: "var(--faint)", fontSize: 18, lineHeight: "18px",
                letterSpacing: "2px", cursor: "grab", marginTop: -10, marginBottom: 6,
                padding: "2px 0", userSelect: "none" }}>⠿</div>
    );
}

// ONE FORM, TWO LANGUAGES (v0.30, 24/09/2026). With 繁體中文 switched on for the form, a box for the
// Chinese sits under every English text the customer reads. Context rather than a prop through
// every card: the switch is the form's, and every text input below it answers to it.
const ZhOn = createContext(false);

function Zh({ value, onChange, placeholder = "繁體中文", multiline = false, style }) {
    if (!useContext(ZhOn)) return null;
    const box = { marginTop: 4, fontSize: 12.5, borderStyle: "dashed", ...style };

    return multiline
        ? <textarea className="pt-input" rows={2} value={value || ""} placeholder={placeholder} style={box}
            onChange={e => onChange(e.target.value)} />
        : <input className="pt-input" value={value || ""} placeholder={placeholder} style={box}
            onChange={e => onChange(e.target.value)} />;
}

function Question({ field, index, sections, sectionTitles, branch, editing, onOpen, onPatch, onRemove,
    onMove, onDuplicate, onImage, onOptionImage, onRoute, first, lastOne }) {
    const kind = field.kind || "text";
    const options = (field.options || []).map(optionOf);
    const rule = field.validate || {};
    const setRule = p => onPatch({ validate: { ...rule, ...p } });
    const [extras, setExtras] = useState({ help: !!field.help, rule: !!rule.op, route: !!branch });
    const id = `q-label-${index}`;
    const setZh = p => onPatch({ zh: { ...(field.zh || {}), ...p } });

    // A DISPLAY ITEM: a title block, a picture or a video. It asks nothing, so it has no type
    // dropdown, no Required and no stored name.
    if (DISPLAY_KINDS.includes(kind)) {
        return (
            <div style={editing ? focused : card} onClick={editing ? undefined : onOpen}>
                <input className="pt-input" value={field.label || ""} placeholder={kind === "note" ? "Title" : "Caption"}
                    style={{ fontSize: 14, fontWeight: 600 }} readOnly={!editing}
                    onChange={e => onPatch({ label: e.target.value })} />
                {editing && <Zh value={field.zh?.label} onChange={v => setZh({ label: v })} placeholder={kind === "note" ? "標題（繁體）" : "說明文字（繁體）"} />}
                {kind === "note" && (
                    <textarea className="pt-input" rows={2} value={field.help || ""} placeholder="Description"
                        style={{ marginTop: 8, fontSize: 12.5 }} readOnly={!editing}
                        onChange={e => onPatch({ help: e.target.value })} />
                )}
                {kind === "note" && editing && <Zh multiline value={field.zh?.help} onChange={v => setZh({ help: v })} placeholder="描述（繁體）" />}
                {kind === "video" && (
                    <input className="pt-input" value={field.url || ""} placeholder="https://www.youtube.com/watch?v=…"
                        style={{ marginTop: 8, fontSize: 12.5 }} readOnly={!editing}
                        onChange={e => onPatch({ url: e.target.value })} />
                )}
                {kind === "image" && (field.image
                    ? <img src={`/storage/${field.image}`} alt="" style={{ maxWidth: 280, borderRadius: 8, display: "block", marginTop: 8 }} />
                    : <label className="pt-btn pt-btn-sm" style={{ marginTop: 8, display: "inline-block", cursor: "pointer" }}>
                        + Choose a picture
                        <input type="file" accept="image/*" hidden onChange={e => onImage(e.target.files?.[0])} />
                    </label>)}
                {editing && (
                    <div style={{ marginTop: 10, paddingTop: 10, borderTop: "1px solid var(--line)", display: "flex", gap: 4, justifyContent: "flex-end" }}>
                        <button type="button" className="pt-btn pt-btn-sm" disabled={first} onClick={() => onMove(-1)} title="Move up">↑</button>
                        <button type="button" className="pt-btn pt-btn-sm" disabled={lastOne} onClick={() => onMove(1)} title="Move down">↓</button>
                        <button type="button" className="pt-btn pt-btn-sm" onClick={onRemove} title="Remove">🗑</button>
                    </div>
                )}
            </div>
        );
    }

    if (!editing) {
        return (
            <div style={{ ...card, cursor: "pointer" }} onClick={onOpen}>
                <Grip />
                <div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: 6 }}>
                    {field.label
                        ? <Marked text={field.label} />
                        : <span style={{ color: "var(--faint)" }}>Untitled question</span>}
                    {field.required && <span style={{ color: "var(--red)" }} title="Required"> *</span>}
                </div>
                {field.help && <div style={{ fontSize: 11.5, color: "var(--faint)", marginBottom: 6 }}><Marked text={field.help} /></div>}
                {field.image && <img src={`/storage/${field.image}`} alt="" style={{ maxWidth: 260, borderRadius: 8, display: "block", marginBottom: 8 }} />}
                {CHOICE_KINDS.includes(kind) ? (
                    <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
                        {options.filter(o => o.label).map((o, n) => (
                            <div key={n} style={{ fontSize: 12.5, color: "var(--ink-2)" }}>{MARK[kind]} {o.label}</div>
                        ))}
                        {field.other && <div style={{ fontSize: 12.5, color: "var(--faint)" }}>{MARK[kind]} {field.other_label || "Other"}…</div>}
                    </div>
                ) : GRID_KINDS.includes(kind) ? (
                    <div style={{ fontSize: 12, color: "var(--ink-2)" }}>
                        {(field.rows || []).length} rows × {(field.columns || []).length} columns
                    </div>
                ) : kind === "scale" ? (
                    <div style={{ fontSize: 12.5, color: "var(--ink-2)" }}>
                        {(field.scale?.min ?? 1)} — {(field.scale?.max ?? 5)}
                    </div>
                ) : kind === "rating" ? (
                    <div style={{ fontSize: 15, color: "var(--faint)" }}>
                        {"☆".repeat(field.rating?.max ?? 5)}
                    </div>
                ) : (
                    <div style={{ fontSize: 12, color: "var(--faint)", borderBottom: "1px dashed var(--line)", paddingBottom: 6 }}>
                        {KIND_LABEL[kind]} text
                    </div>
                )}
            </div>
        );
    }

    const ops = RULE_OPS[kind === "checkbox" ? "checkbox" : (rule.type || "number")] || [];

    return (
        <div style={focused}>
            <Grip />

            <div style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
                <div style={{ flex: 1 }}>
                    <input id={id} className="pt-input" value={field.label} placeholder="Question"
                        style={{ fontSize: 14, fontWeight: 600, background: "var(--bg-2, #f7f7f8)" }}
                        onChange={e => onPatch({ label: e.target.value, ...(field.nameLocked ? {} : { name: nameFrom(e.target.value) }) })} />
                    <RichBar forId={id} value={field.label || ""} onChange={v => onPatch({ label: v })} />
                    <Zh value={field.zh?.label} onChange={v => setZh({ label: v })} placeholder="問題（繁體）— **粗體** 等標記同英文一樣" />
                </div>
                <label className="pt-btn pt-btn-sm" style={{ cursor: "pointer", marginTop: 2 }} title="Add image">
                    🖼
                    <input type="file" accept="image/*" hidden onChange={e => onImage(e.target.files?.[0])} />
                </label>
                <select className="pt-input" style={{ width: 190, marginTop: 2 }} value={kind}
                    onChange={e => onPatch({ kind: e.target.value })}>
                    {KINDS.map(([v, l, icon]) => <option key={v} value={v}>{icon}  {l}</option>)}
                </select>
            </div>

            {field.image && (
                <div style={{ margin: "6px 0" }}>
                    <img src={`/storage/${field.image}`} alt="" style={{ maxWidth: 260, borderRadius: 8, display: "block" }} />
                    <button type="button" className="pt-btn pt-btn-sm" style={{ marginTop: 6 }}
                        onClick={() => onPatch({ image: "" })}>Remove image</button>
                </div>
            )}

            {extras.help && (
                <input className="pt-input" value={field.help || ""} placeholder="Description"
                    style={{ marginTop: 6, fontSize: 12.5 }}
                    onChange={e => onPatch({ help: e.target.value })} />
            )}
            {extras.help && <Zh value={field.zh?.help} onChange={v => setZh({ help: v })} placeholder="描述（繁體）" />}

            {/* THE OPTIONS, each with its own picture, its own remove, and — when this question
                decides where the section goes — its own "Go to section". */}
            {CHOICE_KINDS.includes(kind) && (
                <div style={{ marginTop: 10 }}>
                    {options.map((o, n) => (
                        <div key={n} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 7 }}>
                            <span style={{ color: "var(--faint)" }}>{MARK[kind]}</span>
                            <input className="pt-input" value={o.label} style={{ flex: 1, height: 32 }}
                                onChange={e => onPatch({
                                    options: options.map((x, m) => m === n ? { ...x, label: e.target.value } : x),
                                })} />
                            {/* Kept ON the option, so moving or deleting it keeps its translation with it. */}
                            <Zh value={o.zh} placeholder="繁體" style={{ marginTop: 0, width: 200, height: 32 }}
                                onChange={v => onPatch({ options: options.map((x, m) => m === n ? { ...x, zh: v } : x) })} />
                            {o.image
                                ? <img src={`/storage/${o.image}`} alt="" style={{ height: 32, borderRadius: 6 }} />
                                : <label className="pt-btn pt-btn-sm" style={{ cursor: "pointer" }} title="Add image">
                                    🖼
                                    <input type="file" accept="image/*" hidden onChange={e => onOptionImage(n, e.target.files?.[0])} />
                                </label>}
                            <button type="button" className="pt-btn pt-btn-sm" title="Remove"
                                onClick={() => onPatch({ options: options.filter((_, m) => m !== n) })}>✕</button>
                            {extras.route && (
                                <select className="pt-input" style={{ height: 32, width: 240 }}
                                    value={(branch?.cases || []).find(c => c.value === o.label)?.page ?? ""}
                                    onChange={e => onRoute(o.label, e.target.value)}>
                                    <option value="">Continue to next section</option>
                                    {/* EARLIER sections too since 24/09/2026 — a form may send an answer
                                        back, as Day Day Help's does; never to its own section. */}
                                    {sections.filter(sn => sn !== (field.page || 1)).map(sn => (
                                        <option key={sn} value={sn}>{sn < (field.page || 1) ? "Go back to" : "Go to"} section {sn}{sectionTitles[sn] ? ` (${sectionTitles[sn]})` : ""}</option>
                                    ))}
                                    <option value="submit">Submit form</option>
                                </select>
                            )}
                        </div>
                    ))}
                    {field.other && (
                        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 7 }}>
                            <span style={{ color: "var(--faint)" }}>{MARK[kind]}</span>
                            <input className="pt-input" style={{ flex: 1, height: 32 }} value={field.other_label || ""}
                                placeholder="Other…" onChange={e => onPatch({ other_label: e.target.value })} />
                            <Zh value={field.zh?.other_label} onChange={v => setZh({ other_label: v })} placeholder="其他（繁體）"
                                style={{ marginTop: 0, width: 200, height: 32 }} />
                            <button type="button" className="pt-btn pt-btn-sm"
                                onClick={() => onPatch({ other: false, other_label: "" })}>✕</button>
                        </div>
                    )}
                    <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5, color: "var(--muted)" }}>
                        <span style={{ color: "var(--faint)" }}>{MARK[kind]}</span>
                        <button type="button" onClick={() => onPatch({ options: [...options, { label: "" }] })}
                            style={{ border: "none", background: "none", cursor: "pointer", color: "var(--muted)", padding: 0 }}>
                            Add option
                        </button>
                        {!field.other && <>
                            <span>or</span>
                            <button type="button" onClick={() => onPatch({ other: true })}
                                style={{ border: "none", background: "none", cursor: "pointer", color: "var(--brand)", padding: 0, fontWeight: 600 }}>
                                Add “Other”
                            </button>
                        </>}
                    </div>
                </div>
            )}

            {GRID_KINDS.includes(kind) && (
                <div style={{ display: "flex", gap: 14, marginTop: 10 }}>
                    {["rows", "columns"].map(half => (
                        <div key={half} style={{ flex: 1 }}>
                            <div style={{ ...label, marginBottom: 4, textTransform: "capitalize" }}>{half}</div>
                            <textarea className="pt-input" rows={4} style={{ fontSize: 12.5 }}
                                placeholder={half === "rows" ? "One row per line" : "One column per line"}
                                value={(field[half] || []).join("\n")}
                                onChange={e => onPatch({ [half]: e.target.value.split("\n") })} />
                        </div>
                    ))}
                </div>
            )}

            {kind === "scale" && (
                <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 10, flexWrap: "wrap" }}>
                    <select className="pt-input" style={{ width: 80, height: 32 }} value={field.scale?.min ?? 1}
                        onChange={e => onPatch({ scale: { ...(field.scale || {}), min: Number(e.target.value) } })}>
                        {[0, 1].map(n => <option key={n} value={n}>{n}</option>)}
                    </select>
                    <span style={{ color: "var(--faint)" }}>to</span>
                    <select className="pt-input" style={{ width: 80, height: 32 }} value={field.scale?.max ?? 5}
                        onChange={e => onPatch({ scale: { ...(field.scale || {}), max: Number(e.target.value) } })}>
                        {[2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => <option key={n} value={n}>{n}</option>)}
                    </select>
                    <input className="pt-input" style={{ width: 170, height: 32, fontSize: 12.5 }} placeholder="Label at the low end"
                        value={field.scale?.min_label || ""}
                        onChange={e => onPatch({ scale: { ...(field.scale || {}), min_label: e.target.value } })} />
                    <input className="pt-input" style={{ width: 170, height: 32, fontSize: 12.5 }} placeholder="Label at the high end"
                        value={field.scale?.max_label || ""}
                        onChange={e => onPatch({ scale: { ...(field.scale || {}), max_label: e.target.value } })} />
                </div>
            )}

            {kind === "rating" && (
                <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 10 }}>
                    <select className="pt-input" style={{ width: 90, height: 32 }} value={field.rating?.max ?? 5}
                        onChange={e => onPatch({ rating: { ...(field.rating || {}), max: Number(e.target.value) } })}>
                        {[3, 4, 5, 6, 7, 8, 9, 10].map(n => <option key={n} value={n}>{n}</option>)}
                    </select>
                    <select className="pt-input" style={{ width: 130, height: 32 }} value={field.rating?.icon ?? "star"}
                        onChange={e => onPatch({ rating: { ...(field.rating || {}), icon: e.target.value } })}>
                        <option value="star">★ Stars</option>
                        <option value="heart">♥ Hearts</option>
                        <option value="thumb">👍 Thumbs</option>
                    </select>
                </div>
            )}

            {kind === "file" && (
                <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 10 }}>
                    <span style={label}>At most</span>
                    <input className="pt-input" type="number" min={1} max={5} style={{ width: 90, height: 32 }}
                        value={field.files?.max ?? ""}
                        onChange={e => onPatch({ files: { ...(field.files || {}), max: Number(e.target.value) || undefined } })} />
                    <span style={label}>files, each up to</span>
                    <input className="pt-input" type="number" min={1} max={10} style={{ width: 90, height: 32 }}
                        value={field.files?.mb ?? ""}
                        onChange={e => onPatch({ files: { ...(field.files || {}), mb: Number(e.target.value) || undefined } })} />
                    <span style={label}>MB</span>
                </div>
            )}

            {/* RESPONSE VALIDATION, read left to right as a sentence: type · condition · value · what
                to say. Google's row, and the same words. */}
            {extras.rule && (
                <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginTop: 12,
                    paddingTop: 10, borderTop: "1px solid var(--line)" }}>
                    {kind === "checkbox" ? <span style={label}>Checkbox</span> : (
                        <select className="pt-input" style={{ height: 32, width: 170 }} value={rule.type || "number"}
                            onChange={e => setRule({ type: e.target.value, op: "", value: "", value2: "" })}>
                            {RULE_TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
                        </select>
                    )}
                    <select className="pt-input" style={{ height: 32, width: 200 }} value={rule.op || ""}
                        onChange={e => setRule({ type: kind === "checkbox" ? "checkbox" : (rule.type || "number"), op: e.target.value })}>
                        <option value="">— condition —</option>
                        {ops.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
                    </select>
                    {!NEEDS_NOTHING.includes(rule.op) && (
                        <input className="pt-input" style={{ height: 32, width: 150, fontFamily: rule.type === "regex" ? "var(--mono)" : "inherit" }}
                            placeholder="Value" value={rule.value ?? ""}
                            onChange={e => setRule({ value: e.target.value })} />
                    )}
                    {["between", "not_between"].includes(rule.op) && (
                        <input className="pt-input" style={{ height: 32, width: 150 }} placeholder="and"
                            value={rule.value2 ?? ""} onChange={e => setRule({ value2: e.target.value })} />
                    )}
                    <input className="pt-input" style={{ flex: 1, minWidth: 180, height: 32, fontSize: 12.5 }}
                        placeholder="Custom error text" value={rule.message ?? ""}
                        onChange={e => setRule({ message: e.target.value })} />
                    <Zh value={field.zh?.message} onChange={v => setZh({ message: v })} placeholder="錯誤訊息（繁體）"
                        style={{ marginTop: 0, flex: 1, minWidth: 180, height: 32 }} />
                    <button type="button" className="pt-btn pt-btn-sm" title="Remove validation"
                        onClick={() => { onPatch({ validate: {} }); setExtras(x => ({ ...x, rule: false })); }}>✕</button>
                </div>
            )}

            <div style={{ marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--line)",
                display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
                <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
                    <span style={label}>Saved as</span>
                    <input className="pt-input" value={field.name} placeholder="full_name"
                        style={{ width: 160, height: 30, fontFamily: "var(--mono)", fontSize: 12 }}
                        onChange={e => onPatch({ name: e.target.value, nameLocked: true })} />
                </div>
                {sections.length > 1 && (
                    <select className="pt-input" style={{ width: 130, height: 30 }} value={field.page || 1}
                        onChange={e => onPatch({ page: Number(e.target.value) })}>
                        {sections.map(n => <option key={n} value={n}>Section {n}</option>)}
                    </select>
                )}
                <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
                    <button type="button" className="pt-btn pt-btn-sm" disabled={first} onClick={() => onMove(-1)} title="Move up">↑</button>
                    <button type="button" className="pt-btn pt-btn-sm" disabled={lastOne} onClick={() => onMove(1)} title="Move down">↓</button>
                    <button type="button" className="pt-btn pt-btn-sm" onClick={onDuplicate} title="Make a copy">⧉</button>
                    <button type="button" className="pt-btn pt-btn-sm" onClick={onRemove} title="Delete">🗑</button>
                    <span style={{ width: 1, height: 20, background: "var(--line)" }} />
                    <label style={{ fontSize: 12.5, display: "flex", alignItems: "center", gap: 6 }}>
                        <input type="checkbox" checked={!!field.required}
                            onChange={e => onPatch({ required: e.target.checked })} />
                        Required
                    </label>
                    <Menu items={[
                        ["Description", () => setExtras(x => ({ ...x, help: !x.help }))],
                        ["Response validation", () => setExtras(x => ({ ...x, rule: !x.rule }))],
                        CHOICE_KINDS.includes(kind) && ["Shuffle option order", () => onPatch({ shuffle: !field.shuffle })],
                        ["choice", "dropdown"].includes(kind) && ["Go to section based on answer",
                            () => { setExtras(x => ({ ...x, route: !x.route })); onRoute(null, null); }],
                    ]} />
                </div>
            </div>
        </div>
    );
}

// Where a section sends its answers when no answer decided it — Google's "After section N" line.
function Routing({ page, meta, sections, sectionTitles, onPatch }) {
    // Every section but this one — going BACK is allowed since 24/09/2026.
    const others = sections.filter(n => n !== page);
    const branch = meta.branch || null;
    const value = branch ? (branch.else ?? "") : (meta.next ?? "");

    return (
        <div style={{ display: "flex", alignItems: "center", gap: 10, margin: "4px 0 22px", fontSize: 12.5 }}>
            <span style={{ color: "var(--muted)" }}>After section {page}</span>
            <select className="pt-input" style={{ height: 32, width: 320 }} value={value}
                onChange={e => {
                    const to = e.target.value || undefined;
                    if (branch) return onPatch({ branch: { ...branch, else: to } });
                    onPatch({ next: to });
                }}>
                <option value="">Continue to next section</option>
                {others.map(n => (
                    <option key={n} value={n}>{n < page ? "Go back to" : "Go to"} section {n}{sectionTitles[n] ? ` (${sectionTitles[n]})` : ""}</option>
                ))}
                <option value="submit">Submit form</option>
            </select>
        </div>
    );
}

export default function FormBuilder({ draft, patch, sectioned, bilingual = false }) {
    const [editing, setEditing] = useState(null);
    const [busy, setBusy] = useState(false);
    const [shut, setShut] = useState({});
    const fields = draft.fields || [];
    const sections = sectioned ? pagesOf({ fields, pages: draft.pages || [], layout: 3 }) : [1];
    const sectionTitles = Object.fromEntries(
        (draft.pages || []).map(p => [Number(p.page) || 1, p.title || ""]),
    );

    const patchField = (i, p) => patch({ fields: fields.map((f, n) => n === i ? { ...f, ...p } : f) });

    // ONE STEP OF THE DRAG: the card moves to where the pointer is, and takes the SECTION it lands
    // in — dragging across a section header is how a question moves between sections in Google's,
    // and leaving the page behind would move a question that still answers somewhere else.
    const slide = useCallback((from, to) => {
        setEditing(e => (e === from ? to : e));
        patch(d => {
            const next = [...(d.fields || [])];
            const [moved] = next.splice(from, 1);
            next.splice(to, 0, { ...moved, page: (d.fields[to] || moved).page || 1 });

            return { fields: next };
        });
    }, [patch]);

    const sortable = useSortable(fields.length, slide);
    const removeField = (i) => patch({ fields: fields.filter((_, n) => n !== i) });

    const moveField = (i, by) => {
        const page = fields[i].page || 1;
        const siblings = fields.map((f, n) => ({ f, n })).filter(({ f }) => (f.page || 1) === page);
        const at = siblings.findIndex(({ n }) => n === i);
        const swap = siblings[at + by];
        if (!swap) return;
        const next = [...fields];
        [next[i], next[swap.n]] = [next[swap.n], next[i]];
        patch({ fields: next });
    };

    // A COPY, below the original and with a name of its own: two questions sharing a stored name
    // would keep one answer between them.
    const duplicate = (i) => {
        const copy = { ...fields[i], name: `${fields[i].name || "question"}_copy`, nameLocked: true };
        patch({ fields: [...fields.slice(0, i + 1), copy, ...fields.slice(i + 1)] });
        setEditing(i + 1);
    };

    const patchPage = (page, p) => {
        const pages = [...(draft.pages || [])];
        const at = pages.findIndex(x => Number(x.page) === page);
        const merged = { ...(at >= 0 ? pages[at] : { page }), ...p };
        if (at >= 0) pages[at] = merged; else pages.push(merged);
        patch({ pages });
    };

    // PER-OPTION ROUTING, stored as the SECTION's branch — the same thing said Google's way. The
    // question that owns the options is the question the branch routes on.
    const routeOption = (i) => (optionLabel, to) => {
        const field = fields[i];
        const page = field.page || 1;
        const meta = pageMeta({ pages: draft.pages || [] }, page);
        const branch = meta.branch || { field: field.name, cases: [] };
        if (optionLabel === null) {
            // Turning it on or off from the ⋮ menu.
            return patchPage(page, meta.branch ? { branch: undefined } : { branch: { field: field.name, cases: [] } });
        }
        const cases = [...(branch.cases || []).filter(c => c.value !== optionLabel)];
        if (to) cases.push({ value: optionLabel, page: to });
        patchPage(page, { branch: { ...branch, field: field.name, cases } });
    };

    const upload = async (file, onDone) => {
        if (!file) return;
        setBusy(true);
        try {
            const body = new FormData();
            body.append("file", file);
            const res = await axios.post("/form_template/image", body);
            onDone(res.data?.data?.path);
        } catch (err) {
            notifyError(err?.response?.data?.message || "Could not add that image.");
        } finally {
            setBusy(false);
        }
    };

    const addItem = (kind, page) => {
        const item = kind === "question"
            ? { ...blankField(page) }
            : { ...blankField(page), kind, name: "", label: "", required: false };
        patch({ fields: [...fields, item] });
        setEditing(fields.length);
    };

    const addSection = () => {
        const next = Math.max(...sections) + 1;
        patch({ pages: [...(draft.pages || []), { page: next, title: "" }], fields: [...fields, { ...blankField(next) }] });
        setEditing(fields.length);
    };

    const duplicateSection = (page) => {
        const next = Math.max(...sections) + 1;
        const meta = pageMeta({ pages: draft.pages || [] }, page);
        const copies = fields.filter(f => (f.page || 1) === page)
            .map(f => ({ ...f, page: next, name: f.name ? `${f.name}_copy` : "", nameLocked: true }));
        patch({
            pages: [...(draft.pages || []), { ...meta, page: next, branch: undefined, next: undefined }],
            fields: [...fields, ...copies],
        });
    };

    const removeSection = (page) => {
        // Its questions move back a section rather than being deleted with it — losing a question
        // to a button labelled "delete section" is a surprise nobody recovers from.
        const to = [...sections].reverse().find(n => n < page) || 1;
        patch({
            pages: (draft.pages || []).filter(p => Number(p.page) !== page),
            fields: fields.map(f => (f.page || 1) === page ? { ...f, page: to } : f),
        });
    };

    const last = sections[sections.length - 1];

    return (
        <ZhOn.Provider value={bilingual}>
        <div style={{ display: "flex", gap: 14, alignItems: "flex-start" }}>
            <div style={{ flex: 1, minWidth: 0 }}>
                {sections.map(page => {
                    const meta = pageMeta({ pages: draft.pages || [] }, page);
                    const mine = fields.map((f, i) => ({ f, i })).filter(({ f }) => (f.page || 1) === page);

                    return (
                        // `data-section` is a test hook: the sortable's promise is that a card
                        // takes the section it lands in, and that is only checkable from outside.
                        <div key={page} data-section={page}>
                            {sectioned && (
                                <div style={{ ...card, borderTop: "6px solid var(--brand)", marginBottom: shut[page] ? 22 : 12 }}>
                                    <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
                                        <span style={{ fontSize: 11, fontWeight: 700, color: "var(--brand)", textTransform: "uppercase", letterSpacing: ".06em" }}>
                                            Section {page} of {sections.length}
                                        </span>
                                        <div style={{ marginLeft: "auto", display: "flex", gap: 4, alignItems: "center" }}>
                                            <button type="button" className="pt-btn pt-btn-sm" title={shut[page] ? "Expand" : "Collapse"}
                                                onClick={() => setShut(s => ({ ...s, [page]: !s[page] }))}>{shut[page] ? "⌄" : "⌃"}</button>
                                            <Menu items={[
                                                ["Duplicate section", () => duplicateSection(page)],
                                                sections.length > 1 && ["Delete section", () => removeSection(page)],
                                            ]} />
                                        </div>
                                    </div>
                                    <input className="pt-input" value={meta.title || ""} placeholder="Untitled section"
                                        style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}
                                        onChange={e => patchPage(page, { title: e.target.value })} />
                                    <Zh value={meta.zh?.title} placeholder="部分標題（繁體）" style={{ marginTop: 0, marginBottom: 6 }}
                                        onChange={v => patchPage(page, { zh: { ...(meta.zh || {}), title: v } })} />
                                    <textarea className="pt-input" rows={2} value={meta.description || ""}
                                        placeholder="Description (optional)" style={{ fontSize: 12.5 }}
                                        onChange={e => patchPage(page, { description: e.target.value })} />
                                    <Zh multiline value={meta.zh?.description} placeholder="描述（繁體）"
                                        onChange={v => patchPage(page, { zh: { ...(meta.zh || {}), description: v } })} />
                                    {meta.image ? (
                                        <div style={{ marginTop: 8 }}>
                                            <img src={`/storage/${meta.image}`} alt="" style={{ maxWidth: 280, borderRadius: 8, display: "block" }} />
                                            <button type="button" className="pt-btn pt-btn-sm" style={{ marginTop: 6 }}
                                                onClick={() => patchPage(page, { image: "" })}>Remove image</button>
                                        </div>
                                    ) : (
                                        <label className="pt-btn pt-btn-sm" style={{ marginTop: 8, display: "inline-block", cursor: "pointer" }}>
                                            {busy ? "Adding…" : "+ Add image"}
                                            <input type="file" accept="image/*" hidden
                                                onChange={e => upload(e.target.files?.[0], path => patchPage(page, { image: path }))} />
                                        </label>
                                    )}
                                </div>
                            )}

                            {/* THE WHOLE CARD DRAGS, except when the drag starts inside something
                                you can type in or press. The card being dragged stays where it is
                                in the list and dims — it IS the gap the others open around. */}
                            {!shut[page] && mine.map(({ f, i }, n) => (
                                <div key={i} ref={sortable.hold(i)} onPointerDown={sortable.start(i, f.label)}
                                    // Named for the spec that drags these — see form-builder.spec.js.
                                    data-question-card={f.name || f.kind}
                                    style={sortable.dragging === i ? {
                                        // `--card` is what the card inside paints itself with, so
                                        // setting it here tints the whole card without the card
                                        // having to know a drag is happening.
                                        "--card": "var(--blush-bg, #eef4f1)",
                                        opacity: .9, borderRadius: 12, cursor: "grabbing",
                                        outline: "2px dashed var(--brand)", outlineOffset: "-3px",
                                    } : { transition: "transform .12s ease" }}>
                                <Question field={f} index={i} sections={sections} sectionTitles={sectionTitles}
                                    branch={meta.branch && meta.branch.field === f.name ? meta.branch : null}
                                    editing={editing === i}
                                    onOpen={() => setEditing(i)}
                                    onPatch={p => patchField(i, p)}
                                    onRemove={() => { removeField(i); setEditing(null); }}
                                    onMove={by => moveField(i, by)}
                                    onDuplicate={() => duplicate(i)}
                                    onImage={file => upload(file, path => patchField(i, { image: path }))}
                                    onOptionImage={(n2, file) => upload(file, path => patchField(i, {
                                        options: (f.options || []).map(optionOf).map((o, m) => m === n2 ? { ...o, image: path } : o),
                                    }))}
                                    onRoute={routeOption(i)}
                                    first={n === 0} lastOne={n === mine.length - 1} />
                                </div>
                            ))}

                            {!shut[page] && sectioned && mine.length === 0 && (
                                <div onPointerUp={() => {
                                        // A section with nothing in it has no card to slide past,
                                        // so releasing over it is the move.
                                        if (sortable.dragging === null) return;
                                        patch({ fields: fields.map((f, n) => n === sortable.dragging ? { ...f, page } : f) });
                                    }}
                                    style={{ ...card, borderStyle: "dashed", textAlign: "center",
                                        color: "var(--faint)", fontSize: 12.5 }}>
                                    Nothing here yet — drag a question in, or add one.
                                </div>
                            )}

                            {!shut[page] && sectioned && (
                                <Routing page={page} meta={meta} sections={sections} sectionTitles={sectionTitles}
                                    onPatch={p => patchPage(page, p)} />
                            )}
                            {!sectioned && (
                                <button type="button" className="pt-btn" style={{ marginBottom: 18 }}
                                    onClick={() => addItem("question", 1)}>+ Add question</button>
                            )}
                        </div>
                    );
                })}
            </div>

            {sortable.preview}

            {/* THE ITEM TOOLBAR, down the right of the canvas — the same six controls in the same
                order as Google's, because that is where somebody coming from there will look. */}
            {sectioned && (
                <div style={{
                    position: "sticky", top: 8, display: "flex", flexDirection: "column", gap: 4, padding: 6,
                    border: "1px solid var(--line)", borderRadius: 12, background: "var(--card, #fff)",
                }}>
                    <button type="button" className="pt-btn pt-btn-sm" title="Add question"
                        onClick={() => addItem("question", last)}>⊕</button>
                    <button type="button" className="pt-btn pt-btn-sm" title="Add title and description"
                        onClick={() => addItem("note", last)}>Tт</button>
                    <button type="button" className="pt-btn pt-btn-sm" title="Add image"
                        onClick={() => addItem("image", last)}>🖼</button>
                    <button type="button" className="pt-btn pt-btn-sm" title="Add video"
                        onClick={() => addItem("video", last)}>▶</button>
                    <button type="button" className="pt-btn pt-btn-sm" title="Add section"
                        onClick={addSection}>▤</button>
                </div>
            )}
        </div>
        </ZhOn.Provider>
    );
}
