import React, { useEffect, useMemo, useRef, useState } from "react";
import { pagesOf, pageMeta, nextPage, pathOf, askedFields, problemsFor, richParts, optionOf, CHOICE_KINDS, GRID_KINDS, DISPLAY_KINDS } from "@/shared/formWalk";
import SignaturePad from "signature_pad";
import { Calendar } from "@/prime-react";
import { PAYMENT_METHOD, PAYMENT_METHOD_LABEL } from "@/shared/status";
import { flowApi, errorMessage } from "./client";
import { notifyError } from "@/shared/toast";
import RoomMessage from "../shared/RoomMessage";

// NodeRenderer registry (T8). One engine message → one rendered row.
// Dispatches on payload.type alone. The RequestInput family (choice / slot_picker / evidence /
// verify) arrived under payload.kind until 2026_08_13_001200, so this had to check two keys for
// one job. The muted fallback stays, so an unknown node type never blanks the chat.
//
// Interactive affordances render inline and are live only when `active` — that message's
// node is the conversation's current node and the engine is waiting. Answering advances
// current_node, which flips the affordance off, so past prompts read as history.

const two = (n) => String(n).padStart(2, "0");

function fmtTime(iso) {
    const d = new Date(iso);
    if (isNaN(d)) return String(iso);
    let h = d.getHours();
    const ap = h >= 12 ? "PM" : "AM";
    h = h % 12 || 12;
    return `${h}:${two(d.getMinutes())} ${ap}`;
}

function fmtDay(iso) {
    const d = new Date(iso);
    if (isNaN(d)) return String(iso);
    return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
}

const fmtMoney = (n) => new Intl.NumberFormat("en-HK", { style: "currency", currency: "HKD", maximumFractionDigits: 0 }).format(n || 0);

// Resolve a submitted choice value back to its label by scanning earlier prompts, so the
// user's own bubble reads "Haircut" rather than the raw service id.
function labelForValue(value, messages, index) {
    if (value && typeof value === "object") {
        // A stay reads as dates, not as an instant: "3 nights from Thu, Sep 10". Without this it
        // fell through to JSON.stringify and the customer's own bubble showed a resource id.
        if (value.nights && value.check_in_date) {
            return `${value.nights} night${value.nights > 1 ? "s" : ""} from ${fmtDay(fromYmd(value.check_in_date))}`;
        }

        return value.starts_at ? `${fmtDay(value.starts_at)} · ${fmtTime(value.starts_at)}` : JSON.stringify(value);
    }
    for (let i = index - 1; i >= 0; i--) {
        const opts = messages[i]?.payload?.options;
        if (Array.isArray(opts)) {
            const hit = opts.find((o) => String(o.value) === String(value));
            if (hit) return hit.label;
        }
    }
    return String(value ?? "");
}

const Row = ({ side, children }) => (
    <div className={`wg-row ${side === "out" ? "out" : "in"}`}>{children}</div>
);

// Side is carried by the parent .wg-row (in/out); the bubble only styles itself.
const Bubble = ({ children, wide = false }) => <div className={`wg-bub${wide ? " wide" : ""}`}>{children}</div>;

export const SystemNote = ({ children }) => <div className="wg-sys">{children}</div>;

const Chip = ({ onClick, disabled, children }) => (
    <button type="button" className="wg-chip" onClick={onClick} disabled={disabled}>{children}</button>
);

function ChoiceChips({ options, active, onPick }) {
    return (
        <div className="wg-chips">
            {(options ?? []).map((o, i) => (
                <Chip key={i} disabled={!active} onClick={() => active && onPick(o.value)}>{o.label}</Chip>
            ))}
        </div>
    );
}

// FINDING SOMEBODY TO DELIVER THIS (v0.19).
//
// Two quite different cards behind one node type, and the method decides which. When the CUSTOMER
// is choosing, this is a picker — the people are fetched, because who holds a role and who has
// finished their details is a query whose answer changes while the case waits. When anybody else is
// choosing, there is nothing for the customer to do, and saying "we are finding somebody" is the
// whole card: a chat that goes silent while an office looks for a celebrant reads as broken.
function MatchCard({ payload, active, flowRunId }) {
    const picks = payload.method === "customer_picks";
    const limit = Math.max(1, Number(payload.limit ?? 1));
    const [people, setPeople] = useState(null);
    const [error, setError] = useState(null);
    const [picked, setPicked] = useState([]);
    const [sending, setSending] = useState(false);
    // Said until the poll brings the advanced conversation back. Without it the card sits there
    // looking unpressed for a second, which is how somebody comes to choose twice.
    const [sent, setSent] = useState(false);

    useEffect(() => {
        if (!active || !picks || !flowRunId) return;
        let alive = true;
        setPeople(null);
        setError(null);

        flowApi.matchCandidates(flowRunId)
            .then((rows) => alive && setPeople(rows))
            .catch((e) => alive && setError(errorMessage(e, "Could not load who is available.")));

        return () => { alive = false; };
    }, [active, picks, flowRunId]);

    if (!picks) {
        return <SystemNote>{active ? "Finding somebody for you…" : "Somebody is on this."}</SystemNote>;
    }
    if (sent) return <SystemNote>Chosen — carrying on…</SystemNote>;
    if (!active) return <SystemNote>Chosen.</SystemNote>;
    if (error) return <SystemNote>{error}</SystemNote>;
    if (people === null) return <SystemNote>Loading…</SystemNote>;
    // AN EMPTY POOL IS A NORMAL STATE, not an error. Day Day Help's employer finishes their details
    // before any helper has finished theirs, and the honest answer is that there is nobody yet.
    if (people.length === 0) {
        return <SystemNote>Nobody is available yet — we will come back to you as soon as somebody is.</SystemNote>;
    }

    const toggle = (id) => setPicked((now) => now.includes(id)
        ? now.filter((x) => x !== id)
        : (limit === 1 ? [id] : (now.length < limit ? [...now, id] : now)));

    const send = async () => {
        if (!picked.length || sending) return;
        setSending(true);
        try {
            await flowApi.chooseMatch(flowRunId, picked);
            setSent(true);
        } catch (e) {
            setError(errorMessage(e, "Could not send your choice."));
            setSending(false);
        }
    };

    return (
        <div className="wg-chips" style={{ flexDirection: "column", alignItems: "stretch", gap: 6 }}>
            {people.map((p) => (
                <Chip key={p.user_id} disabled={sending} onClick={() => toggle(p.user_id)}
                    className={picked.includes(p.user_id) ? "on" : ""}>
                    {p.name}{p.role ? ` · ${p.role}` : ""}
                </Chip>
            ))}
            <button type="button" className="wg-book" disabled={!picked.length || sending} onClick={send}>
                {limit === 1
                    ? (picked.length ? "Choose this one" : "Pick somebody")
                    : `Take ${picked.length || 0} of ${limit} forward`}
            </button>
        </div>
    );
}

// The resource_picker node is pure too — it carries only the service, so the candidates come
// from GET /service/resources. Resources differ in their weekly hours, so choosing one here is
// what makes the times that follow the right ones.
function ResourcePicker({ serviceId, active, onPick }) {
    const [resources, setResources] = useState(null);
    const [error, setError] = useState(null);

    useEffect(() => {
        if (!active) return;
        let alive = true;
        setResources(null);
        setError(null);

        flowApi.serviceResources(serviceId)
            .then((r) => alive && setResources(r))
            .catch((e) => alive && setError(errorMessage(e, "Could not load the options.")));

        return () => { alive = false; };
    }, [active, serviceId]);

    if (!active) return <SystemNote>Choice made.</SystemNote>;
    if (error) return <SystemNote>{error}</SystemNote>;
    if (resources === null) return <SystemNote>Loading…</SystemNote>;
    if (resources.length === 0) return <SystemNote>Nothing to choose from here yet — please check back later.</SystemNote>;

    return (
        <div className="wg-chips">
            {resources.map((r) => <Chip key={r.id} onClick={() => onPick(r.id)}>{r.name}</Chip>)}
        </div>
    );
}

const ymd = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;

// Parsed from PARTS, never `new Date("2026-09-10")` — that reads as UTC midnight, which is the
// previous day everywhere west of Greenwich and would shift every night by one.
const fromYmd = (s) => { const [y, m, d] = String(s).split("-").map(Number); return new Date(y, m - 1, d); };

