use std::io;
use std::io::Write;
use std::sync::Arc;
use std::time::Duration;

use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use futures::{Stream, StreamExt};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use thiserror::Error;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;

use capo_agent::{Command, UiEvent};

use crate::events::{Event, InputEvent};
use crate::render::render;
use crate::state::AppState;
use crate::update::update;

#[allow(deprecated)]
type PanicHook = Box<dyn Fn(&std::panic::PanicInfo<'_>) + Sync + Send + 'static>;

#[derive(Error, Debug)]
pub enum TuiError {
    #[error("io: {0}")]
    Io(#[from] io::Error),
    #[error("render: {0}")]
    Render(String),
}

/// Top-level TUI entry point. Drives the MVU loop until `should_quit` is set
/// by an update, or the UI stream ends.
pub async fn run_tui(
    initial_state: AppState,
    ui_stream: impl Stream<Item = UiEvent> + Unpin + Send + 'static,
    command_tx: mpsc::Sender<Command>,
) -> Result<AppState, TuiError> {
    let _guard = TerminalGuard::init()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
    terminal.clear()?;

    let state = initial_state;
    super::runtime::drive(state, ui_stream, command_tx, &mut terminal).await
}

/// RAII guard that enables raw mode + alt screen on creation and restores
/// on drop (including via panic).
pub struct TerminalGuard {
    original_hook: Option<Arc<PanicHook>>,
}

impl TerminalGuard {
    pub fn init() -> Result<Self, TuiError> {
        enable_raw_mode()?;
        if let Err(err) = execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste) {
            restore_terminal();
            return Err(err.into());
        }
        // DECSET 1007 (alternate-scroll): inside the alt-screen, modern
        // terminals translate the mouse wheel into Up/Down key events so
        // the app can scroll its own viewport without claiming the mouse.
        // Mirrors codex's `tui.rs:182-198` — capo never calls
        // `EnableMouseCapture`, so native click-drag selection + Cmd+C
        // keep working. Ignore IO errors (older terminals just no-op).
        let _ = write!(io::stdout(), "\x1b[?1007h");
        let _ = io::stdout().flush();

        // Attach a panic hook that restores the terminal before delegating to
        // the previously installed hook so panic messages still print normally.
        let original_hook = Arc::new(std::panic::take_hook());
        let hook_for_panic = Arc::clone(&original_hook);
        std::panic::set_hook(Box::new(move |info| {
            restore_terminal();
            hook_for_panic(info);
        }));

        Ok(Self {
            original_hook: Some(original_hook),
        })
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        restore_terminal();

        // `set_hook`/`take_hook` panic while unwinding. During panic, the hook
        // has already restored the terminal; normal exits restore the prior hook.
        if std::thread::panicking() {
            return;
        }

        let _installed_hook = std::panic::take_hook();
        if let Some(original_hook) = self.original_hook.take() {
            match Arc::try_unwrap(original_hook) {
                Ok(hook) => std::panic::set_hook(hook),
                Err(hook) => std::panic::set_hook(Box::new(move |info| hook(info))),
            }
        }
    }
}

fn restore_terminal() {
    let _ = disable_raw_mode();
    // Pair with the DECSET 1007 set in `init()`. Sent before LeaveAlternateScreen
    // so the escape lands while the alt-screen is still owned by us.
    let _ = write!(io::stdout(), "\x1b[?1007l");
    let _ = io::stdout().flush();
    let _ = execute!(io::stdout(), DisableBracketedPaste, LeaveAlternateScreen);
}

async fn drive(
    mut state: AppState,
    mut ui_stream: impl Stream<Item = UiEvent> + Unpin + Send,
    command_tx: mpsc::Sender<Command>,
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<AppState, TuiError> {
    let (input_tx, input_rx) = mpsc::channel::<InputEvent>(64);
    let _runtime_tasks = RuntimeTasks {
        input: spawn_input_task(input_tx.clone()),
        tick: spawn_tick_task(input_tx, Duration::from_millis(100)),
    };
    let mut input_stream = ReceiverStream::new(input_rx);

    // Always draw at least once before awaiting events (cold start).
    terminal.draw(|f| render(&state, f))?;
    let mut last_draw = std::time::Instant::now();
    let frame_interval = Duration::from_millis(50);

    loop {
        if state.should_quit {
            break;
        }

        tokio::select! {
            Some(input) = input_stream.next() => {
                let force = !matches!(input, InputEvent::Tick);
                let cmds = update(&mut state, Event::Input(input));
                dispatch(&command_tx, cmds).await;
                if force || last_draw.elapsed() >= frame_interval {
                    terminal.draw(|f| render(&state, f))?;
                    last_draw = std::time::Instant::now();
                }
            }
            ui = ui_stream.next() => {
                let Some(ui) = ui else {
                    break;
                };
                let force = should_force_draw(&ui);
                let cmds = update(&mut state, Event::Ui(ui));
                dispatch(&command_tx, cmds).await;
                if force || last_draw.elapsed() >= frame_interval {
                    terminal.draw(|f| render(&state, f))?;
                    last_draw = std::time::Instant::now();
                }
            }
            else => break,
        }
    }

    Ok(state)
}

fn should_force_draw(ui: &UiEvent) -> bool {
    matches!(
        ui,
        UiEvent::AgentTurnComplete
            | UiEvent::ToolCallCompleted { .. }
            | UiEvent::Error(_)
            | UiEvent::PermissionRequested { .. }
            | UiEvent::PermissionModeChanged { .. }
            | UiEvent::SettingsSnapshot { .. }
            | UiEvent::SessionReplaced { .. }
            | UiEvent::ModelSwitched(_)
    )
}

/// Same loop as `drive`, but without the real terminal. For tests.
pub async fn drive_headless(
    mut state: AppState,
    mut ui_stream: impl Stream<Item = UiEvent> + Unpin + Send,
    command_tx: mpsc::Sender<Command>,
) -> Result<AppState, TuiError> {
    loop {
        if state.should_quit {
            break;
        }
        match ui_stream.next().await {
            Some(ui) => {
                let cmds = update(&mut state, Event::Ui(ui));
                dispatch(&command_tx, cmds).await;
            }
            None => break,
        }
    }
    Ok(state)
}

async fn dispatch(tx: &mpsc::Sender<Command>, cmds: Vec<Command>) {
    for cmd in cmds {
        if tx.send(cmd).await.is_err() {
            tracing::warn!("command channel closed; dropping remaining commands");
            break;
        }
    }
}

struct RuntimeTasks {
    input: tokio::task::JoinHandle<()>,
    tick: tokio::task::JoinHandle<()>,
}

impl Drop for RuntimeTasks {
    fn drop(&mut self) {
        self.input.abort();
        self.tick.abort();
    }
}

fn spawn_input_task(tx: mpsc::Sender<InputEvent>) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let mut reader = crossterm::event::EventStream::new();
        while let Some(Ok(ev)) = reader.next().await {
            let input = match ev {
                crossterm::event::Event::Key(k) => InputEvent::Key(k),
                crossterm::event::Event::Resize(w, h) => InputEvent::Resize(w, h),
                crossterm::event::Event::Paste(s) => InputEvent::Paste(s),
                // Mouse events are intentionally dropped: capo doesn't call
                // `EnableMouseCapture`, so the terminal keeps native
                // selection + Cmd+C. Wheel scrolling reaches us as Up/Down
                // key events via DECSET 1007 (set in `TerminalGuard::init`).
                _ => continue,
            };
            if tx.send(input).await.is_err() {
                break;
            }
        }
    })
}

fn spawn_tick_task(
    tx: mpsc::Sender<InputEvent>,
    interval: Duration,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let mut ticker = tokio::time::interval(interval);
        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
        loop {
            ticker.tick().await;
            if tx.send(InputEvent::Tick).await.is_err() {
                break;
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn session_and_model_replacement_events_force_draw() {
        assert!(should_force_draw(&UiEvent::SessionReplaced {
            session_id: "test".into(),
            history: Vec::new(),
        }));
        assert!(should_force_draw(&UiEvent::ModelSwitched(
            capo_agent::ModelId::from("new-model")
        )));
    }

    #[test]
    fn permission_mode_changed_forces_draw() {
        assert!(should_force_draw(&UiEvent::PermissionModeChanged {
            mode: capo_agent::PermissionMode::Bypass,
        }));
    }

    #[test]
    fn settings_snapshot_forces_draw() {
        assert!(should_force_draw(&UiEvent::SettingsSnapshot {
            settings: capo_agent::Settings::default(),
        }));
    }
}
