use anyhow::Result;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::{Event, EventStream, MouseEventKind};
use crossterm::execute;
use futures_util::{FutureExt, StreamExt};
use ratatui::Terminal;
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::time::sleep_until;

use crate::app_state::AppState;
use crate::config::Config;
use crate::gateway_runtime::GatewayRuntime;

pub struct App {
    pub(crate) state: AppState,
    pub(crate) gateway: GatewayRuntime,
    pending_local_model_fetch: Option<tokio::sync::oneshot::Receiver<Vec<crate::chat::ModelInfo>>>,
    /// Wall-clock origin for the loading spinner (see `loading_animation`).
    /// The spinner frame is `elapsed_ms / 16 % FRAMES`, decoupling the
    /// animation cadence from the event-loop cycle time.
    pub(crate) animation_origin: Instant,
}

const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);

impl App {
    pub fn new_with_config(
        config: &Config,
        config_path: PathBuf,
        workspace: PathBuf,
    ) -> Result<Self, anyhow::Error> {
        let state = AppState::new_with_config(config, config_path, workspace)?;
        let gateway = GatewayRuntime::new(state.client_id.clone());
        Ok(Self {
            state,
            gateway,
            pending_local_model_fetch: None,
            animation_origin: Instant::now(),
        })
    }

    pub async fn run(
        &mut self,
        terminal: &mut Terminal<impl ratatui::backend::Backend>,
    ) -> Result<()> {
        #[cfg(debug_assertions)]
        tracing::debug!("PERF_PROBES_ACTIVE");
        let mut event_stream = EventStream::new();
        let mut pending_event: Option<Event> = None;
        let _ = execute!(io::stdout(), SetCursorStyle::BlinkingBar);
        set_cursor_color(self.state.theme.border_active);
        let mut cursor_visible = true;
        let mut last_cursor_blink_toggle = Instant::now();
        let mut needs_redraw = true;
        // Absolute deadline of the next animation frame while streaming.
        // `Delay`-style: each tick advances by a fixed 16ms from the previous
        // deadline (in the select! sleep branch — never per-iteration), so the
        // draw time is compensated and the cycle stays at ~16ms (60fps) as
        // long as draw < 16ms, while fast event-driven iterations cannot push
        // the deadline into the future. Started at `now`: the first active
        // iteration's `next_wake` equals it, so the deadline falls in the past
        // once the initial draw has run and is reset to `now + period`.
        let animation_period = Duration::from_millis(16);
        let mut next_animation_frame = tokio::time::Instant::now();
        // Heartbeat interval for renewing the daemon's attach lease.
        let mut heartbeat_interval =
            tokio::time::interval(crate::gateway_api::http_timeouts::HEARTBEAT_INTERVAL);
        heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
        // Skip the immediate first tick so we don't fire before any session
        // is open.
        heartbeat_interval.tick().await;

        // Periodic full redraw while idle/ASK: refreshes the header clock
        // (which only updates on `draw`) and recovers from external screen
        // corruption (terminal wake/scrollback clear/reattach) that ratatui's
        // diff optimization would otherwise miss — the previous buffer still
        // matches the last frame, so an unchanged state produces an empty
        // diff and the actual (cleared) screen is never rewritten. Skipped
        // while loading: the 16ms tick already redraws constantly and any
        // streaming state change yields a non-empty diff that recovers the
        // screen naturally. `swap_buffers()` resets the back buffer (without
        // emitting `\x1b[2J`, so no visible clear/flicker) so the next
        // `draw()` writes the full frame instead of a no-op diff.
        let mut force_redraw_interval = tokio::time::interval(Duration::from_secs(1));
        force_redraw_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
        force_redraw_interval.tick().await;

        #[cfg(unix)]
        let mut sigterm =
            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
        #[cfg(unix)]
        if sigterm.is_none() {
            tracing::warn!(
                "Failed to register SIGTERM handler, graceful shutdown on SIGTERM unavailable"
            );
        }

        loop {
            #[cfg(debug_assertions)]
            let _tick_start = std::time::Instant::now();
            if needs_redraw {
                #[cfg(debug_assertions)]
                let _draw_start = std::time::Instant::now();
                terminal.draw(|frame| self.ui(frame))?;
                needs_redraw = false;
                #[cfg(debug_assertions)]
                {
                    let draw_elapsed = _draw_start.elapsed();
                    if draw_elapsed > std::time::Duration::from_micros(500) {
                        tracing::debug!("PERF terminal_draw: {}µs", draw_elapsed.as_micros());
                    }
                }
            }

            #[cfg(debug_assertions)]
            {
                let tick_elapsed = _tick_start.elapsed();
                if tick_elapsed > std::time::Duration::from_millis(15)
                    && self.state.chat_state.is_loading
                {
                    tracing::debug!(
                        "PERF event_loop_tick: {}µs active_refresh=true",
                        tick_elapsed.as_micros()
                    );
                }
            }
            if last_cursor_blink_toggle.elapsed() >= CURSOR_BLINK_INTERVAL {
                cursor_visible = !cursor_visible;
                last_cursor_blink_toggle = Instant::now();
                if cursor_visible {
                    terminal.show_cursor()?;
                } else {
                    terminal.hide_cursor()?;
                }
            }

            let active_refresh =
                self.state.chat_state.is_loading || self.gateway.needs_active_refresh();
            // While streaming, wake at the absolute animation deadline (Delay-
            // style: compensated for draw time → ~16ms cycle = 60fps). While
            // idle, a 250ms relative sleep is enough — the spinner doesn't
            // run and `force_redraw_interval` (1s) handles idle refresh.
            //
            // The deadline is ADVANCED only when the sleep actually fires
            // (see the select! sleep branch), never here: advancing per loop
            // iteration would let a burst of fast event-driven iterations
            // (scroll wheel, key auto-repeat) push the deadline 16ms into the
            // future each time, stalling the loop until it caught up.
            let next_wake = if active_refresh {
                let now = tokio::time::Instant::now();
                // Fallen behind (idle→active switch, or draw > period):
                // restart the deadline from now instead of firing a catch-up
                // burst.
                if next_animation_frame < now {
                    next_animation_frame = now + animation_period;
                }
                next_animation_frame
            } else {
                tokio::time::Instant::now() + Duration::from_millis(250)
            };

            let mut handled_event = None;
            if let Some(event) = pending_event.take() {
                self.handle_event(event.clone()).await?;
                needs_redraw = true;
                handled_event = Some(event);
            } else {
                #[cfg(unix)]
                {
                    tokio::select! {
                        _ = sleep_until(next_wake) => {
                            // Advance the animation deadline only when this
                            // sleep fires (Delay semantics) — see the
                            // `next_wake` comment above. Spinner frames are
                            // wall-clock derived; only `is_loading` needs the
                            // forced redraw (the reveal drain and stream
                            // updates drive their own redraws via
                            // `poll_stream_updates`).
                            if active_refresh {
                                next_animation_frame += animation_period;
                                if self.state.chat_state.is_loading {
                                    needs_redraw = true;
                                }
                            }
                        }
                        maybe_event = event_stream.next().fuse() => {
                            if let Some(Ok(event)) = maybe_event {
                                self.handle_event(event.clone()).await?;
                                needs_redraw = true;
                                handled_event = Some(event);
                            }
                        }
                        models = wait_for_local_models(&mut self.pending_local_model_fetch) => {
                            self.pending_local_model_fetch = None;
                            if let Some(models) = models {
                                if let Some(dialog) = self.state.provider_dialog.as_mut() {
                                    dialog.apply_fetched_local_models(models);
                                }
                            }
                            needs_redraw = true;
                        }
                        _ = heartbeat_interval.tick() => {
                            needs_redraw = self.run_heartbeat_tick().await || needs_redraw;
                        }
                        _ = force_redraw_interval.tick() => {
                            if !self.state.chat_state.is_loading {
                                terminal.swap_buffers();
                                needs_redraw = true;
                            }
                        }
                        _ = tokio::signal::ctrl_c() => {
                            tracing::info!("Received SIGINT (Ctrl+C), initiating graceful shutdown");
                            self.state.should_quit = true;
                            self.state.quit_via_interrupt = true;
                        }
                        _ = async {
                            match &mut sigterm {
                                Some(s) => s.recv().await,
                                None => std::future::pending().await,
                            }
                        } => {
                            tracing::info!("Received SIGTERM, initiating graceful shutdown");
                            self.state.should_quit = true;
                            self.state.quit_via_interrupt = true;
                        }
                    }
                }
                #[cfg(not(unix))]
                {
                    tokio::select! {
                        _ = sleep_until(next_wake) => {
                            // Advance the animation deadline only when this
                            // sleep fires (Delay semantics) — see the
                            // `next_wake` comment above. Spinner frames are
                            // wall-clock derived; only `is_loading` needs the
                            // forced redraw (the reveal drain and stream
                            // updates drive their own redraws via
                            // `poll_stream_updates`).
                            if active_refresh {
                                next_animation_frame += animation_period;
                                if self.state.chat_state.is_loading {
                                    needs_redraw = true;
                                }
                            }
                        }
                        maybe_event = event_stream.next().fuse() => {
                            if let Some(Ok(event)) = maybe_event {
                                self.handle_event(event.clone()).await?;
                                needs_redraw = true;
                                handled_event = Some(event);
                            }
                        }
                        models = wait_for_local_models(&mut self.pending_local_model_fetch) => {
                            self.pending_local_model_fetch = None;
                            if let Some(models) = models {
                                if let Some(dialog) = self.state.provider_dialog.as_mut() {
                                    dialog.apply_fetched_local_models(models);
                                }
                            }
                            needs_redraw = true;
                        }
                        _ = heartbeat_interval.tick() => {
                            needs_redraw = self.run_heartbeat_tick().await || needs_redraw;
                        }
                        _ = force_redraw_interval.tick() => {
                            if !self.state.chat_state.is_loading {
                                terminal.swap_buffers();
                                needs_redraw = true;
                            }
                        }
                        _ = tokio::signal::ctrl_c() => {
                            tracing::info!("Received SIGINT (Ctrl+C), initiating graceful shutdown");
                            self.state.should_quit = true;
                            self.state.quit_via_interrupt = true;
                        }
                    }
                }
            }

            if let Some(event) = handled_event.as_ref() {
                discard_redundant_boundary_scrolls(
                    event,
                    &self.state,
                    &mut event_stream,
                    &mut pending_event,
                );
            }

            needs_redraw |= self.gateway.poll_stream_updates(&mut self.state);
            if self.state.should_quit {
                break;
            }

            // Drain and execute any hook actions received from the daemon
            // (create/switch session, set title).
            //
            // Remote (daemon) mode: actions arrive via the SSE `Done`
            // event's `actions` field after the daemon has already run
            // daemon-side effects (open_session) via
            // `DaemonHookActionSink`; the TUI only needs to switch UI
            // focus and sync its local registry.
            //
            // Local (non-daemon) mode: actions arrive via
            // `SessionTurnUpdate::HookActions` (no SSE, no daemon).
            // `execute_hook_action` → `switch_to_remote_session` checks
            // `remote_base_url()`, finds None, and drops each action with
            // a `tracing::warn!`. This is intentional: create_session /
            // switch_session are not supported in local mode.
            let pending_actions = self.gateway.take_pending_hook_actions();
            if !pending_actions.is_empty() {
                for action in pending_actions {
                    self.execute_hook_action(action).await;
                }
                needs_redraw = true;
            }

            if !self.state.chat_state.is_loading && self.state.chat_state.has_pending_turns() {
                match self.gateway.start_next_queued_turn(&mut self.state).await {
                    Ok(started) => {
                        needs_redraw |= started;
                    }
                    Err(error) => {
                        self.state
                            .chat_state
                            .messages
                            .push(crate::chat::Message::error(error));
                        self.state.chat_state.stick_to_bottom = true;
                        needs_redraw = true;
                    }
                }
            }

            #[cfg(debug_assertions)]
            {
                let tick_elapsed = _tick_start.elapsed();
                if tick_elapsed > std::time::Duration::from_millis(15)
                    && self.state.chat_state.is_loading
                {
                    tracing::debug!(
                        "PERF event_loop_tick: {}µs active_refresh=true",
                        tick_elapsed.as_micros()
                    );
                }
            }
        }
        // If the user interrupted the runtime (Ctrl+C / SIGINT / SIGTERM),
        // persist the session to its rolling automatic slot. Manual
        // checkpoints are never written by this shutdown path.
        if self.state.quit_via_interrupt {
            let record = self.gateway.session_snapshot(&self.state.session_id).await;
            match crate::session_snapshot_service::autosave_on_interrupt(&self.state, record) {
                Ok(Some(path)) => {
                    tracing::info!("Auto-saved session on interrupt to {}", path.display());
                }
                Ok(None) => {}
                Err(error) => {
                    tracing::warn!("Failed to auto-save session on interrupt: {error:#}");
                }
            }
        }

        self.gateway.close_sessions(&self.state.session_id).await;
        reset_cursor_color();
        terminal.show_cursor()?;
        Ok(())
    }