const nightsBetween = (a, b) => Math.round((fromYmd(ymd(b)) - fromYmd(ymd(a))) / 86400000);

// Choosing a STAY: a check-in date and a number of nights, for a service sold by the night.
//
// The resource is not chosen by the customer and is not shown. That matches how the slot picker
// already behaves — three free chairs at 10:00 offer ONE 10:00, tagged with the first free one —
// and it is the only honest reading here: nobody picks kennel pen 2 over pen 3, they pick dates,
// and which pen is free for the WHOLE range is a question only the full range can answer.
function StayPicker({ stays, onPick }) {
    const [range, setRange] = useState(null);

    // Shut only when EVERY resource is taken. A night one pen has lost is still on sale.
    const closed = useMemo(() => {
        const counts = {};
        stays.forEach((s) => (s.unavailable ?? []).forEach((d) => { counts[d] = (counts[d] ?? 0) + 1; }));

        return Object.keys(counts).filter((d) => counts[d] === stays.length);
    }, [stays]);

    const first = stays[0];
    const minDate = fromYmd(stays.reduce((a, s) => (s.from < a ? s.from : a), first.from));
    const maxDate = fromYmd(stays.reduce((a, s) => (s.to > a ? s.to : a), first.to));

    const [start, end] = range ?? [];
    const nights = start && end ? nightsBetween(start, end) : 0;

    // The first resource free for the WHOLE range, which is what actually gets booked. Checked
    // here so the customer is told before they press, rather than by a failed hold afterwards.
    const match = useMemo(() => {
        if (!start || !end || nights < 1) return null;

        const wanted = [];
        for (let i = 0; i < nights; i++) {
            const d = fromYmd(ymd(start));
            d.setDate(d.getDate() + i);
            wanted.push(ymd(d));
        }

        return stays.find((s) =>
            nights >= s.min_nights && nights <= s.max_nights
            && (s.check_in_weekdays ?? []).includes(start.getDay())
            && ymd(start) >= s.from && ymd(start) <= s.to
            && !wanted.some((d) => (s.unavailable ?? []).includes(d))
        ) ?? null;
    }, [stays, start, end, nights]);

    const why = () => {
        if (!start) return "Pick your check-in date.";
        if (!end || nights < 1) return "Now pick the day you are collecting.";
        if (nights < first.min_nights) return `Minimum stay is ${first.min_nights} night${first.min_nights > 1 ? "s" : ""}.`;
        if (nights > first.max_nights) return `Maximum stay is ${first.max_nights} nights.`;

        return "Those dates are not all free — please try another range.";
    };

    return (
        <div className="wg-stay">
            <Calendar
                inline
                value={range}
                onChange={(e) => setRange(e.value)}
                selectionMode="range"
                readOnlyInput
                minDate={minDate}
                maxDate={maxDate}
                disabledDates={closed.map(fromYmd)}
            />
            <div className="wg-stay-sum">
                {match ? (
                    <>
                        <div>
                            <b>{nights} night{nights > 1 ? "s" : ""}</b> · in {fmtDay(start)} from {first.check_in} · out {fmtDay(end)} by {first.check_out}
                        </div>
                        <div className="wg-stay-price">{fmtMoney(match.price_per_night * nights)} total · {fmtMoney(match.price_per_night)} a night</div>
                        <Chip onClick={() => onPick({ resource_id: match.resource_id, check_in_date: ymd(start), nights })}>
                            Confirm these dates
                        </Chip>
                    </>
                ) : (
                    <div className="wg-stay-hint">{why()}</div>
                )}
            </div>
        </div>
    );
}

// The slot_picker node is pure — it carries only the service id, so what is open comes from
// GET /availability/list rather than from the engine.
//
// ONE node, two pickers (07/09/2026). A service sold by the night needs a date RANGE, not a list
// of times, but that is a fact about the resources rather than about the flow — so the flow schema
// did not change and neither did any published flow. The node asks "let them choose when"; the
// answer decides what choosing looks like.
function SlotPicker({ serviceId, resourceId, flowRunId, active, onPick }) {
    const [data, setData] = useState(null);
    const [error, setError] = useState(null);

    useEffect(() => {
        if (!active) return;
        let alive = true;
        setData(null);
        setError(null);

        flowApi.availability(serviceId, resourceId, flowRunId)
            .then((d) => alive && setData(d))
            .catch((e) => alive && setError(errorMessage(e, "Could not load times.")));

        return () => { alive = false; };
    }, [active, serviceId, resourceId, flowRunId]);

    if (!active) return <SystemNote>Time selected.</SystemNote>;
    if (error) return <SystemNote>{error}</SystemNote>;
    if (data === null) return <SystemNote>Loading available times…</SystemNote>;

    if (data.mode === "nights") {
        if (data.stays.length === 0) return <SystemNote>No rooms open right now — please check back later.</SystemNote>;

        return <StayPicker stays={data.stays} onPick={onPick} />;
    }

    const slots = data.slots;
    if (slots.length === 0) return <SystemNote>No open times right now — please check back later.</SystemNote>;

    return <SlotDrill slots={slots} onPick={onPick} />;
}

// The form card. Until 10/09/2026 a `form` payload had no branch at all: the engine pushed the
// ask, the chat fell through to the generic bubble, and a conversation that reached a form stopped
// dead with the customer looking at a title and no inputs.
//
// `layout` comes from the template the org bound (App\Enums\FormLayout): 1 in the chat, 2 in a
// dialog, 3 in a paged dialog. Two or three questions read best inline; a dozen do not, and the org
// is the one who knows which its form is — so this component reads their answer rather than
// guessing from a count.
const FIELD_TYPE = { number: "number", date: "date", email: "email", phone: "tel" };

// **bold** in a label, help line or section description. An org writes these, so the text goes in
// as TEXT — the marks are turned into elements here rather than into markup anywhere.
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>;
    })}</>;
}

// AN ITEM THAT ASKS NOTHING (v0.23): a title block between questions, a picture, a video. It is
// part of the form the way Google's are — and no answer is ever stored for one.
function DisplayItem({ field }) {
    const video = field.kind === "video" ? String(field.url || "") : "";
    // Only the id, and only from YouTube: an org pastes a watch link, and an <iframe> pointed at
    // whatever a string happens to contain is a hole.
    const id = video.match(/(?:v=|youtu\.be\/|embed\/)([A-Za-z0-9_-]{6,20})/)?.[1];

    return (
        <div className="wg-form-note">
            {field.label ? <b><Marked text={field.label} /></b> : null}
            {field.help ? <span><Marked text={field.help} /></span> : null}
            {field.kind === "image" && field.image ? <img src={`/storage/${field.image}`} alt="" /> : null}
            {id ? (
                <iframe src={`https://www.youtube.com/embed/${id}`} title={field.label || "Video"}
                    allow="accelerometer; clipboard-write; encrypted-media; picture-in-picture" allowFullScreen />
            ) : null}
        </div>
    );
}

// Options in a different order for each person, decided once per card — re-shuffling on every
// keystroke would move the option somebody was reaching for.
function useOptions(field) {
    return useMemo(() => {
        const options = (field.options || []).map(optionOf).filter(o => o.label);
        if (!field.shuffle) return options;
        const out = [...options];
        for (let i = out.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [out[i], out[j]] = [out[j], out[i]];
        }

        return out;
    }, [field.options, field.shuffle]);
}

