{% extends "base.html" %}
{% block title %}Chat | ECDA LLM{% endblock %}

{% block content %}
<div class="card card-col chat-container">
    <div class="chat-header">
        <h2>Chat about the documents</h2>
        <div>
            <div class="tooltip-container">
                <a class="btn btn-ghost" href="/upload">Upload</a>
                <span class="tooltip">Upload more?</span>
            </div>
            <div class="tooltip-container">
                <a class="btn btn-ghost" href="/">Home</a>
                <span class="tooltip">Back home</span>
            </div>

            <div class="tooltip-container">
                <button id="resetBtn" class="btn btn-ghost" type="button">Delete</button>
                <span class="tooltip">Delete all documents</span>
            </div>
            <div class="tooltip-container">
                <button id="saveBtn" class="btn btn-ghost" type="button">Save</button>
                <span class="tooltip">Save all chat as PDF</span>
            </div>
        </div>
    </div>

    <div class="meta" id="meta"></div>
    <div id="filesMeta" class="files-meta"></div>

    <div id="messages" class="messages" aria-live="polite"></div>
</div>

<div class="chat-form-wrapper">
    <form id="chatForm" class="chat-form">
        <div class="input-wrap">
            <textarea id="messageInput" class="input" placeholder="Type a message…" rows="1"
                autocomplete="off"></textarea>

            <span id="fakePlaceholder" class="fake-placeholder" style="display:none;"></span>

            <div id="voicePreviewWrap" class="voice-preview" aria-live="polite" style="display:none;">
                <button id="playVoiceBtn" type="button" title="Play/Pause">▶</button>
                <div id="voiceChip" class="voice-chip">Voice message</div>
                <button id="clearVoiceBtn" type="button" title="Remove">✕</button>
                <audio id="voicePreview"></audio>
            </div>
        </div>

        <div class="tooltip-container">
            <button id="voiceBtn" class="btn btn-ghost" type="button" title="Hold to record"
                aria-label="Voice note">🎤</button>
            <span class="tooltip">Use voice</span>
        </div>

        <button class="btn" type="submit">Send</button>
    </form>
</div>