    pub fn start_local_model_fetch(&mut self, api_base: String) {
        let (tx, rx) = tokio::sync::oneshot::channel();
        self.pending_local_model_fetch = Some(rx);
        tokio::spawn(async move {
            let models = fetch_models_from_local_api(&api_base).await;
            let _ = tx.send(models);
        });
    }

    /// Periodic attach-lease heartbeat. Runs every 15 s while the TUI is in
    /// remote mode. On `TakenOver { stale: Some(true) }` it first attempts a
    /// reclaim via `open_remote_session_with_record` (the recorded holder is
    /// itself stale, so `open_session`'s `acquire` will take it over for us,
    /// avoiding a needless takeover notice). On reclaim failure or a live
    /// holder it flips `session_taken_over = true` and pushes a notice so
    /// `handle_event` can short-circuit submissions instead of letting them
    /// fail at the daemon. `Network` errors are just logged — the next tick
    /// retries; a long outage eventually resolves as `TakenOver` or recovery.
    ///
    /// Returns `true` iff this tick changed visible state (so the caller can
    /// skip a needless redraw on `Network` errors / `Ok(())`).
    async fn run_heartbeat_tick(&mut self) -> bool {
        if !self.gateway.is_remote_mode() || self.state.session_taken_over {
            return false;
        }
        let session_id = self.state.session_id.clone();
        match self.gateway.heartbeat_remote_session(&session_id).await {
            Ok(()) => false,
            Err(crate::gateway_api::remote::HeartbeatError::TakenOver { detail, stale }) => {
                // Stale lease: the recorded holder is presumed dead. Attempt
                // a reclaim before flagging a takeover — saves the user a
                // manual `/remote` or `/sessions` round-trip. Only `Some(true)`
                // reclaims; `None` (parse failure) and `Some(false)` (live
                // holder) fall through to the taken-over notice so a parse
                // regression or genuinely-live holder never widens the bypass.
                if stale == Some(true) {
                    tracing::info!(
                        session_id = %session_id,
                        "heartbeat rejected with stale lease; attempting to reclaim via open"
                    );
                    if self
                        .gateway
                        .open_remote_session_with_record(&mut self.state)
                        .await
                        .is_ok()
                    {
                        tracing::info!(
                            session_id = %session_id,
                            "reclaimed stale lease via open; resuming normal operation"
                        );
                        return true;
                    }
                    // Reclaim failed (another client beat us, or network).
                }
                tracing::warn!(
                    session_id = %session_id,
                    detail = %detail,
                    "session taken over by another xiaoo process; further submissions will be rejected"
                );
                self.state.session_taken_over = true;
                // Sync `remote_session_open` so `disconnect_remote` /
                // `close_sessions` skip the now-meaningless detach (the lease
                // is held by another client; our detach would be a no-op).
                self.gateway.mark_remote_session_taken_over();
                self.state
                    .chat_state
                    .messages
                    .push(crate::chat::Message::system(
                        crate::gateway_api::runtime_request::session_taken_over_notice_glyph(),
                    ));
                self.state.chat_state.stick_to_bottom = true;
                true
            }
            Err(crate::gateway_api::remote::HeartbeatError::Network(error)) => {
                tracing::warn!(error = %error, "session heartbeat failed; will retry next tick");
                false
            }
        }
    }