// ONE OF SEVERAL — as radios, or as a list when the author chose a dropdown. "Other, please say"
// (v0.21) puts a box beside the last row: what is typed IS the answer, because a condition reading
// this variable should see what somebody said rather than a flag.
function ChoiceField({ field, value, onChange }) {
    const options = useOptions(field);
    const isOther = value !== undefined && value !== "" && !options.some((o) => o.label === value);
    const [other, setOther] = useState(isOther ? value : "");
    const [picked, setPicked] = useState(isOther ? "__other__" : (value ?? ""));

    const pick = (v) => {
        setPicked(v);
        onChange(v === "__other__" ? other : v);
    };

    if (field.kind === "dropdown") {
        return (
            <>
                <select value={picked} onChange={(e) => pick(e.target.value)}>
                    <option value="">— choose —</option>
                    {options.map((o, i) => <option key={i} value={o.label}>{o.label}</option>)}
                    {field.other ? <option value="__other__">{field.other_label || "Other"}</option> : null}
                </select>
                {picked === "__other__" ? (
                    <input className="wg-form-other" type="text" value={other} placeholder="Type your answer"
                        onChange={(e) => { setOther(e.target.value); onChange(e.target.value); }} />
                ) : null}
            </>
        );
    }

    return (
        <div className="wg-form-opts">
            {options.map((o, i) => (
                <label key={i} className="wg-form-opt">
                    <input type="radio" checked={picked === o.label} onChange={() => pick(o.label)} />
                    {o.image ? <img className="wg-form-opt-img" src={`/storage/${o.image}`} alt="" /> : null}
                    <span>{o.label}</span>
                </label>
            ))}
            {field.other ? (
                <label className="wg-form-opt">
                    <input type="radio" checked={picked === "__other__"} onChange={() => pick("__other__")} />
                    <input className="wg-form-other" type="text" value={other}
                        placeholder={field.other_label || "Other"}
                        onFocus={() => pick("__other__")}
                        onChange={(e) => { setOther(e.target.value); onChange(e.target.value); }} />
                </label>
            ) : null}
        </div>
    );
}

// ANY NUMBER OF SEVERAL. The answer is a LIST, which is why every reader of it — the rules, the
// engine's store, the transcript — had to learn that a value is not always a string.
function CheckboxField({ field, value, onChange }) {
    const options = useOptions(field);
    const on = Array.isArray(value) ? value : [];
    const toggle = (o) => onChange(on.includes(o) ? on.filter((x) => x !== o) : [...on, o]);

    return (
        <div className="wg-form-opts">
            {options.map((o, i) => (
                <label key={i} className="wg-form-opt">
                    <input type="checkbox" checked={on.includes(o.label)} onChange={() => toggle(o.label)} />
                    {o.image ? <img className="wg-form-opt-img" src={`/storage/${o.image}`} alt="" /> : null}
                    <span>{o.label}</span>
                </label>
            ))}
        </div>
    );
}

// A LINE FROM ONE NUMBER TO ANOTHER, with the words at each end — "not at all" to "completely".
function ScaleField({ field, value, onChange }) {
    const { min = 1, max = 5, min_label: low, max_label: high } = field.scale || {};
    const points = [];
    for (let n = Number(min); n <= Number(max); n++) points.push(n);

    return (
        <div className="wg-form-scale">
            {low ? <span className="wg-form-scale-end">{low}</span> : null}
            {points.map((n) => (
                <label key={n} className="wg-form-scale-pt">
                    <input type="radio" checked={String(value) === String(n)} onChange={() => onChange(String(n))} />
                    <span>{n}</span>
                </label>
            ))}
            {high ? <span className="wg-form-scale-end">{high}</span> : null}
        </div>
    );
}

// STARS, HEARTS OR THUMBS — the same question a scale asks and a different thing to look at, which
// is exactly why Google has both.
const RATING_ICON = { star: ["★", "☆"], heart: ["♥", "♡"], thumb: ["👍", "👍"] };

function RatingField({ field, value, onChange }) {
    const { max = 5, icon = "star" } = field.rating || {};
    const [filled, empty] = RATING_ICON[icon] || RATING_ICON.star;
    const points = [];
    for (let n = 1; n <= Number(max); n++) points.push(n);

    return (
        <div className="wg-form-rating">
            {points.map((n) => (
                <button key={n} type="button" title={`${n}`}
                    className={"wg-form-star" + (Number(value) >= n ? " is-on" : "")}
                    onClick={() => onChange(String(n))}>
                    {icon === "thumb" ? empty : (Number(value) >= n ? filled : empty)}
                </button>
            ))}
        </div>
    );
}

// ONE QUESTION PER ROW, answered in columns. Scrolls sideways rather than wrapping: a grid that
// wraps stops being a grid, and the row labels are what make it readable.
function GridField({ field, value, onChange }) {
    const many = field.kind === "grid_checkbox";
    const said = value && typeof value === "object" ? value : {};

    const set = (row, column) => {
        if (!many) return onChange({ ...said, [row]: column });
        const on = Array.isArray(said[row]) ? said[row] : [];

        return onChange({ ...said, [row]: on.includes(column) ? on.filter((c) => c !== column) : [...on, column] });
    };
    const ticked = (row, column) => many
        ? (Array.isArray(said[row]) ? said[row] : []).includes(column)
        : said[row] === column;

    return (
        <div className="wg-form-grid">
            <table>
                <thead>
                    <tr>
                        <th />
                        {(field.columns || []).map((c, i) => <th key={i}>{c}</th>)}
                    </tr>
                </thead>
                <tbody>
                    {(field.rows || []).map((r, i) => (
                        <tr key={i}>
                            <th scope="row">{r}</th>
                            {(field.columns || []).map((c, n) => (
                                <td key={n}>
                                    <input type={many ? "checkbox" : "radio"} name={`${field.name}-${i}`}
                                        checked={ticked(r, c)} onChange={() => set(r, c)} />
                                </td>
                            ))}
                        </tr>
                    ))}
                </tbody>
            </table>
        </div>
    );
}

// DOCUMENTS AND PHOTOGRAPHS. Uploaded before the answers are sent, to the same place a room
// attachment goes — so the org opens one exactly as it opens anything else somebody sent.
function FileField({ field, value, onChange, conversationId }) {
    const files = Array.isArray(value) ? value : [];
    const [busy, setBusy] = useState(false);

    const add = async (chosen) => {
        if (!chosen?.length) return;
        setBusy(true);
        try {
            // The same endpoint an evidence step uses, so a form's attachment is stored where a
            // room's attachment is stored and opens the same way.
            const { attachments } = await flowApi.upload(conversationId, [...chosen]);
            onChange([...files, ...(attachments || [])]);
        } catch (err) {
            // Said on the question, like every other refusal on this card.
            onChange(files);
        } finally {
            setBusy(false);
        }
    };

    return (
        <div className="wg-form-files">
            {files.map((f, i) => (
                <div key={i} className="wg-form-file">
                    <a href={f.url} target="_blank" rel="noreferrer">{f.name}</a>
                    <button type="button" onClick={() => onChange(files.filter((_, n) => n !== i))}>×</button>
                </div>
            ))}
            <label className="wg-form-add">
                {busy ? "Adding…" : "+ Add a file"}
                <input type="file" multiple hidden
                    onChange={(e) => { add(e.target.files); e.target.value = ""; }} />
            </label>
            {field.files?.max ? <span className="wg-form-help">Up to {field.files.max}.</span> : null}
        </div>
    );
}

// WHICH INPUT a question gets. One place, so a kind that is added to the schema and not to this
// list fails loudly here rather than rendering as a text box nobody can answer properly.
function AnswerInput({ field, value, onChange, conversationId }) {
    const kind = field.kind || "text";

    if (CHOICE_KINDS.includes(kind) && kind !== "checkbox") {
        return <ChoiceField field={field} value={value} onChange={onChange} />;
    }
    if (kind === "checkbox") return <CheckboxField field={field} value={value} onChange={onChange} />;
    if (kind === "scale") return <ScaleField field={field} value={value} onChange={onChange} />;
    if (kind === "rating") return <RatingField field={field} value={value} onChange={onChange} />;
    if (GRID_KINDS.includes(kind)) return <GridField field={field} value={value} onChange={onChange} />;
    if (kind === "file") {
        return <FileField field={field} value={value} onChange={onChange} conversationId={conversationId} />;
    }
    if (kind === "long_text") {
        return <textarea rows={3} value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
    }

    return <input type={FIELD_TYPE[kind] || "text"} value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
}

