import { Link, router, useForm } from '@inertiajs/react';
import { AlertCircle, Check, CloudOff, Copy, Eye, LayoutTemplate, Loader2, Plus, Send, Tags } from 'lucide-react';
import { CoverImageCard } from '@/components/proposal/CoverImageCard';
import { ThemeCard, type Theme, type ThemeOptions } from '@/components/proposal/ThemeCard';
import { scanTags, type TagLocation } from '@/lib/smartTags';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatedList, Reveal } from '@/components/motion';
import { PercentInput } from '@/components/NumberInputs';
import { PageHeader } from '@/components/PageHeader';
import { StatusBadge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardBody, CardHeader } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog } from '@/components/ui/dialog';
import { Input, Select, Textarea } from '@/components/ui/input';
import { Field } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout';
import { money } from '@/lib/format';
import { http, HttpError, xsrfToken } from '@/lib/http';
import { calculateTotals, type TaxMode } from '@/lib/totals';
import { ActivityPanel } from '@/components/quote/ActivityPanel';
import { SendDialog, type Compose } from '@/components/quote/SendDialog';
import { ClientLinks } from '@/components/quote/ClientLinks';
import { QuickBooksCard } from '@/components/quote/QuickBooksCard';
import { LinesEditor, stripKeys, toTotalsLine, withKeys, type LibraryItem, type Line, type TemplateOption } from '@/components/quote/LinesEditor';
import { cn } from '@/lib/utils';
import { StatusMenu, VersionsCard, type Workflow } from '@/components/quote/Workflow';
import { Lock, PencilLine, Trash2, Undo2 } from 'lucide-react';

type QuoteData = {
    id: number | null;
    number: string | null;
    status: string;
    current_version?: number;
    title: string;
    title_label: string;
    contact_id: number | null;
    owner_id: number | null;
    quote_date: string;
    expiry_date: string | null;
    currency: string;
    tax_mode: TaxMode;
    discount_bp: number;
    deposit_bp: number;
    pricing_visibility: string;
    show_codes: boolean;
    private_notes: string;
    theme: Theme;
    smart_tags: Record<string, string>;
    template_id: number | null;
    lines: Omit<Line, 'key'>[];
};

type Props = {
    quote: QuoteData;
    company: { id: number; name: string; prefix: string; default_tax_rate_bp: number; default_markup_bp: number; colours: Record<string, string>; heading_font: string; body_font: string };
    contacts: { id: number; name: string; first_name?: string; email: string | null; company: string | null }[];
    library: LibraryItem[];
    templates: TemplateOption[];
    canEditTemplates: boolean;
    owners: { id: number; name: string }[];
    options: { title_labels: string[]; tax_modes: TaxMode[]; pricing_visibility: string[]; currencies: string[] };
    canAssignOwner: boolean;
    systemTags: Record<string, string>;
    themeOptions: ThemeOptions;
    templateOptions: { id: number; name: string }[];
    canSend: boolean;
    workflow: Workflow;
};

const TAX_MODE_LABELS: Record<TaxMode, string> = {
    exclusive_with_inclusive_total: 'Prices exclude GST, total includes GST',
    exclusive: 'Prices exclude GST (total shown +GST)',
    inclusive: 'Prices include GST',
    none: 'No tax',
};

const VISIBILITY_LABELS: Record<string, string> = {
    all: 'Show all pricing',
    totals_only: 'Totals only',
    quantities_only: 'Quantities only',
    hide_all: 'Hide all pricing',
};

/**
 * The key is fixed for the life of this page so the editor keeps its state when an
 * autosaved new quote gets its id (the URL changes, the page does not reload).
 */
export default function QuoteEditPage(props: Props) {
    const key = useRef(props.quote.id ?? 'new').current;
    return <QuoteEdit key={key} {...props} />;
}

