// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import {
    DisplayAppendMode,
    DisplayContent,
    TypeAgentAction,
} from "@typeagent/agent-sdk";
import { TTS, TTSMetrics } from "./tts/tts";
import {
    TemplateEditConfig,
    PhaseTiming,
    NotifyExplainedData,
    Dispatcher,
    UserFeedbackEntry,
} from "agent-dispatcher";

import { ChoicePanel, InputChoice } from "./choicePanel";
import { setContent } from "./setContent";
import { ChatView } from "./chat/chatView";
import { iconCheckMarkCircle, iconRoadrunner, iconTrash, iconX } from "./icon";
import { TemplateEditor } from "./templateEditor";
import { SettingsView } from "./settingsView";
import { FeedbackController, FeedbackWidget } from "./feedbackWidget";

function updateMetrics(
    mainMetricsDiv: HTMLDivElement,
    markMetricsDiv: HTMLDivElement,
    name: string,
    metrics?: PhaseTiming,
    total?: number,
) {
    // clear out previous perf data
    mainMetricsDiv.innerHTML = "";
    markMetricsDiv.innerHTML = "";

    if (metrics?.marks) {
        const messages: string[] = [];
        for (const [key, value] of Object.entries(metrics.marks)) {
            const { duration, count } = value;
            messages.push(metricsString(key, duration, count));
        }
        markMetricsDiv.innerHTML = messages.join("<br>");
    }
    const messages: string[] = [];
    if (metrics?.duration) {
        messages.push(metricsString(`${name} Elapsed Time`, metrics.duration));
    }
    if (total !== undefined) {
        messages.push(metricsString("Total Elapsed Time", total));
    }
    mainMetricsDiv.innerHTML = messages.join("<br>");
}

function formatTimeReaderFriendly(time: number) {
    if (time < 1) {
        return `${time.toFixed(3)}ms`;
    } else if (time > 1000) {
        return `${(time / 1000).toFixed(1)}s`;
    } else {
        return `${time.toFixed(1)}ms`;
    }
}

function metricsString(name: string, duration: number, count = 1) {
    const avg = duration / count;
    return `${name}: <b>${formatTimeReaderFriendly(avg)}${count !== 1 ? `(out of ${count})` : ""}</b>`;
}

function updateMetricsVisibility(visible: boolean, div: HTMLDivElement) {
    const addClass = visible
        ? "chat-message-body"
        : "chat-message-body-hide-metrics";
    const removeClass = visible
        ? "chat-message-body-hide-metrics"
        : "chat-message-body";
    div.classList.remove(removeClass);
    div.classList.add(addClass);
}

export class MessageContainer {
    public readonly div: HTMLDivElement;
    private readonly messageBodyDiv: HTMLDivElement;
    private readonly messageDiv: HTMLDivElement;
    // Sibling of messageDiv inside messageBodyDiv. Holds the action JSON
    // (pretty-printed) shown when the user clicks the agent name. Hidden
    // by default; toggled visible via toggleActionData. Mirrors chat-ui's
    // separate detailsDiv pattern (chatPanel.ts) so the JSON expands
    // BELOW the message instead of replacing it in place.
    private actionDataDiv?: HTMLPreElement;
    private readonly timestampDiv: HTMLDivElement;
    private readonly iconDiv?: HTMLDivElement;
    private nameSpan: HTMLSpanElement;

    private metricsDiv?: {
        mainMetricsDiv: HTMLDivElement;
        markMetricsDiv: HTMLDivElement;
        ttsMetricsDiv: HTMLDivElement;
        firstResponseMetricsDiv: HTMLDivElement;
    };

    private messageStart?: number;
    private audioStart?: number;
    private ttsFirstChunkTotal: number = 0;
    private ttsFirstChunkCount: number = 0;
    private ttsSynthesisTotal: number = 0;