function FormFields({ fields, values, onChange, problems, conversationId }) {
    return (
        <div className="wg-form">
            {fields.map((f) => {
                if (DISPLAY_KINDS.includes(f.kind)) return <DisplayItem key={f.name} field={f} />;
                const bad = problems[f.name];

                return (
                    <div key={f.name} className={`wg-form-row${bad ? " bad" : ""}`}>
                        <span className="wg-form-label">
                            <Marked text={f.label} />
                            {f.required ? <span className="wg-req" title="Required"> *</span> : null}
                        </span>
                        {f.help ? <span className="wg-form-help"><Marked text={f.help} /></span> : null}
                        {f.image ? <img className="wg-form-img" src={`/storage/${f.image}`} alt="" /> : null}
                        <AnswerInput field={f} value={values[f.name]} conversationId={conversationId}
                            onChange={(v) => onChange(f.name, v)} />
                        {/* The reason, under the question it is about — not a count at the foot of
                            a form somebody then has to search. */}
                        {bad ? <span className="wg-form-bad">{bad}</span> : null}
                    </div>
                );
            })}
        </div>
    );
}

// WHAT WAS SENT BACK for this ask, if anything. The answers ride on the customer's own inbound
// message; the card that asked for them is the one that should show them, so it goes looking.
// Matched on the node and the run — a chase re-sends the same card, and an older run in the same
// room can be parked on a node of the same name.
function answersFor(messages, ask, at) {
    // AFTER this ask, not anywhere: a chase re-sends the same card, and the answer belongs to the
    // copy that was on screen when it was given.
    const said = (messages || []).slice((at ?? -1) + 1).find(m => m.direction === "inbound"
        && m.node_id === ask.node_id
        && (!m.flow_run_id || !ask.flow_run_id || m.flow_run_id === ask.flow_run_id)
        && m.payload?.type === "form_result");

    return said ? { values: said.payload.values || {}, answers: said.payload.answers || null } : null;
}

function FormCard({ payload, active, onSubmit, conversationId, answered }) {
    const fields = useMemo(() => (Array.isArray(payload.fields) ? payload.fields : []), [payload.fields]);
    const layout = Number(payload.layout) || 1;
    const form = useMemo(() => ({ fields, pages: payload.pages || [], layout }), [fields, payload.pages, layout]);

    const [values, setValues] = useState({});
    const [problems, setProblems] = useState({});
    const [open, setOpen] = useState(false);
    // WHICH SECTION, by its own number — not by position. A form routes (v0.21), so "the next one"
    // depends on what has been answered, and an index into a list would walk past the branch.
    const [page, setPage] = useState(pagesOf(form)[0]);
    const [busy, setBusy] = useState(false);

    // ANSWERED WINS OVER ACTIVE. A form that hands its answers to a workbook parks on its OWN node
    // while the sums run, so the run is still "waiting" and the card was still live — an empty form
    // with a working Send button, sitting beside the answers somebody had just given.
    // (Arfu, 18/09/2026)
    //
    // The record of a form is the form: the same questions, with what was said in them, and a
    // button that does nothing. A second card underneath listing the answers again was the first
    // attempt and it read as a variable dump.
    if (answered || !active) {
        if (!answered) return <SystemNote>Answers sent.</SystemNote>;

        const asked = askedFields(form, answered.values);
        const words = Object.fromEntries((answered.answers || []).map(one => [one.name, one.value]));

        return (
            <div className="wg-formcard is-done">
                {asked.map((f) => (
                    <div key={f.name} className="wg-form-row">
                        <span className="wg-form-label"><Marked text={f.label} /></span>
                        <div className="wg-form-done">
                            {words[f.name] ?? (Array.isArray(answered.values[f.name])
                                ? answered.values[f.name].join(", ")
                                : String(answered.values[f.name] ?? "—"))}
                        </div>
                    </div>
                ))}
                <button type="button" className="wg-btn" disabled>Answers sent</button>
            </div>
        );
    }

    const set = (name, value) => {
        setValues((v) => ({ ...v, [name]: value }));
        setProblems((p) => { const { [name]: _gone, ...rest } = p; return rest; });
    };

    const meta = pageMeta(form, page);
    const onThisPage = layout === 3 ? fields.filter((f) => Math.max(1, Number(f.page) || 1) === page) : fields;
    // Only the questions this walk actually reached are sent or demanded — a section the answers
    // branched past was never asked.
    const asked = askedFields(form, values);
    const path = pathOf(form, values);
    const last = nextPage(form, page, values) === null;

    const stop = (list) => {
        const found = problemsFor(list, values);
        setProblems(found);

        return Object.keys(found).length > 0;
    };

    const send = async () => {
        if (stop(asked)) {
            // Land them on the section holding the first problem, or the message is about
            // something they cannot see.
            const first = asked.find((f) => problemsFor(asked, values)[f.name]);
            if (layout === 3 && first) setPage(Math.max(1, Number(first.page) || 1));

            return;
        }
        setBusy(true);
        try {
            await onSubmit(Object.fromEntries(asked.map((f) => [f.name, values[f.name] ?? ""])));
        } finally {
            setBusy(false);
        }
    };

    const body = (
        <>
            {/* THE SECTION ITSELF: its heading, what it is for, and a picture of the document
                somebody is copying an answer off. */}
            {layout === 3 && (meta.title || meta.description || meta.image) ? (
                <div className="wg-form-sec">
                    {meta.title ? <b><Marked text={meta.title} /></b> : null}
                    {meta.description ? <span><Marked text={meta.description} /></span> : null}
                    {meta.image ? <img src={`/storage/${meta.image}`} alt="" /> : null}
                </div>
            ) : null}
            <FormFields fields={onThisPage} values={values} onChange={set} problems={problems}
                conversationId={conversationId} />
        </>
    );

    const footer = (
        <div className="wg-form-foot">
            {layout === 3 && pagesOf(form).length > 1 && (
                <span className="wg-form-step">Section {path.indexOf(page) + 1} of {path.length}</span>
            )}
            {path.indexOf(page) > 0 && (
                <button type="button" className="wg-btn ghost"
                    onClick={() => setPage(path[path.indexOf(page) - 1])}>← Back</button>
            )}
            {layout === 3 && !last ? (
                <button type="button" className="wg-btn" onClick={() => {
                    if (!stop(onThisPage)) setPage(nextPage(form, page, values));
                }}>Next →</button>
            ) : (
                <button type="button" className="wg-btn" onClick={send} disabled={busy}>
                    {busy ? "Sending…" : "Send answers"}
                </button>
            )}
        </div>
    );

    // INLINE: another card in the stream, like the choice chips and the signature pad.
    if (layout === 1) {
        return (
            <div className="wg-formcard">
                {body}
                {footer}
            </div>
        );
    }

    // DIALOG: a summary in the stream, the questions over it. A long form inside a chat bubble is
    // a scroll tunnel where people lose both their place and the button.
    return (
        <>
            <div className="wg-form-teaser">
                <span>{fields.length} question{fields.length === 1 ? "" : "s"}</span>
                <button type="button" className="wg-btn" onClick={() => { setOpen(true); setPage(0); }}>
                    Fill this in →
                </button>
            </div>
            {open && (
                <div className="wg-sheet" role="dialog" aria-modal="true">
                    <div className="wg-sheet-box" onClick={(e) => e.stopPropagation()}>
                        <div className="wg-sheet-head">
                            <b>{payload.title || "A few questions"}</b>
                            <button type="button" className="wg-sheet-x" onClick={() => setOpen(false)} aria-label="Close">×</button>
                        </div>
                        <div className="wg-sheet-body">{body}</div>
                        <div className="wg-sheet-foot">{footer}</div>
                    </div>
                </div>
            )}
        </>
    );
}

// Three questions instead of one wall of chips: WHICH DAY, then roughly WHEN, then the time.
//
// A generous grid — four venues open 09:00 to 21:00 on the half hour — is over two hundred chips,
// and the customer scrolls past the answer looking for it. Every step goes back, because a picker
// you cannot reverse makes people close the chat rather than correct themselves.
// (Arfu, 10/09/2026)
const BANDS = [
    { key: "morning",   label: "Morning",   hint: "before noon",  test: (h) => h < 12 },
    { key: "afternoon", label: "Afternoon", hint: "12 till 5",    test: (h) => h >= 12 && h < 17 },
    { key: "evening",   label: "Evening",   hint: "5 onwards",    test: (h) => h >= 17 },
];