    /// Execute a single hook action received after a turn terminates.
    ///
    /// Remote (daemon) mode: the daemon has already executed daemon-side
    /// effects (open_session for create/switch) via `DaemonHookActionSink`
    /// before forwarding; the TUI only needs to switch UI focus.
    ///
    /// Local (non-daemon) mode: there is no daemon, so no `open_session`
    /// has run. `switch_to_remote_session` checks `remote_base_url()`,
    /// finds None, and drops the action with a `tracing::warn!`. This is
    /// intentional — create_session / switch_session are not supported in
    /// local mode. See [`switch_to_remote_session`].
    async fn execute_hook_action(&mut self, action: agent_types::hook::HookAction) {
        match action {
            agent_types::hook::HookAction::CreateSession { session_id } => {
                tracing::info!(
                    session_id = %session_id,
                    "TUI: hook action create_session — switching focus"
                );
                self.switch_to_remote_session(session_id).await;
            }
            agent_types::hook::HookAction::SwitchSession { session_id } => {
                // Skip the switch (and the transcript reload + "Switched to
                // session" system message it inserts) when the TUI is already
                // focused on the target. This commonly happens when a plugin
                // emits `[create_session X, switch_session X, send_prompt X]`
                // in one batch: create already moved focus to X, so the
                // following switch_session would no-op on the daemon
                // (idempotent `open_session`) but still churn the TUI side.
                if self.state.session_id == session_id {
                    tracing::info!(
                        session_id = %session_id,
                        "TUI: hook action switch_session — already focused, skipping"
                    );
                    return;
                }
                tracing::info!(
                    session_id = %session_id,
                    "TUI: hook action switch_session — switching focus"
                );
                self.switch_to_remote_session(session_id).await;
            }
            agent_types::hook::HookAction::SendPrompt {
                session_id,
                text,
                chain_depth,
            } => {
                // Only takes effect in remote (daemon) mode, matching
                // create_session / switch_session: the action's visible
                // execution (echo + streaming) requires a remote backend the
                // TUI can POST `/runtimes/input` to. In local mode the action
                // is dropped with a warning.
                if self.gateway.remote_base_url().is_none() {
                    tracing::warn!(
                        session_id = %session_id,
                        "send_prompt hook action dropped (local mode does not execute daemon-side actions)"
                    );
                    return;
                }
                if text.trim().is_empty() {
                    tracing::warn!(
                        session_id = %session_id,
                        "send_prompt hook action dropped (empty text)"
                    );
                    return;
                }
                // Switch focus only if not already on the target session;
                // skipping avoids a transcript reload and a redundant
                // "Switched to session" system message. The daemon has
                // already called `open_session` for the target (idempotent)
                // in `DaemonHookActionSink::execute_on_daemon`.
                if self.state.session_id != session_id {
                    tracing::info!(
                        session_id = %session_id,
                        chain_depth,
                        "TUI: hook action send_prompt — switching focus"
                    );
                    self.switch_to_remote_session(session_id).await;
                }
                // After a switch, `apply_remote_session_switch` resets
                // `is_loading` (via `reset_for_new_session`), so the start_turn
                // branch below runs. If the switch was skipped and a turn is
                // still running (e.g. turn A's reveal buffer hasn't fully
                // drained when Done arrived), enqueue so `start_next_queued_turn`
                // drains it once idle — same behavior as a user typing while
                // a turn is running.
                if self.state.chat_state.is_loading {
                    tracing::info!(
                        chain_depth,
                        "TUI: hook action send_prompt — turn in progress, enqueueing"
                    );
                    self.state
                        .chat_state
                        .enqueue_pending_turn(text, None, chain_depth);
                } else if let Err(error) = self
                    .gateway
                    .start_turn_for_hook_prompt(&mut self.state, text, chain_depth)
                    .await
                {
                    tracing::warn!(
                        error = %error,
                        "TUI: hook action send_prompt — start_turn failed"
                    );
                }
            }
        }
    }

