var _useState = React.useState;
var _useEffect = React.useEffect;
var _useRef = React.useRef;
var _useCallback = React.useCallback;

/**
 * MissMouldChat — the Miss Mould assistant: grounded RAG answers, spoken aloud, with
 * push-to-talk input.
 *
 * All the hard parts (SSE parsing, sentence chunking for time-to-first-audio, out-of-order
 * audio reassembly, the iOS gesture unlock, microphone capture) live in the
 * MouldDetectChat bundle built from moulddetect-chat/frontend/lib. This file is the view:
 * the app's own idiom (browser-Babel JSX, window globals) and the app's own design tokens.
 * See docs/adr/0009 for why the chat UI was not ported wholesale from the Next.js client.
 *
 * MEMORY. This view is the only place in the app that holds an AudioContext, a live
 * microphone track, decoded audio buffers and an open streaming response. None of that is
 * garbage-collected on navigation — a MediaStream in particular keeps the OS microphone
 * indicator lit until its tracks are explicitly stopped. Everything acquired here is
 * released in the unmount effect below, which is the one piece of this component that
 * must not be simplified away.
 *
 * Route: /miss-mould
 */

// Six turns is what the backend accepts (app/models/api.py caps `history` at 6). Sending
// more is a 422, not a silent truncation, so the cap lives here too rather than being
// discovered in production.
var MAX_HISTORY_TURNS = 6;

var OPENING_LINE =
    "Hello, I'm Miss Mould — your AI mycologist. Ask me anything about mould: " +
    'what it is, whether it is a problem, how to treat it safely, and when to call someone in. ' +
    'I answer from a reviewed knowledge base and I will show you my sources.';

var SUGGESTIONS = [
    'Is black mould dangerous?',
    'How do I clean mould safely?',
    'Why does mould keep coming back?',
    'When should I call a professional?',
];

/**
 * Just enough markdown for the shapes answers actually use: bold, italics, and bullet
 * lists. A real parser (react-markdown + remark-gfm) is ~150 KB on a page that already
 * compiles 63 components in the browser — see ADR-0009 for when that trade should be
 * revisited.
 *
 * Italics matter more than they look: the model writes species names as *Stachybotrys
 * chartarum*, so without single-asterisk handling every answer about the organism people
 * actually ask about is littered with stray asterisks.
 */
var renderInline = function (text, keyPrefix) {
    // Bold first — otherwise the italic pattern claims the inner pair of a ** run.
    return text.split(/(\*\*[^*]+\*\*|\*[^*\n]+\*)/g).map(function (seg, j) {
        var key = keyPrefix + '-' + j;
        if (seg.length > 4 && seg.slice(0, 2) === '**' && seg.slice(-2) === '**') {
            return <strong key={key}>{seg.slice(2, -2)}</strong>;
        }
        if (seg.length > 2 && seg[0] === '*' && seg[seg.length - 1] === '*') {
            return <em key={key}>{seg.slice(1, -1)}</em>;
        }
        return <React.Fragment key={key}>{seg}</React.Fragment>;
    });
};

// A bullet is "- ", "* " or "• " — the space is what distinguishes a list marker from the
// opening of an *italic* run, so it is required rather than optional.
var BULLET = /^[-*•]\s+(.*)$/;