function SlotDrill({ slots, onPick }) {
    const [day, setDay] = useState(null);
    const [band, setBand] = useState(null);
    const [calendar, setCalendar] = useState(false);

    // Grouped by CALENDAR DAY rather than by the formatted label, so the calendar and the chips
    // agree about what a day is.
    const days = useMemo(() => {
        const map = new Map();
        slots.forEach((s) => {
            const key = ymd(new Date(s.starts_at));
            if (!map.has(key)) map.set(key, []);
            map.get(key).push(s);
        });

        return [...map.entries()].map(([key, items]) => ({ key, items }));
    }, [slots]);

    const chosenDay = days.find((d) => d.key === day) || null;

    const bands = useMemo(() => {
        if (!chosenDay) return [];

        return BANDS
            .map((b) => ({ ...b, items: chosenDay.items.filter((s) => b.test(new Date(s.starts_at).getHours())) }))
            .filter((b) => b.items.length > 0);
    }, [chosenDay]);

    // Skip a question that has only one answer: a day whose times are all in one band should not
    // ask which band.
    useEffect(() => {
        if (chosenDay && bands.length === 1 && band === null) setBand(bands[0].key);
    }, [chosenDay, bands, band]);

    if (!chosenDay) {
        return (
            <div className="wg-drill">
                <div className="wg-drill-head">
                    <span>Which day?</span>
                    <button type="button" className="wg-drill-alt" onClick={() => setCalendar(c => !c)}>
                        {calendar ? "List of days" : "📅 Calendar"}
                    </button>
                </div>
                {calendar
                    ? <SlotCalendar days={days} onPick={setDay} />
                    : (
                        <div className="wg-chips">
                            {days.map((d) => (
                                <Chip key={d.key} onClick={() => { setDay(d.key); setBand(null); }}>
                                    {fmtDay(d.items[0].starts_at)}
                                    <span className="wg-left"> · {d.items.length} time{d.items.length === 1 ? "" : "s"}</span>
                                </Chip>
                            ))}
                        </div>
                    )}
            </div>
        );
    }

    const chosenBand = bands.find((b) => b.key === band) || null;

    if (!chosenBand) {
        return (
            <div className="wg-drill">
                <button type="button" className="wg-back" onClick={() => { setDay(null); setBand(null); }}>
                    ← Change day <b>{fmtDay(chosenDay.items[0].starts_at)}</b>
                </button>
                <div className="wg-drill-head"><span>What time of day?</span></div>
                <div className="wg-chips">
                    {bands.map((b) => (
                        <Chip key={b.key} onClick={() => setBand(b.key)}>
                            {b.label}
                            <span className="wg-left"> · {b.items.length}</span>
                        </Chip>
                    ))}
                </div>
            </div>
        );
    }

    return (
        <div className="wg-drill">
            {/* One control, and it says what it undoes. A quiet "← Fri 11 Sep" was read as a label
                rather than a button, so nobody used it and the picker felt one-way.
                (Arfu, 10/09/2026) */}
            <button type="button" className="wg-back"
                onClick={() => (bands.length > 1 ? setBand(null) : (setDay(null), setBand(null)))}>
                ← Change {bands.length > 1 ? "time of day" : "day"}{" "}
                <b>{fmtDay(chosenDay.items[0].starts_at)}{bands.length > 1 ? ` · ${chosenBand.label}` : ""}</b>
            </button>
            <div className="wg-drill-head"><span>Pick a time</span></div>
            <div className="wg-chips">
                {chosenBand.items.map((s, i) => (
                    <Chip key={i} onClick={() => onPick({ resource_id: s.resource_id, starts_at: s.starts_at })}>
                        {fmtTime(s.starts_at)}
                        {/* The PRICE, on every chip. It used to appear only when the times on offer
                            cost different amounts, which meant the usual case — one price — showed
                            none at all, and the customer was picking a time without being told what
                            it cost. A short list can afford to say. (Arfu, 10/09/2026) */}
                        <span className="wg-price"> {fmtMoney(s.price)}</span>
                        {s.capacity > 1 && s.seats_left <= 5 ? <span className="wg-left"> · {s.seats_left} left</span> : ""}
                    </Chip>
                ))}
            </div>
        </div>
    );
}

// The kit's calendar, not a hand-rolled month: StayPicker already draws one for nightly services
// and two date pickers in the same chat should not disagree about what a week looks like. Days
// with no times are disabled from the SAME data as the list — nothing is fetched again, so the
// calendar cannot offer a day the list would refuse.
function SlotCalendar({ days, onPick }) {
    const open = useMemo(() => new Set(days.map((d) => d.key)), [days]);
    const dates = useMemo(() => days.map((d) => fromYmd(d.key)).sort((a, b) => a - b), [days]);
    const min = dates[0];
    const max = dates[dates.length - 1];

    // Every day in the window that has nothing on it. Enumerated rather than inverted, because
    // the calendar takes what to DISABLE.
    const closed = useMemo(() => {
        const out = [];
        if (!min || !max) return out;
        for (let d = new Date(min); d <= max; d.setDate(d.getDate() + 1)) {
            if (!open.has(ymd(d))) out.push(new Date(d));
        }

        return out;
    }, [min, max, open]);

    return (
        <div className="wg-cal">
            <Calendar inline readOnlyInput minDate={min} maxDate={max} disabledDates={closed}
                onChange={(e) => e.value && onPick(ymd(e.value))} />
        </div>
    );
}

// "Send proof" that only took typed text was not asking for proof: a photo of a meter, a scanned
// form, a PDF — those are what people actually have, and the customer was left typing a description
// of the thing they were holding. Either half is enough on its own now. (owner, 03/09/2026)
function EvidenceInput({ active, onSubmit, onUpload }) {
    const [text, setText] = useState("");
    const [files, setFiles] = useState([]);
    const [busy, setBusy] = useState(false);
    const fileRef = useRef(null);
    if (!active) return null;

    const submit = async () => {
        const t = text.trim();
        if ((!t && files.length === 0) || busy) return;

        // Never send an answer whose files were not stored. `onUpload?.()` answering undefined is
        // indistinguishable from "no files" one line later, which is exactly how they came to be
        // dropped in silence — so a missing uploader stops the send rather than shrinking it.
        if (files.length && !onUpload) {
            notifyError("Could not attach those files. Please try again.");

            return;
        }

        setBusy(true);
        try {
            // Stored first: an answer that names files nobody kept would be worse than no answer.
            const attachments = files.length ? await onUpload(files) : [];
            onSubmit({ text: t, ...(attachments?.length ? { attachments } : {}) });
            setText("");
            setFiles([]);
        } finally {
            setBusy(false);
        }
    };

    return (
        <>
            {files.length > 0 && (
                <div className="wg-ev-files">
                    {files.map((f, i) => (
                        <span key={i} className="wg-ev-file">
                            {f.name}
                            <button type="button" aria-label={`Remove ${f.name}`}
                                onClick={() => setFiles(list => list.filter((_, j) => j !== i))}>✕</button>
                        </span>
                    ))}
                </div>
            )}
            <div className="wg-inline-input">
                <input ref={fileRef} type="file" multiple style={{ display: "none" }}
                    accept="image/*,application/pdf,.doc,.docx,.txt"
                    onChange={(e) => {
                        setFiles(list => [...list, ...Array.from(e.target.files || [])].slice(0, 5));
                        e.target.value = "";
                    }} />
                <button type="button" className="wg-ev-clip" onClick={() => fileRef.current?.click()}
                    disabled={busy} title="Attach a photo or document" aria-label="Attach a photo or document">
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
                        strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                        <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.57 8.57a2 2 0 1 1-2.83-2.83l8.49-8.48" />
                    </svg>
                </button>
                <input
                    type="text"
                    value={text}
                    onChange={(e) => setText(e.target.value)}
                    onKeyDown={(e) => e.key === "Enter" && submit()}
                    placeholder={files.length ? "Add a note (optional)…" : "Type your response…"}
                />
                <Chip onClick={submit} disabled={busy || (!text.trim() && files.length === 0)}>
                    {busy ? "Sending…" : "Send"}
                </Chip>
            </div>
        </>
    );
}

