/**
 * Odidere is the composition root and orchestrator. It creates all
 * subsystems, wires them together, owns DOM refs, event delegation,
 * and runTurn (the streaming orchestration formerly known as sendRequest).
 */

import { ConversationTree, extractText } from './conversation.js';
import { EditModal } from './edit.js';
import { Renderer } from './render.js';
import { Settings } from './settings.js';
import { STT, VOICE_MAP } from './stt.js';
import { TTSPlayer } from './tts.js';
import { WakeLock } from './wakelock.js';

const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const MAX_TURNS_KEY = 'odidere_max_turns';
const REASONING_BUDGET_KEY = 'odidere_reasoning_budget';
const MUTE_KEY = 'odidere_mute';
const ICONS_URL = '/static/icons.svg';

const StreamEventDelta = 'delta';
const StreamEventReasoningDelta = 'reasoning_delta';
const StreamEventDone = 'done';
const StreamEventMessage = 'message';

const ContentTypeImageURL = 'image_url';
const ContentTypeText = 'text';

// GeolocationOptions: coarse accuracy is sufficient for chat context.
// 30s cache avoids redundant GPS requests on repeated taps.
const GeolocationOptions = {
  enableHighAccuracy: false,
  timeout: 20_000,
  maximumAge: 30_000,
};

class Odidere {
  constructor({ document }) {
    this.document = document;

    // --- Create subsystems ---
    this.conversation = new ConversationTree();
    this.stt = new STT({
      url: document.querySelector('meta[name="stt-url"]')?.content || '',
    });
    this.tts = new TTSPlayer({
      url: document.querySelector('meta[name="tts-url"]')?.content || '',
      defaultVoice:
        document.querySelector('meta[name="tts-default-voice"]')?.content || '',
    });
    this.settings = new Settings({
      document,
      ttsURL: document.querySelector('meta[name="tts-url"]')?.content || '',
    });
    this.renderer = new Renderer({
      document,
      conversation: this.conversation,
    });
    this.editModal = new EditModal({
      document,
      conversation: this.conversation,
    });
    this.wakeLock = new WakeLock();

    // --- Wire TTS events ---
    this.tts.addEventListener('playbackstart', (e) =>
      this.renderer.setPlayButton(e.detail.messageId, true),
    );
    this.tts.addEventListener('playbackend', (e) =>
      this.renderer.setPlayButton(e.detail.messageId, false),
    );

    // --- State flags ---
    this.isProcessing = false;
    this.isRecording = false;
    this.isTranscribing = false;
    this.isMuted = false;
    this.isResetPending = false;
    this.isStopPending = false;
    this.pendingDeleteId = null;
    // Promise for the currently in-flight runTurn, so reset() can await it.
    this._currentRun = null;
    this.currentController = null;
    this.totalTokens = 0;
    this.systemMessage = '';

    // Attachments & location
    this.attachments = [];
    this.locationData = null;

    // --- DOM refs ---
    this.$attach = document.getElementById('attach');
    this.$context = document.getElementById('context');
    this.$fileInput = document.getElementById('file-input');
    this.$location = document.getElementById('location');
    this.$mute = document.getElementById('mute');
    this.$ptt = document.getElementById('ptt');
    this.$reset = document.getElementById('reset');
    this.$send = document.getElementById('send');
    this.$textInput = document.getElementById('text-input');
    this.$transcribe = document.getElementById('transcribe');
    this.$settings = document.getElementById('settings');
    this.$settingsOverlay = document.getElementById('settings-overlay');
    this.$settingsClose = document.getElementById('settings-close');
    this.$settingsTabs = document.querySelectorAll('.settings-tab');
    this.$saveSystemMessage = document.getElementById('save-system-message');
    this.$composeSettingsOverlay = document.getElementById(
      'compose-settings-overlay',
    );
    this.$composeSettingsClose = document.getElementById(
      'compose-settings-close',
    );
    this.$maxTurnsInput = document.getElementById('max-turns-input');
    this.$reasoningBudgetInput = document.getElementById(
      'reasoning-budget-input',
    );
    this.$compose = document.querySelector('.compose');

    this.init();
  }

  // ====================
  // LIFECYCLE
  // ====================