function QuoteEdit({ quote, company, contacts, library, templates, canEditTemplates, owners, options, canAssignOwner, systemTags, themeOptions, templateOptions, canSend, workflow }: Props) {
    const isNew = quote.id === null;
    const form = useForm({
        ...quote,
        theme: quote.theme ?? {},
        smart_tags: Array.isArray(quote.smart_tags) ? {} : (quote.smart_tags ?? {}),
        lines: withKeys(quote.lines),
    });
    const d = form.data;
    const lines = d.lines;
    const totals = useMemo(() => calculateTotals({ tax_mode: d.tax_mode, discount_bp: d.discount_bp, lines: lines.map(toTotalsLine) }),
        [d.tax_mode, d.discount_bp, lines]);

    // ---- Autosave -------------------------------------------------------------
    // Every change is saved (debounced) as a draft. A new quote is created on the
    // first meaningful change and the URL becomes /quotes/{id}/edit, so the page
    // can be closed and continued later from the quote list.
    const [savedId, setSavedId] = useState<number | null>(quote.id);
    const [number, setNumber] = useState<string | null>(quote.number);
    const [status, setStatus] = useState<SaveStatus>(isNew ? { kind: 'idle' } : { kind: 'saved', at: null });
    const payload = useMemo(() => JSON.stringify({ ...d, lines: stripKeys(d.lines) }), [d]);
    const lastSaved = useRef<string>(isNew ? '' : payload);
    const inFlight = useRef(false);
    const queued = useRef(false);
    const idRef = useRef<number | null>(quote.id);
    const latest = useRef(payload);
    latest.current = payload;

    const meaningful = !!(d.title.trim() || d.lines.length || d.contact_id);

    // ---- Workflow (status, locking, versions) ------------------------------------
    const [qStatus, setQStatus] = useState(quote.status);
    const locked = workflow.locked.includes(qStatus);
    const lockedRef = useRef(locked);
    lockedRef.current = locked;
    const [versionsKey, setVersionsKey] = useState(0);
    const [unlocking, setUnlocking] = useState(false);
    /** Reload the whole editor from the server (after a restore or unlock the content/status changed underneath). */
    const reloadEditor = () => router.visit(window.location.pathname, { preserveState: false, preserveScroll: true });
    const unlock = async () => {
        if (!idRef.current) return;
        setUnlocking(true);
        try {
            await http('POST', `/quotes/${idRef.current}/unlock`, {}, { 'X-Company': String(company.id) });
            reloadEditor();
        } catch { setUnlocking(false); }
    };

    // ---- Smart tags + "Check & send" ------------------------------------------
    const tagMap = useMemo(() => scanTags(d.title, d.lines), [d.title, d.lines]);
    const [sendCheck, setSendCheck] = useState<{ ok: boolean; message: string; unfilled: { tag: string; locations: TagLocation[] }[] } | null>(null);
    const highlight = useMemo(() => new Set((sendCheck?.unfilled ?? []).flatMap((u) => u.locations.map((l) => l.line)).filter((n): n is number => n !== null)), [sendCheck]);
    const titleFlagged = !!sendCheck?.unfilled.some((u) => u.locations.some((l) => l.field === 'title'));
    const [compose, setCompose] = useState<Compose | null>(null);
    const [sending, setSending] = useState(false);
    const [activityKey, setActivityKey] = useState(0);
    const checkSend = async (prefer?: string) => {
        await save();
        if (!idRef.current) return;
        try {
            const res = await http<{ message: string; compose: Compose }>('POST', `/quotes/${idRef.current}/send`, prefer ? { prefer } : {}, { 'X-Company': String(company.id) });
            setSendCheck({ ok: true, message: res.message, unfilled: [] });
            setCompose(res.compose);
            setSending(true);
        } catch (e) {
            if (e instanceof HttpError && e.status === 422) {
                const unfilled = e.body?.unfilled ?? [];
                setSendCheck({ ok: false, message: e.body?.message ?? 'Not ready to send.', unfilled });
                const first = unfilled[0]?.locations[0];
                const el = first ? (first.line === null ? document.getElementById('title') : document.querySelector(`[data-testid="line-${first.line}"]`)) : null;
                el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
            } else setSendCheck({ ok: false, message: 'Couldn’t check the quote. Try again.', unfilled: [] });
        }
    };
    // Re-check quietly as tags get filled so highlights clear.
    useEffect(() => { if (sendCheck && !sendCheck.ok) setSendCheck((sc) => sc && { ...sc, unfilled: sc.unfilled.filter((u) => tagMap.has(u.tag) && !(systemTags[u.tag] ? false : d.smart_tags[u.tag]?.trim())) }); },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [d.smart_tags, tagMap]);
    const dirty = payload !== lastSaved.current && (idRef.current !== null || meaningful);

    const save = async () => {
        if (lockedRef.current) return;
        if (inFlight.current) { queued.current = true; return; }
        const body = latest.current;
        if (body === lastSaved.current) return;
        inFlight.current = true;
        setStatus({ kind: 'saving' });
        try {
            const id = idRef.current;
            const res = await http<{ quote: { id: number; number: string; status: string } }>(id ? 'PUT' : 'POST', id ? `/api/quotes/${id}` : '/api/quotes',
                JSON.parse(body), { 'X-Company': String(company.id) });
            lastSaved.current = body;
            if (res.quote.status !== qStatusRef.current) { setQStatus(res.quote.status); setVersionsKey((k) => k + 1); }
            if (!id) {
                idRef.current = res.quote.id;
                setSavedId(res.quote.id);
                setNumber(res.quote.number);
                // Swap the URL to the saved draft without reloading, so refresh / Back / bookmarks reopen it.
                router.replace({
                    url: `/quotes/${res.quote.id}/edit`, preserveState: true, preserveScroll: true,
                    props: (p) => ({ ...p, quote: { ...(p.quote as QuoteData), id: res.quote.id, number: res.quote.number } }),
                });
            }
            form.clearErrors();
            setStatus({ kind: 'saved', at: new Date() });
        } catch (e) {
            if (e instanceof HttpError && e.status === 409) {
                setStatus({ kind: 'error', message: e.body?.message ?? 'This quote is locked.' });
            } else if (e instanceof HttpError && e.status === 422) {
                const errs = Object.fromEntries(Object.entries(e.body?.errors ?? {}).map(([k, v]) => [k, (v as string[])[0]]));
                form.setError(errs as never);
                setStatus({ kind: 'invalid' });
            } else {
                setStatus({ kind: 'error', message: e instanceof HttpError && e.status === 404 ? 'This quote belongs to another company. Switch back to keep saving.' : 'Couldn’t save — retrying…' });
                window.setTimeout(() => { if (latest.current !== lastSaved.current) void save(); }, 8000);
            }
        } finally {
            inFlight.current = false;
            if (queued.current) { queued.current = false; if (latest.current !== lastSaved.current) void save(); }
        }
    };

    // A page restored from browser history can hold an older copy; load the latest saved version.
    useEffect(() => {
        if (quote.id === null) return;
        http<{ quote: QuoteData }>('GET', `/api/quotes/${quote.id}`, undefined, { 'X-Company': String(company.id) }).then(({ quote: fresh }) => {
            const freshJson = JSON.stringify(fresh);
            if (freshJson === lastSaved.current) return;
            lastSaved.current = freshJson;
            form.setData({ ...fresh, lines: withKeys(fresh.lines) });
        }).catch(() => {});
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    const qStatusRef = useRef(qStatus);
    qStatusRef.current = qStatus;
    // The activity panel noticed a status change (client decision, resend, autosave → Editing).
    const onPanelStatus = useCallback((s: string) => {
        if (s === qStatusRef.current) return;
        if (s === 'accepted' || s === 'declined') { router.visit(window.location.pathname, { preserveState: false, preserveScroll: true }); return; }
        setQStatus(s);
        setVersionsKey((k) => k + 1);
    }, []);
    // Arrived from a dashboard follow-up prompt: open the send dialog with that email.
    useEffect(() => {
        if (workflow.follow_up && quote.id) void checkSend(workflow.follow_up);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    useEffect(() => {
        if (!dirty || locked) return;
        const t = window.setTimeout(() => void save(), 1200);
        return () => window.clearTimeout(t);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [payload, dirty]);

    // Leaving the page (closing the tab, or navigating inside the app): send any unsaved changes.
    useEffect(() => {
        const flush = () => {
            if (latest.current === lastSaved.current || (idRef.current === null && !meaningfulRef.current)) return;
            const id = idRef.current;
            void fetch(id ? `/api/quotes/${id}` : '/api/quotes', {
                method: id ? 'PUT' : 'POST', keepalive: true, credentials: 'same-origin', body: latest.current,
                headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-XSRF-TOKEN': xsrfToken(), 'X-Company': String(company.id) },
            });
            lastSaved.current = latest.current;
        };
        const onHide = () => { if (document.visibilityState === 'hidden') flush(); };
        const off = router.on('before', (event) => {
            // Ignore the editor's own requests; flush before any other visit.
            if (!String(event.detail.visit.url).includes('/api/')) flush();
        });
        window.addEventListener('pagehide', flush);
        document.addEventListener('visibilitychange', onHide);
        return () => { off(); window.removeEventListener('pagehide', flush); document.removeEventListener('visibilitychange', onHide); };
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);
    const meaningfulRef = useRef(meaningful);
    meaningfulRef.current = meaningful;

    const submit = (e: React.FormEvent) => {
        e.preventDefault();
        void save();
    };
    const created = savedId !== null;
    const previewUrl = created ? `${window.location.origin}/quotes/${savedId}/preview` : '';
    /** Save first so the preview shows the latest edits (tab is opened synchronously to avoid popup blockers). */
    const openPreview = async () => {
        const tab = window.open('about:blank', '_blank');
        await save();
        if (tab) tab.location.href = `/quotes/${idRef.current}/preview`;
        else window.location.href = `/quotes/${idRef.current}/preview`;
    };

    // ---- Owner (saved straight away; doesn't count as an edit to a sent quote) ----
    const [ownerBusy, setOwnerBusy] = useState(false);
    const [ownerSaved, setOwnerSaved] = useState(false);
    const [ownerError, setOwnerError] = useState<string | null>(null);
    const changeOwner = async (id: number | null) => {
        if (!id) return;
        setOwnerError(null);
        if (!idRef.current) { form.setData('owner_id', id); return; }
        setOwnerBusy(true);
        try {
            await http('POST', `/quotes/${idRef.current}/owner`, { owner_id: id }, { 'X-Company': String(company.id) });
            // Keep autosave from re-sending it as an edit.
            try { const was = JSON.parse(lastSaved.current); if (was) { was.owner_id = id; lastSaved.current = JSON.stringify(was); } } catch { /* nothing saved yet */ }
            form.setData('owner_id', id);
            setOwnerSaved(true); window.setTimeout(() => setOwnerSaved(false), 1500);
        } catch (e) {
            setOwnerError(e instanceof HttpError ? e.message : 'Couldn’t change the owner.');
        } finally { setOwnerBusy(false); }
    };

    // ---- Unaccept ----
    const [unaccepting, setUnaccepting] = useState(false);
    const [unacceptReason, setUnacceptReason] = useState('');
    const [unacceptBusy, setUnacceptBusy] = useState(false);
    const [unacceptError, setUnacceptError] = useState<string | null>(null);
    const unaccept = async () => {
        if (!idRef.current) return;
        setUnacceptBusy(true); setUnacceptError(null);
        try {
            await http('POST', `/quotes/${idRef.current}/unaccept`, { reason: unacceptReason.trim() || null }, { 'X-Company': String(company.id) });
            setUnaccepting(false);
            reloadEditor();
        } catch (e) {
            setUnacceptError(e instanceof HttpError ? e.message : 'Couldn’t unaccept the quote.');
        } finally { setUnacceptBusy(false); }
    };

    // ---- Delete ----
    const [deleting, setDeleting] = useState(false);
    const [deleteConfirm, setDeleteConfirm] = useState('');
    const [deleteBusy, setDeleteBusy] = useState(false);
    const [deleteError, setDeleteError] = useState<string | null>(null);
    const canDelete = qStatus === 'draft' || canAssignOwner;
    const deleteQuote = async () => {
        if (!idRef.current) return;
        setDeleteBusy(true); setDeleteError(null);
        try {
            await http('DELETE', `/quotes/${idRef.current}`, qStatus === 'draft' ? {} : { confirm: deleteConfirm.trim() }, { 'X-Company': String(company.id) });
            lastSaved.current = latest.current; // nothing left to autosave
            router.visit('/quotes');
        } catch (e) {
            setDeleteError(e instanceof HttpError ? e.message : 'Couldn’t delete the quote.');
            setDeleteBusy(false);
        }
    };

    const err = (path: string) => (form.errors as Record<string, string>)[path];
    const cur = d.currency;
    const oneOff = totals.groups.find((g) => g.key === 'one_off');
    const subs = totals.groups.filter((g) => g.key !== 'one_off');
    const marginPct = totals.subtotal_cents ? (totals.margin_cents / totals.subtotal_cents) * 100 : null;
    const markupPct = totals.cost_cents ? (totals.margin_cents / totals.cost_cents) * 100 : null;
    const hasErrors = Object.keys(form.errors).length > 0;

    return (
        <AppLayout title={created ? `${number} · ${d.title || 'Untitled'}` : `New ${d.title_label.toLowerCase()}`}>
            <form onSubmit={submit} noValidate>
                <PageHeader
                    title={created ? `${number}` : `New ${d.title_label.toLowerCase()}`}
                    breadcrumbs={[{ label: 'Quotes', href: '/quotes' }, { label: created ? `${number}` : 'New' }]}
                    description={<span className="inline-flex items-center gap-2">{company.name}<span data-testid="quote-status"><StatusBadge status={qStatus} /></span>
                        {!created && <span>· saved as a draft automatically once you start ({company.prefix}-…)</span>}</span>}
                    actions={<>
                        <SaveIndicator status={status} dirty={dirty} />
                        <Button type="button" variant="secondary" disabled={!created} onClick={openPreview} data-testid="preview-quote"
                            title={created ? 'Open the client view in a new tab' : 'Available once the draft has saved'}><Eye className="h-4 w-4" /> Preview</Button>
                        <Button type="button" variant="secondary" asChild><Link href="/quotes">Back to quotes</Link></Button>
                        {created && idRef.current && <StatusMenu quoteId={idRef.current} companyId={company.id} status={qStatus}
                            workflow={{ ...workflow, targets: workflow.targets_by_status[qStatus] ?? [] }} onChanged={() => reloadEditor()} />}
                        {canSend && !locked && <Button type="button" disabled={!created} onClick={() => void checkSend()} data-testid="send-quote" title={created ? 'Check the quote is ready, then send' : 'Available once the draft has saved'}><Send className="h-4 w-4" /> {created && qStatus !== 'draft' ? 'Resend' : 'Send'}</Button>}
                        <Button type="submit" variant="secondary" disabled={status.kind === 'saving' || (!dirty && status.kind !== 'error')} data-testid="save-quote">Save now</Button>
                        {created && canDelete && <Button type="button" variant="ghost" className="text-red-700 hover:bg-red-50" onClick={() => { setDeleteConfirm(''); setDeleteError(null); setDeleting(true); }} data-testid="delete-quote" title="Delete this quote"><Trash2 className="h-4 w-4" /> Delete</Button>}
                    </>}
                />
                <Reveal show={created && (locked || qStatus === 'editing' || workflow.sent_states.includes(qStatus))}>
                    <div role="status" data-testid="workflow-banner" className={cn('mb-4 flex flex-wrap items-center gap-3 rounded-md border px-4 py-3 text-sm',
                        locked ? 'border-teal/30 bg-teal/10 text-[#05705f]' : qStatus === 'editing' ? 'border-amber-300 bg-amber-50 text-amber-900' : 'border-line bg-surface text-ink')}>
                        {locked ? <Lock className="h-4 w-4 shrink-0" aria-hidden /> : <PencilLine className="h-4 w-4 shrink-0" aria-hidden />}
                        <p className="min-w-0 flex-1">
                            {locked
                                ? <>This quote is <b>{workflow.labels[qStatus]?.toLowerCase()}</b> and locked. {qStatus === 'accepted' ? <>Editing it keeps the accepted version and sends it back for re-acceptance (or accept on the client’s behalf).{workflow.can_unaccept ? ' Unaccept withdraws the acceptance and returns it to Sent.' : ''}</> : 'Mark it as accepted again from Status to make changes.'}</>
                                : qStatus === 'editing'
                                    ? <>Being edited. The client still sees the version you sent and can’t accept until you <b>resend</b>. The sent version is kept under Versions.</>
                                    : <>This quote has been sent. Your first change moves it to <b>Editing</b> and keeps the sent version; the client sees the sent version until you resend.</>}
                        </p>
                        {qStatus === 'accepted' && workflow.can_unaccept && <Button type="button" size="sm" variant="secondary" onClick={() => { setUnacceptReason(''); setUnacceptError(null); setUnaccepting(true); }} data-testid="unaccept-quote"><Undo2 className="h-4 w-4" /> Unaccept</Button>}
                        {qStatus === 'accepted' && <Button type="button" size="sm" onClick={unlock} disabled={unlocking} data-testid="unlock-quote">{unlocking && <Loader2 className="h-4 w-4 animate-spin" />} Edit accepted quote</Button>}
                    </div>
                </Reveal>
                <Reveal show={created}>
                    {created && idRef.current && <ClientLinks quoteId={idRef.current} companyId={company.id} previewUrl={previewUrl} refreshKey={activityKey} />}
                </Reveal>
                <Reveal show={!!sendCheck}>
                    {sendCheck && (
                        <div role={sendCheck.ok ? 'status' : 'alert'} data-testid="send-check"
                            className={cn('mb-4 rounded-md border px-4 py-3 text-sm', sendCheck.ok ? 'border-teal/30 bg-teal/10 text-[#05705f]' : 'border-amber-300 bg-amber-50 text-amber-900')}>
                            <div className="flex items-start justify-between gap-3">
                                <p className="font-medium">{sendCheck.ok ? sendCheck.message : `Can't send yet. ${sendCheck.message}`}</p>
                                <button type="button" className="text-xs underline" onClick={() => setSendCheck(null)}>Dismiss</button>
                            </div>
                            {sendCheck.unfilled.length > 0 && (
                                <ul className="mt-2 space-y-1">
                                    {sendCheck.unfilled.map((u) => (
                                        <li key={u.tag}><code className="rounded bg-amber-100 px-1">&lt;{u.tag}&gt;</code>{' '}
                                            {systemTags[u.tag] ? `is automatic (${systemTags[u.tag]}) but this quote doesn't have it yet` : 'needs a value in Smart tags'} · in{' '}
                                            {u.locations.map((l) => l.line === null ? 'the title' : `line ${l.line + 1}`).filter((v, i, a) => a.indexOf(v) === i).join(', ')}
                                        </li>
                                    ))}
                                </ul>
                            )}
                        </div>
                    )}
                </Reveal>
                <Reveal show={!created && lines.length === 0 && templateOptions.length > 0}>
                    <div className="mb-4 flex flex-wrap items-center gap-3 rounded-[var(--radius-card)] bg-surface px-5 py-3 text-sm shadow-card">
                        <LayoutTemplate className="h-4 w-4 text-accent" aria-hidden />
                        <span className="font-medium">Start from a template</span>
                        <Select aria-label="Template" className="w-auto" defaultValue="" onChange={(e) => e.target.value && router.visit(`/quotes/create?template_id=${e.target.value}${d.contact_id ? `&contact_id=${d.contact_id}` : ''}`)} data-testid="start-from-template">
                            <option value="">Choose a template…</option>
                            {templateOptions.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
                        </Select>
                    </div>
                </Reveal>
                <Reveal show={hasErrors}>
                    <div role="alert" className="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
                        Changes aren’t being saved. Fix the highlighted fields and saving resumes automatically.
                    </div>
                </Reveal>

                <div className="grid gap-6 lg:grid-cols-3">
                    <fieldset disabled={locked} className={cn('min-w-0 space-y-6 lg:col-span-2', locked && 'pointer-events-none select-text opacity-75')} data-testid="editor-main">
                        <Card>
                            <CardHeader title="Details" />
                            <CardBody className="grid gap-4 sm:grid-cols-2">
                                <Field label="Title" htmlFor="title" error={err('title')} className="sm:col-span-2">
                                    <Input id="title" value={d.title} placeholder="e.g. Website rebuild for Harbour Dental"
                                        onChange={(e) => form.setData('title', e.target.value)} aria-invalid={!!err('title') || undefined}
                                        className={cn(titleFlagged && 'ring-2 ring-amber-400')} />
                                </Field>
                                <ContactPicker contacts={contacts} value={d.contact_id} onChange={(id) => form.setData('contact_id', id)} error={err('contact_id')} />
                                <Field label="Document type" htmlFor="title_label" error={err('title_label')}>
                                    <Select id="title_label" value={d.title_label} onChange={(e) => form.setData('title_label', e.target.value)}>
                                        {options.title_labels.map((t) => <option key={t}>{t}</option>)}
                                    </Select>
                                </Field>
                                <Field label="Quote date" htmlFor="quote_date" error={err('quote_date')}>
                                    <Input id="quote_date" type="date" value={d.quote_date} onChange={(e) => form.setData('quote_date', e.target.value)} />
                                </Field>
                                <Field label="Expiry date" htmlFor="expiry_date" error={err('expiry_date')}>
                                    <Input id="expiry_date" type="date" value={d.expiry_date ?? ''} onChange={(e) => form.setData('expiry_date', e.target.value || null)} />
                                </Field>
                                <Field label="Currency" htmlFor="currency" error={err('currency')}>
                                    <Select id="currency" value={d.currency} onChange={(e) => form.setData('currency', e.target.value)}>
                                        {options.currencies.map((c) => <option key={c}>{c}</option>)}
                                    </Select>
                                </Field>
                            </CardBody>
                        </Card>

                        <CoverImageCard theme={d.theme} onChange={(t) => form.setData('theme', t)} />
                        <Card>
                            <CardHeader title="Items" description="Type calculations like 12*150 into any price or quantity. Discounts apply to the item first, then the quote." />
                            <LinesEditor
                                lines={lines} onChange={(next) => form.setData('lines', next)} errors={form.errors as Record<string, string>}
                                totals={totals} currency={d.currency} showCodes={d.show_codes} taxMode={d.tax_mode}
                                library={library} templates={templates} canEditTemplates={canEditTemplates}
                                defaultTaxBp={company.default_tax_rate_bp} defaultMarkupBp={company.default_markup_bp} highlight={highlight} />
                        </Card>

                        <Card>
                            <CardHeader title="Private notes" description="Only visible to staff." />
                            <CardBody>
                                <Textarea aria-label="Private notes" rows={4} value={d.private_notes} onChange={(e) => form.setData('private_notes', e.target.value)} />
                            </CardBody>
                        </Card>
                    </fieldset>

                    <aside className="space-y-6">
                        <Card data-testid="owner-card">
                            <CardHeader title="Quote owner" description="The person editing and responsible for this quote. They're named on the proposal as the contact, client replies go to them, and they get its notifications." />
                            <CardBody>
                                <Field label="Owner" htmlFor="owner_id" error={err('owner_id') ?? ownerError ?? undefined} hint={canAssignOwner ? 'You can change this at any time, even after sending.' : 'Quotes you create are assigned to you.'}>
                                    <div className="flex items-center gap-2">
                                        <Select id="owner_id" value={d.owner_id ?? ''} disabled={!canAssignOwner || ownerBusy} onChange={(e) => void changeOwner(Number(e.target.value) || null)} data-testid="owner-select">
                                            {owners.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
                                        </Select>
                                        {ownerBusy && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-ink-muted" aria-label="Saving" />}
                                        {ownerSaved && <Check className="h-4 w-4 shrink-0 text-teal" aria-label="Saved" />}
                                    </div>
                                </Field>
                            </CardBody>
                        </Card>
                        {created && idRef.current && quote.status !== 'draft' || activityKey > 0 ? (
                            <ActivityPanel key={activityKey} quoteId={idRef.current!} companyId={company.id} onStatus={onPanelStatus} />
                        ) : null}
                        {created && idRef.current && qStatus !== 'draft' && (
                            <QuickBooksCard quoteId={idRef.current} companyId={company.id} status={qStatus} refreshKey={activityKey} />
                        )}
                        {created && idRef.current && qStatus !== 'draft' && (
                            <VersionsCard quoteId={idRef.current} companyId={company.id} refreshKey={versionsKey}
                                onRestored={() => reloadEditor()} />
                        )}
                        <Card className="relative z-20 lg:sticky lg:top-20 lg:shadow-md" data-testid="totals">
                            <CardHeader title="Totals" />
                            <CardBody>
                                <Reveal show={totals.incomplete_choice_groups.length > 0}>
                                    <p role="status" className="mb-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-900">
                                        Pick an option in: {totals.incomplete_choice_groups.join(', ') || '…'}
                                    </p>
                                </Reveal>
                                <Reveal show={!!oneOff || subs.length === 0}>
                                    <TotalsBlock label={subs.length ? 'One-off' : undefined} currency={cur} mode={d.tax_mode}
                                        subtotal={oneOff?.subtotal_cents ?? 0} discount={oneOff?.discount_cents ?? 0} tax={oneOff?.tax_cents ?? 0} total={oneOff?.total_cents ?? 0} />
                                </Reveal>
                                <AnimatedList>
                                    {subs.map((g) => (
                                        <div key={g.key} className="pt-4">
                                            <TotalsBlock label={g.label} currency={cur} mode={d.tax_mode}
                                                subtotal={g.subtotal_cents} discount={g.discount_cents} tax={g.tax_cents} total={g.total_cents}
                                                extra={g.term_total_cents !== null ? <Row label={`Over ${g.term} periods`} value={money(g.term_total_cents, cur)} /> : undefined} />
                                        </div>
                                    ))}
                                </AnimatedList>
                                <Reveal show={subs.length > 0}>
                                    <div className="mt-4 border-t border-line pt-3">
                                        <Row label="First invoice" value={money(totals.total_cents, cur)} strong testId={subs.length > 0 ? 'grand-total' : undefined} />
                                    </div>
                                </Reveal>
                                {subs.length === 0 && <span className="sr-only" data-testid="grand-total">{money(totals.total_cents, cur)}</span>}
                                <Reveal show={d.deposit_bp > 0}>
                                    <div className="pt-3"><Row label={`Deposit (${d.deposit_bp / 100}%)`} value={money(Math.round(totals.total_cents * d.deposit_bp / 10000), cur)} /></div>
                                </Reveal>
                            </CardBody>
                        </Card>

                        <fieldset disabled={locked} className={cn('min-w-0 space-y-6', locked && 'pointer-events-none opacity-75')}>
                        <Card>
                            <CardHeader title="Pricing & tax" />
                            <CardBody className="space-y-4">
                                <Field label="Tax mode" htmlFor="tax_mode" error={err('tax_mode')}>
                                    <Select id="tax_mode" value={d.tax_mode} onChange={(e) => form.setData('tax_mode', e.target.value as TaxMode)}>
                                        {options.tax_modes.map((m) => <option key={m} value={m}>{TAX_MODE_LABELS[m]}</option>)}
                                    </Select>
                                </Field>
                                <div className="grid grid-cols-2 gap-3">
                                    <Field label="Quote discount" htmlFor="discount_bp" error={err('discount_bp')}>
                                        <PercentInput id="discount_bp" bp={d.discount_bp} onChange={(bp) => form.setData('discount_bp', bp)} />
                                    </Field>
                                    <Field label="Deposit" htmlFor="deposit_bp" error={err('deposit_bp')}>
                                        <PercentInput id="deposit_bp" bp={d.deposit_bp} onChange={(bp) => form.setData('deposit_bp', bp)} />
                                    </Field>
                                </div>
                                <Field label="Pricing shown to client" htmlFor="pricing_visibility">
                                    <Select id="pricing_visibility" value={d.pricing_visibility} onChange={(e) => form.setData('pricing_visibility', e.target.value)}>
                                        {options.pricing_visibility.map((v) => <option key={v} value={v}>{VISIBILITY_LABELS[v] ?? v}</option>)}
                                    </Select>
                                </Field>
                                <Checkbox label="Show item codes" checked={d.show_codes} onChange={(e) => form.setData('show_codes', e.target.checked)} />
                            </CardBody>
                        </Card>

                        <Card data-testid="smart-tags">
                            <CardHeader title="Smart tags" description={<>Anything in angle brackets, like <code className="rounded bg-stone-100 px-1">&lt;site_pages&gt;</code>, is a tag. Fill it once here and it appears everywhere.</>} />
                            <CardBody className="space-y-3">
                                {tagMap.size === 0 && <p className="flex items-center gap-2 text-sm text-ink-muted"><Tags className="h-4 w-4" aria-hidden /> No smart tags in this quote.</p>}
                                {[...tagMap.keys()].map((t) => systemTags[t] ? (
                                    <div key={t} className="flex items-center justify-between gap-2 text-sm">
                                        <code className="rounded bg-stone-100 px-1.5 py-0.5">&lt;{t}&gt;</code>
                                        <span className="text-right text-xs text-ink-muted">{systemTags[t]}{t.startsWith('client_') && !d.contact_id ? ' · choose a client' : ' · automatic'}</span>
                                    </div>
                                ) : (
                                    <Field key={t} label={<code>&lt;{t}&gt;</code>} htmlFor={`tag-${t}`}>
                                        <Input id={`tag-${t}`} value={d.smart_tags[t] ?? ''} placeholder="Fill in once"
                                            className={cn(!d.smart_tags[t]?.trim() && sendCheck?.unfilled.some((u) => u.tag === t) && 'ring-2 ring-amber-400')}
                                            onChange={(e) => form.setData('smart_tags', { ...d.smart_tags, [t]: e.target.value })} />
                                    </Field>
                                ))}
                            </CardBody>
                        </Card>

                        <ThemeCard theme={d.theme} onChange={(t) => form.setData('theme', t)} options={themeOptions}
                            companyColours={company.colours} companyFonts={company} />
                        </fieldset>

                        <Card>
                            <CardHeader title="Cost & margin" description="Staff only." />
                            <CardBody className="space-y-1.5" data-testid="margin-panel">
                                <Row label="Revenue (ex GST)" value={money(totals.subtotal_cents, cur)} />
                                <Row label="Cost" value={money(totals.cost_cents, cur)} />
                                <Row label="Margin" value={money(totals.margin_cents, cur)} strong />
                                <Row label="Margin %" value={marginPct === null ? '—' : `${marginPct.toFixed(1)}%`} />
                                <Row label="Markup %" value={markupPct === null ? '—' : `${markupPct.toFixed(1)}%`} />
                                <Reveal show={lines.some((l) => l.type === 'price' && l.cost_cents === null)}>
                                    <p className="pt-1 text-xs text-ink-muted">Lines without a cost price count as 100% margin.</p>
                                </Reveal>
                            </CardBody>
                        </Card>
                    </aside>
                </div>
            </form>
            <Dialog open={unaccepting} onOpenChange={(o) => !unacceptBusy && setUnaccepting(o)} title={`Unaccept ${number ?? 'this quote'}?`}
                description="The client's acceptance is withdrawn and the quote goes back to Sent, so they can accept it again from their link. The original acceptance (signature, time and details) is kept in the activity history.">
                <div className="space-y-4">
                    <Field label="Reason (optional)" htmlFor="unaccept-reason" hint="Kept in the audit log and activity timeline, e.g. “Accepted by mistake” or “Scope changed”.">
                        <Input id="unaccept-reason" value={unacceptReason} maxLength={500} onChange={(e) => setUnacceptReason(e.target.value)} data-testid="unaccept-reason" />
                    </Field>
                    {unacceptError && <p role="alert" className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-800">{unacceptError}</p>}
                    <div className="flex justify-end gap-2">
                        <Button type="button" variant="secondary" onClick={() => setUnaccepting(false)} disabled={unacceptBusy}>Cancel</Button>
                        <Button type="button" onClick={() => void unaccept()} disabled={unacceptBusy} data-testid="unaccept-confirm">{unacceptBusy && <Loader2 className="h-4 w-4 animate-spin" />} Unaccept quote</Button>
                    </div>
                </div>
            </Dialog>
            <Dialog open={deleting} onOpenChange={(o) => !deleteBusy && setDeleting(o)} title={`Delete ${number ?? 'this quote'}?`}
                description={qStatus === 'draft' ? 'This draft will be deleted. This can’t be undone.' : 'This quote has been sent. Deleting it removes it for good, including its versions, activity and messages, and the client’s links stop working. This can’t be undone.'}>
                <div className="space-y-4">
                    {qStatus !== 'draft' && (
                        <Field label={<>Type <b>{number}</b> to confirm</>} htmlFor="delete-confirm">
                            <Input id="delete-confirm" value={deleteConfirm} onChange={(e) => setDeleteConfirm(e.target.value)} autoComplete="off" data-testid="delete-confirm-input" />
                        </Field>
                    )}
                    {deleteError && <p role="alert" className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-800">{deleteError}</p>}
                    <div className="flex justify-end gap-2">
                        <Button type="button" variant="secondary" onClick={() => setDeleting(false)} disabled={deleteBusy}>Cancel</Button>
                        <Button type="button" className="bg-red-700 hover:bg-red-800" onClick={() => void deleteQuote()} data-testid="delete-confirm"
                            disabled={deleteBusy || (qStatus !== 'draft' && deleteConfirm.trim() !== number)}>{deleteBusy && <Loader2 className="h-4 w-4 animate-spin" />} Delete quote</Button>
                    </div>
                </div>
            </Dialog>
            {idRef.current && (
                <SendDialog open={sending} onOpenChange={setSending} quoteId={idRef.current} companyId={company.id} compose={compose} contacts={contacts}
                    onSent={(message) => {
                        setSendCheck({ ok: true, message, unfilled: [] }); setActivityKey((k) => k + 1);
                        http<{ quote: QuoteData }>('GET', `/api/quotes/${idRef.current}`, undefined, { 'X-Company': String(company.id) })
                            .then(({ quote: fresh }) => { setQStatus(fresh.status); setVersionsKey((k) => k + 1); }).catch(() => {});
                    }} />
            )}
        </AppLayout>
    );
}

type SaveStatus = { kind: 'idle' } | { kind: 'saving' } | { kind: 'saved'; at: Date | null } | { kind: 'invalid' } | { kind: 'error'; message: string };

function SaveIndicator({ status, dirty }: { status: SaveStatus; dirty: boolean }) {
    const time = (d: Date) => new Intl.DateTimeFormat('en-AU', { hour: 'numeric', minute: '2-digit' }).format(d);
    let icon = <Check className="h-4 w-4 text-success" aria-hidden />;
    let text = 'All changes saved';
    if (status.kind === 'idle') { icon = <CloudOff className="h-4 w-4" aria-hidden />; text = 'Not saved yet'; }
    else if (status.kind === 'saving' || (dirty && status.kind === 'saved')) { icon = <Loader2 className="h-4 w-4 animate-spin" aria-hidden />; text = 'Saving…'; }
    else if (status.kind === 'saved') text = status.at ? `Saved ${time(status.at)}` : 'All changes saved';
    else if (status.kind === 'invalid') { icon = <AlertCircle className="h-4 w-4 text-danger" aria-hidden />; text = 'Not saved: fix highlighted fields'; }
    else if (status.kind === 'error') { icon = <AlertCircle className="h-4 w-4 text-danger" aria-hidden />; text = status.message; }
    return (
        <span className="inline-flex items-center gap-1.5 text-sm text-ink-muted" role="status" aria-live="polite" data-testid="save-status">
            {icon}{text}
        </span>
    );
}

function Row({ label, value, strong, testId }: { label: string; value: React.ReactNode; strong?: boolean; testId?: string }) {
    return (
        <div className={cn('flex items-baseline justify-between gap-3 text-sm', strong && 'text-base font-semibold')}>
            <span className={strong ? '' : 'text-ink-muted'}>{label}</span>
            <span className="tabular-nums" data-testid={testId}>{value}</span>
        </div>
    );
}

function TotalsBlock({ label, currency, mode, subtotal, discount, tax, total, extra }: {
    label?: string; currency: string; mode: TaxMode; subtotal: number; discount: number; tax: number; total: number; extra?: React.ReactNode;
}) {
    const taxLabel = mode === 'inclusive' ? 'Includes GST' : mode === 'exclusive' ? 'GST' : 'GST';
    return (
        <div className="space-y-1.5">
            {label && <div className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</div>}
            {discount !== 0 && <Row label="Discounts" value={`−${money(discount, currency)}`} />}
            <Row label={mode === 'inclusive' ? 'Subtotal (ex GST)' : 'Subtotal'} value={money(subtotal, currency)} />
            {mode === 'exclusive_with_inclusive_total' || mode === 'inclusive'
                ? <Row label={taxLabel} value={money(tax, currency)} />
                : mode === 'exclusive' ? <Row label={taxLabel} value="+GST (not included)" /> : null}
            <Row label="Total" value={money(total, currency)} strong />
            {extra}
        </div>
    );
}

type ContactOption = Props['contacts'][number];

/** Client picker with a quick "New client" form (contacts are shared across all companies). */
function ContactPicker({ contacts: initial, value, onChange, error }: {
    contacts: ContactOption[]; value: number | null; onChange: (id: number | null) => void; error?: string;
}) {
    const [contacts, setContacts] = useState(initial);
    const [open, setOpen] = useState(false);
    const [f, setF] = useState({ first_name: '', last_name: '', email: '', company: '' });
    const [errors, setErrors] = useState<Record<string, string>>({});
    const [busy, setBusy] = useState(false);

    const save = async () => {
        setBusy(true);
        setErrors({});
        try {
            const res = await http<{ contact: ContactOption }>('POST', '/api/contacts', {
                first_name: f.first_name, last_name: f.last_name || null, new_company_name: f.company || null,
                emails: f.email ? [{ email: f.email, primary: true }] : [],
            });
            setContacts((cs) => [...cs, res.contact].sort((a, b) => a.name.localeCompare(b.name)));
            onChange(res.contact.id);
            setOpen(false);
            setF({ first_name: '', last_name: '', email: '', company: '' });
        } catch (e) {
            const body = e instanceof HttpError ? e.body : null;
            setErrors(Object.fromEntries(Object.entries(body?.errors ?? { first_name: ['Could not save the contact.'] }).map(([k, v]) => [k, (v as string[])[0]])));
        } finally {
            setBusy(false);
        }
    };

    return (
        <Field label="Client" htmlFor="contact_id" error={error}>
            <div className="flex gap-2">
                <Select id="contact_id" value={value ?? ''} onChange={(e) => onChange(Number(e.target.value) || null)}>
                    <option value="">{contacts.length ? 'No client yet' : 'No contacts yet — add one'}</option>
                    {contacts.map((c) => (
                        <option key={c.id} value={c.id}>{c.name}{c.company ? ` · ${c.company}` : ''}</option>
                    ))}
                </Select>
                <Button type="button" variant="secondary" onClick={() => setOpen(true)} data-testid="new-client"><Plus className="h-4 w-4" /> New</Button>
            </div>
            <Dialog open={open} onOpenChange={setOpen} title="New client" description="Added to your shared contacts.">
                <div className="grid gap-3 sm:grid-cols-2">
                    <Field label="First name" htmlFor="nc-first" error={errors.first_name}>
                        <Input id="nc-first" autoFocus value={f.first_name} onChange={(e) => setF({ ...f, first_name: e.target.value })} />
                    </Field>
                    <Field label="Last name" htmlFor="nc-last" error={errors.last_name}>
                        <Input id="nc-last" value={f.last_name} onChange={(e) => setF({ ...f, last_name: e.target.value })} />
                    </Field>
                    <Field label="Email" htmlFor="nc-email" error={errors['emails.0.email']} className="sm:col-span-2">
                        <Input id="nc-email" type="email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
                    </Field>
                    <Field label="Company" htmlFor="nc-company" error={errors.new_company_name} className="sm:col-span-2" hint="Optional. Matched to an existing company with the same name.">
                        <Input id="nc-company" value={f.company} onChange={(e) => setF({ ...f, company: e.target.value })} />
                    </Field>
                </div>
                <div className="mt-5 flex justify-end gap-2">
                    <Button type="button" variant="secondary" onClick={() => setOpen(false)}>Cancel</Button>
                    <Button type="button" onClick={save} disabled={busy || !f.first_name.trim()} data-testid="save-new-client">{busy ? 'Adding…' : 'Add client'}</Button>
                </div>
            </Dialog>
        </Field>
    );
}