    /// Switch the TUI focus to a remote session. The daemon has already
    /// called `/runtimes/open` for the target session; the TUI resets its
    /// state, sets the new session_id, and configures remote mode so the
    /// next turn uses the existing session on the daemon.
    ///
    /// Invoked both by hook actions (Create/SwitchSession received from the
    /// daemon after a turn ends) and by the TUI's `/sessions` built-in
    /// command for manual switching. In local (non-daemon) mode there is no
    /// remote backend to switch to, so the action is dropped with a
    /// `tracing::warn` — daemon-side effects (open_session) cannot be
    /// reproduced locally by this path.
    pub(crate) async fn switch_to_remote_session(&mut self, session_id: String) {
        let Some(base_url) = self.gateway.remote_base_url().map(str::to_string) else {
            tracing::warn!(
                session_id = %session_id,
                "switch_to_remote_session called without remote backend; \
                 hook action dropped (local mode does not execute daemon-side actions)"
            );
            return;
        };
        let token_env = self
            .state
            .agent_config
            .tui
            .remote
            .as_ref()
            .and_then(|remote| remote.bearer_token_env.clone());

        self.apply_remote_session_switch(
            session_id.clone(),
            base_url,
            token_env,
            format!("Switched to session: {session_id}"),
        )
        .await;
    }