    private lastAppendMode?: DisplayAppendMode;
    private completed = false;
    private pendingSpeakText: string = "";
    private defaultSource?: string;
    private action?: TypeAgentAction | string[];
    private feedbackWidget?: FeedbackWidget;
    private trashButton?: HTMLButtonElement;
    /** Lazily-created "queued"/"running" chip; user bubbles only. */
    private queueStatusChip?: HTMLDivElement;

    public setDisplayInfo(source: string, action?: TypeAgentAction | string[]) {
        this.defaultSource = source;
        this.updateSource();
        if (action !== undefined) {
            this.updateActionData(action);
        }
    }

    private get sourceLabel() {
        if (this.source !== this.defaultSource) {
            return this.source;
        }
        if (this.action !== undefined) {
            if (Array.isArray(this.action)) {
                return `${this.source} ${this.action.join(" ")}`;
            } else if (
                (this.action as TypeAgentAction).schemaName !== undefined
            ) {
                return `${this.action.schemaName}.${this.action.actionName}`;
            }
        }
        return this.defaultSource;
    }

    private _sourceIcon: string | undefined;

    private get sourceIcon() {
        // use agent icon for agents, user Initial for users
        if (this.classNameSuffix === "agent") {
            const baseName = this.source.split(".")[0];
            return (
                this.agents.get(this.source) ??
                this.agents.get(baseName) ??
                this._sourceIcon ??
                "❔"
            );
        }
        return this.source?.charAt(0).toLocaleUpperCase();
    }

    private updateSource() {
        if (this.iconDiv !== undefined) {
            // set source and source icon
            const sourceLabel = this.sourceLabel;
            const label = this.timestampDiv.firstChild as HTMLSpanElement;
            if (sourceLabel !== undefined && sourceLabel.length > 0) {
                label.innerText = sourceLabel;
            }

            this.iconDiv.innerText = this.sourceIcon;
        }
    }

    public updateActionData(data: any) {
        this.action = data;
        if (this.action !== undefined && !Array.isArray(this.action)) {
            const json = JSON.stringify(this.action, undefined, 2);
            this.setActionDataPanel(json);
            // Keep the legacy attribute for back-compat with any external
            // callers (e.g. tests, copy-action handlers); the panel below
            // is the user-visible surface.
            const label = this.timestampDiv.firstChild as HTMLSpanElement;
            label.setAttribute("action-data", json);
            this.nameSpan.classList.add("clickable");
        }
    }

    private diagnosticData: any[] | undefined;

    public appendDiagnosticData(data: any) {
        if (this.diagnosticData === undefined) {
            this.diagnosticData = [data];
        } else {
            this.diagnosticData.push(data);
        }
        const json = JSON.stringify(
            this.diagnosticData.length === 1
                ? this.diagnosticData[0]
                : this.diagnosticData,
            undefined,
            2,
        );
        this.setActionDataPanel(json);
        const label = this.timestampDiv.firstChild as HTMLSpanElement;
        label.setAttribute("action-data", json);
        this.nameSpan.classList.add("clickable");
    }

    private setActionDataPanel(json: string): void {
        if (this.actionDataDiv === undefined) {
            const pre = document.createElement("pre");
            pre.className = "chat-action-data-panel";
            // Hidden by default — visibility is toggled by clicking the
            // agent name span (see constructor).
            pre.style.display = "none";
            this.messageBodyDiv.appendChild(pre);
            this.actionDataDiv = pre;
        }
        // textContent (not innerHTML) so the JSON renders verbatim and
        // can't introduce script/markup execution from action payloads.
        this.actionDataDiv.textContent = json;
    }

    private createTimestampDiv(timestamp: Date, className: string) {
        const timeStampDiv = document.createElement("div");
        timeStampDiv.classList.add(className);

        timeStampDiv.appendChild(this.nameSpan); // name placeholder

        const dateSpan = document.createElement("span");
        dateSpan.className = "timestring";
        dateSpan.setAttribute("data", timestamp.toString());

        timeStampDiv.appendChild(dateSpan); // time string

        dateSpan.innerText = "- " + timestamp.toLocaleTimeString();

        return timeStampDiv;
    }