// What a `payment` node puts on screen: the amount, WHERE to send it, and a way to send the
// receipt back. Before 01/09/2026 a flow could only ask for the receipt (await_evidence) — a
// prompt saying "upload your payment receipt" with no amount, no account and a text box.
//
// The pay-to block comes from the org's Settings, attached by FlowRunner rather than typed into
// the flow, so it is right on every flow the org ever writes or is visibly absent on all of them.
// What the identifier is called, per method — a PayMe number is not an "account number", and a
// card that says so invites the customer to type the wrong thing.
const ACCOUNT_LABEL = {
    [PAYMENT_METHOD.BANK_TRANSFER]: "Account",
    [PAYMENT_METHOD.FPS]:           "FPS ID",
    [PAYMENT_METHOD.PAYME]:         "PayMe",
    [PAYMENT_METHOD.ALIPAY_HK]:     "AlipayHK",
    [PAYMENT_METHOD.WECHAT_PAY]:    "WeChat Pay",
    [PAYMENT_METHOD.PAYPAL]:        "PayPal",
    [PAYMENT_METHOD.OCTOPUS]:       "Octopus",
    [PAYMENT_METHOD.CHEQUE]:        "Payable to",
    [PAYMENT_METHOD.CASH]:          "Where",
};

// A card written before 02/09/2026 carries the single {bank, account, name, fps, note} block.
// Read as the arrangements it was describing, so an old transcript still tells the customer where
// the money went — bank and FPS were always two ways to pay, sharing one cramped block.
function legacyMethods(payTo) {
    if (!payTo || (!payTo.bank && !payTo.account && !payTo.fps && !payTo.name)) return [];

    const out = [];
    if (payTo.bank || payTo.account) {
        out.push({
            id: "legacy_bank", method: PAYMENT_METHOD.BANK_TRANSFER,
            bank: payTo.bank, account: payTo.account, name: payTo.name, note: payTo.note,
        });
    }
    if (payTo.fps) {
        out.push({
            id: "legacy_fps", method: PAYMENT_METHOD.FPS,
            account: payTo.fps, name: payTo.name, note: out.length ? "" : payTo.note,
        });
    }

    return out;
}

function PaymentRequest({ payload, active, onPaid }) {
    const [busy, setBusy] = useState(false);
    const [error, setError] = useState(null);
    const fileRef = useRef(null);

    // An org publishes a LIST of ways to be paid since 02/09/2026. A card written before that
    // carries the single `pay_to` block instead, and a transcript is read months later — so the
    // old shape is converted rather than dropped, and the customer never opens an old chat to find
    // the account gone.
    const payTo = payload.pay_to || {};
    const methods = payload.pay_methods?.length ? payload.pay_methods : legacyMethods(payTo);

    // PICK one, THEN pay it. Every method's details at once was a wall of account numbers, most of
    // them for ways this customer is not paying — and a QR belonging to one of them sat in the
    // middle of it. With a single method there is no choice to make, so it opens straight on the
    // details. (Arfu, 02/09/2026)
    const [chosenId, setChosenId] = useState(null);
    const chosen = methods.length === 1
        ? methods[0]
        : methods.find((m, i) => (m.id || `m${i}`) === chosenId) || null;

    // Picking a method GROWS this card — account details, often a QR, and the upload button all
    // appear below the fold. The chat only scrolls itself when a new MESSAGE arrives, and choosing
    // is not a message, so the button the customer needs next was left off-screen. Scrolled from
    // here because this card is the only thing that knows it just changed size.
    const cardRef = useRef(null);
    useEffect(() => {
        if (!chosenId) return;
        cardRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
    }, [chosenId]);

    const send = async (file) => {
        if (!file || busy) return;
        setBusy(true);
        setError(null);
        try {
            // The method the customer said they were using — the invoice was raised "not
            // specified" precisely because nobody knew yet.
            await flowApi.uploadReceipt(payload.payment_id, file, chosen?.method ?? null);
            // Only once the file is STORED does the flow advance — otherwise a failed upload
            // would leave the conversation past the step that was meant to collect it.
            onPaid({ payment_id: payload.payment_id, receipt: file.name });
        } catch (e) {
            setError(errorMessage(e, "Could not send that receipt."));
            setBusy(false);
        }
    };

    const detailsOf = (m) => [
        ["Bank", m.bank],
        [ACCOUNT_LABEL[m.method] || "Account", m.account],
        ["Account name", m.name],
    ].filter(([, value]) => value);

    return (
        <div className="wg-pay" ref={cardRef}>
            <div className="wg-pay-amt">{fmtMoney(payload.amount)}</div>
            {payload.note ? <div className="wg-pay-note">{payload.note}</div> : null}

            {methods.length === 0 ? (
                <div className="wg-pay-hint">Ask us how to pay — we have not published our payment details yet.</div>
            ) : !chosen ? (
                <>
                    <div className="wg-pay-choose">How would you like to pay?</div>
                    <div className="wg-pay-picks">
                        {methods.map((m, i) => (
                            <button key={m.id || i} type="button" className="wg-pay-pick" disabled={busy}
                                onClick={() => setChosenId(m.id || `m${i}`)}>
                                <span className="wg-pay-pick-n">{m.method_label || PAYMENT_METHOD_LABEL[m.method] || "Payment"}</span>
                                {/* One line of what to expect, so the choice is not blind: the
                                    account they will send to, or that there is a code to scan. */}
                                <span className="wg-pay-pick-s">
                                    {m.qr_url && !m.account ? "Scan a code" : (m.account || m.name || "")}
                                </span>
                            </button>
                        ))}
                    </div>
                </>
            ) : (
                <div className="wg-pay-to">
                    <div className="wg-pay-method">{chosen.method_label || PAYMENT_METHOD_LABEL[chosen.method] || "Payment"}</div>
                    {detailsOf(chosen).map(([label, value]) => (
                        <div key={label} className="wg-pay-row">
                            <span className="wg-pay-k">{label}</span>
                            <span className="wg-pay-v">{value}</span>
                        </div>
                    ))}
                    {/* Scanning is the whole instruction for PayMe or AlipayHK — there is often
                        no number to type at all. Full width so it is big enough to point a
                        camera at without the customer having to zoom. */}
                    {chosen.qr_url ? (
                        <img className="wg-pay-qr" src={chosen.qr_url} alt={`${chosen.method_label || "Payment"} code`} />
                    ) : null}
                    {chosen.note ? <div className="wg-pay-hint">{chosen.note}</div> : null}
                    {/* Changing your mind must not mean starting the step again. Hidden when there
                        was never a choice to make. */}
                    {methods.length > 1 && (
                        <button type="button" className="wg-pay-back" disabled={busy} onClick={() => setChosenId(null)}>
                            Pay a different way
                        </button>
                    )}
                </div>
            )}

            {/* The reference for THIS invoice. A booking may carry a deposit, a balance and an
                instalment per stage, so "the payment" stops being an answer the moment there are
                two — and this is the string the vendor's own Payments screen lists it under. */}
            {payload.code ? (
                <div className="wg-pay-hint" style={{ fontFamily: "var(--mono)" }}>Reference {payload.code}</div>
            ) : null}

            {/* Not offered until they have picked a way to pay — "Upload receipt" under a list of
                choices reads as a way to skip past making one. An org that has published nothing
                still gets the button, because the step has to be answerable either way. */}
            {active ? (
                (chosen || methods.length === 0) && (
                    <>
                        <input ref={fileRef} type="file" accept="image/*,application/pdf" style={{ display: "none" }}
                            onChange={(e) => send(e.target.files?.[0])} />
                        <Chip onClick={() => fileRef.current?.click()} disabled={busy}>
                            {busy ? "Sending…" : "Upload receipt"}
                        </Chip>
                        {error ? <SystemNote>{error}</SystemNote> : null}
                    </>
                )
            ) : (
                <SystemNote>Receipt sent.</SystemNote>
            )}
        </div>
    );
}

// The sentence above the card. The note is the org's own words for what this is for, so it leads;
// without one there is still something to read.
const payload_label = (p) => (p.note ? `${p.note} — please pay and send us the receipt:` : "Please pay and send us the receipt:");

function BookingCard({ payload }) {
    return (
        <Row side="in">
            <div className="wg-card" style={{ marginTop: 0, maxWidth: "82%" }}>
                <div className="wg-card-hd ok">Booking confirmed</div>
                <div className="wg-card-bd">
                    {payload.starts_at ? (
                        <div className="wg-kv"><span>When</span><b>{fmtDay(payload.starts_at)} · {fmtTime(payload.starts_at)}</b></div>
                    ) : null}
                    {payload.booking_ref ? (
                        <div className="wg-kv"><span>Reference</span><b className="mono">{payload.booking_ref}</b></div>
                    ) : null}
                </div>
            </div>
        </Row>
    );
}

