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

/// <reference path="../../lib/lib.android.d.ts" />

// Augment Window with the test hook exposed by registerClient() so that
// Playwright tests can inject interactions without requiring a live
// agent-server connection.
declare global {
    interface Window {
        __clientIO__?: import("agent-dispatcher").ClientIO;
    }
}

import {
    ClientAPI,
    NotifyCommands,
    SpeechToken,
    ShellUserSettings,
    Client,
    SearchMenuItem,
    UserExpression,
} from "../../preload/electronTypes";
import { ChatView } from "./chat/chatView";
import { TabView } from "./tabView";
import { getSpeechToken, setSpeechToken } from "./speechToken";
import { iconHelp, iconMetrics, iconSettings } from "./icon";
import { SettingsView } from "./settingsView";
import { HelpView } from "./helpView";
import { MetricsView } from "./chat/metricsView";
import { CameraView } from "./cameraView";
import { createWebSocket, webapi } from "./webSocketAPI";
import * as jose from "jose";
import { AppAgentEvent } from "@typeagent/agent-sdk";
import {
    ClientIO,
    Dispatcher,
    PendingInteractionResponse,
    QueueSnapshot,
    RequestId,
} from "agent-dispatcher";
import { swapContent } from "./setContent";
import {
    remoteSearchMenuUIOnCompletion,
    remoteSearchMenuUIOnSelectionChanged,
} from "./searchMenuUI/remoteSearchMenuUI";
import { ChatInput } from "./chat/chatInput";
import { escapeHtml } from "./htmlUtil";

export function isElectron(): boolean {
    return globalThis.api !== undefined;
}

export function getClientAPI(): ClientAPI {
    if (globalThis.api !== undefined) {
        return globalThis.api;
    } else {
        return getWebSocketAPI();
    }
}

export function getAndroidAPI() {
    return globalThis.Android;
}

function getWebSocketAPI(): ClientAPI {
    if (globalThis.webApi === undefined) {
        globalThis.webApi = webapi;

        createWebSocket(true).then((ws) => (globalThis.ws = ws));
    }

    return globalThis.webApi;
}

async function initializeChatHistory(chatView: ChatView) {
    const result = await getClientAPI().getChatHistory();
    if (result === undefined) {
        return;
    }
    const { html: history, seq } = result;
    maxSeqSeen = seq;

    // load the history
    chatView.getScrollContainer().innerHTML = history;

    // add the separator
    if (history.length > 0) {
        // don't add a separator if there's already one there
        if (
            !chatView
                .getScrollContainer()
                .children[0].classList.contains("chat-separator")
        ) {
            let separator: HTMLDivElement = document.createElement("div");
            separator.classList.add("chat-separator");
            separator.innerHTML =
                '<div class="chat-separator-line"></div><div class="chat-separator-text">previously</div><div class="chat-separator-line"></div>';

            chatView.getScrollContainer().prepend(separator);
        }

        // make all old messages "inactive" and set the context for each separator
        let lastSeparatorText: HTMLDivElement | null;
        for (
            let i = 0;
            i < chatView.getScrollContainer().children.length;
            i++
        ) {
            // gray out this item
            const div = chatView.getScrollContainer().children[i];
            div.classList.add("history");

            // is this a separator?
            const separator = div.querySelector(".chat-separator-text");
            if (separator != null) {
                lastSeparatorText = div.querySelector(".chat-separator-text");
            }

            // get the timestamp for this chat bubble (if applicable)
            const span: HTMLSpanElement | null =
                div.querySelector(".timestring");

            if (span !== null) {
                const timeStamp: Date = new Date(span.attributes["data"].value);
                lastSeparatorText!.innerText = getDateDifferenceDescription(
                    new Date(),
                    timeStamp,
                );
            }

            // rewire up action-data click handler
            const nameDiv = div.querySelector(".agent-name.clickable");
            if (nameDiv != null) {
                const messageDiv = div.querySelector(".chat-message-content");

                if (messageDiv) {
                    nameDiv.addEventListener("click", () => {
                        swapContent(
                            nameDiv as HTMLSpanElement,
                            messageDiv as HTMLDivElement,
                        );
                    });
                }
            }

            // TODO: wire up any other functionality (player agent?)
        }

        // Reattach feedback widgets on restored agent bubbles. Each
        // container that was saved with a data-feedback-request-id gets
        // a fresh widget wired to a dispatcher-backed controller so the
        // copy / 👍 / 👎 / ⋯ buttons work again.
        chatView.rewireHistoricalFeedback();
    }
}