    constructor(
        private readonly chatView: ChatView,
        private settingsView: SettingsView,
        private classNameSuffix: "agent" | "user",
        private source: string,
        private readonly agents: Map<string, string>,
        beforeElem: Element,
        private hideMetrics: boolean,
        private readonly requestStart: number,
        private showFirstResponseMetrics = false,
    ) {
        const div = document.createElement("div");
        div.className = `chat-message-container-${classNameSuffix}`;

        // create the name placeholder
        this.nameSpan = document.createElement("span");
        this.nameSpan.className = "agent-name";
        this.nameSpan.addEventListener("click", () => {
            // Toggle the action-data panel below the message instead of
            // swapping the message body (the previous behavior overlaid
            // the JSON on top of the response, which the user perceived
            // as the action XML "covering" the message). The panel is
            // created lazily by setActionDataPanel; if no action data
            // has been attached, this click is a no-op.
            if (this.actionDataDiv === undefined) return;
            this.actionDataDiv.style.display =
                this.actionDataDiv.style.display === "none" ? "" : "none";
        });

        const timestampDiv = this.createTimestampDiv(
            new Date(),
            `chat-timestamp-${classNameSuffix}`,
        );
        div.append(timestampDiv);
        this.timestampDiv = timestampDiv;

        const agentIconDiv = document.createElement("div");
        agentIconDiv.className = `${classNameSuffix}-icon`;
        this.iconDiv = agentIconDiv;
        if (classNameSuffix === "user") {
            // Click the user-letter avatar to start a Microsoft sign-in
            // flow. ChatView owns the signed-in state — applyUserIconState
            // sets cursor/title/initial based on it, and the click handler
            // reads it at click time so the avatar becomes inert once the
            // user is signed in. Calendar+email share an MS Graph identity
            // (single tenant), so one login covers both.
            this.chatView.applyUserIconState(agentIconDiv);
            agentIconDiv.setAttribute("role", "button");
            agentIconDiv.tabIndex = 0;
            agentIconDiv.setAttribute("aria-label", "Sign in with Microsoft");

            const activateUserIcon = () => {
                if (this.chatView.isUserSignedIn()) return;
                void this.chatView.addUserMessage("@calendar login");
            };

            agentIconDiv.addEventListener("click", () => {
                activateUserIcon();
            });
            agentIconDiv.addEventListener("keydown", (event) => {
                if (event.key === "Enter" || event.key === " ") {
                    event.preventDefault();
                    activateUserIcon();
                }
            });
        }
        div.append(agentIconDiv);

        const messageBodyDiv = document.createElement("div");
        const bodyClass = this.hideMetrics
            ? "chat-message-body-hide-metrics"
            : "chat-message-body";
        messageBodyDiv.className = `${bodyClass} chat-message-${classNameSuffix}`;
        div.append(messageBodyDiv);
        this.messageBodyDiv = messageBodyDiv;

        const message = document.createElement("div");
        message.className = "chat-message-content";
        messageBodyDiv.append(message);
        this.messageDiv = message;

        // The chat message list has the style flex-direction: column-reverse;
        beforeElem.before(div);

        this.div = div;

        this.updateSource();

        // Don't show initialize without any messages.
        this.hide();
    }

    public getMessage() {
        return this.messageDiv.innerText;
    }

    public getMessageDiv() {
        return this.messageDiv;
    }