  init() {
    this.conversation.load();
    this.systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY) || '';
    this.renderer.renderSystemMessage(this.systemMessage);
    if (this.conversation.size > 0) {
      this.renderer.renderMessages(this.conversation.getActivePath());
    }
    this.bindEvents();
    this.settings.fetchVoices();
    this.settings.fetchModels();
    this.updateSendButtonState();
    // Restore mute state
    const storedMute = localStorage.getItem(MUTE_KEY);
    if (storedMute === 'true') {
      this.isMuted = true;
      this.tts.setMuted(true);
      this.$mute.replaceChildren(this._icon('volume-off'));
      this.$mute.classList.add('footer__toolbar-btn--muted');
      this.$mute.setAttribute('aria-label', 'Unmute');
    }
    this._initVisualViewport();
  }

  destroy() {
    this.stt.destroy();
    this.tts.stop();
    this.currentController?.abort();
    this.wakeLock.release();
  }

  /**
   * Reset stops any in-flight turn, waits for it to settle, then
   * clears the conversation, DOM, attachments, location, and input.
   * The abort and await pattern ensures message streaming in runTurn completes
   * before conversation.clear().
   */
  async reset() {
    this.tts.stop();
    if (this.currentController) {
      this.currentController.abort();
      this.currentController = null;
    }
    this.setLoadingState(false);

    // Wait for the current run to settle before clearing.
    if (this._currentRun) {
      try {
        await this._currentRun;
      } catch {}
    }
    this.pendingDeleteId = null;
    this.conversation.clear();
    this.renderer.setAutoScroll(false);
    this.renderer.clearChat();
    this.systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY) || '';
    this.renderer.renderSystemMessage(this.systemMessage);
    this.clearAttachments();
    this.clearLocation();
    this.$textInput.value = '';
    this.$textInput.style.height = 'auto';
    this.totalTokens = 0;
    this.updateContext();
    this.updateSendButtonState();
  }

  // ====================
  // EVENT BINDING
  // ====================

  bindEvents() {
    // --- Delegated click for all data-action elements ---
    this.document.addEventListener('click', (e) => this._handleClick(e));

    // --- Press-and-hold for PTT and transcribe ---
    this.$compose.addEventListener('mousedown', (e) =>
      this._handlePressStart(e),
    );
    this.$compose.addEventListener('touchstart', (e) =>
      this._handlePressStart(e),
    );
    this.document.addEventListener('mouseup', (e) => this._handlePressEnd(e));
    this.document.addEventListener('touchend', (e) => this._handlePressEnd(e));
    this.document.addEventListener('touchcancel', (e) =>
      this._handlePressEnd(e),
    );

    // Prevent context menu on long press for PTT and transcribe.
    this.$ptt.addEventListener('contextmenu', (e) => e.preventDefault());
    this.$transcribe.addEventListener('contextmenu', (e) => e.preventDefault());

    // --- Keyboard: spacebar PTT ---
    this.document.addEventListener('keydown', (e) => this._handleKeyDown(e));
    this.document.addEventListener('keyup', (e) => this._handleKeyUp(e));

    // --- Textarea ---
    this.$textInput.addEventListener('keydown', (e) =>
      this._handleTextareaKeyDown(e),
    );
    this.$textInput.addEventListener('input', () =>
      this._handleTextareaInput(),
    );

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

    // --- Chat scroll ---
    this.renderer.$chat.addEventListener(
      'scroll',
      () => this.renderer.handleChatScroll(),
      { passive: true },
    );
    this.renderer.updateFollowIndicator();

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

    // --- Compose settings ---
    this.$composeSettingsClose.addEventListener('click', () =>
      this._closeComposeSettings(),
    );
    this.$composeSettingsOverlay.addEventListener('click', (e) => {
      if (e.target === this.$composeSettingsOverlay)
        this._closeComposeSettings();
    });

    // --- Model/voice change persistence ---
    this.settings.$model.addEventListener('change', () =>
      this.settings.saveModelSelection(),
    );
    this.settings.$voice.addEventListener('change', () =>
      this.settings.saveVoiceSelection(),
    );

    // --- Wake lock: reacquire on visibility change ---
    this.document.addEventListener('visibilitychange', () =>
      this.wakeLock.handleVisibilityChange(
        this.isRecording || this.isProcessing,
      ),
    );
  }

  // ====================
  // CLICK DELEGATION
  // ====================

  _handleClick(event) {
    const $target = event.target.closest('[data-action]');
    const action = $target?.dataset.action;

    // Click outside any action button: cancel all pending states.
    if (!action) {
      this._cancelAllPending();
      return;
    }

    // Cancel pending states for other actions.
    if (action !== 'reset') this._cancelResetPending();
    if (action !== 'send') this._cancelStopPending();
    if (action !== 'delete') this._cancelDeletePending();

    this.handleAction(action, event, $target);
  }

  handleAction(action, event, $target) {
    switch (action) {
      case 'send':
        this._handleSendClick();
        break;
      case 'reset':
        this._handleResetClick();
        break;
      case 'scroll-up':
        this._handleScrollUp();
        break;
      case 'scroll-down':
        this._handleScrollDown();
        break;
      case 'mute':
        this.toggleMute();
        break;
      case 'settings':
        this.settings.open();
        break;
      case 'compose-settings':
        this._openComposeSettings();
        break;
      case 'attach':
        // Handled by direct listener on $attach.
        break;
      case 'location':
        this.handleLocationToggle();
        break;
      case 'inspect': {
        const $msg = $target.closest('.message');
        const isOpen = $msg.classList.toggle('message--debug-open');
        $target.setAttribute('aria-expanded', String(isOpen));
        break;
      }
      case 'copy': {
        const $msg = $target.closest('.message');
        const text = $msg.dataset.copyText || '';
        this.renderer.copyToClipboard($target, text);
        break;
      }
      case 'play': {
        const $msg = $target.closest('.message');
        const messageId = $msg.dataset.id;
        const text = $msg.querySelector('.message__content').textContent;
        const voice = $msg.dataset.voice || '';
        if (this.tts.currentMessageId === messageId) {
          this.tts.stop();
        } else {
          this.tts.stop();
          this.tts.enqueue(text, voice, messageId);
        }
        break;
      }
      case 'edit': {
        const $msg = $target.closest('.message');
        const messageId = $msg.dataset.messageId || $msg.dataset.id;
        const field = $msg.dataset.field || null;
        const toolCallId = $msg.dataset.toolCallId || null;
        this._openEdit(messageId, field, toolCallId);
        break;
      }
      case 'delete': {
        const $msg = $target.closest('.message');
        // Error messages have no data-id: just remove from DOM.
        if (!$msg.dataset.id && !$msg.dataset.messageId) {
          $msg.remove();
          break;
        }
        const messageId = $msg.dataset.messageId || $msg.dataset.id;
        const field = $msg.dataset.field || null;
        const toolCallId = $msg.dataset.toolCallId || null;
        this._handleDelete(event, messageId, field, toolCallId, $target);
        break;
      }
    }
  }

  // ====================
  // PRESS-AND-HOLD
  // ====================

  _handlePressStart(event) {
    const $target = event.target.closest('[data-action]');
    if (!$target) return;
    const action = $target.dataset.action;

    if (action === 'ptt') {
      if (event.type === 'touchstart') {
        event.preventDefault();
        event.stopPropagation();
      } else if (event.sourceCapabilities?.firesTouchEvents) {
        return;
      } else {
        event.preventDefault();
        event.stopPropagation();
      }
      this.handleSendButtonPress();
    } else if (action === 'transcribe') {
      if (event.type === 'touchstart') {
        event.preventDefault();
        event.stopPropagation();
      } else if (event.sourceCapabilities?.firesTouchEvents) {
        return;
      } else {
        event.preventDefault();
        event.stopPropagation();
      }
      this.handleTranscribePress();
    }
  }

  _handlePressEnd(_event) {
    if (this.isRecording) {
      this.handleSendButtonRelease();
    } else if (this.isTranscribing) {
      this.handleTranscribeRelease();
    }
  }

  // ====================
  // STT HANDLERS
  // ====================

  async handleSendButtonPress() {
    if (this.isRecording || this.isTranscribing || this.isProcessing) return;
    this.tts.stop();
    this.isRecording = true;
    this.setRecordingState(true);
    try {
      await this.stt.acquire();
      // User may have released while acquire was pending (e.g. during
      // the iOS permission dialog). Bail before starting.
      if (!this.isRecording) return;
      this.stt.start();
    } catch (e) {
      if (!this.isRecording) return;
      this.isRecording = false;
      this.setRecordingState(false);
      this.renderer.renderError(`Microphone access denied: ${e.message}`);
    }
  }

  async handleSendButtonRelease() {
    if (!this.isRecording) return;
    // Clear the flag immediately so that a pending acquire() in the
    // press handler bails out instead of starting the recorder.
    this.isRecording = false;
    this.setRecordingState(false);
    try {
      const { text: transcription, detectedLanguage } = await this.stt.stop();
      if (!transcription) return;
      const voice = this.resolveVoice(detectedLanguage);
      const existing = this.$textInput.value.trim();
      this.$textInput.value = '';
      this.$textInput.style.height = 'auto';
      const combined = existing
        ? `${existing}\n${transcription}`
        : transcription;
      await this.runTurn({ text: combined, detectedLanguage, voice });
    } catch (e) {
      console.error('transcription failed:', e);
      this.renderer.renderError(`Transcription failed: ${e.message}`);
      this.setLoadingState(false);
    }
  }

  async handleTranscribePress() {
    if (this.isRecording || this.isTranscribing || this.isProcessing) return;
    this.tts.stop();
    this.isTranscribing = true;
    this.$transcribe.classList.add('compose__action-btn--recording');
    try {
      await this.stt.acquire();
      if (!this.isTranscribing) return;
      this.stt.start();
    } catch (e) {
      if (!this.isTranscribing) return;
      this.isTranscribing = false;
      this.$transcribe.classList.remove('compose__action-btn--recording');
      this.renderer.renderError(`Microphone access denied: ${e.message}`);
    }
  }

  async handleTranscribeRelease() {
    if (!this.isTranscribing) return;
    this.isTranscribing = false;
    this.$transcribe.classList.remove('compose__action-btn--recording');
    try {
      const { text: transcription } = await this.stt.stop();
      if (transcription) {
        const existing = this.$textInput.value;
        if (existing.trim()) {
          this.$textInput.value = `${existing}\n${transcription}`;
        } else {
          this.$textInput.value = transcription;
        }
        this._handleTextareaInput();
      }
      this.$textInput.focus();
    } catch (e) {
      console.error('transcription failed:', e);
      this.renderer.renderError(`Transcription failed: ${e.message}`);
    }
  }

  // ====================
  // KEYBOARD
  // ====================

  _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.handleSendButtonPress();
  }

  _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.handleSendButtonRelease();
  }

  _handleTextareaKeyDown(event) {
    if (event.code !== 'Enter') return;
    if (event.shiftKey) return;
    event.preventDefault();
    this._submitText();
  }

  _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();
  }

  // ====================
  // CONVERSATION ORCHESTRATION
  // ====================

  async _submitText() {
    const text = this.$textInput.value.trim();
    if (this.isProcessing) return;
    this.tts.stop();
    this.$textInput.value = '';
    this.$textInput.style.height = 'auto';
    await this.runTurn({ text, voice: this.settings.getVoice() });
  }

  /**
   * Build and send a chat request, then consume the streaming SSE response.
   *
   * The body executes inside an async IIFE stored as this._currentRun so
   * reset() can abort the stream and await the IIFE before clearing state.
   * On interruption (stop or reset), the catch block persists any partially-
   * received assistant message or pending tool calls; on reset, clear() runs
   * afterward, so the persisted data is immediately discarded.
   *
   * @param {Object} options
   * @param {string} [options.text]
   * @param {string} [options.detectedLanguage]
   * @param {string} [options.voice]
   */
  async runTurn({ text = '', detectedLanguage = '', voice } = {}) {
    this.setLoadingState(true);
    // Follow state is sticky across turns; runTurn does not reset it. It turns
    // off only when the user scrolls up or presses the up arrow. Off at load.
    this.currentController = new AbortController();

    this._currentRun = (async () => {
      let streamingBubbles = {
        assistantId: null,
        message: null,
        reasoning: null,
        text: null,
      };
      let pendingTools = null;

      const streamingMeta = (usage, timings) => {
        const meta = {};
        if (voice) meta.voice = voice;
        if (usage) meta.usage = usage;
        if (timings) meta.timings = timings;
        return meta;
      };

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

        const activePath = this.conversation.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 {
          messages = activePath.map(
            ({ parent, children, id, meta, ...msg }) => msg,
          );
        }

        const { provider, model } = this.settings.getModel();
        const payload = { messages, provider, model };
        if (this.systemMessage) {
          payload.system_message = this.systemMessage;
        }
        const maxTurns = this._getMaxTurns();
        if (maxTurns > 0) {
          payload.max_iterations = maxTurns;
        }

        const reasoningBudget = this._getReasoningBudget();
        if (reasoningBudget !== null) {
          payload.thinking_budget_tokens = reasoningBudget;
        }

        // Clear attachments and location after building payload.
        this.clearAttachments();
        this.clearLocation();

        // For text-only requests with new input, add to history and
        // render immediately.
        if (hasNewInput) {
          const meta = {};
          if (detectedLanguage) meta.language = detectedLanguage;
          if (voice) meta.voice = voice;
          const appended = this.conversation.append([userMessage], meta);
          this.renderer.renderMessages(appended, meta);
          // When not following, anchor the new user message to the top so the
          // reply grows below it. When following, let streaming pin the bottom.
          if (this.renderer.isAutoScrollEnabled) {
            this.renderer.scrollToBottom();
          } else {
            this.renderer.scrollToLastUserMessage();
          }
        }

        const res = await fetch(STREAM_ENDPOINT, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload),
          signal: this.currentController.signal,
        });
        const requestId = res.headers.get('X-Request-ID') || '';

        if (!res.ok) {
          const err = await res.text();
          this.renderer.renderError(
            `server error ${res.status}: ${err}`,
            requestId,
          );
          return;
        }

        for await (const { eventType, event } of this._parseSSEStream(res)) {
          if (event.error) {
            this.renderer.renderError(event.error, requestId);
            return;
          }

          if (
            eventType === StreamEventDelta ||
            eventType === StreamEventReasoningDelta
          ) {
            if (!streamingBubbles.message) {
              streamingBubbles.assistantId = crypto.randomUUID();
              streamingBubbles.message = {
                id: streamingBubbles.assistantId,
                role: 'assistant',
                content: [{ type: ContentTypeText, text: '' }],
              };
            }

            if (eventType === StreamEventReasoningDelta) {
              if (!streamingBubbles.reasoning) {
                const $el = this.renderer.createStreamingReasoning(
                  streamingBubbles.assistantId,
                  streamingMeta(event.usage, event.timings),
                );
                streamingBubbles.reasoning = { $el };
                streamingBubbles.message.reasoning_content = '';
              }
              streamingBubbles.message.reasoning_content +=
                event.reasoning_delta || '';
              this.renderer.appendReasoningDelta(
                streamingBubbles.reasoning.$el,
                event.reasoning_delta || '',
              );
            } else {
              if (!streamingBubbles.text) {
                const $el = this.renderer.createStreamingText(
                  streamingBubbles.message,
                  streamingMeta(event.usage, event.timings),
                );
                streamingBubbles.text = { $el };
              }
              streamingBubbles.message.content[0].text += event.delta || '';
              this.renderer.appendDelta(
                streamingBubbles.text.$el,
                event.delta || '',
              );
            }
            continue;
          }

          const message = event.message;
          if (!message) continue;

          if (eventType === StreamEventDone) {
            if (message.role !== 'assistant') continue;

            if (event.usage?.total_tokens) {
              this.totalTokens = event.usage.total_tokens;
              this.updateContext();
            }

            const meta = streamingMeta(event.usage, event.timings);
            let finalMessage = message;

            if (streamingBubbles.message) {
              finalMessage.id = streamingBubbles.assistantId;
              const appended = this.conversation.append([finalMessage], meta);
              finalMessage = appended[0];

              if (streamingBubbles.reasoning) {
                this.renderer.finalizeStreamingReasoning(
                  streamingBubbles.reasoning.$el,
                  finalMessage,
                  meta,
                );
              }
              if (streamingBubbles.text && !finalMessage.tool_calls?.length) {
                this.renderer.finalizeStreamingText(
                  streamingBubbles.text.$el,
                  finalMessage,
                  meta,
                );
              }
            }

            if (finalMessage.tool_calls?.length > 0) {
              if (!streamingBubbles.message) {
                const appended = this.conversation.append([finalMessage], meta);
                finalMessage = appended[0];
              }
              pendingTools = {
                assistant: finalMessage,
                results: [],
                expected: finalMessage.tool_calls.length,
                $el: streamingBubbles.text?.$el || null,
              };
              streamingBubbles = {
                assistantId: null,
                message: null,
                reasoning: null,
                text: null,
              };
              if (extractText(finalMessage.content)) {
                this.tts.enqueue(
                  extractText(finalMessage.content),
                  voice || '',
                  finalMessage.id,
                );
              }
              continue;
            }

            if (!streamingBubbles.message) {
              const appended = this.conversation.append([finalMessage], meta);
              finalMessage = appended[0];
              this.renderer.renderMessages(appended, meta);
            }
            streamingBubbles = {
              assistantId: null,
              message: null,
              reasoning: null,
              text: null,
            };
            if (extractText(finalMessage.content)) {
              this.tts.enqueue(
                extractText(finalMessage.content),
                voice || '',
                finalMessage.id,
              );
            }
            continue;
          }

          // Collect tool results.
          if (
            message.role === 'tool' &&
            pendingTools &&
            pendingTools.assistant
          ) {
            const appended = this.conversation.append([message]);
            pendingTools.results.push(appended[0]);

            if (pendingTools.results.length >= pendingTools.expected) {
              if (pendingTools.$el) {
                this.renderer.finalizeStreamingText(
                  pendingTools.$el,
                  pendingTools.assistant,
                  pendingTools.assistant.meta,
                );
              }
              this.renderer.renderMessages([
                pendingTools.assistant,
                ...pendingTools.results,
              ]);
              pendingTools = null;
            }
          }
        }

        this.setLoadingState(false);
      } catch (e) {
        if (e.name !== 'AbortError') {
          console.error('failed to send request:', e);
          this.renderer.renderError(`Error: ${e.message}`);
        }
        this.setLoadingState(false);

        // Persist a streaming message interrupted by stop or error.
        if (streamingBubbles.message) {
          const meta = streamingMeta(null, null);
          const finalMessage = streamingBubbles.message;
          const hasContent = !!extractText(finalMessage.content);
          const hasToolCalls = finalMessage.tool_calls?.length > 0;

          if (hasContent || hasToolCalls) {
            const appended = this.conversation.append([finalMessage], meta);
            if (streamingBubbles.reasoning) {
              this.renderer.finalizeStreamingReasoning(
                streamingBubbles.reasoning.$el,
                appended[0],
                meta,
              );
            }
            if (streamingBubbles.text) {
              this.renderer.finalizeStreamingText(
                streamingBubbles.text.$el,
                appended[0],
                meta,
              );
            }
          } else {
            streamingBubbles.reasoning?.$el?.remove();
            streamingBubbles.text?.$el?.remove();
          }
        }

        // Finalize pending tool calls interrupted by stop or error.
        if (pendingTools) {
          if (pendingTools.$el) {
            this.renderer.finalizeStreamingText(
              pendingTools.$el,
              pendingTools.assistant,
              pendingTools.assistant.meta ?? {},
            );
          }
          this.renderer.renderMessages([
            pendingTools.assistant,
            ...pendingTools.results,
          ]);
          pendingTools = null;
        }
      } finally {
        this.currentController = null;
      }
    })();

    try {
      await this._currentRun;
    } finally {
      this._currentRun = null;
    }
  }

  // ====================
  // SSE PARSING
  // ====================

  async *_parseSSEStream(response) {
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

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

      buffer += decoder.decode(value, { stream: true });
      const parts = buffer.split('\n\n');
      buffer = parts.pop();

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

        let eventType = StreamEventMessage;
        let data = '';
        for (const line of part.split('\n')) {
          if (line.startsWith('event: ')) eventType = line.slice(7);
          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;
        }

        yield { eventType, event };
      }
    }
  }

  // ====================
  // BUILD USER MESSAGE
  // ====================

  async _buildUserMessage(text, attachments) {
    const textParts = [];
    const imageParts = [];

    for (const file of attachments) {
      if (file.type.startsWith('image/')) {
        const buffer = await file.arrayBuffer();
        const base64 = new Uint8Array(buffer).toBase64();
        const dataURL = `data:${file.type};base64,${base64}`;
        imageParts.push({
          type: ContentTypeImageURL,
          image_url: { url: dataURL },
          filename: file.name,
        });
      } else {
        const content = await file.text();
        textParts.push(`=== File: ${file.name} ===\n\n${content}`);
      }
    }

    if (text) {
      textParts.push(text);
    }

    if (this.locationData) {
      textParts.push(JSON.stringify(this.locationData, null, 2));
    }

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

    if (imageParts.length === 0) {
      return {
        id: crypto.randomUUID(),
        role: 'user',
        content: combinedText || '',
      };
    }

    const parts = [];
    if (combinedText) {
      parts.push({ type: ContentTypeText, text: combinedText });
    }
    parts.push(...imageParts);

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

  // ====================
  // VOICE RESOLUTION
  // ====================

  resolveVoice(detectedLanguage) {
    let voice = this.settings.getVoice();
    if (!voice && detectedLanguage) {
      voice = VOICE_MAP[detectedLanguage] || '';
    }
    return voice;
  }

  // ====================
  // STATE
  // ====================

  updateSendButtonState() {
    const hasText = this.$textInput.value.trim().length > 0;
    const hasAttachments = this.attachments.length > 0;
    const hasLocation = this.locationData !== null;
    const hasHistory = this.conversation.size > 0;
    const enabled = hasText || hasAttachments || hasLocation || hasHistory;
    this.$send.disabled = !enabled;
    this.$send.setAttribute(
      'aria-label',
      enabled ? 'Send message' : 'Start a conversation to enable send',
    );
  }

  setLoadingState(loading) {
    this.isProcessing = loading;
    this.$send.classList.toggle('loading', loading);
    this.$send.setAttribute('aria-label', loading ? 'Stop' : 'Send message');
    this.renderer.$chatLoading.hidden = !loading;
    this.$ptt.disabled = loading;
    this.$transcribe.disabled = loading;
    if (!loading && this.isStopPending) {
      this.isStopPending = false;
      this.$send.classList.remove('pending');
    }
    // While loading, the send button acts as the stop control and must stay
    // clickable regardless of the normal enable/disable logic. When loading
    // ends, restore the standard state.
    if (loading) {
      this.$send.disabled = false;
    } else {
      this.updateSendButtonState();
    }
    if (loading) {
      this.wakeLock.acquire();
    } else {
      this.wakeLock.release();
    }
  }

  setRecordingState(recording) {
    this.$ptt.classList.toggle('recording', recording);
    this.$ptt.setAttribute('aria-pressed', String(recording));
  }

  toggleMute() {
    this.isMuted = !this.isMuted;
    localStorage.setItem(MUTE_KEY, String(this.isMuted));
    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');
    this.tts.setMuted(this.isMuted);
  }

  updateContext() {
    if (!this.$context) return;
    const { model } = this.settings.getModel();
    const contextSize = model ? this.settings.getModelContextSize(model) : null;

    if (this.totalTokens === 0) {
      this.$context.hidden = true;
      return;
    }
    this.$context.hidden = false;

    this.$context.classList.remove(
      'footer__toolbar-context--warning',
      'footer__toolbar-context--critical',
    );

    const tokenLabel =
      this.totalTokens >= 1000
        ? `${(this.totalTokens / 1000).toFixed(1)}k tokens`
        : `${this.totalTokens} tokens`;

    if (contextSize && contextSize > 0) {
      const pct = Math.round((this.totalTokens / contextSize) * 100);
      this.$context.textContent = `${pct}%`;
      this.$context.title = tokenLabel;
      if (pct >= 90) {
        this.$context.classList.add('footer__toolbar-context--critical');
      } else if (pct >= 70) {
        this.$context.classList.add('footer__toolbar-context--warning');
      }
    } else {
      this.$context.textContent = tokenLabel;
      this.$context.title = '';
    }
  }

  // ====================
  // ATTACHMENTS & LOCATION
  // ====================

  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 = '';
  }

  _renderAttachments() {
    this.renderer.renderAttachments(this.attachments, (i) =>
      this._removeAttachment(i),
    );
  }

  _removeAttachment(index) {
    this.attachments.splice(index, 1);
    this._renderAttachments();
    this.updateSendButtonState();
  }

  clearAttachments() {
    this.attachments = [];
    this._renderAttachments();
  }

  handleLocationToggle() {
    if (this.locationData) {
      this.clearLocation();
      this.updateSendButtonState();
      return;
    }
    if (!navigator.geolocation) return;
    navigator.geolocation.getCurrentPosition(
      (position) => {
        this.locationData = position;
        this.$location.classList.add('compose__action-btn--location', 'active');
        this.updateSendButtonState();
      },
      () => {},
      GeolocationOptions,
    );
  }

  clearLocation() {
    this.locationData = null;
    this.$location.classList.remove('compose__action-btn--location', 'active');
  }

  // ====================
  // COMPOSE SETTINGS
  // ====================

  _openComposeSettings() {
    this.$maxTurnsInput.value = this._getMaxTurns();
    this.$reasoningBudgetInput.value = this._getReasoningBudget() ?? -1;
    this.$composeSettingsOverlay.classList.add('open');
    this.$composeSettingsClose.focus();
  }

  _closeComposeSettings() {
    this._setMaxTurns(this.$maxTurnsInput.value);
    this._setReasoningBudget(this.$reasoningBudgetInput.value);
    this.$composeSettingsOverlay.classList.remove('open');
    this.$composeSettings.focus();
  }

  _getMaxTurns() {
    const stored = localStorage.getItem(MAX_TURNS_KEY);
    return stored ? Number(stored) : 0;
  }

  _setMaxTurns(value) {
    localStorage.setItem(MAX_TURNS_KEY, String(value));
  }

  _getReasoningBudget() {
    const stored = localStorage.getItem(REASONING_BUDGET_KEY);
    if (stored === null) return null;
    return Number(stored);
  }

  _setReasoningBudget(value) {
    localStorage.setItem(REASONING_BUDGET_KEY, String(value));
  }

  // ====================
  // TWO-PRESS SAFETY PATTERNS
  // ====================

  _handleSendClick() {
    if (this.$send.classList.contains('loading')) {
      this._handleStopClick();
    } else {
      this._submitText();
    }
  }

  _handleStopClick() {
    if (this.isStopPending) {
      this.isStopPending = false;
      this.$send.classList.remove('pending');
      this.stopConversation();
    } else {
      this.isStopPending = true;
      this.$send.classList.add('pending');
    }
  }

  _cancelStopPending() {
    if (!this.isStopPending) return;
    this.isStopPending = false;
    this.$send.classList.remove('pending');
  }

  _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');
    }
  }

  _cancelResetPending() {
    if (!this.isResetPending) return;
    this.isResetPending = false;
    this.$reset.classList.remove('footer__toolbar-btn--pending');
  }

  _handleScrollUp() {
    const $chat = this.renderer.$chat;
    this.renderer.setAutoScroll(false);
    $chat.scrollTop = 0;
  }

  _handleScrollDown() {
    const $chat = this.renderer.$chat;
    // Set scrollTop first so the queued scroll event sees bottom position
    // and does not disable following.
    $chat.scrollTop = $chat.scrollHeight;
    this.renderer.setAutoScroll(true);
  }

  _handleDelete(event, messageId, field, toolCallId, $btn) {
    event.stopPropagation();
    const deleteKey = field
      ? `${messageId}:${field}:${toolCallId || ''}`
      : messageId;

    if (this.pendingDeleteId === deleteKey) {
      this.pendingDeleteId = null;
      $btn.classList.remove('pending');
      this._deleteField(messageId, field, toolCallId);
    } else {
      this._cancelDeletePending();
      this.pendingDeleteId = deleteKey;
      $btn.classList.add('pending');
    }
  }

  _deleteField(messageId, field, toolCallId) {
    if (!field) {
      this.conversation.delete(messageId);
      this.renderer.reRenderActivePath();
      this.totalTokens = 0;
      this.updateContext();
      this.updateSendButtonState();
      return;
    }

    const msg = this.conversation.get(messageId);
    if (msg?.role !== 'assistant') return;

    if (field === 'reasoning') {
      msg.reasoning_content = '';
    } else if (field === 'text') {
      msg.content = '';
    } else if (field === 'tool' && toolCallId) {
      const idx = msg.tool_calls?.findIndex((tc) => tc.id === toolCallId);
      if (idx !== -1) {
        msg.tool_calls.splice(idx, 1);
      }
      for (const [id, m] of this.conversation.messagesMap) {
        if (m.role === 'tool' && m.tool_call_id === toolCallId) {
          this.conversation.delete(id);
          break;
        }
      }
    }

    if (
      !extractText(msg.content) &&
      !msg.reasoning_content &&
      (!msg.tool_calls || msg.tool_calls.length === 0)
    ) {
      this.conversation.delete(messageId);
      this.renderer.reRenderActivePath();
      this.totalTokens = 0;
      this.updateContext();
      this.updateSendButtonState();
    } else {
      this.renderer.reRenderActivePath();
      this.conversation.save();
    }
  }

  _cancelDeletePending() {
    if (!this.pendingDeleteId) return;
    const deleteKey = this.pendingDeleteId;
    const parts = deleteKey.split(':');
    const messageId = parts[0];
    const field = parts[1] || null;
    const toolCallId = parts[2] || null;

    let $msg;
    if (field === 'reasoning') {
      $msg = this.renderer.$chat.querySelector(
        `[data-id="${messageId}/reasoning"]`,
      );
    } else if (field === 'tool' && toolCallId) {
      $msg = this.renderer.$chat.querySelector(
        `[data-tool-call-id="${toolCallId}"]`,
      );
    } else {
      $msg = this.renderer.$chat.querySelector(`[data-id="${messageId}"]`);
    }

    if ($msg) {
      const $btn = $msg.querySelector('[data-action="delete"]');
      if ($btn) $btn.classList.remove('pending');
    }
    this.pendingDeleteId = null;
    this._currentRun = null;
  }

  _cancelAllPending() {
    this._cancelResetPending();
    this._cancelStopPending();
    this._cancelDeletePending();
  }

  // ====================
  // EDIT
  // ====================

  async _openEdit(messageId, field, toolCallId) {
    if (messageId === 'system') {
      const saved = await this._openSystemEdit();
      if (saved) {
        this.renderer.reRenderSystemMessage(this.systemMessage);
      }
      return;
    }
    const saved = await this.editModal.open(messageId, field, toolCallId);
    if (saved) this.renderer.reRenderActivePath();
  }

  async _openSystemEdit() {
    return new Promise((resolve) => {
      const $tpl = this.document.getElementById('tpl-edit-modal');
      if (!$tpl) return resolve(false);

      const $modal = $tpl.content.cloneNode(true).firstElementChild;
      const $overlay = this.document.createElement('div');
      $overlay.className = 'modal-overlay';
      $overlay.appendChild($modal);

      $modal.querySelector('h2').textContent = 'Edit System Message';

      const textarea = $modal.querySelector('#edit-textarea');
      textarea.value = this.systemMessage || '';

      const saveBtn = $modal.querySelector('#edit-save');
      const closeBtn = $modal.querySelector('.modal__close');

      const close = () => {
        $overlay.remove();
        this.document.removeEventListener('keydown', handleEscape);
        resolve(false);
      };

      const save = () => {
        const newText = textarea.value.trim();
        const settingsDefault = localStorage.getItem(SYSTEM_MESSAGE_KEY) || '';
        this.systemMessage = newText || settingsDefault;
        $overlay.remove();
        this.document.removeEventListener('keydown', handleEscape);
        resolve(true);
      };

      closeBtn.addEventListener('click', close);
      saveBtn.addEventListener('click', save);

      $overlay.addEventListener('click', (e) => {
        if (e.target === $overlay) close();
      });

      const handleEscape = (e) => {
        if (e.key === 'Escape') close();
      };
      this.document.addEventListener('keydown', handleEscape);

      this.document.body.appendChild($overlay);
      textarea.focus();
    });
  }

  // ====================
  // STOP CONVERSATION
  // ====================

  stopConversation() {
    this.tts.stop();
    if (this.currentController) {
      this.currentController.abort();
      this.currentController = null;
    }
    this.setLoadingState(false);
  }

  // ====================
  // VISUAL VIEWPORT
  // ====================

  _initVisualViewport() {
    if (!window.visualViewport) return;
    const container = this.document.querySelector('.container');
    if (!container) return;

    const handler = () => {
      const h = visualViewport.height;
      if (h > 0) {
        container.style.height = `${h}px`;
      }
    };

    visualViewport.addEventListener('resize', handler);
    handler();
  }

  // ====================
  // ICONS
  // ====================

  _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;
  }
}

// Bootstrap
new Odidere({ document });