function registerClient(
    chatView: ChatView,
    agents: Map<string, string>,
    settingsView: SettingsView,
    tabsView: TabView,
    cameraView: CameraView,
    chatHistoryReady: Promise<void>,
) {
    // Tracks open deferred interaction prompts so they can be dismissed when
    // another client answers or the server cancels the interaction.
    const activeInteractions = new Map<string, AbortController>();

    const clientIO: ClientIO = {
        clear: () => {
            chatView.clear();
        },
        exit: () => {
            window.close();
        },
        shutdown: () => {
            window.close();
        },
        setUserRequest: (requestId, command, seq?) => {
            if (seq !== undefined) {
                maxSeqSeen = Math.max(maxSeqSeen, seq);
            }
            // Only treat the stop button + ghost-text + active-request state
            // as ours when this shell actually originated the command. For
            // mirrored requests from peer clients (e.g. vscode extension)
            // the shell can't cancel; showing a stop button strands it
            // because no commandComplete on this side ever clears it.
            if (chatView.isLocalRequest(requestId)) {
                chatView.setActiveRequestId(requestId.requestId);
            }
            // For remote clients or replay, creates a new MessageGroup
            // keyed by UUID. For local clients, this is a no-op because
            // addRemoteUserMessage skips pending locals — they get promoted
            // lazily by getMessageGroup when the first output arrives.
            chatView.addRemoteUserMessage(requestId, command);
        },
        setDisplayInfo: (requestId, source, actionIndex, action, seq?) => {
            if (seq !== undefined) {
                maxSeqSeen = Math.max(maxSeqSeen, seq);
            }
            chatView.setDisplayInfo(requestId, source, actionIndex, action);
        },
        setDisplay: (message, seq?) => {
            if (seq !== undefined) {
                maxSeqSeen = Math.max(maxSeqSeen, seq);
            }
            // Agent-initiated messages (SessionContext.beginAgentThread) carry
            // a render kind. "toast" and "inline" go through the same
            // ephemeral notification path used for AppAgentEvent.Toast/Inline
            // above; "bubble" (or absent) renders as a regular agent bubble.
            if (message.kind === "toast" || message.kind === "inline") {
                chatView.addNotificationMessage(
                    message.message,
                    message.source,
                    message.requestId,
                );
                return;
            }
            chatView.addAgentMessage(message);
        },
        appendDisplay: (message, mode, seq?) => {
            if (seq !== undefined) {
                maxSeqSeen = Math.max(maxSeqSeen, seq);
            }
            if (message.kind === "toast" || message.kind === "inline") {
                chatView.addNotificationMessage(
                    message.message,
                    message.source,
                    message.requestId,
                );
                return;
            }
            chatView.addAgentMessage(message, { appendMode: mode });
        },
        appendDiagnosticData: (requestId, data) => {
            chatView.appendDiagnosticData(requestId, data);
        },
        setDynamicDisplay: (
            requestId,
            source,
            actionIndex,
            displayId,
            nextRefreshMs,
        ) => {
            chatView.setDynamicDisplay(
                requestId,
                source,
                actionIndex,
                displayId,
                nextRefreshMs,
            );
        },
        // Called only in standalone mode (shell running its own dispatcher).
        // In connected mode the SharedDispatcher implements question() by
        // assigning an interactionId and broadcasting requestInteraction() to
        // all connected clients — so the shell receives requestInteraction(),
        // not question(), when attached to an agent-server.
        question: async (requestId, message, choices, defaultId) => {
            if (requestId !== undefined) {
                // Route through the unified interaction question UI which handles
                // both binary Yes/No and arbitrary multi-choice prompts.
                // interactionId is left empty because this path has no
                // server-assigned lifecycle — there are no other clients that
                // could call interactionResolved/Cancelled for this prompt.
                return chatView.showInteractionQuestion({
                    interactionId: "",
                    type: "question",
                    requestId,
                    source: "",
                    timestamp: Date.now(),
                    message,
                    choices,
                    defaultId,
                });
            }
            // No requestId — main process should have handled this via
            // dialog.showMessageBox before it reached the renderer.
            throw new Error(
                "Main process should have handled question with no requestId",
            );
        },
        requestChoice: (
            requestId,
            choiceId,
            type,
            message,
            choices,
            source,
        ) => {
            chatView.showChoice(
                requestId,
                choiceId,
                type,
                message,
                choices,
                source,
            );
        },
        proposeAction: async (requestId, actionTemplates, source) => {
            return chatView.proposeAction(requestId, actionTemplates, source);
        },
        notify: (requestId, event, data, source, seq?) => {
            if (seq !== undefined) {
                maxSeqSeen = Math.max(maxSeqSeen, seq);
            }
            switch (event) {
                case "explained":
                    chatView.notifyExplained(requestId, data);
                    break;
                case "randomCommandSelected":
                    chatView.randomCommandSelected(requestId, data.message);
                    break;
                case "grammarRule":
                    // Update roadrunner color based on grammar result.
                    // Grammar details are diagnostic-only — accessible via
                    // the clickable label, not displayed inline.
                    chatView.updateGrammarResult(
                        requestId,
                        data.success,
                        data.message,
                    );
                    break;
                case "showNotifications":
                    switch (data) {
                        case NotifyCommands.Clear:
                            notifications.length = 0;
                            break;
                        case NotifyCommands.ShowAll:
                            showNotifications(
                                requestId,
                                chatView,
                                notifications,
                                true,
                            );
                            break;
                        case NotifyCommands.ShowSummary:
                            summarizeNotifications(
                                requestId,
                                chatView,
                                notifications,
                            );
                            break;
                        case NotifyCommands.ShowUnread:
                            showNotifications(
                                requestId,
                                chatView,
                                notifications,
                            );
                            break;
                        default:
                            console.log("unknown notify command");
                            break;
                    }
                    break;
                case AppAgentEvent.Error:
                case AppAgentEvent.Warning:
                case AppAgentEvent.Info:
                    // Keep all Info/Warning/Error events in the @notify
                    // buffer (including osNotifications) so @notify show can
                    // surface them consistently.
                    notifications.push({
                        event,
                        source,
                        data,
                        read: false,
                        requestId,
                    });
                    break;

                // Display-focused events - for now show toast notification inline
                // TODO: Design for toast notifications in shell
                case AppAgentEvent.Inline:
                case AppAgentEvent.Toast:
                    chatView.addNotificationMessage(data, source, requestId);
                    if (source !== "osNotifications") {
                        // OS notifications are tracked by the OS itself; do
                        // not surface them in @notify show.
                        notifications.push({
                            event,
                            source,
                            data,
                            read: false,
                            requestId,
                        });
                    }
                    break;

                // OS-notifications agent: the underlying OS notification has
                // been dismissed (left the action center). Remove the chat
                // bubble we added when it first arrived. data.id matches the
                // notificationId we used on the corresponding "added" event.
                case "osDismiss":
                    if (data && typeof data.id === "string") {
                        chatView.removeNotificationGroup(
                            `notification-async-osNotifications-${data.id}`,
                        );
                    }
                    break;

                default:
                // ignore
            }
        },
        openLocalView: async () => {
            throw new Error("Main process should have handled openLocalView");
        },
        closeLocalView: async () => {
            throw new Error("Main process should have handled closeLocalView");
        },
        requestInteraction: (interaction) => {
            const ac = new AbortController();
            activeInteractions.set(interaction.interactionId, ac);
            (async () => {
                let response: PendingInteractionResponse;
                try {
                    if (interaction.type === "question") {
                        const value = await chatView.showInteractionQuestion(
                            interaction,
                            ac.signal,
                        );
                        response = {
                            interactionId: interaction.interactionId,
                            type: "question",
                            value,
                        };
                    } else {
                        const requestId = interaction.requestId;
                        if (requestId === undefined) {
                            console.error(
                                `[requestInteraction] proposeAction interaction ${interaction.interactionId} has no requestId — skipping`,
                            );
                            activeInteractions.delete(
                                interaction.interactionId,
                            );
                            return;
                        }
                        const value = await chatView.proposeAction(
                            requestId,
                            interaction.actionTemplates,
                            interaction.source,
                        );
                        response = {
                            interactionId: interaction.interactionId,
                            type: "proposeAction",
                            value,
                        };
                    }
                } catch (e) {
                    // Expected: aborted by interactionResolved / interactionCancelled.
                    // The abort reason is either { kind: "resolved-by-other" } or
                    // the string "cancelled".  Anything else is unexpected and logged.
                    const isKnownAbort =
                        e === "cancelled" ||
                        (e !== null &&
                            typeof e === "object" &&
                            (e as any).kind === "resolved-by-other");
                    if (!isKnownAbort) {
                        console.error(
                            `[requestInteraction] unexpected error for ${interaction.interactionId}:`,
                            e,
                        );
                    }
                    activeInteractions.delete(interaction.interactionId);
                    return;
                }
                activeInteractions.delete(interaction.interactionId);
                try {
                    await chatView.respondToInteraction(response);
                } catch {
                    // Interaction may have already timed out on the server.
                }
            })().catch((e) => {
                console.error(
                    `[requestInteraction] unhandled error for ${interaction.interactionId}:`,
                    e,
                );
                activeInteractions.delete(interaction.interactionId);
            });
        },
        interactionResolved: (interactionId) => {
            const ac = activeInteractions.get(interactionId);
            if (ac) {
                activeInteractions.delete(interactionId);
                ac.abort({ kind: "resolved-by-other" });
            }
        },
        interactionCancelled: (interactionId) => {
            const ac = activeInteractions.get(interactionId);
            if (ac) {
                activeInteractions.delete(interactionId);
                ac.abort("cancelled");
            }
        },
        takeAction: (_, action, data) => {
            // Android object gets injected on Android devices, otherwise unavailable
            try {
                console.log(`Take Action '${action}' Data: ${data}`);
                let d: any = data;
                switch (action) {
                    case "show-camera": {
                        cameraView.show();
                        break;
                    }
                    case "trash-restore": {
                        chatView.dispatcher
                            ?.restoreAllHidden()
                            .catch((e: any) =>
                                console.error("restoreAllHidden failed", e),
                            );
                        break;
                    }
                    case "trash-flush": {
                        chatView.dispatcher
                            ?.flushHidden()
                            .catch((e: any) =>
                                console.error("flushHidden failed", e),
                            );
                        break;
                    }
                    case "set-alarm": {
                        getAndroidAPI()?.setAlarm(d.time);
                        break;
                    }
                    case "call-phonenumber": {
                        getAndroidAPI()?.callPhoneNumber(d.phoneNumber);
                        break;
                    }
                    case "send-sms": {
                        getAndroidAPI()?.sendSMS(d.phoneNumber, d.message);
                        break;
                    }
                    case "search-nearby": {
                        getAndroidAPI()?.searchNearby(d.searchTerm);
                        break;
                    }
                    case "automate-phone-ui": {
                        getAndroidAPI()?.automateUI(d.originalRequest);
                        break;
                    }
                    case "open-folder": {
                        getClientAPI().openFolder(data as string);
                        break;
                    }
                    case "manage-conversation": {
                        const payload = d as {
                            subcommand: string;
                            name?: string;
                            newName?: string;
                        };
                        const api = getClientAPI();
                        (async () => {
                            switch (payload.subcommand) {
                                case "new": {
                                    let newName = payload.name;
                                    if (!newName) {
                                        // Generate a default name so that NL
                                        // requests like "create a new
                                        // conversation" succeed end-to-end
                                        // without forcing the user to provide
                                        // one. Format: "Conversation YYYY-MM-DD HH:MM".
                                        const d = new Date();
                                        const pad = (n: number) =>
                                            n.toString().padStart(2, "0");
                                        newName = `Conversation ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
                                    }
                                    const conversationName = newName;
                                    const created = await chatView.withBusy(
                                        "Creating conversation…",
                                        () =>
                                            api.conversationCreate(
                                                conversationName,
                                            ),
                                    );
                                    const switchResult =
                                        await chatView.withBusy(
                                            "Switching conversation…",
                                            () =>
                                                api.conversationSwitch(
                                                    created.conversationId,
                                                ),
                                        );
                                    const msg = switchResult.success
                                        ? `✅ Created and switched to conversation "<b>${escapeHtml(created.name)}</b>"`
                                        : `✅ Created conversation "<b>${escapeHtml(created.name)}</b>" but could not switch: ${escapeHtml(switchResult.error ?? "unknown error")}`;
                                    chatView.addNotificationMessage(
                                        {
                                            type: "html",
                                            content: msg,
                                            kind: "info",
                                        },
                                        "conversation",
                                        undefined,
                                    );
                                    break;
                                }
                                case "list": {
                                    const sessions =
                                        await api.conversationList();
                                    const current =
                                        await api.conversationGetCurrent();
                                    let html: string;
                                    if (sessions.length === 0) {
                                        html = "No conversations found.";
                                    } else {
                                        const lines = sessions.map((s) => {
                                            const isCurrent =
                                                current &&
                                                s.conversationId ===
                                                    current.conversationId;
                                            const marker = isCurrent
                                                ? " ← <b>current</b>"
                                                : "";
                                            const date = new Date(
                                                s.createdAt,
                                            ).toLocaleDateString();
                                            return `• <b>${escapeHtml(s.name)}</b> (${escapeHtml(s.conversationId)}) — ${s.clientCount} client(s), created ${date}${marker}`;
                                        });
                                        html = `<b>Conversations (${sessions.length})</b><br>${lines.join("<br>")}`;
                                    }
                                    chatView.addNotificationMessage(
                                        {
                                            type: "html",
                                            content: html,
                                            kind: "info",
                                        },
                                        "conversation",
                                        undefined,
                                    );
                                    break;
                                }
                                case "info": {
                                    const cur =
                                        await api.conversationGetCurrent();
                                    const html = cur
                                        ? `Current conversation: <b>${escapeHtml(cur.name)}</b> (${escapeHtml(cur.conversationId)})`
                                        : "No active conversation.";
                                    chatView.addNotificationMessage(
                                        {
                                            type: "html",
                                            content: html,
                                            kind: "info",
                                        },
                                        "conversation",
                                        undefined,
                                    );
                                    break;
                                }
                                case "switch": {
                                    if (!payload.name) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content:
                                                    "A conversation name is required to switch.",
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                        break;
                                    }
                                    const sessions =
                                        await api.conversationList();
                                    const match = sessions.find(
                                        (s) =>
                                            s.name.toLowerCase() ===
                                            payload.name!.toLowerCase(),
                                    );
                                    if (!match) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content: `No conversation named "<b>${escapeHtml(payload.name)}</b>" found.`,
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                        break;
                                    }
                                    const result = await chatView.withBusy(
                                        "Switching conversation…",
                                        () =>
                                            api.conversationSwitch(
                                                match.conversationId,
                                            ),
                                    );
                                    if (!result.success) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content: `❌ ${escapeHtml(result.error ?? "Failed to switch conversation")}`,
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                    } else {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content: `✅ Switched to conversation "<b>${escapeHtml(match.name)}</b>"`,
                                                kind: "info",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                    }
                                    break;
                                }
                                case "prev":
                                case "next": {
                                    const sessions =
                                        await api.conversationList();
                                    if (sessions.length === 0) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content:
                                                    "No conversations to switch to.",
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                        break;
                                    }
                                    const cur =
                                        await api.conversationGetCurrent();
                                    const curIdx = cur
                                        ? sessions.findIndex(
                                              (s) =>
                                                  s.conversationId ===
                                                  cur.conversationId,
                                          )
                                        : -1;
                                    const delta =
                                        payload.subcommand === "next" ? 1 : -1;
                                    const nextIdx =
                                        curIdx === -1
                                            ? 0
                                            : (curIdx +
                                                  delta +
                                                  sessions.length) %
                                              sessions.length;
                                    const target = sessions[nextIdx];
                                    if (
                                        target.conversationId ===
                                        cur?.conversationId
                                    ) {
                                        // Only one conversation — surface a
                                        // warning so users in self-hosted
                                        // mode (which has a single default
                                        // conversation) don't think the
                                        // command silently failed.
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content:
                                                    "Only one conversation is available — nothing to switch to. Connect to the Agent Server to use multiple conversations.",
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                        break;
                                    }
                                    const result = await chatView.withBusy(
                                        "Switching conversation…",
                                        () =>
                                            api.conversationSwitch(
                                                target.conversationId,
                                            ),
                                    );
                                    if (!result.success) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content: `❌ ${escapeHtml(result.error ?? "Failed to switch conversation")}`,
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                    } else {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content: `✅ Switched to conversation "<b>${escapeHtml(target.name)}</b>"`,
                                                kind: "info",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                    }
                                    break;
                                }
                                case "rename": {
                                    if (!payload.newName) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content:
                                                    "A new name is required to rename the conversation.",
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                        break;
                                    }
                                    let conversationId: string;
                                    if (payload.name) {
                                        const sessions =
                                            await api.conversationList();
                                        const match = sessions.find(
                                            (s) =>
                                                s.name.toLowerCase() ===
                                                payload.name!.toLowerCase(),
                                        );
                                        if (!match) {
                                            chatView.addNotificationMessage(
                                                {
                                                    type: "html",
                                                    content: `No conversation named "<b>${escapeHtml(payload.name)}</b>" found.`,
                                                    kind: "warning",
                                                },
                                                "conversation",
                                                undefined,
                                            );
                                            break;
                                        }
                                        conversationId = match.conversationId;
                                    } else {
                                        const cur =
                                            await api.conversationGetCurrent();
                                        if (!cur) {
                                            chatView.addNotificationMessage(
                                                {
                                                    type: "html",
                                                    content:
                                                        "No active conversation to rename.",
                                                    kind: "warning",
                                                },
                                                "conversation",
                                                undefined,
                                            );
                                            break;
                                        }
                                        conversationId = cur.conversationId;
                                    }
                                    await api.conversationRename(
                                        conversationId,
                                        payload.newName,
                                    );
                                    chatView.addNotificationMessage(
                                        {
                                            type: "html",
                                            content: `✅ Renamed conversation to "<b>${escapeHtml(payload.newName)}</b>"`,
                                            kind: "info",
                                        },
                                        "conversation",
                                        undefined,
                                    );
                                    break;
                                }
                                case "delete": {
                                    if (!payload.name) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content:
                                                    "A conversation name is required to delete.",
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                        break;
                                    }
                                    const sessions =
                                        await api.conversationList();
                                    const match = sessions.find(
                                        (s) =>
                                            s.name.toLowerCase() ===
                                            payload.name!.toLowerCase(),
                                    );
                                    if (!match) {
                                        chatView.addNotificationMessage(
                                            {
                                                type: "html",
                                                content: `❌ Conversation "<b>${escapeHtml(payload.name)}</b>" not found.`,
                                                kind: "warning",
                                            },
                                            "conversation",
                                            undefined,
                                        );
                                        break;
                                    }
                                    await api.conversationDelete(
                                        match.conversationId,
                                    );
                                    chatView.addNotificationMessage(
                                        {
                                            type: "html",
                                            content: `🗑️ Deleted conversation "<b>${escapeHtml(match.name)}</b>"`,
                                            kind: "info",
                                        },
                                        "conversation",
                                        undefined,
                                    );
                                    break;
                                }
                                default: {
                                    chatView.addNotificationMessage(
                                        {
                                            type: "html",
                                            content: `Unknown manage-conversation subcommand: "<b>${escapeHtml(payload.subcommand)}</b>"`,
                                            kind: "warning",
                                        },
                                        "conversation",
                                        undefined,
                                    );
                                    break;
                                }
                            }
                        })().catch((e) =>
                            chatView.addNotificationMessage(
                                {
                                    type: "html",
                                    content: `❌ ${escapeHtml(e?.message ?? String(e))}`,
                                    kind: "warning",
                                },
                                "conversation",
                                undefined,
                            ),
                        );
                        break;
                    }
                }
            } catch (e) {
                console.log(e);
            }
        },
        onUserFeedback: (entry) => {
            chatView.applyFeedback(entry);
        },
        onUserHide: (entry) => {
            chatView.applyHide(entry);
        },
        // Server-side queue push events.
        requestQueued: (entry, version) => {
            chatView.onRequestQueued(entry, version);
        },
        requestStarted: (entry, version) => {
            chatView.onRequestStarted(entry, version);
        },
        requestCancelled: (requestId, reason, version) => {
            chatView.onRequestCancelled(requestId, reason, version);
        },
        queueStateChanged: (snapshot) => {
            chatView.onQueueStateChanged(snapshot);
        },
    };

    const client: Client = {
        clientIO,
        async dispatcherInitialized(
            d: Dispatcher,
            initialQueueSnapshot?: QueueSnapshot,
        ): Promise<void> {
            chatView.initializeDispatcher(d, initialQueueSnapshot);
            await chatHistoryReady;

            // Signal that the dispatcher is fully initialised.
            // Tests wait for this attribute before sending the first request.
            chatView
                .getScrollContainer()
                .setAttribute("data-dispatcher-ready", "true");
        },
        updateRegisterAgents(updatedAgents: [string, string][]): void {
            agents.clear();
            for (const [key, value] of updatedAgents) {
                agents.set(key, value);
            }
        },
        async showInputText(message: string): Promise<void> {
            return chatView.showInputText(message);
        },
        showDialog(key: string): void {
            if (key.toLocaleLowerCase() == "settings") {
                tabsView.showTab(key);
            }

            tabsView.showTab(key);
        },
        updateSettings(settings: ShellUserSettings): void {
            settingsView.shellSettings = settings;
        },
        fileSelected(fileName: string, fileContent: string): void {
            chatView.chatInput?.loadImageContent(fileName, fileContent);
        },
        listen(token: SpeechToken | undefined, useLocalWhisper: boolean): void {
            if (token !== undefined) {
                setSpeechToken(token);
            }

            chatView.chatInput?.recognizeOnce(token, useLocalWhisper);
        },
        toggleAlwaysListen(waitforWakeWord: boolean): void {
            chatView.chatInput?.toggleContinuous(waitforWakeWord);
        },
        focusInput(): void {
            chatView.chatInput?.focus();
        },
        searchMenuCompletion(id: number, item: SearchMenuItem) {
            remoteSearchMenuUIOnCompletion(id, item);
        },
        searchMenuSelectionChanged(id: number, selected: number) {
            remoteSearchMenuUIOnSelectionChanged(id, selected);
        },
        titleUpdated(title: string): void {
            document.title = title;
        },
        continuousSpeechProcessed(expressions: UserExpression[]): void {
            console.log(
                `Continuous speech processed: ${JSON.stringify(expressions)}`,
            );

            for (const expression of expressions) {
                if (expression.complete_statement) {
                    // if (
                    //     expression.type === "question" ||
                    //     expression.type === "command"
                    // ) {
                    chatView.addUserMessage(JSON.stringify(expression.text));
                    //}
                }
            }
        },
        tabRestoreStatus(count: number): void {
            // Ensure shell has an icon in the agents map
            if (!agents.has("shell")) {
                agents.set("shell", "\uD83D\uDC1A");
            }
            if (count > 0) {
                chatView.addNotificationMessage(
                    `Restoring ${count} browser tab${count > 1 ? "s" : ""}...`,
                    "shell",
                    "tab-restore",
                );
            } else {
                chatView.addNotificationMessage(
                    `Browser tabs restored.`,
                    "shell",
                    "tab-restore",
                );
            }
        },
        systemNotification(
            message: string,
            id: string,
            _timestamp: number,
        ): void {
            // Ensure shell has an icon in the agents map
            if (!agents.has("shell")) {
                agents.set("shell", "\uD83D\uDC1A");
            }
            chatView.addNotificationMessage(message, "shell", id);
        },
        conversationChanged(
            _conversationId: string,
            _name: string,
            queueSnapshot?: QueueSnapshot,
        ): void {
            // Re-bootstrap the queue snapshot for the new conversation's version stream.
            chatView.applyQueueSnapshot(queueSnapshot);
        },
        markHistoryEntries(): void {
            for (const child of chatView.getScrollContainer().children) {
                child.classList.add("history");
            }
        },
        demoStateChanged(state: "running" | "paused" | "idle"): void {
            chatView.setDemoState(state);
        },
        reconnectStatusChanged(message: string | undefined): void {
            chatView.setReconnectStatus(message);
        },
    };

    getClientAPI().registerClient(client);

    // Expose the clientIO object on window for integration tests so that tests
    // can trigger requestInteraction / interactionResolved / interactionCancelled
    // without requiring a live agent-server connection.
    if (window.__clientIO__ !== undefined) {
        console.warn(
            "[registerClient] window.__clientIO__ is already set — registerClient() called more than once. The previous activeInteractions map will be orphaned.",
        );
    }
    window.__clientIO__ = clientIO;
}

function showNotifications(
    requestId: RequestId,
    chatView: ChatView,
    messages: Array<any>,
    showRead: boolean = false,
) {
    const status: string = showRead ? "all" : "the new";
    let html: string = `Here are ${status} notifications:<br/> <ul>`;

    for (let i = 0; i < messages.length; i++) {
        if (showRead || !messages[i].read) {
            html += `<li class="notification-${messages[i].event}">${messages[i].event} ${messages[i].data.toString()}</li>`;

            messages[i].read = true;
        }
    }

    html += "</ul><br/>";

    chatView.addAgentMessage(
        {
            message: { type: "html", content: html },
            source: "shell.showNotifications",
            requestId,
        },
        { notification: true },
    );
}

function summarizeNotifications(
    requestId: RequestId,
    chatView: ChatView,
    messages: Array<any>,
) {
    const msgMap: Map<AppAgentEvent, number> = new Map<AppAgentEvent, number>();

    let read: number = 0;

    for (let i = 0; i < messages.length; i++) {
        if (!msgMap.has(messages[i].event)) {
            msgMap.set(messages[i].event, 0);
        }

        msgMap.set(messages[i].event, msgMap.get(messages[i].event)! + 1);

        if (messages[i].read) {
            read++;
        }
    }

    let summary = `There are <b>${messages.length - read}</b> unread and <b>${read}</b> notifications in total.<br/><br/>
    <div style="display: flex;justify-content: space-evenly">`;
    for (const [key, value] of msgMap) {
        summary += `<span class="notification-${key}">${key}:</span> <b>${value}</b>`;
    }
    summary += `</div><br/><span style="font-size: 10px">Run @notify show [all | unread] so see notifications.</span>`;

    chatView.addAgentMessage({
        message: {
            type: "html",
            content: summary,
        },
        requestId,
        source: "shell.notificationSummary",
    });
}

const notifications = new Array();

// Tracks the highest display log seq seen by this client.
// Set from saved snapshot and updated as live entries arrive.
let maxSeqSeen: number = -1;

// IdGenerator produces clientRequestIds for commands originated from
// this shell renderer. The prefix is a per-launch random suffix so ids
// are globally unique across shell launches: a fresh launch otherwise
// resets the counter to 0 and collides with prior-session ids that
// linger in the agent-server's DisplayLog (and in any peer client's
// userMessageById / agentContainersByRequestId maps), which can cause
// silently-dropped mirror bubbles in connected peers.
export class IdGenerator {
    private count = 0;
    private readonly prefix: string;
    constructor() {
        this.prefix =
            typeof crypto !== "undefined" && crypto.randomUUID
                ? crypto.randomUUID().slice(0, 8)
                : Math.random().toString(36).slice(2, 10);
    }
    public genId() {
        return `cmd-${this.prefix}-${this.count++}`;
    }
}

document.addEventListener("DOMContentLoaded", async function () {
    const inputOnly =
        new URLSearchParams(window.location.search).get("inputOnly") === "true";
    const wrapper = document.getElementById("wrapper")!;
    const idGenerator = new IdGenerator();
    const agents = new Map<string, string>();

    const tabs = new TabView(
        ["Settings", "Metrics", "Help"],
        [iconSettings(), iconMetrics(), iconHelp()],
        [iconSettings(), iconMetrics(), iconHelp()],
    );

    const chatView = new ChatView(idGenerator, agents, inputOnly);
    const chatInput = new ChatInput({}, "phraseDiv");

    chatView.setChatInput(chatInput);

    const chatHistoryReady = initializeChatHistory(chatView);

    const cameraView = new CameraView((image: HTMLImageElement) => {
        // copy image
        const newImage: HTMLImageElement = document.createElement("img");
        newImage.src = image.src;

        newImage.classList.add("chat-input-dropImage");
        chatView.chatInput?.textarea.getTextEntry().append(newImage);

        if (chatView.chatInput?.sendButton !== undefined) {
            chatView.chatInput.sendButton.disabled =
                chatView.chatInput.textarea.getTextEntry().innerHTML.length ==
                0;
        }
    });

    wrapper.appendChild(cameraView.getContainer());
    wrapper.appendChild(chatView.getMessageElm());
    // Settings / Metrics / Help dialogs live in TabView; they overlay the
    // chat (z-index 1000) when shown via @shell show settings. The tabs
    // container itself was never appended to the wrapper, so `showTab`
    // was a no-op visually.
    wrapper.appendChild(tabs.getContainer());

    chatView.chatInput!.camButton.onclick = () => {
        cameraView.toggleVisibility();
    };

    chatView.chatInput!.attachButton.onclick = () => {
        getClientAPI().openImageFile();
    };

    const settingsView = new SettingsView(chatView);
    chatView.settingsView = settingsView;
    tabs.getTabContainerByName("Settings").append(settingsView.getContainer());
    tabs.getTabContainerByName("Metrics").append(
        new MetricsView().getContainer(),
    );
    tabs.getTabContainerByName("Help").append(new HelpView().getContainer());

    registerClient(
        chatView,
        agents,
        settingsView,
        tabs,
        cameraView,
        chatHistoryReady,
    );

    try {
        if (Android !== undefined) {
            Bridge.interfaces.Android.domReady((userMessage: string) => {
                chatView.addUserMessage(userMessage);
            });
        }
    } catch (e) {
        console.log(e);
    }

    // get the users's name to show in the chat view
    let token: SpeechToken | undefined = await getSpeechToken();
    const actualToken = token?.token.substring(token?.token.indexOf("#"));
    if (actualToken) {
        const decoded = jose.decodeJwt(actualToken);

        if (decoded.given_name) {
            chatView.userGivenName = decoded.given_name
                .toString()
                .toLocaleLowerCase();
        }
    }

    watchForDOMChanges(chatView.getScrollContainer());
});

function watchForDOMChanges(element: HTMLDivElement) {
    // timeout
    let lastModifiedTime: number = 0;
    let hasTimeout = false;
    const scheduleSaveChatHistory = () => {
        if (hasTimeout) {
            // Already scheduled.
            return;
        }
        hasTimeout = true;
        setTimeout(() => {
            hasTimeout = false;
            const idleTime = Date.now() - lastModifiedTime;
            if (idleTime >= 250) {
                getClientAPI().saveChatHistory(element.innerHTML, maxSeqSeen);
            } else {
                // not idle long enough, reschedule
                scheduleSaveChatHistory();
            }
        });
    };
    // observer
    const observer = new MutationObserver(() => {
        // Update the last modified time
        lastModifiedTime = Date.now();

        // schedule to save chat history
        scheduleSaveChatHistory();
    });

    // ignore attribute changes but watch for
    const config = { attributes: false, childList: true, subtree: true };
    // start observing
    observer.observe(element, config);

    // observer.disconnect();
}

function getDateDifferenceDescription(date1: Date, date2: Date): string {
    const diff = Math.abs(date1.getTime() - date2.getTime());
    const diffMinutes = Math.floor(diff / (1000 * 60));
    const diffHours = Math.floor(diff / (1000 * 60 * 60));
    const diffDays = Math.floor(diff / (1000 * 60 * 60 * 24));
    const diffWeeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
    const diffMonths = Math.floor(diff / (1000 * 60 * 60 * 24 * 30));
    const diffYears = Math.floor(diff / (1000 * 60 * 60 * 24 * 365));

    if (diffMinutes < 1) {
        return "just now";
    } else if (diffMinutes < 15) {
        return "a few minutes ago";
    } else if (diffMinutes < 60) {
        return "under an hour ago";
    } else if (diffHours < 2) {
        return "an hour ago";
    } else if (diffDays < 1) {
        return "earlier today";
    } else if (diffDays < 2) {
        return "yesterday";
    } else if (diffDays < 7) {
        return date1.toLocaleDateString("en-US", { weekday: "long" });
    } else if (diffWeeks < 2) {
        return "last week";
    } else if (diffMonths < 2) {
        return "last month";
    } else if (diffYears < 2) {
        return "last year";
    } else {
        return date1.toLocaleDateString("en-US", { weekday: "long" });
    }
}