    /**
     * Render a "queued"/"running" chip above the message body. No-op on non-user
     * bubbles. Pass `null` to remove the chip. When `status === "queued"` and
     * `onCancel` is provided, a small X button is rendered inside the chip and
     * invokes `onCancel()` on click.
     */
    public setQueueStatus(
        status: "queued" | "running" | null,
        onCancel?: () => void,
    ): void {
        if (this.classNameSuffix !== "user") return;
        if (status === null) {
            if (this.queueStatusChip) {
                this.queueStatusChip.remove();
                this.queueStatusChip = undefined;
            }
            return;
        }
        if (!this.queueStatusChip) {
            const chip = document.createElement("div");
            chip.className = "chat-queue-status-chip";
            chip.style.display = "inline-flex";
            chip.style.alignItems = "center";
            chip.style.gap = "4px";
            chip.style.padding = "1px 6px";
            chip.style.marginLeft = "4px";
            chip.style.marginTop = "4px";
            chip.style.marginBottom = "2px";
            chip.style.fontSize = "11px";
            chip.style.lineHeight = "1";
            chip.style.borderRadius = "8px";
            chip.style.opacity = "0.85";
            this.messageBodyDiv.insertBefore(
                chip,
                this.messageBodyDiv.firstChild,
            );
            this.queueStatusChip = chip;
        }
        const chip = this.queueStatusChip;
        chip.replaceChildren();
        const label = document.createElement("span");
        label.textContent = status;
        chip.appendChild(label);
        if (status === "queued") {
            chip.style.background = "rgba(255, 200, 0, 0.18)";
            chip.style.color = "rgba(120, 80, 0, 0.95)";
            if (onCancel) {
                const btn = document.createElement("button");
                btn.type = "button";
                btn.className = "chat-queue-cancel-button";
                btn.title = "Cancel this queued request";
                btn.setAttribute("aria-label", "Cancel queued request");
                // A bare "×" glyph sized by `font-size: 0.7em` scales with the
                // chip's own font, avoids loading an SVG icon for a single
                // character, and keeps the button height roughly 2/3 of the
                // chip text. Using inline-flex for vertical centering.
                btn.textContent = "×";
                btn.style.display = "inline-flex";
                btn.style.alignItems = "center";
                btn.style.justifyContent = "center";
                btn.style.padding = "0";
                btn.style.border = "none";
                btn.style.background = "transparent";
                btn.style.color = "inherit";
                btn.style.cursor = "pointer";
                btn.style.fontSize = "0.7em";
                btn.style.lineHeight = "1";
                btn.addEventListener("click", (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    onCancel();
                });
                chip.appendChild(btn);
            }
        } else {
            chip.style.background = "rgba(0, 150, 255, 0.18)";
            chip.style.color = "rgba(0, 80, 140, 0.95)";
        }
    }

    /**
     * Build the feedback affordance for this bubble. Idempotent: if a
     * widget already exists, leaves it alone (subsequent calls during
     * RequestId promotion are no-ops).
     */
    public attachFeedbackController(controller: FeedbackController) {
        if (this.classNameSuffix !== "agent") return;
        if (this.feedbackWidget !== undefined) return;
        this.feedbackWidget = new FeedbackWidget(
            {
                container: this.div,
                bodyDiv: this.messageBodyDiv,
                headerDiv: this.timestampDiv,
                messageDiv: this.messageDiv,
            },
            controller,
            this.chatView.feedbackUIVariant,
        );
        this.attachTrashButton(controller);
    }