var renderText = function (text) {
    var blocks = [];

    // Blank lines separate blocks; inside a block, consecutive lines are either list items
    // or one soft-wrapped paragraph. Treating every newline as a paragraph break would
    // split wrapped prose; treating only blank lines as breaks would run a list together.
    text.split(/\n{2,}/).forEach(function (block, i) {
        var lines = block.split('\n').map(function (l) { return l.trim(); })
            .filter(function (l) { return l.length > 0; });
        if (!lines.length) return;

        var bullets = lines.filter(function (l) { return BULLET.test(l); });

        // A block is a list when most of its lines are bullets; a stray leading sentence
        // ("What matters more than colour:") rides above as its own paragraph.
        if (bullets.length && bullets.length >= lines.length - 1) {
            var lead = BULLET.test(lines[0]) ? null : lines[0];
            if (lead) {
                blocks.push(
                    <p key={'lead-' + i} className={blocks.length ? 'mt-2' : ''}>
                        {renderInline(lead, 'lead-' + i)}
                    </p>
                );
            }
            blocks.push(
                <ul key={'ul-' + i} className="list-disc pl-4 mt-1.5 space-y-1">
                    {bullets.map(function (l, k) {
                        return <li key={k}>{renderInline(l.match(BULLET)[1], 'li-' + i + '-' + k)}</li>;
                    })}
                </ul>
            );
            return;
        }

        blocks.push(
            <p key={'p-' + i} className={blocks.length ? 'mt-2' : ''}>
                {renderInline(lines.join(' '), 'p-' + i)}
            </p>
        );
    });

    return blocks;
};

var Avatar = function (props) {
    return (
        <img
            src="assets/images/miss-mould-96.webp"
            alt="Miss Mould"
            width={props.size}
            height={props.size}
            decoding="async"
            className={'rounded-full object-cover shrink-0 ' + (props.className || '')}
            style={{ width: props.size + 'px', height: props.size + 'px' }}
        />
    );
};

/**
 * Sources, collapsed by default.
 *
 * Whole-document expansion means a single answer routinely cites six to ten documents, and
 * rendered as open chips they wrap one per line and push the answer itself off screen —
 * the opposite of the "quiet footnote" these are meant to be. Collapsed, the count is still
 * visible on every answer, so the transparency is not lost, only the clutter.
 */
var SourceList = function (props) {
    var _o = _useState(false); var open = _o[0]; var setOpen = _o[1];
    var sources = props.sources;

    return (
        <div className="mt-1.5">
            <button
                type="button"
                onClick={function () { setOpen(!open); }}
                aria-expanded={open}
                className="inline-flex items-center gap-1 text-[10px] font-bold text-sage hover:text-forest transition-colors"
            >
                <span className="material-symbols-outlined text-[13px]">description</span>
                {sources.length} {sources.length === 1 ? 'source' : 'sources'}
                <span className="material-symbols-outlined text-[13px]">
                    {open ? 'expand_less' : 'expand_more'}
                </span>
            </button>

            {open && (
                <ul className="mt-1.5 space-y-1">
                    {sources.map(function (s, j) {
                        var label = s.authority || s.title || s.doc_id;
                        var body = (
                            <span className="block text-[10px] leading-snug text-forest/80 truncate" title={s.title || label}>
                                {label}
                            </span>
                        );
                        return (
                            <li key={j} className="flex items-start gap-1.5">
                                <span className="material-symbols-outlined text-[12px] text-sage mt-px shrink-0">
                                    {s.source_url ? 'open_in_new' : 'description'}
                                </span>
                                {s.source_url ? (
                                    <a
                                        href={s.source_url}
                                        target="_blank"
                                        rel="noopener noreferrer"
                                        className="min-w-0 flex-1 hover:underline"
                                    >
                                        {body}
                                    </a>
                                ) : (
                                    <span className="min-w-0 flex-1">{body}</span>
                                )}
                            </li>
                        );
                    })}
                </ul>
            )}
        </div>
    );
};

