const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const ICONS_URL = '/static/icons.svg';
const MODELS_ENDPOINT = '/v1/models';
const MODEL_KEY = 'odidere_model';
const STORAGE_KEY = 'odidere_history';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const VOICE_KEY = 'odidere_voice';

/**
 * VOICE_MAP maps whisper language names to default Kokoro voices.
 * Used for auto-selecting a voice when the selector is set to "Auto".
 */
const VOICE_MAP = {
  chinese: 'zf_xiaobei',
  english: 'af_heart',
  french: 'ff_siwis',
  hindi: 'hf_alpha',
  italian: 'if_sara',
  japanese: 'jf_alpha',
  korean: 'kf_sarah',
  portuguese: 'pf_dora',
  spanish: 'ef_dora',
};
/**
 * Odidere is the main application class for the voice assistant UI.
 * It manages audio recording, chat history, and communication with the API.
 * Creates a new instance bound to the given document.
 * @param {Object} options
 * @param {Document} options.document
 */
class Odidere {
  constructor({ document }) {
    this.document = document;

    // State
    this.attachments = [];
    this.audioChunks = [];
    this.currentAudio = null;
    this.currentAudioUrl = null;
    this.currentController = null;
    this.isProcessing = false;
    this.isRecording = false;
    this.isTranscribing = false;
    this.isMuted = false;
    this.isResetPending = false;
    this.isScrollPending = false;
    this.isStopPending = false;
    this.mediaRecorder = null;
    this.wakeLock = null;
    this.pendingDeleteId = null;
    // Conversation state
    this.messagesMap = new Map();
    this.leafId = null;
    this.rootId = null;

    // TTS state
    this.ttsUrl = document.querySelector('meta[name="tts-url"]')?.content || '';
    this.ttsDefaultVoice =
      document.querySelector('meta[name="tts-default-voice"]')?.content || '';
    this.playQueue = [];
    this.isPlaying = false;
    this.currentMessageId = null;

    // STT state
    this.sttUrl = document.querySelector('meta[name="stt-url"]')?.content || '';

    // Auto-scroll: enabled by default, disabled when user scrolls up,
    // re-enabled when they scroll back to bottom or send a new message.
    this.isAutoScrollEnabled = true;
    this._lastScrollTop = 0;

    // DOM Elements
    this.$attach = document.getElementById('attach');
    this.$attachments = document.getElementById('attachments');
    this.$chat = document.getElementById('chat');
    this.$chatLoading = document.getElementById('chat-loading');
    this.$fileInput = document.getElementById('file-input');
    this.$model = document.getElementById('model');
    this.$ptt = document.getElementById('ptt');
    this.$reset = document.getElementById('reset');
    this.$scroll = document.getElementById('scroll');
    this.$send = document.getElementById('send');
    this.$textInput = document.getElementById('text-input');
    this.$voice = document.getElementById('voice');
    this.$mute = document.getElementById('mute');
    this.$transcribe = document.getElementById('transcribe');

    // Settings modal
    this.$settings = document.getElementById('settings');
    this.$settingsOverlay = document.getElementById('settings-overlay');
    this.$settingsClose = document.getElementById('settings-close');
    this.$settingsTabs = document.querySelectorAll('.settings-tab');
    this.$settingsPanels = document.querySelectorAll('.settings-tab-panel');
    this.$systemMessageInput = document.getElementById('system-message-input');
    this.$saveSystemMessage = document.getElementById('save-system-message');

    // Templates
    this.$tplAssistantMessage = document.getElementById(
      'tpl-assistant-message',
    );
    this.$tplAttachmentChip = document.getElementById('tpl-attachment-chip');
    this.$tplCollapsible = document.getElementById('tpl-collapsible');
    this.$tplDebugRow = document.getElementById('tpl-debug-row');
    this.$tplErrorMessage = document.getElementById('tpl-error-message');
    this.$tplToolCall = document.getElementById('tpl-tool-call');
    this.$tplUserMessage = document.getElementById('tpl-user-message');

    this.#init();
  }

  // ====================
  // PUBLIC API
  // ====================
  /**
   * destroy releases all resources including media streams and audio.
   */
  destroy() {
    for (const track of this.mediaRecorder?.stream?.getTracks() ?? []) {
      track.stop();
    }
    this.#stopCurrentAudio();
    this.#stopTTS();
    this.currentController?.abort();
    this.#releaseWakeLock();
  }

  /**
   * reset clears all state including history, attachments, and pending
   * requests.
   */
  reset() {
    this.#stopCurrentAudio();
    this.#stopTTS();

    if (this.currentController) {
      this.currentController.abort();
      this.currentController = null;
    }

    this.#setLoadingState(false);
    this.pendingDeleteId = null;
    this.messagesMap.clear();
    this.leafId = null;
    this.rootId = null;
    this.isAutoScrollEnabled = true;
    this._lastScrollTop = 0;
    localStorage.removeItem(STORAGE_KEY);
    this.$chat.innerHTML = '';
    this.#clearAttachments();
    this.$textInput.value = '';
    this.$textInput.style.height = 'auto';
    this.#updateSendButtonState();
  }