    /**
     * Attach a trash button to this bubble that moves the entire
     * message group to the trash bin via `controller.setHidden(true)`.
     *
     *   - Agent bubbles get the trash anchored absolute top-right
     *     inside the bubble body (hidden until hover).
     *   - User bubbles get the trash as an inline child of the message
     *     content, inserted *before* the roadrunner so the roadrunner
     *     stays at the trailing edge.
     *
     * Wired both from `attachFeedbackController` (agent path) and from
     * MessageGroup directly (user path).
     */
    public attachTrashButton(controller: FeedbackController) {
        if (this.trashButton !== undefined) return;
        if (controller.setHidden === undefined) return;
        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = "chat-action-button chat-message-trash";
        btn.title = "Move to trash";
        btn.setAttribute("aria-label", "Move to trash");
        btn.dataset.action = "trash";
        btn.appendChild(iconTrash());
        // Trash on a user bubble hides only the user bubble; trash on
        // an agent bubble hides only that agent response. The user can
        // delete one side without affecting the other.
        //
        // We optimistically toggle the .chat-message-trashed class on
        // the container right away — the dispatcher roundtrip would
        // otherwise insert a noticeable delay before the placeholder
        // appears. The class is also re-applied via the broadcast on
        // success (idempotent toggle) and removed if the call fails.
        const target: "user" | "agent" = this.classNameSuffix;
        btn.addEventListener("click", async (ev) => {
            ev.stopPropagation();
            this.div.classList.add("chat-message-trashed");
            try {
                await controller.setHidden!(true, target);
            } catch (e) {
                this.div.classList.remove("chat-message-trashed");
                console.error("recordUserHide failed", e);
            }
        });
        this.trashButton = btn;

        // Anchored top-right of the bubble body (works for both user
        // and agent). Placing it as a sibling of the message content
        // keeps it outside the text flow — earlier attempts to put it
        // inline in the user bubble had it wrap to a new line because
        // the user-text span renders trailing newlines literally
        // (`white-space: break-spaces`). The roadrunner stays inline
        // in the content and lives "below/left" of the trash visually.
        this.messageBodyDiv.appendChild(btn);
    }

    public setFeedbackState(entry: UserFeedbackEntry | null) {
        this.feedbackWidget?.setFeedbackState(entry);
    }

    public setMessage(
        content: DisplayContent,
        source: string,
        appendMode?: DisplayAppendMode, // default to not appending.
        sourceIcon?: string,
    ) {
        if (this.messageStart === undefined) {
            // Don't count dispatcher status messages as first response.
            if (source !== "dispatcher") {
                this.messageStart = performance.now();
                this.updateFirstResponseMetrics();
            }
        }

        // Flush last temporary reset the lastAppendMode.
        this.flushLastTemporary();

        // Don't change the displayed source/icon when the dispatcher's
        // transient "Executing action ..." status overlays a bubble that
        // already represents a real agent. Keeps the agent's avatar
        // stable while showing the in-flight indicator inline.
        const isDispatcherStatusOverlay =
            source === "dispatcher" &&
            this.source !== "" &&
            this.source !== "dispatcher";
        if (!isDispatcherStatusOverlay) {
            this.source = source;
            if (sourceIcon !== undefined) {
                this._sourceIcon = sourceIcon;
            }
            this.updateSource();
        }

        const speakText = setContent(
            this.messageDiv,
            content,
            this.settingsView,
            this.classNameSuffix,
            appendMode === "inline" && this.lastAppendMode !== "inline"
                ? "block"
                : appendMode,
        );

        this.speak(speakText, appendMode);

        // After the agent's HTML lands in the DOM, lift any embedded
        // user-signed-in marker into ChatView state. The marker is emitted
        // by the calendar/email login handlers on success.
        if (this.classNameSuffix === "agent") {
            this.chatView.extractUserMarker(this.messageDiv);
        }

        this.lastAppendMode = appendMode;

        this.updateDivState();
    }

    public addChoicePanel(
        choices: InputChoice[],
        onSelected: (choice: InputChoice) => boolean | void,
    ) {
        const choicePanel = new ChoicePanel(
            this.messageDiv,
            choices,
            (choice: InputChoice) => {
                if (onSelected(choice) !== false) {
                    choicePanel.remove();
                }
            },
        );
    }