// ctx: { active, index, messages, waitingAck, actions:{ submitInput, submitEvidence, submitAck } }
// A CHASE, not the first asking. When a node's reminder fires, the engine sends that node's own
// words again — same prompt, same buttons — so a second identical bubble reads as the chat
// repeating itself rather than as a nudge. The payload has carried `reminder: true` since T11 and
// nothing drew it. (Arfu reported a chase he could not tell apart, 03/09/2026)

// The e_sign card (schema v0.12). Three gates in front of one canvas, in the order the node states
// them: establish who is holding the pen, get them to agree to a sentence, then take the mark.
//
// `identity` decides the first gate. `session` trusts the participant already authenticated into
// this room, which is honest for a register-gated chat. `document_number` makes them type the ID
// their signature has to match — Day Day Help's own rule for a statutory form — and the number is
// stored with the signature so a person can check it later. The check itself is deliberately NOT
// done here: the browser is the last place to decide whether a passport number is right.
function SignatureInput({ active, payload, onSubmit, onUpload }) {
    const canvasRef = useRef(null);
    const padRef = useRef(null);
    const [docNumber, setDocNumber] = useState("");
    const [agreed, setAgreed] = useState(false);
    const [drawn, setDrawn] = useState(false);
    const [busy, setBusy] = useState(false);

    const needsDocNumber = payload.identity === "document_number";

    // The canvas is sized in CSS pixels but drawn in device pixels, so a pad set up without the
    // ratio records strokes offset from where the finger was — worse on the phones this is for.
    useEffect(() => {
        if (!active) return;
        const canvas = canvasRef.current;
        if (!canvas) return;

        const pad = new SignaturePad(canvas, { backgroundColor: "#ffffff", penColor: "#111111" });
        padRef.current = pad;

        const resize = () => {
            const ratio = Math.max(window.devicePixelRatio || 1, 1);
            const { width, height } = canvas.getBoundingClientRect();
            if (!width || !height) return;
            canvas.width = width * ratio;
            canvas.height = height * ratio;
            canvas.getContext("2d").scale(ratio, ratio);
            // Resizing clears the surface, so the state has to follow or the button stays enabled
            // over an empty pad.
            pad.clear();
            setDrawn(false);
        };
        resize();
        window.addEventListener("resize", resize);
        pad.addEventListener("endStroke", () => setDrawn(!pad.isEmpty()));

        return () => {
            window.removeEventListener("resize", resize);
            pad.off();
            padRef.current = null;
        };
    }, [active]);

    if (!active) return null;

    const clear = () => {
        padRef.current?.clear();
        setDrawn(false);
    };

    const ready = drawn && agreed && (!needsDocNumber || docNumber.trim().length > 0);

    const submit = async () => {
        const pad = padRef.current;
        if (!ready || busy || !pad || pad.isEmpty()) return;

        // Stored as a FILE, never inline. A base64 PNG in the payload would sit in the run's vars
        // and in the message document, both of which are read constantly and neither of which
        // wants a picture in it. Same two-step the evidence card uses.
        if (!onUpload) {
            notifyError("Could not save the signature. Please try again.");

            return;
        }

        setBusy(true);
        try {
            const dataUrl = pad.toDataURL("image/png");
            const blob = await (await fetch(dataUrl)).blob();
            const file = new File([blob], "signature.png", { type: "image/png" });
            const attachments = await onUpload([file]);
            if (!attachments?.length) return;

            onSubmit({
                image: attachments[0],
                consent: payload.consent || null,
                ...(needsDocNumber ? { document_number: docNumber.trim() } : {}),
            });
        } finally {
            setBusy(false);
        }
    };

    return (
        <div className="wg-sign">
            {needsDocNumber && (
                <label className="wg-sign-id">
                    <span>證件號碼 / Document number</span>
                    <input type="text" value={docNumber} disabled={busy} autoComplete="off"
                        onChange={(e) => setDocNumber(e.target.value)} />
                </label>
            )}

            <canvas ref={canvasRef} className="wg-sign-pad" aria-label="Signature pad" />

            <div className="wg-sign-bar">
                <button type="button" className="wg-sign-clear" onClick={clear} disabled={busy}>
                    重簽 Clear
                </button>
            </div>

            {payload.consent && (
                <label className="wg-sign-consent">
                    <input type="checkbox" checked={agreed} disabled={busy}
                        onChange={(e) => setAgreed(e.target.checked)} />
                    <span>{payload.consent}</span>
                </label>
            )}

            <button type="button" className="wg-sign-submit" onClick={submit} disabled={!ready || busy}>
                {busy ? "簽署中…" : "確認簽署 Sign"}
            </button>
        </div>
    );
}

// A form the ORG hosts, not us. Two things on the card, and the second one is the point.
//
// The link carries the reference already, so the ordinary path is one tap. But a link is fragile
// in ways we do not control — it gets forwarded, opened on a phone that strips the query string,
// or reached from a bookmark — and the reference is what says which case a submission belongs to.
// So it is also shown in full, grouped and copyable, as the RECOVERY PATH: somebody who ends up on
// the form with an empty reference box can still type it in. Without that, a lost link is a dead
// conversation nobody can rescue. (FP-T2)
function ExternalFormCard({ payload, active }) {
    const [copied, setCopied] = useState(false);
    const reference = payload.reference || "";

    const copy = async () => {
        try {
            await navigator.clipboard.writeText(reference);
            setCopied(true);
            setTimeout(() => setCopied(false), 1800);
        } catch {
            // Clipboard access is refused outside a secure context and in some in-app browsers.
            // The reference is on screen and selectable either way, so this is not an error worth
            // interrupting anyone about.
            notifyError("Copy the reference by selecting it.");
        }
    };

    return (
        <div className="wg-xform">
            <a className="wg-xform-go" href={payload.url} target="_blank" rel="noopener noreferrer"
                onClick={(e) => { if (!active) e.preventDefault(); }}
                aria-disabled={!active}>
                Open the form
            </a>
            {reference ? (
                <div className="wg-xform-ref">
                    <span className="wg-xform-refl">Your reference</span>
                    <code>{reference}</code>
                    <button type="button" className="wg-xform-copy" onClick={copy}>
                        {copied ? "Copied" : "Copy"}
                    </button>
                </div>
            ) : null}
            <div className="wg-xform-note">
                The form already knows your reference. If it asks for one, use the code above.
            </div>
        </div>
    );
}

function Chase({ on }) {
    if (!on) return null;

    return <div className="wg-chase">Still waiting on you</div>;
}

// Cards that ASK somebody for something. A card addressed to the other side is replaced wholesale
// for everybody else — see below — and only these types have anything to replace; a plain message,
// a receipt or a booking confirmation is news, and news is for the room.
const ASK_TYPES = [
    "choice", "resource_picker", "slot_picker", "evidence", "form",
    "signature", "external_form", "payment", "verify",
];