    /// Shared body of [`switch_to_remote_session`] and
    /// [`activate_remote_session`]: reset local state for `session_id`,
    /// configure the remote backend, restore the prior transcript from the
    /// daemon, persist the remote-session record, and push `system_message`.
    ///
    /// The daemon's `open_session` call is wrapped in a `tokio::time::timeout`
    /// so an unreachable daemon cannot block the TUI event loop
    /// indefinitely — on timeout we log and continue with an empty
    /// transcript rather than hanging the UI.
    pub(crate) async fn apply_remote_session_switch(
        &mut self,
        session_id: String,
        base_url: String,
        bearer_token_env: Option<String>,
        system_message: String,
    ) {
        self.gateway.reset_for_new_session(&mut self.state);
        self.state.reset_for_new_session();
        self.state.session_id = session_id.clone();
        self.gateway
            .configure_remote(&mut self.state, base_url.clone(), bearer_token_env.clone());

        // Restore conversation context from the daemon. `open_session` is
        // idempotent on the daemon side; calling it here both ensures the
        // session is opened on the daemon (so `remote_session_open` becomes
        // true and the next turn skips the open call) and gives us back
        // the stored `SessionRecord` so we can repopulate local state.
        // Bound the wait so a dead/unreachable daemon does not freeze the
        // TUI event loop.
        let restored_messages = match tokio::time::timeout(
            std::time::Duration::from_secs(10),
            self.gateway
                .open_remote_session_with_record(&mut self.state),
        )
        .await
        {
            Ok(Ok(record)) => {
                let loop_messages = record.loop_state.map(|ls| ls.messages).unwrap_or_default();
                let display_messages =
                    crate::chat::messages_from_chat_messages(loop_messages.clone());
                self.state.session_messages = loop_messages;
                display_messages
            }
            Ok(Err(error)) => {
                tracing::warn!(
                    session_id = %session_id,
                    error = %error,
                    "remote session switch: failed to fetch session record from daemon; \
                     transcript will be empty until the next turn"
                );
                Vec::new()
            }
            Err(_) => {
                tracing::warn!(
                    session_id = %session_id,
                    "remote session switch: open_session timed out after 10 seconds; \
                     transcript will be empty until the next turn"
                );
                Vec::new()
            }
        };

        if !restored_messages.is_empty() {
            self.state.chat_state.messages.extend(restored_messages);
            self.state.chat_state.stick_to_bottom = true;
        }

        let _ = crate::remote_sessions_service::record_remote_session(
            &session_id,
            &base_url,
            bearer_token_env,
            None,
        );

        self.state
            .chat_state
            .messages
            .push(crate::chat::Message::system(system_message));
        self.state.chat_state.stick_to_bottom = true;
    }
}