    public addCheckboxPanel(
        labels: string[],
        onConfirm: (selectedIndices: number[]) => void,
    ) {
        const panelDiv = document.createElement("div");
        panelDiv.className = "checkbox-panel";

        const checkboxes: HTMLInputElement[] = [];
        for (let i = 0; i < labels.length; i++) {
            const label = document.createElement("label");
            label.className = "checkbox-choice";
            const cb = document.createElement("input");
            cb.type = "checkbox";
            cb.dataset.index = String(i);
            checkboxes.push(cb);
            label.appendChild(cb);
            const span = document.createElement("span");
            span.textContent = labels[i];
            label.appendChild(span);
            panelDiv.appendChild(label);
        }

        const buttonDiv = document.createElement("div");
        buttonDiv.className = "checkbox-buttons";

        const confirmBtn = document.createElement("button");
        confirmBtn.className = "choice-button";
        confirmBtn.textContent = "Confirm (Enter)";
        confirmBtn.addEventListener("click", submit);

        const cancelBtn = document.createElement("button");
        cancelBtn.className = "choice-button";
        cancelBtn.textContent = "Cancel (Del)";
        cancelBtn.addEventListener("click", cancel);

        buttonDiv.appendChild(confirmBtn);
        buttonDiv.appendChild(cancelBtn);
        panelDiv.appendChild(buttonDiv);

        this.messageDiv.after(panelDiv);

        const keyHandler = (ev: KeyboardEvent) => {
            if (!panelDiv.isConnected) {
                window.removeEventListener("keydown", keyHandler);
                return;
            }
            if (ev.key === "Enter") {
                ev.preventDefault();
                submit();
            } else if (ev.key === "Delete") {
                ev.preventDefault();
                cancel();
            }
        };
        window.addEventListener("keydown", keyHandler);

        function submit() {
            const selected = checkboxes
                .filter((cb) => cb.checked)
                .map((cb) => parseInt(cb.dataset.index!, 10));
            window.removeEventListener("keydown", keyHandler);
            panelDiv.remove();
            onConfirm(selected);
        }

        function cancel() {
            window.removeEventListener("keydown", keyHandler);
            panelDiv.remove();
            onConfirm([]);
        }
    }

    public async proposeAction(
        dispatcher: Dispatcher,
        actionTemplates: TemplateEditConfig,
    ) {
        // use this div to show the proposed action
        const actionContainer = document.createElement("div");
        actionContainer.className = "action-container";
        this.messageDiv.appendChild(actionContainer);
        this.updateDivState();

        const actionCascade = new TemplateEditor(
            actionContainer,
            dispatcher,
            actionTemplates,
        );

        const createTextSpan = (text: string) => {
            const span = document.createElement("span");
            span.innerText = text;
            return span;
        };

        return new Promise((resolve) => {
            const confirm = () => {
                const choices: InputChoice[] = [
                    {
                        text: "Accept",
                        element: createTextSpan("✓"),
                        selectKey: ["y", "Y", "Enter"],
                        value: true,
                    },
                    {
                        text: "Edit",
                        element: createTextSpan("✎"),
                        selectKey: ["n", "N", "Delete"],
                        value: undefined,
                    },
                    {
                        text: "Cancel",
                        element: createTextSpan("✕"),
                        selectKey: ["Escape"],
                        value: false,
                    },
                ];
                this.addChoicePanel(choices, (choice: InputChoice) => {
                    if (choice.value === undefined) {
                        edit();
                        return;
                    }
                    actionContainer.remove();
                    const replacement = choice.value ? undefined : null;
                    resolve(replacement);
                });
                this.scrollIntoView();
            };
            const edit = () => {
                actionCascade.setEditMode(true);
                const choices: InputChoice[] = [
                    {
                        text: "Replace",
                        element: iconCheckMarkCircle(),
                        value: true,
                    },
                    {
                        text: "Cancel",
                        element: iconX(),
                        value: false,
                    },
                ];
                this.addChoicePanel(choices, (choice: InputChoice) => {
                    if (choice.value === true) {
                        if (actionCascade.hasErrors) {
                            return false;
                        }
                        actionContainer.remove();
                        resolve(actionCascade.value);
                    } else {
                        actionCascade.reset();
                        actionCascade.setEditMode(false);
                        confirm();
                    }
                    return true;
                });
                this.scrollIntoView();
            };

            confirm();
        });
    }