  // ====================
  // INITIALIZATION
  // ====================
  /**
   * #init initializes the application.
   */
  #init() {
    this.#loadHistory();
    this.#bindEvents();
    this.#fetchVoices();
    this.#fetchModels();
    this.#initWakeLock();
  }

  /**
   * #bindEvents attaches all event listeners to DOM elements.
   */
  #bindEvents() {
    // PTT button: touch
    this.$ptt.addEventListener('touchstart', this.#handlePttTouchStart, {
      passive: false,
    });
    this.$ptt.addEventListener('touchend', this.#handlePttTouchEnd);
    this.$ptt.addEventListener('touchcancel', this.#handlePttTouchEnd);
    // Prevent context menu on long press.
    this.$ptt.addEventListener('contextmenu', (e) => e.preventDefault());

    // PTT button: mouse
    this.$ptt.addEventListener('mousedown', this.#handlePttMouseDown);
    this.$ptt.addEventListener('mouseup', this.#handlePttMouseUp);
    this.$ptt.addEventListener('mouseleave', this.#handlePttMouseUp);

    // Keyboard: spacebar PTT (outside inputs)
    this.document.addEventListener('keydown', this.#handleKeyDown);
    this.document.addEventListener('keyup', this.#handleKeyUp);

    // Textarea: Enter to send, Shift+Enter for newline
    this.$textInput.addEventListener('keydown', this.#handleTextareaKeyDown);
    this.$textInput.addEventListener('input', this.#handleTextareaInput);

    // Send button: when loading, acts as stop button with two-press safety
    this.$send.addEventListener('click', () => this.#handleSendClick());
    this.document.addEventListener('click', (e) => this.#handleStopCancel(e));

    // Reset button: two-press safety
    this.$reset.addEventListener('click', () => this.#handleResetClick());
    this.document.addEventListener('click', (e) => this.#handleResetCancel(e));

    // Scroll button: two-press safety, swaps between scroll-to-bottom and
    // scroll-to-top.
    this.$scroll.addEventListener('click', () => this.#handleScrollClick());
    this.document.addEventListener('click', (e) => this.#handleScrollCancel(e));
    this.document.addEventListener('click', (e) => this.#handleDeleteCancel(e));

    // File attachment.
    this.$attach.addEventListener('click', () => this.$fileInput.click());
    this.$fileInput.addEventListener('change', (e) =>
      this.#handleAttachments(e.target.files),
    );

    // Transcribe button: hold to record, release to transcribe and populate textarea.
    this.$transcribe.addEventListener(
      'touchstart',
      this.#handleTranscribeTouchStart,
      {
        passive: false,
      },
    );
    this.$transcribe.addEventListener(
      'touchend',
      this.#handleTranscribeTouchEnd,
    );
    this.$transcribe.addEventListener(
      'touchcancel',
      this.#handleTranscribeTouchEnd,
    );
    // Prevent context menu on long press.
    this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault());

    this.$transcribe.addEventListener(
      'mousedown',
      this.#handleTranscribeMouseDown,
    );
    this.$transcribe.addEventListener('mouseup', this.#handleTranscribeMouseUp);
    this.$transcribe.addEventListener(
      'mouseleave',
      this.#handleTranscribeMouseUp,
    );

    // Save selections on change.
    this.$model.addEventListener('change', () => {
      localStorage.setItem(MODEL_KEY, this.$model.value);
    });
    this.$voice.addEventListener('change', () => {
      localStorage.setItem(VOICE_KEY, this.$voice.value);
    });
    // Mute button
    this.$mute.addEventListener('click', () => this.#toggleMute());

    // Scroll button: update icon as user scrolls
    this.$chat.addEventListener(
      'scroll',
      () => {
        this.#updateScrollIcon();
        this.#handleChatScroll();
      },
      { passive: true },
    );
    this.#updateScrollIcon();

    // Settings modal
    this.$settings.addEventListener('click', () => this.openSettings());
    this.$settingsClose.addEventListener('click', () => this.closeSettings());
    this.$settingsOverlay.addEventListener('click', (e) => {
      if (e.target === this.$settingsOverlay) this.closeSettings();
    });
    this.document.addEventListener('keydown', (e) => {
      if (
        e.key === 'Escape' &&
        this.$settingsOverlay.classList.contains('open')
      ) {
        e.preventDefault();
        this.closeSettings();
      }
    });
    this.$settingsTabs.forEach(($tab) => {
      $tab.addEventListener('click', () => this.#switchTab($tab.dataset.tab));
    });
    this.$saveSystemMessage.addEventListener('click', () =>
      this.#saveSystemMessage(),
    );

    // Wake lock: reacquire on visibility change
    this.document.addEventListener('visibilitychange', () =>
      this.#handleVisibilityChange(),
    );
  }

  // ====================
  // CONVERSATION TREE
  // ====================

  /**
   * #loadHistory loads chat history from localStorage and renders it.
   */
  #loadHistory() {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      if (!stored) return;

      const parsed = JSON.parse(stored);

      // Ensure messages have parent/children fields (defensive).
      for (const msg of parsed.messages) {
        if (msg.parent === undefined) msg.parent = null;
        if (msg.children === undefined) msg.children = [];
      }

      this.messagesMap = new Map(parsed.messages.map((m) => [m.id, m]));
      this.leafId = parsed.leafId;
      this.rootId = parsed.rootId;

      // Render the active path.
      const activePath = this.getActivePath();
      this.#renderMessages(activePath);
      this.#updateSendButtonState();
    } catch (e) {
      console.error('failed to load history:', e);
      this.messagesMap.clear();
      this.leafId = null;
      this.rootId = null;
    }
  }

  /**
   * #saveToStorage persists the tree to localStorage.
   */
  #saveToStorage() {
    const messages = Array.from(this.messagesMap.values());
    const data = {
      messages,
      leafId: this.leafId,
      rootId: this.rootId,
    };
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
    } catch (e) {
      console.error('failed to save history:', e);
    }
  }

  /**
   * getActivePath resolves the ordered array of messages from root to leaf
   * by walking parent pointers from leafId.
   * @returns {Object[]} ordered messages
   */
  getActivePath() {
    if (!this.leafId) return [];
    const path = [];
    let current = this.leafId;
    while (current) {
      const msg = this.messagesMap.get(current);
      if (!msg) break;
      path.push(msg);
      current = msg.parent;
    }
    path.reverse();
    return path;
  }

  /**
   * #appendHistory adds messages to the tree. Each new message is a child
   * of the previous one (or the current leaf for the first).
   * Only the last message receives the metadata.
   * Mutates the passed-in message objects in place (adds id, parent, children,
   * meta) and stores the same references in messagesMap. Callers should use
   * the returned messages for rendering to ensure the delete handler captures
   * the correct IDs.
   * @param {Object[]} messages
   * @param {Object} [meta]
   * @returns {Object[]} the same message objects, now enriched
   */
  #appendHistory(messages, meta = {}) {
    let prevId = this.leafId;
    for (let i = 0; i < messages.length; i++) {
      const msg = messages[i];

      // Generate ID if not present.
      if (!msg.id) {
        msg.id = crypto.randomUUID();
      }

      // Attach metadata only to the final message.
      if (i === messages.length - 1) {
        msg.meta = meta;
      }

      // Link to the tree.
      msg.parent = prevId;
      msg.children = [];

      if (prevId) {
        const parent = this.messagesMap.get(prevId);
        if (parent) {
          parent.children.push(msg.id);
        }
      } else {
        // First message in the conversation.
        this.rootId = msg.id;
      }

      this.messagesMap.set(msg.id, msg);
      this.leafId = msg.id;
      prevId = msg.id;
    }

    this.#saveToStorage();
    this.#updateSendButtonState();
    return messages;
  }

  /**
   * deleteMessage removes a message from the tree and reparents its
   * children to the grandparent. Also handles tool call / tool result
   * pairing. When deleting the root, the first child becomes the new root
   * and any remaining children are reparented under it.
   * @param {string} messageId
   */
  deleteMessage(messageId) {
    const msg = this.messagesMap.get(messageId);
    if (!msg) return;

    const parentId = msg.parent; // null if this is the root
    const parent = parentId ? this.messagesMap.get(parentId) : null;

    // Identify tool result messages that belong to this assistant message.
    const idsToDelete = new Set([messageId]);
    if (msg.role === 'assistant' && msg.tool_calls?.length > 0) {
      const toolCallIds = new Set(msg.tool_calls.map((tc) => tc.id));
      for (const [id, m] of this.messagesMap) {
        if (m.role === 'tool' && toolCallIds.has(m.tool_call_id)) {
          idsToDelete.add(id);
        }
      }
    }

    // Collect all children that need reparenting.
    const childrenToReparent = [];
    for (const deleteId of idsToDelete) {
      const delMsg = this.messagesMap.get(deleteId);
      if (delMsg?.children) {
        childrenToReparent.push(...delMsg.children);
      }
    }

    // Remove deleted messages from the map.
    for (const deleteId of idsToDelete) {
      this.messagesMap.delete(deleteId);
    }

    // Remove deleted IDs from parent's children.
    if (parent) {
      parent.children = parent.children.filter((c) => !idsToDelete.has(c));
    }

    // Reparent children of deleted messages to the grandparent.
    // When deleting the root, the first child becomes the new root and the
    // remaining children are reparented under it.
    if (parent) {
      for (const childId of childrenToReparent) {
        const child = this.messagesMap.get(childId);
        if (child) {
          child.parent = parentId;
          if (!parent.children.includes(childId)) {
            parent.children.push(childId);
          }
        }
      }
    } else if (childrenToReparent.length > 0) {
      // Root deletion: first child becomes the new root, rest become its children.
      const newRootId = childrenToReparent[0];
      const newRoot = this.messagesMap.get(newRootId);
      if (newRoot) {
        newRoot.parent = null;
        for (let i = 1; i < childrenToReparent.length; i++) {
          const childId = childrenToReparent[i];
          const child = this.messagesMap.get(childId);
          if (child) {
            child.parent = newRootId;
            if (!newRoot.children.includes(childId)) {
              newRoot.children.push(childId);
            }
          }
        }
      }
      this.rootId = newRootId;
    } else {
      this.rootId = null;
    }

    // Update leafId if it was deleted or was a descendant of a deleted message.
    if (idsToDelete.has(this.leafId)) {
      if (parentId) {
        this.#updateLeafId(parentId);
      } else if (childrenToReparent.length > 0) {
        this.#updateLeafId(childrenToReparent[0]);
      } else {
        this.leafId = null;
      }
    }

    // Remove the DOM element(s).
    for (const deleteId of idsToDelete) {
      const $el = this.$chat.querySelector(`[data-id="${deleteId}"]`);
      if ($el) $el.remove();
    }

    this.#saveToStorage();
    this.#updateSendButtonState();
  }

  /**
   * #updateLeafId walks the tree from a starting node to find the deepest
   * leaf by following the first child at each step. Used when the active
   * leaf is deleted and a new one must be chosen. With branching, this
   * picks the first branch; future work may track which branch was active.
   * @param {string} startId
   */
  #updateLeafId(startId) {
    let current = startId;
    while (current) {
      const msg = this.messagesMap.get(current);
      if (!msg?.children || msg.children.length === 0) break;
      // Follow the first child. With branching, this picks the first branch.
      current = msg.children[0];
    }
    this.leafId = current;
  }

  // ====================
  // EVENT HANDLERS
  // ====================
  /**
   * #handlePttTouchStart handles touch start on the PTT button.
   * @param {TouchEvent} event
   */
  #handlePttTouchStart = (event) => {
    event.preventDefault();
    event.stopPropagation();
    this.#startRecording();
  };

  /**
   * #handlePttTouchEnd handles touch end on the PTT button.
   * @param {TouchEvent} event
   */
  #handlePttTouchEnd = (event) => {
    event.stopPropagation();
    this.#stopRecording();
  };

  /**
   * #handlePttMouseDown handles mouse down on the PTT button.
   * Ignores events that originate from touch to avoid double-firing.
   * @param {MouseEvent} event
   */
  #handlePttMouseDown = (event) => {
    if (event.sourceCapabilities?.firesTouchEvents) return;
    event.preventDefault();
    event.stopPropagation();
    this.#startRecording();
  };

  /**
   * #handlePttMouseUp handles mouse up on the PTT button.
   * @param {MouseEvent} event
   */
  #handlePttMouseUp = (event) => {
    if (event.sourceCapabilities?.firesTouchEvents) return;
    event.stopPropagation();
    this.#stopRecording();
  };

  /**
   * #handleKeyDown handles keydown events for spacebar PTT.
   * Only triggers when focus is not in an input element.
   * @param {KeyboardEvent} event
   */
  #handleKeyDown = (event) => {
    if (event.code !== 'Space') return;
    if (event.repeat) return;

    const isInput =
      event.target.tagName === 'INPUT' ||
      event.target.tagName === 'TEXTAREA' ||
      event.target.tagName === 'SELECT';
    if (isInput) return;

    event.preventDefault();
    this.#startRecording();
  };

  /**
   * #handleKeyUp handles keyup events for spacebar PTT.
   * @param {KeyboardEvent} event
   */
  #handleKeyUp = (event) => {
    if (event.code !== 'Space') return;

    const isInput =
      event.target.tagName === 'INPUT' ||
      event.target.tagName === 'TEXTAREA' ||
      event.target.tagName === 'SELECT';
    if (isInput) return;

    event.preventDefault();
    this.#stopRecording();
  };

  /**
   * #handleTextareaKeyDown handles Enter key to submit text.
   * Shift+Enter inserts a newline instead.
   * @param {KeyboardEvent} event
   */
  #handleTextareaKeyDown = (event) => {
    if (event.code !== 'Enter') return;
    if (event.shiftKey) return;

    event.preventDefault();
    this.#submitText();
  };

  /**
   * #handleTextareaInput adjusts textarea height to fit content.
   */
  #handleTextareaInput = () => {
    const textarea = this.$textInput;
    textarea.style.height = 'auto';
    const computed = getComputedStyle(textarea);
    const maxHeight = parseFloat(computed.maxHeight);
    const newHeight = Math.min(textarea.scrollHeight, maxHeight);
    textarea.style.height = `${newHeight}px`;
    this.#updateSendButtonState();
  };

  /**
   * #handleAttachments adds files to the attachment list.
   * Duplicates are ignored based on name and size.
   * @param {FileList} files
   */
  #handleAttachments(files) {
    for (const file of files) {
      const isDuplicate = this.attachments.some(
        (a) =>
          a.name === file.name &&
          a.size === file.size &&
          a.lastModified === file.lastModified,
      );
      if (isDuplicate) continue;

      this.attachments.push(file);
    }
    this.#renderAttachments();
    this.#updateSendButtonState();
    this.$fileInput.value = '';
  }

  /**
   * #handleResetClick toggles the pending state on first press,
   * executes the reset on second press.
   */
  #handleResetClick() {
    if (this.isResetPending) {
      this.isResetPending = false;
      this.$reset.classList.remove('footer__toolbar-btn--pending');
      this.reset();
    } else {
      this.isResetPending = true;
      this.$reset.classList.add('footer__toolbar-btn--pending');
    }
  }

  /**
   * #handleResetCancel clears the pending reset state when the user
   * clicks anywhere outside the reset button.
   * @param {MouseEvent} event
   */
  #handleResetCancel(event) {
    if (!this.isResetPending) return;
    if (this.$reset.contains(event.target)) return;
    this.isResetPending = false;
    this.$reset.classList.remove('footer__toolbar-btn--pending');
  }

  /**
   * #handleScrollClick toggles the pending scroll state on first press,
   * executes the scroll on second press. At bottom → scroll to top,
   * elsewhere → scroll to bottom.
   */
  #handleScrollClick() {
    if (this.isScrollPending) {
      this.isScrollPending = false;
      this.$scroll.classList.remove('footer__toolbar-btn--pending');
      if (this.#isAtBottom()) {
        this.$chat.scrollTo({ top: 0, behavior: 'smooth' });
      } else {
        this.$chat.scrollTo({
          top: this.$chat.scrollHeight,
          behavior: 'smooth',
        });
      }
      this.#updateScrollIcon();
    } else {
      this.isScrollPending = true;
      this.$scroll.classList.add('footer__toolbar-btn--pending');
    }
  }

  /**
   * #handleScrollCancel clears the pending scroll state when the user
   * clicks anywhere outside the scroll button.
   * @param {MouseEvent} event
   */
  #handleScrollCancel(event) {
    if (!this.isScrollPending) return;
    if (this.$scroll.contains(event.target)) return;
    this.isScrollPending = false;
    this.$scroll.classList.remove('footer__toolbar-btn--pending');
  }

  /**
   * #handleChatScroll detects whether the user scrolled up or down and
   * toggles auto-scroll accordingly. Scrolling up disables it; scrolling
   * back to the bottom re-enables it.
   */
  #handleChatScroll() {
    const { scrollTop, scrollHeight, clientHeight } = this.$chat;
    const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
    const isAtBottom = distanceFromBottom < 10;

    if (scrollTop < this._lastScrollTop && !isAtBottom) {
      // User scrolled up and is not at the bottom → disable auto-scroll.
      this.isAutoScrollEnabled = false;
    } else if (isAtBottom) {
      // User scrolled back to the bottom → re-enable auto-scroll.
      this.isAutoScrollEnabled = true;
    }

    this._lastScrollTop = scrollTop;
  }

  /**
   * #isAtBottom checks if the chat is scrolled within 10px of the bottom.
   * @returns {boolean}
   */
  #isAtBottom() {
    const { scrollTop, scrollHeight, clientHeight } = this.$chat;
    return scrollHeight - clientHeight - scrollTop < 10;
  }

  /**
   * #updateScrollIcon toggles a CSS class to show the appropriate arrow
   * and updates the aria-label. No DOM nodes are created on scroll.
   */
  #updateScrollIcon() {
    const atBottom = this.#isAtBottom();
    this.$scroll.classList.toggle('scrolled-up', !atBottom);
    this.$scroll.setAttribute(
      'aria-label',
      atBottom ? 'Scroll to top' : 'Scroll to bottom',
    );
  }

  /**
   * #scrollToBottom scrolls the chat to the bottom if auto-scroll is
   * enabled (i.e., the user is following along and hasn't scrolled up).
   */
  #scrollToBottom() {
    if (this.isAutoScrollEnabled) {
      this.$chat.scrollTop = this.$chat.scrollHeight;
    }
  }

  /**
   * #handleSendClick routes to either send or stop based on loading state.
   */
  #handleSendClick() {
    if (this.$send.classList.contains('loading')) {
      this.#handleStopClick();
    } else {
      this.#submitText();
    }
  }

  /**
   * #handleStopClick toggles the pending stop state on first press,
   * aborts the current request on second press.
   */
  #handleStopClick() {
    if (this.isStopPending) {
      this.isStopPending = false;
      this.$send.classList.remove('pending');
      this.#stopConversation();
    } else {
      this.isStopPending = true;
      this.$send.classList.add('pending');
    }
  }

  /**
   * #handleStopCancel clears the pending stop state when the user
   * clicks anywhere outside the send button.
   * @param {MouseEvent} event
   */
  #handleStopCancel(event) {
    if (!this.isStopPending) return;
    if (this.$send.contains(event.target)) return;
    this.isStopPending = false;
    this.$send.classList.remove('pending');
  }

  /**
   * #handleDeleteClick toggles the pending delete state on first press
   * (yellow highlight), executes the delete on second press. Clicking
   * anywhere outside the button cancels the pending state.
   * @param {MouseEvent} event
   * @param {string} messageId
   */
  #handleDeleteClick(event, messageId) {
    event.stopPropagation();
    const $btn = event.currentTarget;

    if (this.pendingDeleteId === messageId) {
      this.pendingDeleteId = null;
      $btn.classList.remove('pending');
      this.deleteMessage(messageId);
    } else {
      // Clear any previously pending delete button.
      this.#clearPendingDelete();
      this.pendingDeleteId = messageId;
      $btn.classList.add('pending');
    }
  }

  /**
   * #clearPendingDelete clears the pending delete state and removes the
   * yellow highlight from the delete button.
   */
  #clearPendingDelete() {
    if (!this.pendingDeleteId) return;
    const $msg = this.$chat.querySelector(
      `[data-id="${this.pendingDeleteId}"]`,
    );
    if ($msg) {
      const $btn = $msg.querySelector('[data-action="delete"]');
      if ($btn) $btn.classList.remove('pending');
    }
    this.pendingDeleteId = null;
  }

  /**
   * #handleDeleteCancel clears the pending delete state when the user
   * clicks anywhere outside the delete button.
   * @param {MouseEvent} event
   */
  #handleDeleteCancel(event) {
    if (!this.pendingDeleteId) return;
    const $btn = event.target.closest('[data-action="delete"]');
    if ($btn) return;
    this.#clearPendingDelete();
  }

  /**
   * #stopConversation aborts the current streaming request and audio.
   */
  #stopConversation() {
    this.#stopCurrentAudio();
    this.#stopTTS();

    if (this.currentController) {
      this.currentController.abort();
      this.currentController = null;
    }

    this.#setLoadingState(false);
  }

  // ====================
  // STT: FRONTEND TRANSCRIPTION
  // ====================
  /**
   * #transcribeAudio sends an audio blob to whisper-server directly from
   * the browser and returns the transcription result.
   * @param {Blob} audioBlob
   * @returns {Promise<{text: string, detectedLanguage: string}>}
   */
  async #transcribeAudio(audioBlob) {
    if (!this.sttUrl) {
      throw new Error('STT URL not configured');
    }

    const formData = new FormData();
    formData.append('file', audioBlob, 'audio.webm');
    formData.append('response_format', 'verbose_json');

    const res = await fetch(`${this.sttUrl}/inference`, {
      method: 'POST',
      body: formData,
    });

    if (!res.ok) {
      const errText = await res.text().catch(() => '');
      throw new Error(`STT error ${res.status}: ${errText}`);
    }

    const data = await res.json();
    const text = (data.text || '').trim();
    const detectedLanguage = data.detected_language || data.language || '';
    return { text, detectedLanguage };
  }

  /**
   * #handleTranscribeTouchStart handles touch start on the transcribe button.
   * Begins recording for transcription (hold-to-record, like PTT).
   * @param {TouchEvent} event
   */
  #handleTranscribeTouchStart = (event) => {
    event.preventDefault();
    event.stopPropagation();
    this.#startTranscribeRecording();
  };

  /**
   * #handleTranscribeTouchEnd handles touch end on the transcribe button.
   * Stops recording; the shared stop handler will populate the textarea.
   * @param {TouchEvent} event
   */
  #handleTranscribeTouchEnd = (event) => {
    event.stopPropagation();
    this.#stopTranscribeRecording();
  };

  /**
   * #handleTranscribeMouseDown handles mouse down on the transcribe button.
   * Ignores events that originate from touch to avoid double-firing.
   * @param {MouseEvent} event
   */
  #handleTranscribeMouseDown = (event) => {
    if (event.sourceCapabilities?.firesTouchEvents) return;
    event.preventDefault();
    event.stopPropagation();
    this.#startTranscribeRecording();
  };

  /**
   * #handleTranscribeMouseUp handles mouse up on the transcribe button.
   * @param {MouseEvent} event
   */
  #handleTranscribeMouseUp = (event) => {
    if (event.sourceCapabilities?.firesTouchEvents) return;
    event.stopPropagation();
    this.#stopTranscribeRecording();
  };

  /**
   * #startTranscribeRecording begins recording for transcription mode.
   * Unlike PTT, this populates the textarea on stop rather than sending.
   */
  async #startTranscribeRecording() {
    if (this.isTranscribing) return;
    if (this.isRecording) return;
    if (this.isProcessing) return;

    this.#stopCurrentAudio();
    this.#stopTTS();

    // Initialize MediaRecorder on first use (shared with PTT).
    if (!this.mediaRecorder) {
      const success = await this.#initMediaRecorder();
      if (!success) return;
    }

    this.audioChunks = [];
    this.mediaRecorder.start();
    this.isTranscribing = true;
    this.$transcribe.classList.add('compose__action-btn--recording');
  }

  /**
   * #stopTranscribeRecording stops the media recorder for transcription mode.
   * The shared 'stop' event handler (set up in #initMediaRecorder) will
   * fire and clear UI state since this.isTranscribing is still true.
   */
  #stopTranscribeRecording() {
    if (!this.isTranscribing) return;
    if (!this.mediaRecorder) return;

    // Keep isTranscribing=true so the stop handler knows to populate textarea.
    // The stop handler is responsible for clearing the recording class,
    // so we do not remove it here.
    this.mediaRecorder.stop();
  }

  // ====================
  // AUDIO RECORDING
  // ====================
  /**
   * #startRecording begins audio recording if not already recording or
   * processing.
   * Initializes the MediaRecorder on first use.
   */
  async #startRecording() {
    if (this.isRecording) return;
    if (this.isTranscribing) return;
    if (this.isProcessing) return;

    this.#stopCurrentAudio();
    this.#stopTTS();

    // Initialize MediaRecorder on first use
    if (!this.mediaRecorder) {
      const success = await this.#initMediaRecorder();
      if (!success) return;
    }

    this.audioChunks = [];
    this.mediaRecorder.start();
    this.#setRecordingState(true);
  }

  /**
   * #stopRecording stops the current audio recording.
   */
  #stopRecording() {
    if (!this.isRecording) return;
    if (!this.mediaRecorder) return;

    // Do not clear recording state here. The async 'stop' event handler
    // is responsible for clearing it (#setRecordingState(false)) after
    // the audio blob has been assembled.
    this.mediaRecorder.stop();
  }

  /**
   * #initMediaRecorder initializes the MediaRecorder with microphone access.
   * Prefers opus codec for better compression if available.
   * The stop handler branches on this.isTranscribing: when true it populates
   * the textarea; when false (PTT flow) it sends the request.
   * @returns {Promise<boolean>} true if successful
   */
  async #initMediaRecorder() {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
        ? 'audio/webm;codecs=opus'
        : 'audio/webm';

      this.mediaRecorder = new MediaRecorder(stream, { mimeType });

      this.mediaRecorder.addEventListener('dataavailable', (event) => {
        if (event.data.size > 0) {
          this.audioChunks.push(event.data);
        }
      });

      this.mediaRecorder.addEventListener('stop', async () => {
        if (this.audioChunks.length === 0) return;

        // Capture the mode synchronously before any async work.
        // The 'stop' event fires asynchronously, so this.isTranscribing
        // may have changed by the time we await below.
        const wasTranscribing = this.isTranscribing;

        const audioBlob = new Blob(this.audioChunks, {
          type: this.mediaRecorder.mimeType,
        });
        this.audioChunks = [];

        // Clear the flag now that the stop event has fired.
        if (wasTranscribing) {
          this.isTranscribing = false;
          this.$transcribe.classList.remove('compose__action-btn--recording');
        } else {
          this.#setRecordingState(false);
        }

        // Transcribe the audio.
        try {
          const { text: transcription, detectedLanguage } =
            await this.#transcribeAudio(audioBlob);

          // Resolve the effective voice for this message.
          // If the selector is "Auto", pick a default for the detected language.
          // This does NOT change the dropdown - the voice is per-message only.
          let voice = this.$voice.value;
          if (!voice && detectedLanguage) {
            voice = VOICE_MAP[detectedLanguage] || '';
          }

          // Branch: transcribe-only (populate textarea) vs PTT (send).
          if (wasTranscribing) {
            // Transcribe mode: populate textarea, do not send.
            this.$transcribe.classList.add('compose__action-btn--transcribing');
            try {
              if (transcription) {
                const existing = this.$textInput.value;
                if (existing.trim()) {
                  this.$textInput.value = `${existing}\n${transcription}`;
                } else {
                  this.$textInput.value = transcription;
                }
                this.#handleTextareaInput();
              }
            } finally {
              this.$transcribe.classList.remove(
                'compose__action-btn--transcribing',
              );
              this.$textInput.focus();
            }
          } else {
            // PTT mode: capture text, clear input, send request.
            const text = this.$textInput.value.trim();
            this.$textInput.value = '';
            this.$textInput.style.height = 'auto';

            const combined = text ? `${text}\n${transcription}` : transcription;
            await this.#sendRequest({
              text: combined,
              detectedLanguage,
              voice,
            });
          }
        } catch (e) {
          console.error('transcription failed:', e);
          this.#renderError(`Transcription failed: ${e.message}`);
          if (!wasTranscribing) {
            this.#setLoadingState(false);
          }
        }
      });

      this.mediaRecorder.addEventListener('error', (event) => {
        console.error('MediaRecorder error:', event.error);
        if (this.isTranscribing) {
          this.isTranscribing = false;
          this.$transcribe.classList.remove('compose__action-btn--recording');
        } else {
          this.#setRecordingState(false);
        }
        this.audioChunks = [];
        this.#renderError(
          `Recording error: ${event.error?.message || 'Unknown error'}`,
        );
      });

      return true;
    } catch (e) {
      console.error('failed to initialize media recorder:', e);
      this.#renderError(`Microphone access denied: ${e.message}`);
      return false;
    }
  }

  // ====================
  // TTS PLAYBACK
  // ====================

  /**
   * #enqueueTTS adds a message to the TTS playback queue and starts
   * playback if not already playing.
   * @param {string} text
   * @param {string} voice
   * @param {string} messageId
   */
  #enqueueTTS(text, voice, messageId) {
    if (!text || !this.ttsUrl) return;
    this.playQueue.push({ text, voice, messageId });
    if (!this.isPlaying) {
      this.#processQueue();
    }
  }

  /**
   * #processQueue pops the next item from the TTS queue and synthesizes it.
   */
  async #processQueue() {
    if (this.playQueue.length === 0) {
      this.isPlaying = false;
      this.currentMessageId = null;
      return;
    }

    this.isPlaying = true;
    const item = this.playQueue.shift();
    this.currentMessageId = item.messageId;

    // Update the play button on the message to show stop state.
    this.#setPlayButtonState(item.messageId, true);

    await this.#synthesizeAndPlay(item.text, item.voice, item.messageId);

    // Reset this message's button back to play state before moving on.
    this.#setPlayButtonState(item.messageId, false);

    // Move to next item in queue.
    this.#processQueue();
  }

  /**
   * #stopTTS stops current audio playback, clears the queue, and resets UI.
   */
  #stopTTS() {
    this.#stopCurrentAudio();
    this.playQueue = [];
    this.isPlaying = false;

    // Reset all play buttons.
    if (this.currentMessageId) {
      this.#setPlayButtonState(this.currentMessageId, false);
    }
    this.currentMessageId = null;
  }

  /**
   * #synthesizeAndPlay calls kokoro-fastapi to synthesize text and plays
   * the resulting audio. On error, logs and returns without blocking the queue.
   * @param {string} text
   * @param {string} voice
   * @param {string} _messageId
   */
  async #synthesizeAndPlay(text, voice, _messageId) {
    try {
      this.#stopCurrentAudio();

      const res = await fetch(`${this.ttsUrl}/v1/audio/speech`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model: 'tts-1',
          input: text,
          voice: voice || this.$voice.value || this.ttsDefaultVoice,
          response_format: 'wav',
        }),
      });

      if (!res.ok) {
        throw new Error(`TTS error ${res.status}`);
      }

      const audioBlob = await res.blob();
      const audioUrl = URL.createObjectURL(audioBlob);

      this.currentAudioUrl = audioUrl;
      this.currentAudio = new Audio(audioUrl);
      this.currentAudio.muted = !!this.isMuted;

      await new Promise((resolve, reject) => {
        this.currentAudio.addEventListener(
          'ended',
          () => {
            this.#cleanupAudio();
            resolve();
          },
          { once: true },
        );
        this.currentAudio.addEventListener(
          'error',
          (e) => {
            this.#cleanupAudio();
            reject(e);
          },
          { once: true },
        );
        this.currentAudio.play().catch(reject);
      });
    } catch (e) {
      console.error('TTS synthesis failed:', e);
      this.#cleanupAudio();
    }
  }

  /**
   * #setPlayButtonState updates the play/stop button icon and state for a
   * given message.
   * @param {string} messageId
   * @param {boolean} playing
   */
  #setPlayButtonState(messageId, playing) {
    const $msg = this.$chat.querySelector(`[data-id="${messageId}"]`);
    if (!$msg) return;
    const $btn = $msg.querySelector('[data-action="play"]');
    if (!$btn) return;

    if (playing) {
      $btn.classList.add('playing');
      $btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#stop"></use></svg>`;
      $btn.setAttribute('aria-label', 'Stop');
    } else {
      $btn.classList.remove('playing');
      $btn.innerHTML = `<svg class="icon"><use href="${ICONS_URL}#volume"></use></svg>`;
      $btn.setAttribute('aria-label', 'Play');
    }
  }

  /**
   * #handlePlayClick handles the play/stop button on an assistant message.
   * If not playing: stop any current audio, enqueue this message, start playing.
   * If currently playing this message: stop playback, clear remaining queue, reset.
   * @param {MouseEvent} event
   * @param {string} messageId
   * @param {string} text
   * @param {string} voice
   */
  #handlePlayClick(event, messageId, text, voice) {
    event.stopPropagation();

    if (this.currentMessageId === messageId) {
      // Currently playing this message: stop everything.
      this.#stopTTS();
    } else {
      // Not playing this message: stop current playback, enqueue this one.
      this.#stopTTS();
      this.#enqueueTTS(text, voice, messageId);
    }
  }

  // ====================
  // AUDIO PLAYBACK
  // ====================
  /**
   * #stopCurrentAudio stops and cleans up any playing audio.
   */
  #stopCurrentAudio() {
    if (this.currentAudio) {
      this.currentAudio.pause();
      this.currentAudio.currentTime = 0;
    }
    this.#cleanupAudio();
  }

  /**
   * #cleanupAudio revokes the object URL and clears audio references.
   */
  #cleanupAudio() {
    if (this.currentAudioUrl) {
      URL.revokeObjectURL(this.currentAudioUrl);
      this.currentAudioUrl = null;
    }
    this.currentAudio = null;
  }

  // ====================
  // API: FETCH
  // ====================
  /**
   * #fetchModels fetches available models from the API and populates selectors.
   */
  async #fetchModels() {
    try {
      const res = await fetch(MODELS_ENDPOINT);
      if (!res.ok) throw new Error(`${res.status}`);
      const data = await res.json();
      this.#populateModels(data.models, data.default_model);
    } catch (e) {
      console.error('failed to fetch models:', e);
      this.#populateModelsFallback();
    }
  }

  /**
   * #fetchVoices fetches available voices from the API and populates
   * selectors.
   */
  async #fetchVoices() {
    try {
      const res = await fetch(`${this.ttsUrl}/v1/audio/voices`);
      if (!res.ok) throw new Error(`${res.status}`);
      const data = await res.json();

      // Filter out legacy v0 voices.
      const filtered = data.voices.filter((v) => {
        const id = typeof v === 'string' ? v : v.id;
        return !id.includes('_v0');
      });
      const voiceIds = filtered.map((v) => (typeof v === 'string' ? v : v.id));
      this.#populateVoiceSelect(voiceIds);
    } catch (e) {
      console.error('failed to fetch voices:', e);
      this.$voice.innerHTML = '';

      const $opt = this.document.createElement('option');
      $opt.value = '';
      $opt.disabled = true;
      $opt.selected = true;
      $opt.textContent = 'Failed to load';
      this.$voice.appendChild($opt);
    }
  }

  // ====================
  // API: CHAT
  // ====================

  /**
   * #submitText submits the current text input and attachments.
   */
  async #submitText() {
    const text = this.$textInput.value.trim();
    if (this.isProcessing) return;

    this.#stopCurrentAudio();
    this.#stopTTS();
    this.$textInput.value = '';
    this.$textInput.style.height = 'auto';

    await this.#sendRequest({ text });
  }

  /**
   * #sendRequest sends a chat request to the streaming SSE endpoint.
   *
   * For text-only input, renders the user message immediately for
   * responsiveness.
   *
   * Messages are rendered incrementally as SSE events arrive:
   * - user, assistant (tool calls), tool (results),
   *   and a final assistant message with content.
   *
   * @param {Object} options
   * @param {string} [options.text] - Text input
   * @param {string} [options.detectedLanguage] - Detected language from STT
   * @param {string} [options.voice] - Resolved voice for this message (from auto-selection)
   */
  async #sendRequest({ text = '', detectedLanguage = '', voice }) {
    this.#setLoadingState(true);
    // Re-enable auto-scroll when the user sends a new message.
    this.isAutoScrollEnabled = true;
    this.currentController = new AbortController();

    try {
      const hasNewInput = text || this.attachments.length > 0;

      // Build messages array from active path + current user message.
      const activePath = this.getActivePath();
      let messages;
      let userMessage;

      if (hasNewInput) {
        userMessage = await this.#buildUserMessage(text, this.attachments);
        messages = [
          ...activePath.map(({ parent, children, id, meta, ...msg }) => msg),
          userMessage,
        ];
      } else {
        // Continue conversation: send existing history without a new user message.
        messages = activePath.map(
          ({ parent, children, id, meta, ...msg }) => msg,
        );
      }

      const payload = {
        messages,
        voice: voice ?? this.$voice.value,
        model: this.$model.value,
      };
      const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
      if (systemMessage) {
        payload.system_message = systemMessage;
      }

      // Clear attachments after building payload (before async operations).
      this.#clearAttachments();

      // For text-only requests with new input, add to history and render
      // immediately. Skip when continuing conversation (no new user message).
      if (hasNewInput) {
        const meta = {};
        if (detectedLanguage) meta.language = detectedLanguage;
        const appended = this.#appendHistory([userMessage], meta);
        this.#renderMessages(appended, meta);
      }

      const res = await fetch(STREAM_ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
        signal: this.currentController.signal,
      });
      if (!res.ok) {
        const err = await res.text();
        throw new Error(`server error ${res.status}: ${err}`);
      }
      // Parse SSE stream from response body.
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let sseBuffer = '';

      // Stash assistant messages with tool_calls until their results arrive.
      let pendingTools = null;
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

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

        // Process complete SSE events (separated by double newlines).
        const parts = sseBuffer.split('\n\n');
        sseBuffer = parts.pop();

        for (const part of parts) {
          if (!part.trim()) continue;

          // Extract data from SSE event lines.
          let data = '';
          for (const line of part.split('\n')) {
            if (line.startsWith('data: ')) {
              data += line.slice(6);
            }
          }
          if (!data) continue;

          let event;
          try {
            event = JSON.parse(data);
          } catch {
            console.error('failed to parse SSE data:', data);
            continue;
          }

          // Handle errors sent mid-stream.
          if (event.error) {
            this.#renderError(event.error);
            continue;
          }

          const message = event.message;

          // Stash assistant messages with tool calls; render once all
          // tool results have arrived so the output is visible in the UI.
          if (message.role === 'assistant' && message.tool_calls?.length > 0) {
            const appended = this.#appendHistory([message]);
            pendingTools = {
              assistant: appended[0],
              results: [],
              expected: message.tool_calls.length,
            };
            continue;
          }

          // Collect tool results and render once all have arrived.
          if (
            message.role === 'tool' &&
            pendingTools &&
            pendingTools.assistant
          ) {
            const appended = this.#appendHistory([message]);
            pendingTools.results.push(appended[0]);

            // Once all tool results are in, render the combined message.
            if (pendingTools.results.length >= pendingTools.expected) {
              this.#renderMessages([
                pendingTools.assistant,
                ...pendingTools.results,
              ]);
              pendingTools = null;
            }
            continue;
          }

          // Regular assistant message (final reply with content).
          // Skip non-assistant messages (tool results, etc.) that may have
          // fallen through above — they should not be rendered or spoken.
          if (message.role !== 'assistant') continue;

          const meta = {};
          if (event.voice) meta.voice = event.voice;

          const appended = this.#appendHistory([message], meta);
          this.#renderMessages(appended, meta);

          // Enqueue TTS for the final assistant message with content.
          if (message.content) {
            this.#enqueueTTS(
              message.content,
              event.voice || this.$voice.value || '',
              message.id,
            );
          }
        }
      }

      this.#setLoadingState(false);
    } catch (e) {
      if (e.name === 'AbortError') {
        console.debug('request aborted');
        return;
      }
      console.error('failed to send request:', e);
      this.#renderError(`Error: ${e.message}`);
      this.#setLoadingState(false);
    } finally {
      this.currentController = null;
    }
  }

  /**
   * #buildUserMessage builds a user message object for the API.
   * Text files are included as text, images as base64 data URLs.
   * Returns either a simple string content or multipart array format
   * depending on whether images are present.
   * @param {string} text
   * @param {File[]} attachments
   * @returns {Promise<{id: string, role: 'user', content: string | Array}>}
   */
  async #buildUserMessage(text, attachments) {
    const textParts = [];
    const imageParts = [];

    // Process attachments.
    // Images become data URLs, text files become inline text.
    for (const file of attachments) {
      if (file.type.startsWith('image/')) {
        const base64 = await this.#toBase64(file);
        const dataUrl = `data:${file.type};base64,${base64}`;
        imageParts.push({
          type: 'image_url',
          image_url: { url: dataUrl },
        });
      } else {
        const content = await file.text();
        textParts.push(`=== File: ${file.name} ===\n\n${content}`);
      }
    }

    // Add user's message text after files.
    if (text) {
      textParts.push(text);
    }

    const combinedText = textParts.join('\n\n');

    // If no images, return simple string content.
    if (imageParts.length === 0) {
      return {
        id: crypto.randomUUID(),
        role: 'user',
        content: combinedText || '',
      };
    }

    // If images present, use multipart array format.
    const parts = [];
    if (combinedText) {
      parts.push({ type: 'text', text: combinedText });
    }
    parts.push(...imageParts);

    return {
      id: crypto.randomUUID(),
      role: 'user',
      content: parts,
    };
  }

  // ====================
  // STATE
  // ====================
  /**
   * #updateSendButtonState enables the send button when there is text,
   * attachments, or at least one message in the conversation history,
   * and disables it otherwise.
   */
  #updateSendButtonState() {
    const hasText = this.$textInput.value.trim().length > 0;
    const hasAttachments = this.attachments.length > 0;
    const hasHistory = this.messagesMap.size > 0;
    const enabled = hasText || hasAttachments || hasHistory;
    this.$send.disabled = !enabled;
    this.$send.setAttribute(
      'aria-label',
      enabled ? 'Send message' : 'Start a conversation to enable send',
    );
  }

  /**
   * #setLoadingState updates UI to reflect loading state.
   * @param {boolean} loading
   */
  #setLoadingState(loading) {
    this.isProcessing = loading;
    this.$send.classList.toggle('loading', loading);
    this.$send.setAttribute('aria-label', loading ? 'Stop' : 'Send message');
    // Show/hide the spinner in the chat area.
    // Positioning is handled by #renderMessages to ensure it's always at the end.
    this.$chatLoading.hidden = !loading;
    // Keep the send button enabled during loading so it can act as a stop button.
    this.$ptt.disabled = loading;
    this.$transcribe.disabled = loading;
    // Clear any pending stop state when loading ends.
    if (!loading && this.isStopPending) {
      this.isStopPending = false;
      this.$send.classList.remove('pending');
    }

    if (loading) {
      this.#acquireWakeLock();
    } else {
      this.#releaseWakeLock();
    }
  }

  /**
   * #setRecordingState updates UI to reflect recording state.
   * @param {boolean} recording
   */
  #setRecordingState(recording) {
    this.isRecording = recording;
    this.$ptt.classList.toggle('recording', recording);
    this.$ptt.setAttribute('aria-pressed', String(recording));
  }

  /**
   * #toggleMute toggles mute state and updates button label.
   */
  #toggleMute() {
    this.isMuted = !this.isMuted;
    if (this.$mute) {
      const iconName = this.isMuted ? 'volume-off' : 'volume';
      this.$mute.replaceChildren(this.#icon(iconName));
      this.$mute.classList.toggle('footer__toolbar-btn--muted', this.isMuted);
      this.$mute.setAttribute('aria-label', this.isMuted ? 'Unmute' : 'Mute');
    }
    // Apply mute state to any currently playing audio.
    if (this.currentAudio) {
      this.currentAudio.muted = !!this.isMuted;
    }
  }

  /**
   * openSettings opens the settings modal and loads current values.
   */
  openSettings() {
    this.$settingsOverlay.classList.add('open');
    this.#loadSystemMessage();
    this.#switchTab('model-voice');
    // Focus the close button for accessibility.
    this.$settingsClose.focus();
  }

  /**
   * closeSettings closes the settings modal and restores focus.
   */
  closeSettings() {
    this.$settingsOverlay.classList.remove('open');
    this.$settings.focus();
  }

  /**
   * #switchTab switches the active tab in the settings modal.
   * @param {string} tabName
   */
  #switchTab(tabName) {
    this.$settingsTabs.forEach(($tab) => {
      const isActive = $tab.dataset.tab === tabName;
      $tab.classList.toggle('active', isActive);
      $tab.setAttribute('aria-selected', String(isActive));
    });
    this.$settingsPanels.forEach(($panel) => {
      const isActive = $panel.dataset.tabPanel === tabName;
      $panel.classList.toggle('active', isActive);
      $panel.hidden = !isActive;
    });
  }

  /**
   * #saveSystemMessage saves the textarea value to localStorage.
   */
  #saveSystemMessage() {
    const value = this.$systemMessageInput.value;
    localStorage.setItem(SYSTEM_MESSAGE_KEY, value);

    // Brief visual feedback on the save button.
    this.$saveSystemMessage.replaceChildren(this.#icon('check'));
    this.$saveSystemMessage.classList.add('settings-save-btn--success');

    setTimeout(() => {
      this.$saveSystemMessage.textContent = 'Save';
      this.$saveSystemMessage.classList.remove('settings-save-btn--success');
    }, 1500);
  }

  /**
   * #loadSystemMessage reads from localStorage and populates the textarea.
   */
  #loadSystemMessage() {
    const stored = localStorage.getItem(SYSTEM_MESSAGE_KEY);
    this.$systemMessageInput.value = stored || '';
  }

  // ====================
  // RENDER: SELECTS
  // ====================
  /**
   * #populateModels populates the model selector with available options.
   * @param {Array<{id: string}>} models
   * @param {string} defaultModel
   */
  #populateModels(models, defaultModel) {
    this.$model.innerHTML = '';

    for (const m of models) {
      const $opt = this.document.createElement('option');
      $opt.value = m.id;
      $opt.textContent = m.id;
      this.$model.appendChild($opt);
    }

    this.#loadModel(defaultModel);
  }

  /**
   * #populateModelsFallback shows an error state when models fail to load.
   */
  #populateModelsFallback() {
    this.$model.innerHTML = '';

    const $opt = this.document.createElement('option');
    $opt.value = '';
    $opt.disabled = true;
    $opt.selected = true;
    $opt.textContent = 'Failed to load';
    this.$model.appendChild($opt);
  }

  /**
   * #loadModel restores the model selection from localStorage or uses the
   * default.
   * @param {string} defaultModel
   */
  #loadModel(defaultModel) {
    const stored = localStorage.getItem(MODEL_KEY);
    const escaped = stored ? CSS.escape(stored) : null;

    // Try stored value first, then default, then first available option.
    let selectedValue;
    if (escaped && this.$model.querySelector(`option[value="${escaped}"]`)) {
      selectedValue = stored;
    } else if (defaultModel) {
      selectedValue = defaultModel;
    } else if (this.$model.options.length > 0) {
      selectedValue = this.$model.options[0].value;
    }

    if (selectedValue) {
      this.$model.value = selectedValue;
    }
  }

  /**
   * #populateVoiceSelect populates voice selector grouped by language.
   * Voice IDs follow the pattern: {lang}{gender}_{name} (e.g., "af_bella").
   * @param {string[]} voices
   */
  #populateVoiceSelect(voices) {
    const langLabels = {
      af: '🇺🇸 American English (F)',
      am: '🇺🇸 American English (M)',
      bf: '🇬🇧 British English (F)',
      bm: '🇬🇧 British English (M)',
      jf: '🇯🇵 Japanese (F)',
      jm: '🇯🇵 Japanese (M)',
      zf: '🇨🇳 Mandarin Chinese (F)',
      zm: '🇨🇳 Mandarin Chinese (M)',
      ef: '🇪🇸 Spanish (F)',
      em: '🇪🇸 Spanish (M)',
      ff: '🇫🇷 French (F)',
      fm: '🇫🇷 French (M)',
      hf: '🇮🇳 Hindi (F)',
      hm: '🇮🇳 Hindi (M)',
      if: '🇮🇹 Italian (F)',
      im: '🇮🇹 Italian (M)',
      pf: '🇧🇷 Brazilian Portuguese (F)',
      pm: '🇧🇷 Brazilian Portuguese (M)',
      kf: '🇰🇷 Korean (F)',
      km: '🇰🇷 Korean (M)',
    };

    // Group voices by their two-character prefix (language + gender)
    const groups = {};
    for (const voice of voices) {
      const prefix = voice.substring(0, 2);
      if (!groups[prefix]) groups[prefix] = [];
      groups[prefix].push(voice);
    }

    // Clear and rebuild selector.
    this.$voice.innerHTML = '';

    // Add "Auto" option for automatic language detection.
    const $auto = this.document.createElement('option');
    $auto.value = '';
    $auto.textContent = '🌐 Auto';
    this.$voice.appendChild($auto);

    // Sort prefixes: by language code first, then by gender.
    const sortedPrefixes = Object.keys(groups).sort((a, b) => {
      if (a[0] !== b[0]) return a[0].localeCompare(b[0]);
      return a[1].localeCompare(b[1]);
    });

    // Create optgroups for each language/gender combination.
    for (const prefix of sortedPrefixes) {
      const label = langLabels[prefix] || prefix.toUpperCase();

      const $optgroup = this.document.createElement('optgroup');
      $optgroup.label = label;

      for (const voice of groups[prefix].sort()) {
        // Extract voice name from ID (e.g., "af_bella" -> "Bella").
        const name = voice.substring(3).replace(/_/g, ' ');
        const displayName = name.charAt(0).toUpperCase() + name.slice(1);

        const $opt = this.document.createElement('option');
        $opt.value = voice;
        $opt.textContent = displayName;
        $optgroup.appendChild($opt);
      }

      this.$voice.appendChild($optgroup);
    }

    this.#loadVoice();
  }

  /**
   * #loadVoice restores the voice selection from localStorage.
   */
  #loadVoice() {
    const stored = localStorage.getItem(VOICE_KEY);

    if (stored === null) {
      this.$voice.value = '';
      return;
    }

    // Empty string is valid ("Auto").
    const escaped = stored === '' ? '' : CSS.escape(stored);
    const exists =
      stored === '' || this.$voice.querySelector(`option[value="${escaped}"]`);

    if (!exists) {
      this.$voice.value = '';
      return;
    }

    this.$voice.value = stored;
  }

  // ====================
  // RENDER: MESSAGES
  // ====================
  /**
   * #renderMessages renders messages to the chat container.
   * Messages already in the DOM (matched by data-id) are skipped.
   * Tool results are matched to their corresponding tool calls.
   * @param {Object[]} messages
   * @param {Object} [meta] - Default metadata for messages without their own
   */
  #renderMessages(messages, meta = {}) {
    // Build tool result map for matching tool calls to their results.
    const toolResultMap = new Map();
    for (const msg of messages) {
      if (msg.role === 'tool' && msg.tool_call_id) {
        toolResultMap.set(msg.tool_call_id, msg);
      }
    }

    for (const msg of messages) {
      // Skip if already rendered.
      if (msg.id && this.$chat.querySelector(`[data-id="${msg.id}"]`)) {
        continue;
      }
      // Render user message.
      if (msg.role === 'user') {
        this.#renderUserMessage(msg, msg.meta ?? meta);
        continue;
      }
      // Tool call -- already processed with the result map.
      if (msg.role !== 'assistant') continue;

      // Skip assistant messages with no displayable content.
      const hasContent =
        msg.content || msg.reasoning_content || msg.tool_calls?.length;
      if (!hasContent) continue;

      // Otherwise, render assistant message.
      const toolResults = (msg.tool_calls ?? [])
        .map((tc) => toolResultMap.get(tc.id))
        .filter(Boolean);

      this.#renderAssistantMessage(msg, msg.meta ?? meta, toolResults);
    }

    // Ensure the loading spinner is always at the end of the chat.
    if (!this.$chatLoading.hidden) {
      this.$chat.appendChild(this.$chatLoading);
    }
  }

  /**
   * #renderUserMessage renders a user message to the chat.
   * @param {Object} message
   * @param {Object} [meta]
   */
  #renderUserMessage(message, meta = null) {
    const content = (() => {
      if (typeof message.content === 'string') {
        return message.content;
      }
      if (Array.isArray(message.content)) {
        return message.content
          .filter((p) => p.type === 'text')
          .map((p) => p.text)
          .join('\n');
      }
      return '';
    })();

    const $msg = this.$tplUserMessage.content.cloneNode(true).firstElementChild;
    if (message.id) $msg.dataset.id = message.id;

    $msg.querySelector('.message__content').textContent = content;

    // Populate debug panel.
    const $dl = $msg.querySelector('.message__debug-list');
    if (meta?.language) this.#appendDebugRow($dl, 'Language', meta.language);
    if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
    if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');

    // Bind action buttons.
    const $inspect = $msg.querySelector('[data-action="inspect"]');
    $inspect.addEventListener('click', () => {
      const isOpen = $msg.classList.toggle('message--debug-open');
      $inspect.setAttribute('aria-expanded', String(isOpen));
    });

    const $copy = $msg.querySelector('[data-action="copy"]');
    $copy.addEventListener('click', () =>
      this.#copyToClipboard($copy, content),
    );

    const $delete = $msg.querySelector('[data-action="delete"]');
    $delete.addEventListener('click', (e) =>
      this.#handleDeleteClick(e, message.id),
    );

    this.$chat.appendChild($msg);
    this.#scrollToBottom();
  }

  /**
   * #renderAssistantMessage renders an assistant message with optional
   * reasoning, tool calls, and their results.
   * @param {Object} message
   * @param {string} [message.content]
   * @param {string} [message.reasoning_content]
   * @param {Array} [message.tool_calls]
   * @param {Object} [meta]
   * @param {Object[]} [toolResults]
   */
  #renderAssistantMessage(message, meta = null, toolResults = []) {
    const $msg =
      this.$tplAssistantMessage.content.cloneNode(true).firstElementChild;
    if (message.id) $msg.dataset.id = message.id;

    const $body = $msg.querySelector('.message__body');
    const $content = $msg.querySelector('.message__content');

    // Reasoning block (collapsible, shows LLM's chain-of-thought)
    if (message.reasoning_content) {
      const $reasoning = this.#createCollapsibleBlock(
        'reasoning',
        'Reasoning',
        message.reasoning_content,
      );
      $body.insertBefore($reasoning, $content);
    }

    // Tool call blocks (collapsible, shows function name, args, and result)
    if (message.tool_calls?.length > 0) {
      for (const toolCall of message.tool_calls) {
        const result = toolResults.find((r) => r.tool_call_id === toolCall.id);
        const $toolBlock = this.#createToolCallBlock(toolCall, result);
        $body.insertBefore($toolBlock, $content);
      }
    }

    // Main content
    if (message.content) {
      $content.textContent = message.content;
    } else {
      $content.remove();
    }

    // Populate debug panel.
    const $dl = $msg.querySelector('.message__debug-list');
    if (meta?.voice) this.#appendDebugRow($dl, 'Voice', meta.voice);
    if ($dl.children.length === 0) this.#appendDebugRow($dl, 'No data', '');

    // Bind action buttons.
    const $inspect = $msg.querySelector('[data-action="inspect"]');
    $inspect.addEventListener('click', () => {
      const isOpen = $msg.classList.toggle('message--debug-open');
      $inspect.setAttribute('aria-expanded', String(isOpen));
    });

    // Play button: only for messages with non-empty content.
    const $play = $msg.querySelector('[data-action="play"]');
    if (message.content) {
      const voice = meta?.voice || this.$voice.value || '';
      $play.addEventListener('click', (e) =>
        this.#handlePlayClick(e, message.id, message.content, voice),
      );
    } else {
      $play.remove();
    }

    // Assemble text from all available content.
    const copyParts = [];
    if (message.reasoning_content) copyParts.push(message.reasoning_content);
    for (const tc of message.tool_calls ?? []) {
      const name = tc.function?.name || 'unknown';
      const args = tc.function?.arguments || '{}';
      copyParts.push(`${name}(${args})`);
    }
    if (message.content) copyParts.push(message.content);
    const copyText = copyParts.join('\n\n');

    const $copy = $msg.querySelector('[data-action="copy"]');
    $copy.addEventListener('click', () =>
      this.#copyToClipboard($copy, copyText),
    );

    const $delete = $msg.querySelector('[data-action="delete"]');
    $delete.addEventListener('click', (e) =>
      this.#handleDeleteClick(e, message.id),
    );

    this.$chat.appendChild($msg);
    this.#scrollToBottom();
  }

  /**
   * #renderError renders an error message in the chat.
   * @param {string} message
   */
  #renderError(message) {
    const $msg =
      this.$tplErrorMessage.content.cloneNode(true).firstElementChild;
    $msg.querySelector('.message__content').textContent = message;

    this.$chat.appendChild($msg);
    this.#scrollToBottom();
  }

  // ====================
  // RENDER: ATTACHMENTS
  // ====================
  /**
   * #renderAttachments renders the attachment chips in the footer.
   */
  #renderAttachments() {
    this.$attachments.innerHTML = '';

    for (let i = 0; i < this.attachments.length; i++) {
      const file = this.attachments[i];

      const $chip =
        this.$tplAttachmentChip.content.cloneNode(true).firstElementChild;

      const $name = $chip.querySelector('.compose__attachment-name');
      $name.textContent = file.name;
      $name.title = file.name;

      const $remove = $chip.querySelector('.compose__attachment-remove');
      $remove.setAttribute('aria-label', `Remove ${file.name}`);
      $remove.addEventListener('click', () => this.#removeAttachment(i));

      this.$attachments.appendChild($chip);
    }
  }

  /**
   * #removeAttachment removes the attachment at the given index.
   * @param {number} index
   */
  #removeAttachment(index) {
    this.attachments.splice(index, 1);
    this.#renderAttachments();
    this.#updateSendButtonState();
  }

  /**
   * #clearAttachments removes all attachments.
   */
  #clearAttachments() {
    this.attachments = [];
    this.#renderAttachments();
  }

  // ====================
  // RENDER: COMPONENTS
  // ====================
  /**
   * #createCollapsibleBlock creates a collapsible details element.
   * @param {string} type - CSS modifier for styling
   * @param {string} label - Summary text
   * @param {string} content - Content to display when expanded
   * @returns {HTMLDetailsElement}
   */
  #createCollapsibleBlock(type, label, content) {
    const $details =
      this.$tplCollapsible.content.cloneNode(true).firstElementChild;
    $details.classList.add(`collapsible--${type}`);

    const $summary = $details.querySelector('.collapsible__summary');
    $summary.insertBefore(this.#icon(type), $summary.firstChild);

    $details.querySelector('.collapsible__label').textContent = label;
    $details.querySelector('pre').textContent = content;

    return $details;
  }

  /**
   * #createToolCallBlock creates a collapsible block for a tool call.
   * Shows the tool name, arguments, and optionally the result.
   * @param {Object} toolCall
   * @param {Object} [result]
   * @returns {HTMLDetailsElement}
   */
  #createToolCallBlock(toolCall, result = null) {
    const $details =
      this.$tplToolCall.content.cloneNode(true).firstElementChild;

    $details.querySelector('.collapsible__label').textContent =
      toolCall.function?.name || 'Tool Call';

    // Arguments section
    const $args = $details.querySelector('[data-args]');
    try {
      const args = JSON.parse(toolCall.function?.arguments || '{}');
      $args.textContent = JSON.stringify(args, null, 2);
    } catch {
      $args.textContent = toolCall.function?.arguments || '';
    }

    // Output section (only if result is available)
    if (result) {
      const $outputSection = $details.querySelector(
        '.collapsible__section--output',
      );
      $outputSection.hidden = false;

      const $output = $details.querySelector('[data-output]');
      try {
        const output = JSON.parse(result.content || '{}');
        $output.textContent = JSON.stringify(output, null, 2);
      } catch {
        $output.textContent = result.content || '';
      }
    }

    return $details;
  }

  /**
   * #appendDebugRow appends a label/value pair to a debug list.
   * @param {HTMLDListElement} $dl
   * @param {string} label
   * @param {string} value
   */
  #appendDebugRow($dl, label, value) {
    const $row = this.$tplDebugRow.content.cloneNode(true);
    $row.querySelector('dt').textContent = label;
    $row.querySelector('dd').textContent = value;
    $dl.appendChild($row);
  }

  // ====================
  // UTILITIES
  // ====================
  /**
   * #icon creates an SVG icon element referencing the icon sprite.
   * @param {string} name
   * @returns {SVGSVGElement}
   */
  #icon(name) {
    const svg = this.document.createElementNS(
      'http://www.w3.org/2000/svg',
      'svg',
    );
    svg.setAttribute('class', 'icon');

    const use = this.document.createElementNS(
      'http://www.w3.org/2000/svg',
      'use',
    );
    use.setAttribute('href', `${ICONS_URL}#${name}`);

    svg.appendChild(use);
    return svg;
  }

  /**
   * #toBase64 converts a Blob to a base64-encoded string.
   * @param {Blob} blob
   * @returns {Promise<string>}
   */
  async #toBase64(blob) {
    const buffer = await blob.arrayBuffer();
    return new Uint8Array(buffer).toBase64();
  }

  /**
   * #copyToClipboard copies text and shows a brief success indicator.
   * @param {HTMLButtonElement} $button - The button to update with feedback
   * @param {string} text - The text to copy
   */
  async #copyToClipboard($button, text) {
    try {
      await navigator.clipboard.writeText(text);
      $button.replaceChildren(this.#icon('check'));
      $button.classList.add('message__action-btn--success');

      setTimeout(() => {
        $button.replaceChildren(this.#icon('copy'));
        $button.classList.remove('message__action-btn--success');
      }, 1500);
    } catch (e) {
      console.error('failed to copy:', e);
    }
  }

  // ====================
  // WAKE LOCK
  // ====================
  /**
   * #initWakeLock performs feature detection for the Screen Wake Lock API.
   */
  #initWakeLock() {
    if (!('wakeLock' in navigator)) {
      console.debug('Screen Wake Lock API not supported');
    }
  }

  /**
   * #acquireWakeLock requests a screen wake lock. Idempotent.
   */
  async #acquireWakeLock() {
    if (this.wakeLock) return;
    if (!('wakeLock' in navigator)) return;

    try {
      this.wakeLock = await navigator.wakeLock.request('screen');
    } catch (e) {
      console.debug('wake lock acquire failed:', e);
    }
  }

  /**
   * #releaseWakeLock releases the active wake lock. Idempotent.
   */
  async #releaseWakeLock() {
    if (!this.wakeLock) return;

    try {
      await this.wakeLock.release();
    } catch (e) {
      console.debug('wake lock release failed:', e);
    }

    this.wakeLock = null;
  }

  /**
   * #handleVisibilityChange reacquires the wake lock when the tab becomes
   * visible, if the app is currently active (recording or processing).
   */
  #handleVisibilityChange() {
    if (this.document.visibilityState !== 'visible') return;
    if (!this.isRecording && !this.isProcessing) return;
    this.#acquireWakeLock();
  }
}

// Initialize
new Odidere({ document });