var MissMouldChat = function () {
    var _m = _useState([]);            var messages = _m[0];      var setMessages = _m[1];
    var _d = _useState('');            var draft = _d[0];         var setDraft = _d[1];
    var _b = _useState(false);         var busy = _b[0];          var setBusy = _b[1];
    var _s = _useState('');            var status = _s[0];        var setStatus = _s[1];
    // Speaker ON by default: this is the demo posture (an assistant with a voice should
    // use it without being asked). It costs roughly 8x a silent answer — see
    // docs/OPERATING-COSTS.md — so it stays a toggle rather than being hard-wired.
    var _sp = _useState(true);         var speakerOn = _sp[0];    var setSpeakerOn = _sp[1];
    var _l = _useState(false);         var listening = _l[0];     var setListening = _l[1];
    var _t = _useState(false);         var transcribing = _t[0];  var setTranscribing = _t[1];
    var _e = _useState('');            var error = _e[0];         var setError = _e[1];

    var voiceRef = _useRef(null);
    var sessionRef = _useRef(null);
    var sessionPromiseRef = _useRef(null);
    var abortRef = _useRef(null);
    var scrollRef = _useRef(null);
    var speakerOnRef = _useRef(true);
    // The transcript is read inside async callbacks that would otherwise close over a
    // stale `messages`; a ref keeps history current without re-creating send() per token.
    var messagesRef = _useRef([]);

    _useEffect(function () { messagesRef.current = messages; }, [messages]);
    _useEffect(function () { speakerOnRef.current = speakerOn; }, [speakerOn]);

    // --- lifecycle: acquire once, release everything on the way out --------------------

    _useEffect(function () {
        var cancelled = false;

        if (typeof MouldDetectChat === 'undefined') {
            setError('The assistant failed to load. Please refresh the page.');
            return;
        }

        // Local development serves this app and the API from different origins; the
        // deployed topology is same-origin (CloudFront routes /api/* to the Lambda), where
        // the empty base is correct and no configure() call is needed.
        // getSessionId is read at request time, not captured now: the voice endpoints
        // require a real session (they call a paid service on a public URL) and the
        // session is established asynchronously just below.
        MouldDetectChat.configure({
            apiBase: window.MOULD_DETECT_API_BASE || '',
            getSessionId: function () {
                return sessionRef.current ? sessionRef.current.session_id : null;
            },
        });

        voiceRef.current = MouldDetectChat.createVoice({
            onError: function (stage, err) {
                console.warn('[MissMould] voice ' + stage + ' failed', err);
                // Voice failing must never take the chat down with it — text still works.
                if (stage === 'listen') setError('I could not hear that. Try typing instead.');
            },
        });

        // Held as a PROMISE, not just its result. send() awaits it rather than reading a
        // ref that may not be populated yet — see the note in send() for why the obvious
        // fallback is actively harmful here.
        sessionPromiseRef.current = MouldDetectChat.openSession().then(
            function (session) {
                if (!cancelled) sessionRef.current = session;
                return session;
            },
            function (err) {
                if (cancelled) return null;
                console.warn('[MissMould] session failed', err);
                setError(err && err.reason ? err.reason : 'Could not reach the assistant.');
                return null;
            }
        );

        setMessages([{ role: 'assistant', text: OPENING_LINE, sources: [], opening: true }]);

        return function () {
            cancelled = true;

            // Order matters. Abort the in-flight response first so no further tokens are
            // pushed into a speech session we are about to tear down.
            if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; }

            // cancel() stops playback, aborts a pending transcription, and — the part that
            // matters most — calls release() on any open recording, which stops the
            // MediaStream tracks and closes the AudioContext. Without it the browser keeps
            // the microphone indicator lit after the user has navigated away, and each
            // visit leaks another AudioContext (browsers cap these, so it eventually stops
            // working rather than merely wasting memory).
            if (voiceRef.current) { voiceRef.current.cancel(); voiceRef.current = null; }

            // Drop the transcript. Answers plus their source lists are the largest thing
            // this view retains, and nothing outside it needs them once it is gone.
            messagesRef.current = [];
            sessionRef.current = null;
            sessionPromiseRef.current = null;
        };
    }, []);

    // Keep the newest message in view as tokens arrive.
    _useEffect(function () {
        var el = scrollRef.current;
        if (el) el.scrollTop = el.scrollHeight;
    }, [messages, status]);

    // --- sending -----------------------------------------------------------------------

    var send = _useCallback(function (question) {
        var text = (question || '').trim();
        if (!text || busy) return;

        setError('');
        setBusy(true);
        setStatus('Looking this up…');
        setDraft('');

        // Wait for the session before sending. The obvious shortcut — read sessionRef and
        // fall back to a literal like "local" — produces a genuinely misleading failure:
        // the server raises QuotaExceeded for any session id it did not issue ("an
        // invented session id must not mint free quota"), which surfaces as a 429 reading
        // "This session's 50-question demo quota is used up." A first-time user who types
        // before the session call returns would be told they had exhausted a quota they
        // had never used. The window is small locally and seconds wide on a cold Lambda.
        var ready = sessionPromiseRef.current || Promise.resolve(sessionRef.current);

        // The opening line is ours, not a real exchange — replaying it as history would
        // spend tokens on every turn to tell the model what it already knows.
        var history = messagesRef.current
            .filter(function (m) { return !m.opening; })
            .slice(-MAX_HISTORY_TURNS)
            .map(function (m) { return { role: m.role, text: m.text }; });

        setMessages(function (prev) {
            return prev.concat([
                { role: 'user', text: text, sources: [] },
                { role: 'assistant', text: '', sources: [], pending: true },
            ]);
        });

        var voice = voiceRef.current;
        var speech = speakerOnRef.current && voice ? voice.startSpeaking() : null;

        abortRef.current = new AbortController();

        ready.then(function (resolved) {
        var session = resolved || sessionRef.current;
        if (!session || !session.session_id) {
            // No session means the server would reject this as an unknown id. Say what is
            // actually wrong rather than letting it surface as a quota message.
            setError('Could not start a session with the assistant. Please try again.');
            setMessages(function (prev) { return prev.slice(0, -1); });
            if (speech) speech.end();
            setBusy(false);
            setStatus('');
            return;
        }

        return MouldDetectChat.streamChat(text, {
            threadId: session.session_id,
            token: session.token || '',
            sessionId: session.session_id,
            history: history,
            signal: abortRef.current.signal,
            onStatus: function (label) { setStatus(label); },
            onToken: function (chunk) {
                setStatus('');
                // Check the ref, not the captured session. Turning the speaker off
                // mid-answer used to stop playback but keep pushing text into the
                // still-live speech session, so every remaining sentence was synthesised,
                // paid for, and dropped into a stopped queue. The user heard silence and
                // it looked correct — it just cost the same as leaving it on.
                if (speech && speakerOnRef.current) speech.push(chunk);
                setMessages(function (prev) {
                    var next = prev.slice();
                    var last = next[next.length - 1];
                    next[next.length - 1] = {
                        role: 'assistant',
                        text: last.text + chunk,
                        sources: last.sources,
                        pending: true,
                    };
                    return next;
                });
            },
            onDone: function (done) {
                setMessages(function (prev) {
                    var next = prev.slice();
                    next[next.length - 1] = {
                        role: 'assistant',
                        // Prefer the terminal answer: it is the authoritative text, and a
                        // dropped token would otherwise leave a gap nobody notices.
                        text: done.answer || next[next.length - 1].text,
                        sources: done.sources || [],
                        pending: false,
                    };
                    return next;
                });
                if (speech) speech.end();
                setBusy(false);
                setStatus('');
                abortRef.current = null;
            },
            onError: function (err) {
                setError(err && err.reason ? err.reason : 'Something went wrong.');
                setMessages(function (prev) { return prev.slice(0, -1); });
                if (speech) speech.end();
                setBusy(false);
                setStatus('');
                abortRef.current = null;
            },
        });
        }).catch(function (err) {
            // An abort during unmount is expected, not an error worth surfacing.
            if (err && err.name === 'AbortError') return;
            setError('Something went wrong.');
            setBusy(false);
            setStatus('');
        });
    }, [busy]);

    // --- push to talk ------------------------------------------------------------------

    var toggleMic = _useCallback(function () {
        var voice = voiceRef.current;
        if (!voice || busy) return;

        if (voice.isListening()) {
            setListening(false);
            setTranscribing(true);
            voice.stopListening().then(function (result) {
                setTranscribing(false);
                if (result && result.text) send(result.text);
            }, function () {
                setTranscribing(false);
            });
            return;
        }

        // unlock() must run inside this tap. iOS refuses audio playback that was not
        // started by a user gesture, and it refuses it *silently* — no error, no event,
        // just nothing audible. Doing it here means the first answer can speak.
        voice.unlock();
        voice.listen().then(function () {
            setListening(true);
        }, function (err) {
            var reason = voice.capabilities().listenBlockedReason;
            setError(reason || 'Microphone unavailable.');
            console.warn('[MissMould] listen failed', err);
        });
    }, [busy, send]);

    var onSubmit = function (e) {
        e.preventDefault();
        send(draft);
    };

    // --- render ------------------------------------------------------------------------

    var micLabel = listening ? 'Stop recording' : 'Ask by voice';

    return (
        <Layout hideNav={true}>
            <main className="flex-1 flex flex-col overflow-hidden">
                <PageHeader title="Miss Mould" showMenu={false} />

                {/* The conversation as a single rounded card floating on the page
                    background, with a gap below the header pill rather than butting against
                    it. min-h-0 is load-bearing: without it this flex child refuses to shrink
                    and the transcript's own overflow-y never engages, so the whole page
                    scrolls instead of the message list. */}
                <div className="flex-1 min-h-0 flex flex-col mx-3 mt-3 mb-3 rounded-3xl overflow-hidden bg-background-light border border-stone-100/60 shadow-soft">

                {/* Identity strip — the avatar in the chrome, so she is present in the
                    conversation rather than only on the dashboard tile. Given its own light
                    surface: the app's page background is a saturated green, and both this
                    strip and the transcript below read as floating debris without one. */}
                <div className="px-5 py-3 flex items-center gap-3 bg-background-light border-b border-stone-200/70">
                    <Avatar size={44} className="ring-2 ring-primary/40" />
                    <div className="min-w-0 flex-1">
                        <p className="text-sm font-extrabold text-forest leading-tight">Miss Mould</p>
                        <p className="text-[10px] font-bold text-sage uppercase tracking-wider">AI Mycologist</p>
                    </div>
                    <button
                        type="button"
                        onClick={function () {
                            var next = !speakerOn;
                            setSpeakerOn(next);
                            // Silence takes effect immediately, mid-answer — a speaker
                            // toggle that only applies to the next question reads as broken.
                            if (!next && voiceRef.current) voiceRef.current.cancel();
                        }}
                        aria-label={speakerOn ? 'Turn off spoken answers' : 'Turn on spoken answers'}
                        aria-pressed={speakerOn}
                        className={
                            'w-10 h-10 rounded-full flex items-center justify-center transition-colors ' +
                            (speakerOn ? 'bg-primary/15 text-forest' : 'bg-stone-100 text-muted')
                        }
                    >
                        <span className="material-symbols-outlined text-xl">
                            {speakerOn ? 'volume_up' : 'volume_off'}
                        </span>
                    </button>
                </div>

                {/* Transcript */}
                <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-5 py-4 space-y-4 bg-background-light">
                    {messages.map(function (m, i) {
                        if (m.role === 'user') {
                            return (
                                <div key={i} className="flex justify-end">
                                    <div className="max-w-[85%] bg-forest text-white rounded-2xl rounded-br-sm px-4 py-2.5 text-sm leading-relaxed">
                                        {m.text}
                                    </div>
                                </div>
                            );
                        }
                        return (
                            <div key={i} className="flex gap-2.5 items-start">
                                <Avatar size={32} className="mt-0.5" />
                                <div className="max-w-[85%] min-w-0">
                                    <div className="bg-white border border-stone-100 shadow-soft rounded-2xl rounded-tl-sm px-4 py-2.5 text-sm text-text-main leading-relaxed">
                                        {m.text ? renderText(m.text) : (
                                            <span className="inline-flex gap-1 py-1" aria-label="Miss Mould is typing">
                                                <span className="w-1.5 h-1.5 rounded-full bg-sage animate-bounce" style={{ animationDelay: '0ms' }} />
                                                <span className="w-1.5 h-1.5 rounded-full bg-sage animate-bounce" style={{ animationDelay: '150ms' }} />
                                                <span className="w-1.5 h-1.5 rounded-full bg-sage animate-bounce" style={{ animationDelay: '300ms' }} />
                                            </span>
                                        )}
                                    </div>
                                    {m.sources && m.sources.length > 0 && (
                                        <SourceList sources={m.sources} />
                                    )}
                                </div>
                            </div>
                        );
                    })}

                    {status && (
                        <p className="text-[11px] text-muted italic pl-11">{status}</p>
                    )}

                    {messages.length === 1 && (
                        <div className="pl-11 flex flex-wrap gap-2 pt-1">
                            {SUGGESTIONS.map(function (q) {
                                return (
                                    <button
                                        key={q}
                                        type="button"
                                        onClick={function () { send(q); }}
                                        className="text-[11px] font-bold text-forest bg-white border border-stone-200 rounded-full px-3 py-1.5 btn-nature hover:bg-forest hover:text-white transition-colors"
                                    >
                                        {q}
                                    </button>
                                );
                            })}
                        </div>
                    )}

                    {error && (
                        <p className="text-[11px] text-terracotta bg-warning-light border border-terracotta/30 rounded-lg px-3 py-2">
                            {error}
                        </p>
                    )}
                </div>

                {/* Composer */}
                <form onSubmit={onSubmit} className="px-5 py-3 border-t border-stone-200/70 bg-background-light">
                    <div className="flex items-end gap-2">
                        <button
                            type="button"
                            onClick={toggleMic}
                            disabled={busy || transcribing}
                            aria-label={micLabel}
                            title={micLabel}
                            className={
                                'w-11 h-11 rounded-full flex items-center justify-center shrink-0 transition-colors disabled:opacity-40 ' +
                                (listening ? 'bg-terracotta text-white animate-pulse' : 'bg-primary/15 text-forest')
                            }
                        >
                            <span className="material-symbols-outlined text-xl">
                                {listening ? 'stop' : 'mic'}
                            </span>
                        </button>

                        <textarea
                            value={draft}
                            onChange={function (e) { setDraft(e.target.value); }}
                            onKeyDown={function (e) {
                                if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(draft); }
                            }}
                            rows={1}
                            placeholder={
                                listening ? 'Listening…' :
                                transcribing ? 'Just a moment…' :
                                'Ask Miss Mould about mould…'
                            }
                            disabled={listening || transcribing}
                            className="flex-1 resize-none rounded-2xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-text-main placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 max-h-32"
                        />

                        <button
                            type="submit"
                            disabled={busy || !draft.trim()}
                            aria-label="Send"
                            className="w-11 h-11 rounded-full bg-forest text-white flex items-center justify-center shrink-0 disabled:opacity-30 btn-nature"
                        >
                            <span className="material-symbols-outlined text-xl">arrow_upward</span>
                        </button>
                    </div>

                    <p className="text-[10px] text-muted text-center mt-2 leading-snug">
                        Miss Mould gives general guidance, not medical advice. For health concerns
                        speak to your doctor; for large or structural mould, engage a qualified professional.
                    </p>
                </form>

                </div>{/* /conversation card */}
            </main>
        </Layout>
    );
};

window.MissMouldChat = MissMouldChat;