    private speakText(tts: TTS, speakText: string) {
        let cbAudioStart: (() => void) | undefined;
        if (this.audioStart === undefined) {
            this.audioStart = -1;
            cbAudioStart = () => {
                this.audioStart = performance.now();
                this.updateFirstResponseMetrics();
            };
        }
        const p = tts.speak(speakText, cbAudioStart);
        p.then((timing) => {
            if (timing) {
                this.addTTSTiming(timing);
            }
        });
    }

    private speak(
        speakText: string | undefined,
        appendMode?: DisplayAppendMode,
    ) {
        const tts = this.chatView.tts;
        if (tts === undefined) {
            return;
        }
        if (speakText === undefined) {
            // Flush the pending text.
            this.flushPendingSpeak(tts);
            return;
        }

        if (appendMode !== "inline") {
            this.flushPendingSpeak(tts);
            this.speakText(tts, speakText);
            return;
        }

        this.pendingSpeakText += speakText;
        const minSpeak = 10; // TODO: Adjust this value.
        if (this.pendingSpeakText.length <= minSpeak) {
            // Too short to speak.
            return;
        }
        const segmenter = new Intl.Segmenter(navigator.language, {
            granularity: "sentence",
        });
        const segments = Array.from(segmenter.segment(this.pendingSpeakText));

        if (segments.length < 2) {
            // No sentence boundary.
            return;
        }

        // Try Keep the last segment and speak the rest.
        const index = segments[segments.length - 1].index;
        if (index <= minSpeak) {
            // Too short to speak.
            return;
        }

        const speakTextPartial = this.pendingSpeakText.slice(0, index);
        this.pendingSpeakText = this.pendingSpeakText.slice(index);
        this.speakText(tts, speakTextPartial);
    }

    private flushPendingSpeak(tts: TTS) {
        // Flush the pending text.
        if (this.pendingSpeakText) {
            this.speakText(tts, this.pendingSpeakText);
            this.pendingSpeakText = "";
        }
    }

    public complete() {
        this.completed = true;
        if (this.chatView.tts) {
            this.flushPendingSpeak(this.chatView.tts);
        }
        this.flushLastTemporary();
        this.updateDivState();
    }

    private updateDivState() {
        if (this.completed && !this.messageDiv.firstChild) {
            this.hide();
        } else {
            this.show();
        }
    }

    private flushLastTemporary() {
        if (this.lastAppendMode === "temporary") {
            this.messageDiv.lastChild?.remove();
            this.lastAppendMode = undefined;
        }
    }

    private ensureMetricsDiv() {
        if (this.metricsDiv === undefined) {
            const metricsContainer = document.createElement("div");
            metricsContainer.className = `chat-message-metrics chat-message-metrics-${this.classNameSuffix}`;
            this.messageBodyDiv.append(metricsContainer);

            const metricsDetails = document.createElement("div");
            metricsDetails.className = "metrics-details";
            metricsContainer.append(metricsDetails);

            const left = document.createElement("div");
            metricsDetails.append(left);
            const middle = document.createElement("div");
            metricsDetails.append(middle);
            const right = document.createElement("div");
            metricsDetails.append(right);

            const firstResponseMetricsDiv = document.createElement("div");
            left.append(firstResponseMetricsDiv);

            const markMetricsDiv = document.createElement("div");
            middle.append(markMetricsDiv);

            // Only show with dev UI.
            const ttsMetricsDiv = document.createElement("div");
            ttsMetricsDiv.className = "tts-metrics";
            middle.append(ttsMetricsDiv);

            const mainMetricsDiv = document.createElement("div");
            right.append(mainMetricsDiv);

            this.metricsDiv = {
                mainMetricsDiv,
                markMetricsDiv,
                ttsMetricsDiv,
                firstResponseMetricsDiv,
            };
        }
        return this.metricsDiv;
    }