<script>
    const baseUrl = sessionStorage.getItem("LLM_BASE_URL");
    const apiKey = sessionStorage.getItem("LLM_API_KEY") || "lm-studio";
    const model = sessionStorage.getItem("LLM_MODEL");
    const embedModel = sessionStorage.getItem("EMBED_MODEL") || "nomic-embed-text-v1.5";

    if (!baseUrl || !model) window.location.href = "/";

    let chatSessionId = sessionStorage.getItem("CHAT_SESSION_ID");
    if (!chatSessionId) {
        if (crypto && crypto.randomUUID) chatSessionId = crypto.randomUUID();
        else chatSessionId = String(Date.now()) + "_" + Math.random().toString(16).slice(2);
        sessionStorage.setItem("CHAT_SESSION_ID", chatSessionId);
    }

    const metaEl = document.getElementById("meta");
    if (metaEl) metaEl.innerHTML = `Chat: ${model} @ ${baseUrl} <br> Embeddings: ${embedModel} (local)`;

    const messagesEl = document.getElementById("messages");

    let autoScroll = true;

    messagesEl.addEventListener("scroll", () => {
        const el = messagesEl;
        autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
    });

    const form = document.getElementById("chatForm");
    const input = document.getElementById("messageInput");
    const sendBtn = form.querySelector("button[type='submit']");
    const resetBtn = document.getElementById("resetBtn");
    const saveBtn = document.getElementById("saveBtn");
    const voiceBtn = document.getElementById("voiceBtn");

    function autoResizeTextarea(el) {
        if (!el) return;
        el.style.height = "0px";
        el.style.height = el.scrollHeight + "px";
    }

    input.addEventListener("input", (e) => {
        autoResizeTextarea(e.target);
    });

    input.addEventListener("keydown", (e) => {
        if (e.key === "Enter" && !e.shiftKey) {
            e.preventDefault();
            form.requestSubmit();
        }
    });

    function resetTextareaHeight() {
        input.style.height = "0px";
        input.style.height = input.scrollHeight + "px";
    }

    function forceLinksNewTab(containerEl) {
        if (!containerEl) return;
        containerEl.querySelectorAll("a[href]").forEach(a => {
            a.setAttribute("target", "_blank");
            a.setAttribute("rel", "noopener noreferrer");
        });
    }

    function renderMarkdownSafe(mdText) {
        let html = marked.parse(mdText || "", { breaks: true });
        html = html.trim();
        if (html.startsWith("<p>") && html.endsWith("</p>")) html = html.slice(3, -4);

        html = html.replace(/<table>/g, '<div class="table-wrap"><table>');
        html = html.replace(/<\/table>/g, '</table></div>');

        return DOMPurify.sanitize(html);
    }

    function scrollToBottom(force = false) {
        const el = messagesEl;
        const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;

        if (force || nearBottom) {
            requestAnimationFrame(() => {
                el.scrollTop = el.scrollHeight;
            });
        }
    }

    function scrollPageToBottom(force = false) {
        requestAnimationFrame(() => {
            window.scrollTo({
                top: document.documentElement.scrollHeight,
                behavior: force ? "auto" : "smooth"
            });
        });
    }

    const chatLog = [];

    function appendMessage(role, text) {
        const row = document.createElement("div");
        row.className = "msg " + (role === "user" ? "msg-user" : "msg-bot");

        if (role === "assistant") {
            row.innerHTML = renderMarkdownSafe(text);
            forceLinksNewTab(row);
        } else {
            row.textContent = text;
        }

        messagesEl.appendChild(row);
        scrollToBottom();

        chatLog.push({
            role: role === "user" ? "user" : "assistant",
            text: row.textContent || ""
        });

        return row;
    }

    function appendVoiceMessage(blob) {
        const row = document.createElement("div");
        row.className = "msg-voice-user";

        const url = URL.createObjectURL(blob);

        const audio = document.createElement("audio");
        audio.controls = true;
        audio.preload = "metadata";
        audio.src = url;

        row.appendChild(audio);

        messagesEl.appendChild(row);
        scrollToBottom();

        chatLog.push({ role: "user", text: "[voice message]" });

        audio.addEventListener("emptied", () => {
            try { URL.revokeObjectURL(url); } catch { }
        });

        return row;
    }

    function updateMessage(el, newText) {
        if (!el) return;

        if (el.classList.contains("msg-bot")) {
            el.innerHTML = renderMarkdownSafe(newText);
            forceLinksNewTab(el);
        } else {
            el.textContent = newText;
        }

        scrollToBottom();

        for (let i = chatLog.length - 1; i >= 0; i--) {
            if (chatLog[i].role === "assistant") {
                chatLog[i].text = el.textContent || "";
                break;
            }
        }
    }

    function setBusy(isBusy) {
        if (sendBtn) sendBtn.disabled = isBusy;
        if (resetBtn) resetBtn.disabled = isBusy;
        if (saveBtn) saveBtn.disabled = isBusy;
        if (voiceBtn) voiceBtn.disabled = isBusy;

        if (isBusy) input.readOnly = true;
    }

    function renderFilesMeta(filesMetaEl, files, expanded) {
        const total = files.length;
        const names = files.map(f => f.original);
        const prefix = `Documents (${total}): `;
        const visible = expanded ? names : names.slice(0, 3);

        filesMetaEl.innerHTML = "";

        const prefixSpan = document.createElement("span");
        prefixSpan.textContent = prefix + visible.join(" • ");
        filesMetaEl.appendChild(prefixSpan);

        if (total > 3) {
            const sep = document.createElement("span");
            sep.textContent = " ";
            filesMetaEl.appendChild(sep);

            const toggle = document.createElement("button");
            toggle.type = "button";
            toggle.className = "btn btn-ghost btn-mini";
            toggle.textContent = expanded ? "Show less..." : "Show more...";
            toggle.addEventListener("click", () => {
                const next = !expanded;
                sessionStorage.setItem("FILES_META_EXPANDED", next ? "1" : "0");
                renderFilesMeta(filesMetaEl, files, next);
            });

            filesMetaEl.appendChild(toggle);
        }
    }

    async function loadUploadsIntoMeta() {
        const filesMeta = document.getElementById("filesMeta");
        if (!filesMeta) return;

        try {
            const res = await fetch("/api/uploads");
            const data = await res.json();

            if (!data.ok) {
                filesMeta.textContent = "Documents: (could not load)";
                return;
            }

            const files = data.files || [];
            if (!files.length) {
                filesMeta.textContent = "Documents (0): none";
                return;
            }

            const expanded = sessionStorage.getItem("FILES_META_EXPANDED") === "1";
            renderFilesMeta(filesMeta, files, expanded);

        } catch (e) {
            filesMeta.textContent = "Documents: (could not load)";
        }
    }

    loadUploadsIntoMeta();

    resetBtn.addEventListener("click", async () => {
        const confirm = await Swal.fire({
            icon: "warning",
            title: "Delete everything?",
            text: "All uploaded documents and the vector database will be deleted.",
            showCancelButton: true,
            confirmButtonText: "Yes, delete all",
            cancelButtonText: "Cancel",
        });

        if (!confirm.isConfirmed) return;

        setBusy(true);

        Swal.fire({
            title: "Deleting…",
            allowOutsideClick: false,
            allowEscapeKey: false,
            didOpen: () => Swal.showLoading()
        });

        try {
            const res = await fetch("/api/reset", { method: "POST" });
            const data = await res.json();

            if (!res.ok || !data.ok) throw new Error(data.detail || data.message || "Delete failed");

            await Swal.fire({
                icon: "success",
                title: "Deleted",
                text: "All uploads and vector DB data were removed. Returning to home…",
                timer: 900,
                showConfirmButton: false
            });

            const keep = {
                LLM_BASE_URL: sessionStorage.getItem("LLM_BASE_URL"),
                LLM_MODEL: sessionStorage.getItem("LLM_MODEL"),
                EMBED_MODEL: sessionStorage.getItem("EMBED_MODEL"),
                LLM_API_KEY: sessionStorage.getItem("LLM_API_KEY"),
            };

            sessionStorage.clear();

            for (const [k, v] of Object.entries(keep)) {
                if (v) sessionStorage.setItem(k, v);
            }

            sessionStorage.removeItem("CHAT_SESSION_ID");

            window.location.href = "/";

        } catch (err) {
            await Swal.fire({ icon: "error", title: "Delete failed", text: String(err) });
        } finally {
            setBusy(false);
            input.readOnly = false;
        }
    });

    function startTypingEffect(targetEl) {
        let pending = "";
        let timer = null;
        let full = "";

        function render() {
            targetEl.innerHTML = renderMarkdownSafe(full);
            forceLinksNewTab(targetEl);
            scrollToBottom();
        }

        function flushChunk() {
            if (!pending) return;
            const n = Math.min(pending.length, 12);
            full += pending.slice(0, n);
            pending = pending.slice(n);
            render();
        }

        function start() {
            if (timer) return;
            timer = setInterval(() => {
                flushChunk();
                if (!pending) {
                    clearInterval(timer);
                    timer = null;
                }
            }, 18);
        }

        return {
            push(delta) { pending += delta; start(); },
            stop() {
                if (timer) clearInterval(timer);
                timer = null;
                if (pending) { full += pending; pending = ""; }
                render();
            },
            setText(text) { full = text || ""; pending = ""; render(); },
            getText() { return full; }
        };
    }

    const voicePreviewWrap = document.getElementById("voicePreviewWrap");
    const voicePreview = document.getElementById("voicePreview");
    const playVoiceBtn = document.getElementById("playVoiceBtn");
    const clearVoiceBtn = document.getElementById("clearVoiceBtn");
    const voiceChip = document.getElementById("voiceChip");
    const originalPlaceholder = input.placeholder;
    let voiceWavBlob = null;

    let isRecording = false;
    let mediaStream = null;
    let audioCtx = null;
    let sourceNode = null;
    let processorNode = null;
    let recordedBuffers = [];
    let recordedSampleRate = 48000;

    const fakePlaceholder = document.getElementById("fakePlaceholder");

    function showFakePlaceholder(text) {
        if (!fakePlaceholder) return;
        fakePlaceholder.textContent = text || "";
        fakePlaceholder.style.display = text ? "block" : "none";
    }

    function hideFakePlaceholder() {
        showFakePlaceholder("");
    }

    function setPlaceholder(text) {
        input.placeholder = text ?? "";
    }

    function showOverlay(text, showPlay) {
        if (showPlay) {
            input.placeholder = "";
            showFakePlaceholder(text);

            voicePreviewWrap.style.display = "flex";
            playVoiceBtn.style.display = "inline-block";
            voiceChip.textContent = "Voice message";
            return;
        }

        hideFakePlaceholder();
        input.placeholder = text;
        voicePreviewWrap.style.display = "none";
    }

    function showVoicePreview(blob) {
        const url = URL.createObjectURL(blob);
        input.placeholder = originalPlaceholder;
        voicePreview.src = url;
        voicePreview.load();
        playVoiceBtn.textContent = "▶";
        showOverlay("Voice message ready — Press Send", true);
    }

    function clearVoice() {
        voiceWavBlob = null;
        recordedBuffers = [];
        hideFakePlaceholder();

        input.placeholder = originalPlaceholder;
        try { if (voicePreview.src) URL.revokeObjectURL(voicePreview.src); } catch { }
        try { voicePreview.pause(); } catch { }

        voicePreview.removeAttribute("src");
        voicePreview.load();

        voicePreviewWrap.style.display = "none";
        playVoiceBtn.textContent = "▶";
        input.value = "";
        resetTextareaHeight();
        input.readOnly = false;
    }

    function removeVoicePreviewUI() {
        try { voicePreview.pause(); } catch { }

        voicePreview.removeAttribute("src");
        voicePreview.load();

        voicePreviewWrap.style.display = "none";
        playVoiceBtn.textContent = "▶";

        hideFakePlaceholder();
        input.placeholder = originalPlaceholder;
    }

    if (clearVoiceBtn) clearVoiceBtn.addEventListener("click", clearVoice);

    if (playVoiceBtn) {
        playVoiceBtn.addEventListener("click", async () => {
            if (!voicePreview.src) return;
            try {
                if (voicePreview.paused) {
                    await voicePreview.play();
                    playVoiceBtn.textContent = "⏸";
                } else {
                    voicePreview.pause();
                    playVoiceBtn.textContent = "▶";
                }
            } catch (e) {
                Swal.fire({ icon: "error", title: "Playback failed", text: String(e) });
            }
        });

        voicePreview.addEventListener("ended", () => {
            playVoiceBtn.textContent = "▶";
        });
    }

    function floatTo16BitPCM(float32Array) {
        const out = new Int16Array(float32Array.length);
        for (let i = 0; i < float32Array.length; i++) {
            let s = Math.max(-1, Math.min(1, float32Array[i]));
            out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
        }
        return out;
    }

    function writeWavHeader(view, sampleRate, numChannels, numFrames) {
        const bytesPerSample = 2;
        const blockAlign = numChannels * bytesPerSample;
        const byteRate = sampleRate * blockAlign;
        const dataSize = numFrames * blockAlign;

        function writeString(offset, str) {
            for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
        }

        writeString(0, "RIFF");
        view.setUint32(4, 36 + dataSize, true);
        writeString(8, "WAVE");
        writeString(12, "fmt ");
        view.setUint32(16, 16, true);
        view.setUint16(20, 1, true);
        view.setUint16(22, numChannels, true);
        view.setUint32(24, sampleRate, true);
        view.setUint32(28, byteRate, true);
        view.setUint16(32, blockAlign, true);
        view.setUint16(34, 16, true);
        writeString(36, "data");
        view.setUint32(40, dataSize, true);
    }

    function encodeWavFromFloat32(float32, sampleRate, numChannels = 1) {
        const pcm16 = floatTo16BitPCM(float32);
        const buffer = new ArrayBuffer(44 + pcm16.length * 2);
        const view = new DataView(buffer);

        writeWavHeader(view, sampleRate, numChannels, pcm16.length);

        let offset = 44;
        for (let i = 0; i < pcm16.length; i++, offset += 2) {
            view.setInt16(offset, pcm16[i], true);
        }

        return new Blob([view], { type: "audio/wav" });
    }

    function mergeFloat32(buffers) {
        let total = 0;
        for (const b of buffers) total += b.length;
        const out = new Float32Array(total);
        let pos = 0;
        for (const b of buffers) { out.set(b, pos); pos += b.length; }
        return out;
    }

    async function resampleFloat32(inputFloat32, inRate, outRate) {
        if (inRate === outRate) return inputFloat32;

        const inLength = inputFloat32.length;
        const duration = inLength / inRate;
        const outLength = Math.ceil(duration * outRate);

        const tmpCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: inRate });
        const audioBuffer = tmpCtx.createBuffer(1, inLength, inRate);
        audioBuffer.copyToChannel(inputFloat32, 0);

        const offline = new OfflineAudioContext(1, outLength, outRate);
        const src = offline.createBufferSource();
        src.buffer = audioBuffer;
        src.connect(offline.destination);
        src.start(0);

        const rendered = await offline.startRendering();
        await tmpCtx.close();
        return rendered.getChannelData(0);
    }

    async function startRecording() {
        if (isRecording) return;

        try {
            if (voiceWavBlob) clearVoice();

            input.readOnly = true;
            input.value = "";
            resetTextareaHeight();

            showOverlay("🎙 Recording… release to stop", false);

            recordedBuffers = [];
            voiceWavBlob = null;

            mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });

            audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            recordedSampleRate = audioCtx.sampleRate;

            sourceNode = audioCtx.createMediaStreamSource(mediaStream);

            processorNode = audioCtx.createScriptProcessor(4096, 1, 1);
            processorNode.onaudioprocess = (e) => {
                const ch0 = e.inputBuffer.getChannelData(0);
                recordedBuffers.push(new Float32Array(ch0));
            };

            sourceNode.connect(processorNode);
            processorNode.connect(audioCtx.destination);

            isRecording = true;
            voiceBtn.classList.add("recording");
        } catch (err) {
            input.readOnly = false;
            voicePreviewWrap.style.display = "none";
            await Swal.fire({ icon: "error", title: "Mic error", text: String(err) });
        }
    }

    async function stopRecording() {
        if (!isRecording) return;
        isRecording = false;
        voiceBtn.classList.remove("recording");

        try { if (processorNode) processorNode.disconnect(); } catch { }
        try { if (sourceNode) sourceNode.disconnect(); } catch { }

        try { if (mediaStream) mediaStream.getTracks().forEach(t => t.stop()); } catch { }
        try { if (audioCtx) await audioCtx.close(); } catch { }

        processorNode = null;
        sourceNode = null;
        mediaStream = null;
        audioCtx = null;

        input.readOnly = false;

        if (!recordedBuffers.length) {
            voicePreviewWrap.style.display = "none";
            await Swal.fire({ icon: "info", title: "No audio captured", text: "Hold the mic longer." });
            return;
        }

        const merged = mergeFloat32(recordedBuffers);

        if (merged.length < recordedSampleRate * 0.3) {
            voicePreviewWrap.style.display = "none";
            await Swal.fire({ icon: "info", title: "Too short", text: "Record at least ~1 second." });
            return;
        }

        const resampled = await resampleFloat32(merged, recordedSampleRate, 16000);
        const wavBlob = encodeWavFromFloat32(resampled, 16000, 1);

        voiceWavBlob = wavBlob;
        showVoicePreview(wavBlob);
    }

    if (voiceBtn) {
        voiceBtn.addEventListener("mousedown", (e) => { e.preventDefault(); startRecording(); });
        voiceBtn.addEventListener("mouseup", (e) => { e.preventDefault(); stopRecording(); });
        voiceBtn.addEventListener("mouseleave", () => { stopRecording(); });

        voiceBtn.addEventListener("touchstart", (e) => { e.preventDefault(); startRecording(); }, { passive: false });
        voiceBtn.addEventListener("touchend", (e) => { e.preventDefault(); stopRecording(); }, { passive: false });
    }

    form.addEventListener("submit", async (e) => {
        e.preventDefault();

        if (voiceWavBlob) {
            setBusy(true);

            appendVoiceMessage(voiceWavBlob);
            scrollPageToBottom(true);

            removeVoicePreviewUI();
            input.value = "";
            resetTextareaHeight();
            hideFakePlaceholder();
            input.placeholder = originalPlaceholder;
            input.readOnly = false;
            input.focus();

            const botMsg = appendMessage("assistant", "Thinking...");
            let thinkingStep = 0;
            const thinkingTimer = setInterval(() => {
                thinkingStep = (thinkingStep + 1) % 4;
                botMsg.textContent = "Thinking" + ".".repeat(thinkingStep);
            }, 300);

            botMsg.classList.add("thinking");
            const typer = startTypingEffect(botMsg);

            try {
                const fd = new FormData();
                fd.append("audio", voiceWavBlob, "voice.wav");

                const res = await fetch("/api/chat_voice_stream", {
                    method: "POST",
                    headers: {
                        "X-Session-Id": chatSessionId,
                        "X-LLM-Base-URL": baseUrl,
                        "X-LLM-API-Key": apiKey,
                        "X-LLM-Model": model,
                        "X-Embed-Model": embedModel
                    },
                    body: fd
                });

                if (!res.ok || !res.body) {
                    clearInterval(thinkingTimer);
                    botMsg.classList.remove("thinking");
                    typer.stop();
                    updateMessage(botMsg, `Server error (${res.status})`);
                    return;
                }

                botMsg.classList.remove("thinking");
                let clearedThinking = false;

                const reader = res.body.getReader();
                const decoder = new TextDecoder("utf-8");

                let buffer = "";
                let fullText = "";

                while (true) {
                    const { value, done } = await reader.read();
                    if (done) break;

                    buffer += decoder.decode(value, { stream: true });

                    const parts = buffer.split("\n\n");
                    buffer = parts.pop() || "";

                    for (const part of parts) {
                        if (!part.startsWith("data: ")) continue;
                        const jsonStr = part.slice(6).trim();
                        if (!jsonStr) continue;

                        let payload;
                        try { payload = JSON.parse(jsonStr); }
                        catch { continue; }

                        if (payload.delta) {
                            if (!clearedThinking) {
                                clearInterval(thinkingTimer);
                                typer.setText("");
                                clearedThinking = true;
                            }
                            fullText += payload.delta;
                            typer.push(payload.delta);
                            scrollPageToBottom(false);
                        }

                        if (payload.done) {
                            clearInterval(thinkingTimer);
                            typer.stop();
                            botMsg.innerHTML = renderMarkdownSafe(fullText || "(empty response)");
                            forceLinksNewTab(botMsg);
                            scrollPageToBottom(true);

                            for (let i = chatLog.length - 1; i >= 0; i--) {
                                if (chatLog[i].role === "assistant") {
                                    chatLog[i].text = botMsg.textContent || "";
                                    break;
                                }
                            }
                            break;
                        }
                    }
                }

                typer.stop();
                updateMessage(botMsg, fullText || "(empty response)");

                clearVoice();

            } catch (err) {
                typer.stop();
                botMsg.classList.remove("thinking");
                updateMessage(botMsg, "Network error: " + String(err));
            } finally {
                setBusy(false);
                input.readOnly = false;
                input.focus();
            }

            return;
        }

        const text = input.value.trim();
        if (!text) return;

        appendMessage("user", text);
        scrollPageToBottom(true);

        input.value = "";
        resetTextareaHeight();
        input.focus();
        setBusy(true);

        const botMsg = appendMessage("assistant", "Thinking...");
        let thinkingStep = 0;
        const thinkingTimer = setInterval(() => {
            thinkingStep = (thinkingStep + 1) % 4;
            botMsg.textContent = "Thinking" + ".".repeat(thinkingStep);
        }, 300);

        botMsg.classList.add("thinking");
        const typer = startTypingEffect(botMsg);

        try {
            const res = await fetch("/api/chat_stream", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                    "X-Session-Id": chatSessionId,
                    "X-LLM-Base-URL": baseUrl,
                    "X-LLM-API-Key": apiKey,
                    "X-LLM-Model": model,
                    "X-Embed-Model": embedModel
                },
                body: JSON.stringify({
                    message: text,
                    system: "You are a helpful assistant.",
                    temperature: 0.7,
                    top_k: 3
                })
            });

            if (!res.ok || !res.body) {
                clearInterval(thinkingTimer);
                botMsg.classList.remove("thinking");
                typer.stop();
                updateMessage(botMsg, `Server error (${res.status})`);
                return;
            }

            botMsg.classList.remove("thinking");
            let clearedThinking = false;

            const reader = res.body.getReader();
            const decoder = new TextDecoder("utf-8");

            let buffer = "";
            let fullText = "";

            while (true) {
                const { value, done } = await reader.read();
                if (done) break;

                buffer += decoder.decode(value, { stream: true });

                const parts = buffer.split("\n\n");
                buffer = parts.pop() || "";

                for (const part of parts) {
                    if (!part.startsWith("data: ")) continue;
                    const jsonStr = part.slice(6).trim();
                    if (!jsonStr) continue;

                    let payload;
                    try { payload = JSON.parse(jsonStr); }
                    catch { continue; }

                    if (payload.delta) {
                        if (!clearedThinking) {
                            clearInterval(thinkingTimer);
                            typer.setText("");
                            clearedThinking = true;
                        }
                        fullText += payload.delta;
                        typer.push(payload.delta);
                        scrollPageToBottom(false);
                    }

                    if (payload.done) {
                        clearInterval(thinkingTimer);
                        typer.stop();
                        botMsg.innerHTML = renderMarkdownSafe(fullText || "(empty response)");
                        forceLinksNewTab(botMsg);
                        scrollPageToBottom(true);

                        for (let i = chatLog.length - 1; i >= 0; i--) {
                            if (chatLog[i].role === "assistant") {
                                chatLog[i].text = botMsg.textContent || "";
                                break;
                            }
                        }
                        break;
                    }
                }
            }

            typer.stop();
            updateMessage(botMsg, fullText || "(empty response)");

        } catch (err) {
            typer.stop();
            botMsg.classList.remove("thinking");
            updateMessage(botMsg, "Network error: " + String(err));
        } finally {
            setBusy(false);
            input.readOnly = false;
            input.focus();
        }
    });

    saveBtn.addEventListener("click", async () => {
        if (!chatLog.length) {
            Swal.fire({ icon: "info", title: "Nothing to save", text: "Chat is empty." });
            return;
        }

        const confirm = await Swal.fire({
            icon: "warning",
            title: "Before you save",
            html: "This conversation may include <b>personal information</b>.<br>Please be careful where you store/share the PDF.",
            showCancelButton: true,
            confirmButtonText: "Save PDF",
            cancelButtonText: "Cancel",
            reverseButtons: true,
        });

        if (!confirm.isConfirmed) return;

        try {
            setBusy(true);

            const filesMetaEl = document.getElementById("filesMeta");
            const metaText =
                `Chat: ${model} @ ${baseUrl}\n` +
                `Embeddings: ${embedModel} (local)\n` +
                (filesMetaEl ? filesMetaEl.textContent : "");

            const res = await fetch("/api/export_chat_pdf", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({
                    title: "ECDA LLM Chat Export",
                    meta: metaText,
                    messages: chatLog,
                }),
            });

            if (!res.ok) {
                const t = await res.text();
                throw new Error(t || `Export failed (${res.status})`);
            }

            const blob = await res.blob();
            const url = URL.createObjectURL(blob);

            const a = document.createElement("a");
            a.href = url;

            const cd = res.headers.get("Content-Disposition") || "";
            const match = cd.match(/filename="([^"]+)"/i);
            a.download = match ? match[1] : "ecda_chat_export.pdf";

            document.body.appendChild(a);
            a.click();
            a.remove();

            URL.revokeObjectURL(url);

            await Swal.fire({ icon: "success", title: "Saved", timer: 800, showConfirmButton: false });
        } catch (err) {
            await Swal.fire({ icon: "error", title: "Save failed", text: String(err) });
        } finally {
            setBusy(false);
            input.readOnly = false;
        }
    });
    resetTextareaHeight();
</script>

{% endblock %}