export function MessageItem({ message, ctx }) {
    const p = message?.payload ?? {};
    const { active } = ctx;

    // A STEP FOR THE OTHER SIDE READS DIFFERENTLY FROM HERE. The author wrote "A customer has asked
    // for this slot. Take it?" for the supplier; showing that sentence to the customer who asked,
    // over live Accept and Decline buttons, is three wrongs at once — it is not their question, the
    // words are not about them, and pressing either earned a refusal.
    //
    // Replaced rather than disabled: greying the buttons would leave the supplier's sentence on the
    // customer's screen. What they need to know is that the conversation is with somebody else now,
    // and who. (Arfu, 16/09/2026)
    // Only while it is the step the run is actually parked on. An answered card belongs to the
    // case's history and reads as one — replacing it too would leave a supplier told they are
    // "waiting for the customer" under the time that customer already picked.
    if (message?.for_you === false && ctx.isCurrent && ASK_TYPES.includes(p.type)) {
        const who = (message.waiting_for || []).filter(Boolean).join(" or ");

        return <SystemNote>{who ? `Waiting for ${who} to answer.` : "Waiting for the other side to answer."}</SystemNote>;
    }

    // Somebody ELSE'S answer belongs on their side of the chat, not on the reader's. Every branch
    // below that echoes an answer is written from the answerer's point of view, so this is the one
    // place that has to know the difference.
    const mySide = message?.mine === false ? "in" : "out";

    if (p.type === "choice") {
        return (
            <Row side="in"><Bubble>
                <Chase on={p.reminder} />
                {p.prompt}
                <ChoiceChips options={p.options} active={active} onPick={ctx.actions.submitInput} />
            </Bubble></Row>
        );
    }

    if (p.type === "resource_picker") {
        return (
            <Row side="in"><Bubble>
                <Chase on={p.reminder} />
                Which one would you like?
                <ResourcePicker serviceId={p.service} active={active} onPick={ctx.actions.submitInput} />
            </Bubble></Row>
        );
    }

    if (p.type === "form") {
        // WIDE WHILE IT IS A FORM — answered or not. `wide` followed `active` alone, so the moment
        // the answers went in the card was squeezed into a narrow bubble: the same questions, the
        // same values, half the width. A filled form is still a form. (Arfu, 18/09/2026)
        const answers = answersFor(ctx.messages, message, ctx.index);

        return (
            <Row side="in"><Bubble wide={active || !!answers}>
                <Chase on={p.reminder} />
                {p.title || "A few questions"}
                <FormCard payload={p} active={active} onSubmit={ctx.actions.submitForm}
                    conversationId={ctx.conversationId}
                    answered={answers} />
            </Bubble></Row>
        );
    }

    if (p.type === "slot_picker") {
        return (
            <Row side="in"><Bubble>
                <Chase on={p.reminder} />
                Pick a time that works for you:
                <SlotPicker serviceId={p.service} resourceId={p.resource} flowRunId={ctx.conversationId}
                    active={active} onPick={ctx.actions.submitInput} />
            </Bubble></Row>
        );
    }

    if (p.type === "payment") {
        return (
            <Row side="in"><Bubble>
                <Chase on={p.reminder} />
                {payload_label(p)}
                <PaymentRequest payload={p} active={active} onPaid={ctx.actions.submitInput} />
            </Bubble></Row>
        );
    }

    if (p.type === "evidence") {
        return (
            <Row side="in"><Bubble>
                <Chase on={p.reminder} />
                {p.prompt}
                <EvidenceInput active={active} onSubmit={ctx.actions.submitEvidence} onUpload={ctx.actions.uploadEvidence} />
            </Bubble></Row>
        );
    }

    // A document the flow produced and handed over (v0.18). One line of the org's own words and
    // the file itself — a message saying "your statement is ready" with nothing to open is the
    // shape this node exists to avoid.
    if (p.type === "document") {
        const doc = p.document || {};

        return (
            <Row side="in"><Bubble>
                {p.text}
                {doc.url ? (
                    <a className="wg-sign-file" href={doc.url} target="_blank" rel="noopener noreferrer">
                        📄 {doc.name || "Your document"}
                    </a>
                ) : null}
            </Bubble></Row>
        );
    }

    if (p.type === "signature") {
        return (
            <Row side="in"><Bubble>
                <Chase on={p.reminder} />
                <div className="wg-sign-doc">✍️ {p.document}</div>
                {/* The actual paper, when one has been produced for this case. A signature against
                    a title alone asks somebody to agree to a document they have not read. */}
                {p.document_file ? (
                    <a className="wg-sign-file" href={p.document_file.url} target="_blank" rel="noopener noreferrer">
                        Read it first — {p.document_file.name}
                    </a>
                ) : null}
                {p.prompt}
                <SignatureInput active={active} payload={p}
                    onSubmit={ctx.actions.submitSignature} onUpload={ctx.actions.uploadEvidence} />
            </Bubble></Row>
        );
    }

    if (p.type === "match") {
        return (
            <Row side="in"><Bubble>
                {p.text || "We are finding somebody for you."}
                <MatchCard payload={p} active={active} flowRunId={ctx.conversationId} />
            </Bubble></Row>
        );
    }

    if (p.type === "match_result") {
        return <SystemNote>{p.text || "Somebody is on this case."}</SystemNote>;
    }

    if (p.type === "external_form") {
        return (
            <Row side="in"><Bubble>
                <Chase on={p.reminder} />
                {p.prompt || "Please fill in this form, then come back here."}
                <ExternalFormCard payload={p} active={active} />
            </Bubble></Row>
        );
    }

    if (p.type === "external_form_result") {
        return <SystemNote>Their form came back — carrying on.</SystemNote>;
    }

    if (p.type === "verify") {
        return (
            <>
                <Row side="in"><Bubble>{p.prompt}</Bubble></Row>
                <SystemNote>Waiting for the team to verify…</SystemNote>
            </>
        );
    }

    switch (p.type) {
        case "message":
            return (
                <>
                    <Row side="in"><Bubble><Chase on={p.reminder} />{p.text}</Bubble></Row>
                    {/* LEFT, with the message it answers. Every affordance in this chat hangs off
                        the asking message — the choice chips, the slot list, the receipt picker —
                        so an ack alone on the right would be the odd one out. */}
                    {ctx.waitingAck ? (
                        <div className="wg-chips" style={{ marginTop: 2 }}>
                            <Chip onClick={ctx.actions.submitAck}>Got it</Chip>
                        </div>
                    ) : null}
                </>
            );

        case "api_result":
            // Only the booking success is worth a card; failures are narrated by the
            // engine's own on_error message node that follows.
            return p.ok && p.payload?.booking_ref ? <BookingCard payload={p.payload} /> : null;

        case "user_input":
            // A receipt is a file, not a value to echo: printing the object gave the customer
            // "[object Object]" back as their own message.
            return (
                <Row side={mySide}><Bubble>
                    {p.value?.payment_id
                        ? `Receipt sent${p.value.receipt ? ` — ${p.value.receipt}` : ""}`
                        // `label` is what FlowRunner resolved when it wrote this message — the
                        // venue's NAME for a resource answer, where the value itself is an id and
                        // the asking card carried no options to look it up in. Without it the
                        // customer's own bubble read "31883" back at them. (Arfu, 10/09/2026)
                        : (p.label || labelForValue(p.value, ctx.messages, ctx.index))}
                </Bubble></Row>
            );

        case "ack":
            return <Row side={mySide}><Bubble>Got it</Bubble></Row>;

        // What they actually answered, not "form submitted" — a transcript somebody reads back a
        // month later has to say what was said.
        // NOTHING HERE. The ask card above shows the answers in place and says they were sent,
        // so an echo underneath is the same thing twice — and as a list of `name value` pairs it
        // was the worse of the two. The payload still carries `answers`, which is what the org's
        // Inbox draws and what `payload.text` is written from. (Arfu, 18/09/2026)
        case "form_result":
            return null;

        case "evidence": {
            // Show what was actually sent. A row reading "Response sent" over three attached photos
            // tells the customer nothing about whether the right ones went.
            const files = Array.isArray(p.attachments) ? p.attachments : (Array.isArray(p.payload?.attachments) ? p.payload.attachments : []);

            return (
                <Row side={mySide}><Bubble>
                    {p.payload?.text || (files.length ? "" : "Response sent")}
                    {files.map((a, i) => (
                        <a key={i} href={a.url} target="_blank" rel="noreferrer" className="wg-ev-sent">
                            {a.kind === "image"
                                ? <img src={a.url} alt={a.name || ""} />
                                : <span>{a.name}</span>}
                        </a>
                    ))}
                </Bubble></Row>
            );
        }

        default:
            // NOT null. This surface shows the whole room now, so anything the flow did not write
            // — a reschedule card, a booking-status announcement, the vendor's own reply from the
            // Inbox — arrives here, and returning null made those messages simply not exist for a
            // customer sitting in the guided chat. Same component the room draws, so the two
            // cannot disagree about what a card looks like. (Arfu, 02/09/2026)
            //
            // `mine` reads the RAW direction: this endpoint does not flip it, so the engine's sense
            // applies and "inbound" is the customer speaking.
            return (
                <RoomMessage message={message} messages={ctx.messages}
                    mine={message.mine !== false && message.direction === "inbound"}
                    onPickChoice={ctx.roomActions?.onPickChoice}
                    onPickReceipt={ctx.roomActions?.onPickReceipt}
                    onPickReschedule={ctx.roomActions?.onPickReschedule} />
            );
    }
}