    public updateMainMetrics(
        name: string,
        metrics?: PhaseTiming,
        total?: number,
    ) {
        if (metrics === undefined && total === undefined) {
            return;
        }
        const metricsDiv = this.ensureMetricsDiv();
        updateMetrics(
            metricsDiv.mainMetricsDiv,
            metricsDiv.markMetricsDiv,
            name,
            metrics,
            total,
        );
    }

    private updateFirstResponseMetrics() {
        if (this.showFirstResponseMetrics) {
            const messages: string[] = [];
            if (this.messageStart !== undefined) {
                messages.push(
                    metricsString(
                        "First Message",
                        this.messageStart - this.requestStart,
                    ),
                );
            }
            if (this.audioStart !== undefined) {
                messages.push(
                    metricsString(
                        "First Audio",
                        this.audioStart - this.requestStart,
                    ),
                );
            }
            const div = this.ensureMetricsDiv().firstResponseMetricsDiv;
            div.innerHTML = messages.join("<br>");
        } else if (this.metricsDiv) {
            this.metricsDiv.firstResponseMetricsDiv.innerHTML = "";
        }
    }

    public setFirstResponseMetricsVisibility(visible: boolean) {
        this.showFirstResponseMetrics = visible;
        this.updateFirstResponseMetrics();
    }

    public addTTSTiming(timing: TTSMetrics) {
        const messages: string[] = [];
        if (timing.firstChunkTime) {
            this.ttsFirstChunkTotal += timing.firstChunkTime;
            this.ttsFirstChunkCount++;
        }
        this.ttsSynthesisTotal += timing.duration;

        if (this.ttsFirstChunkCount !== 0) {
            messages.push(
                metricsString(
                    "TTS First Chunk",
                    this.ttsFirstChunkTotal,
                    this.ttsFirstChunkCount,
                ),
            );
        }
        messages.push(metricsString("TTS Synthesis", this.ttsSynthesisTotal));
        this.ensureMetricsDiv().ttsMetricsDiv.innerHTML = messages.join("<br>");
    }

    private show() {
        this.div.classList.remove("chat-message-hidden");
    }
    public hide() {
        this.div.classList.add("chat-message-hidden");
    }
    public scrollIntoView() {
        this.div.scrollIntoView(false);
    }

    public setMetricsVisible(visible: boolean) {
        this.hideMetrics = !visible;
        updateMetricsVisibility(visible, this.messageBodyDiv);
    }

    public notifyExplained(data: NotifyExplainedData) {
        const fromCache = data.fromCache;
        const timestamp = data.time;
        const cachePart = fromCache
            ? `Translated by ${fromCache}`
            : "Translated by model";
        let message: string;
        let color: string;
        if (data.error === undefined) {
            message = `${cachePart}. Explained at ${timestamp}`;
            color = fromCache ? "#00c000" : "#c0c000";
        } else {
            message = `${cachePart}. Nothing to put in cache: ${data.error}`;
            color = "lightblue";
        }
        this.div.setAttribute("data-expl", message);
        this.div.classList.add("chat-message-explained");
        const icon = iconRoadrunner();
        icon.getElementsByTagName("svg")[0].style.fill = color;
        icon.className = "chat-message-explained-icon";
        this.messageDiv.appendChild(icon);
    }

    /**
     * Update roadrunner color based on grammar rule generation result.
     * Called when "grammarRule" notification arrives (after "explained").
     * Blue = no dynamic rule created; keeps green/gold if rule succeeded.
     */
    public updateGrammarResult(success: boolean, message?: string) {
        if (!success) {
            const icon = this.messageDiv.querySelector(
                ".chat-message-explained-icon svg",
            );
            if (icon) {
                (icon as SVGElement).style.fill = "cornflowerblue";
            }
            // Update tooltip with reason why no grammar rule was cached
            if (message) {
                const existing = this.div.getAttribute("data-expl") || "";
                this.div.setAttribute(
                    "data-expl",
                    `${existing}. No fast-path cached (${message})`,
                );
            }
        }
    }
}