async fn wait_for_local_models(
    rx: &mut Option<tokio::sync::oneshot::Receiver<Vec<crate::chat::ModelInfo>>>,
) -> Option<Vec<crate::chat::ModelInfo>> {
    match rx {
        Some(inner) => match inner.await {
            Ok(models) => models.into(),
            Err(_) => None,
        },
        None => std::future::pending().await,
    }
}

async fn fetch_models_from_local_api(api_base: &str) -> Vec<crate::chat::ModelInfo> {
    let url = format!("{}/models", api_base.trim_end_matches('/'));
    let client = match reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build()
    {
        Ok(c) => c,
        Err(_) => return vec![],
    };
    let response = match client.get(&url).send().await {
        Ok(r) => r,
        Err(_) => return vec![],
    };
    let body: serde_json::Value = match response.json().await {
        Ok(b) => b,
        Err(_) => return vec![],
    };
    let models = body["data"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|model| {
                    model["id"].as_str().map(|id| crate::chat::ModelInfo {
                        id: id.to_string(),
                        name: id.to_string(),
                    })
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    if models.is_empty() {
        return vec![];
    }
    models
}

fn discard_redundant_boundary_scrolls(
    handled_event: &Event,
    state: &AppState,
    event_stream: &mut EventStream,
    pending_event: &mut Option<Event>,
) {
    let boundary_kind = match handled_event {
        Event::Mouse(mouse)
            if mouse.kind == MouseEventKind::ScrollDown
                && state.active_transcript_scroll_offset()
                    >= state.active_transcript_max_scroll_offset() =>
        {
            Some(MouseEventKind::ScrollDown)
        }
        Event::Mouse(mouse)
            if mouse.kind == MouseEventKind::ScrollUp
                && state.active_transcript_scroll_offset() == 0 =>
        {
            Some(MouseEventKind::ScrollUp)
        }
        _ => None,
    };

    let Some(boundary_kind) = boundary_kind else {
        return;
    };
    let opposite_kind = match boundary_kind {
        MouseEventKind::ScrollDown => MouseEventKind::ScrollUp,
        MouseEventKind::ScrollUp => MouseEventKind::ScrollDown,
        _ => return,
    };

    for _ in 0..128 {
        let Some(ready) = event_stream.next().now_or_never() else {
            break;
        };
        let Some(Ok(event)) = ready else {
            break;
        };

        match &event {
            Event::Mouse(mouse) if mouse.kind == boundary_kind => {
                continue;
            }
            Event::Mouse(mouse) if mouse.kind == MouseEventKind::Moved => {
                continue;
            }
            Event::Mouse(mouse) if mouse.kind == opposite_kind => {
                *pending_event = Some(event);
                return;
            }
            _ => {
                *pending_event = Some(event);
                return;
            }
        }
    }
}

fn set_cursor_color(color: ratatui::style::Color) {
    let Some(value) = color_to_ansi(color) else {
        return;
    };
    let _ = io::stdout().write_all(format!("\x1b]12;{value}\x07").as_bytes());
    let _ = io::stdout().flush();
}

fn reset_cursor_color() {
    let _ = io::stdout().write_all(b"\x1b]112\x07");
    let _ = io::stdout().flush();
}

fn color_to_ansi(color: ratatui::style::Color) -> Option<String> {
    match color {
        ratatui::style::Color::Rgb(r, g, b) => Some(format!("#{r:02x}{g:02x}{b:02x}")),
        ratatui::style::Color::Black => Some("black".to_string()),
        ratatui::style::Color::Red => Some("red".to_string()),
        ratatui::style::Color::Green => Some("green".to_string()),
        ratatui::style::Color::Yellow => Some("yellow".to_string()),
        ratatui::style::Color::Blue => Some("blue".to_string()),
        ratatui::style::Color::Magenta => Some("magenta".to_string()),
        ratatui::style::Color::Cyan => Some("cyan".to_string()),
        ratatui::style::Color::Gray => Some("gray".to_string()),
        ratatui::style::Color::DarkGray => Some("darkgray".to_string()),
        ratatui::style::Color::LightRed => Some("lightred".to_string()),
        ratatui::style::Color::LightGreen => Some("lightgreen".to_string()),
        ratatui::style::Color::LightYellow => Some("lightyellow".to_string()),
        ratatui::style::Color::LightBlue => Some("lightblue".to_string()),
        ratatui::style::Color::LightMagenta => Some("lightmagenta".to_string()),
        ratatui::style::Color::LightCyan => Some("lightcyan".to_string()),
        ratatui::style::Color::White => Some("white".to_string()),
        ratatui::style::Color::Indexed(index) => Some(index.to_string()),
        ratatui::style::Color::Reset => None,
    }
}