use std::path::Path;

use clap::{Parser, Subcommand};
use diffler::app::{self, App, CiRequest, Flow};
use diffler::event::AppEvent;
use diffler::{ci, clipboard, config, editor, event, mcp, ui, watch};
use diffler_core::review::Review;
use ratatui::DefaultTerminal;
use tokio::sync::mpsc;

/// Terminal code review for AI coding agents.
#[derive(Parser, Debug)]
#[command(version, about)]
struct Cli {
    /// Path to the repository to review (defaults to the current directory)
    #[arg(default_value = ".")]
    path: String,

    /// MCP server port (overrides config key `mcp.port`)
    #[arg(long, global = true)]
    port: Option<u16>,

    /// Disable the MCP server (overrides config key `mcp.enabled`)
    #[arg(long, global = true)]
    no_mcp: bool,

    /// UI theme name (overrides `ui.theme`)
    #[arg(long, global = true)]
    theme: Option<String>,

    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Inspect configuration
    Config {
        /// Print the merged effective config with per-key origins
        #[arg(long)]
        dump: bool,
    },
}

#[tokio::main]
async fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;
    let cli = Cli::parse();
    let repo = diffler_core::repo::discover(std::path::Path::new(&cli.path));

    let overrides = config::CliOverrides {
        port: cli.port,
        mcp_enabled: cli.no_mcp.then_some(false),
        theme: cli.theme.clone(),
    };
    let loaded = config::load(repo.as_deref().ok(), &overrides)?;

    match cli.command {
        Some(Command::Config { dump: true }) => print_config_dump(&loaded),
        Some(Command::Config { dump: false }) => Err(color_eyre::eyre::eyre!(
            "nothing to do: try `diffler config --dump`"
        )),
        None => {
            // fail before touching the terminal so the error stays readable
            let root = match repo {
                Ok(root) => root,
                Err(err) => exit_without_repo(&err, &cli.path),
            };
            let review = Review::open_with_context(&root, loaded.config.ui.context_lines)?;
            let app = App::new(review, loaded);
            let terminal = ratatui::init();
            set_mouse_capture(true);
            install_mouse_panic_hook();
            let result = run(terminal, app).await;
            set_mouse_capture(false);
            ratatui::restore();
            result
        }
    }
}

/// Toggle SGR mouse reporting (crossterm uses mode 1006, which tmux forwards to
/// apps that request it). Best-effort: a terminal without mouse support just
/// ignores it.
fn set_mouse_capture(on: bool) {
    use crossterm::event::{
        DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture,
    };
    let mut out = std::io::stdout();
    // focus reporting rides along: a terminal without it ignores the private
    // mode, and knowing when nobody is looking is what keeps the CI poll cheap
    let _ = if on {
        crossterm::execute!(out, EnableMouseCapture, EnableFocusChange)
    } else {
        crossterm::execute!(out, DisableMouseCapture, DisableFocusChange)
    };
}

/// Chain mouse-disable ahead of the existing (ratatui screen-restore) panic
/// hook, so a crash doesn't leave the terminal emitting mouse escape codes.
fn install_mouse_panic_hook() {
    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        set_mouse_capture(false);
        previous(info);
    }));
}

// a missing repository is user error, not a crash: a plain line and a hint beat
// an eyre report with a source location pointing into our own main
#[allow(clippy::print_stderr)]
fn exit_without_repo(err: &diffler_core::repo::RepoError, path: &str) -> ! {
    let shown = std::fs::canonicalize(path)
        .map_or_else(|_| path.to_owned(), |full| full.display().to_string());
    match err {
        diffler_core::repo::RepoError::NotFound(_) => {
            eprintln!("diffler: no git repository at {shown} or any parent");
            eprintln!(
                "start one with `git init`, or point diffler at a repository: diffler <path>"
            );
        }
        diffler_core::repo::RepoError::Bare(_) => {
            eprintln!("diffler: {shown} is a bare repository, which has nothing to review");
            eprintln!("open a clone with a working tree instead");
        }
        git @ diffler_core::repo::RepoError::Git(_) => eprintln!("diffler: {git}"),
    }
    std::process::exit(1)
}

// stdout is correct for a non-TUI subcommand; the workspace print_stdout lint
// exists to keep the TUI screen intact, which never starts on this path
#[allow(clippy::print_stdout)]
fn print_config_dump(loaded: &config::LoadedConfig) -> color_eyre::Result<()> {
    print!("{}", config::render_dump(loaded)?);
    Ok(())
}

async fn run(mut terminal: DefaultTerminal, mut app: App) -> color_eyre::Result<()> {
    let (tx, mut rx) = mpsc::unbounded_channel();
    let mut events = event::spawn_event_loop(tx.clone());
    // a missing watcher is not fatal: the app falls back to periodic polling
    let git_dir = app
        .review
        .vcs
        .git_dir()
        .unwrap_or_else(|_| app.review.repo_root.join(".git"));
    let watcher = watch::spawn_watcher(&app.review.repo_root, &git_dir, tx.clone()).ok();
    if let Some(handle) = &watcher {
        app.watcher_healthy = Some(handle.healthy.clone());
    }
    let mcp = start_mcp(&mut app, &tx);
    let mut needs_draw = true;
    loop {
        if needs_draw {
            // rendering writes to the tty and handling an event runs git: both
            // block, so hand the worker back to the runtime for the duration or
            // a slow repo stalls the MCP server and the watcher with it
            tokio::task::block_in_place(|| terminal.draw(|frame| ui::draw(frame, &mut app)))?;
            needs_draw = false;
        }
        if let Some(text) = app.pending_clipboard.take() {
            // OSC52 addresses the terminal emulator, not the screen: it must
            // bypass ratatui's buffer and go out raw, right after the draw so
            // it cannot interleave with one. The native CLI pipe (on a blocking
            // thread, since it spawns a process) covers terminals without OSC52.
            use std::io::Write as _;
            let mut out = std::io::stdout();
            out.write_all(clipboard::osc52(&text).as_bytes())?;
            out.flush()?;
            tokio::task::spawn_blocking(move || clipboard::native_copy(&text));
        }
        if let Some(request) = app.pending_editor.take() {
            // the event pump must release the tty before the editor gets
            // it, or both end up reading the same keystrokes
            events.abort();
            let _ = (&mut events).await;
            set_mouse_capture(false);
            ratatui::restore();
            let editor::EditorRequest { cmd, purpose } = request;
            let outcome = tokio::task::spawn_blocking(move || editor::run(&cmd))
                .await
                .map_err(|err| err.to_string())
                .and_then(|result| result.map_err(|err| err.to_string()));
            terminal = ratatui::init();
            set_mouse_capture(true);
            terminal.clear()?;
            events = event::spawn_event_loop(tx.clone());
            app.editor_finished(purpose, outcome);
            // the screen came back blank from the suspend
            needs_draw = true;
            continue;
        }
        if let Some(app::GitOp { label, argv }) = app.pending_git.take() {
            // the terminal stays up: spawn the process on a blocking thread
            // with a tx clone so the result returns as an event and the loop
            // keeps drawing the "running …" status meanwhile
            let repo_root = app.review.repo_root.clone();
            let tx = tx.clone();
            tokio::task::spawn_blocking(move || {
                let _ = tx.send(run_git(&label, &argv, &repo_root));
            });
            continue;
        }
        dispatch_workers(&mut app, &tx);
        if let Some(request) = app.pending_ci.take() {
            // service the CI provider call off-thread; the result returns as an
            // event so the active CI screen stays live without blocking the loop
            dispatch_ci(&app, request, &tx);
            continue;
        }
        let Some(event) = rx.recv().await else { break };
        match tokio::task::block_in_place(|| app.handle(event)) {
            Flow::Quit => break,
            Flow::Continue => needs_draw = true,
            Flow::Idle => {}
        }
        // drain whatever queued while handling so one draw covers the batch:
        // held-down keys and watcher bursts otherwise pay a full draw (and its
        // first-view enrichment) per event and the UI lags behind the input
        let mut quit = false;
        while let Ok(event) = rx.try_recv() {
            match tokio::task::block_in_place(|| app.handle(event)) {
                Flow::Quit => {
                    quit = true;
                    break;
                }
                Flow::Continue => needs_draw = true,
                Flow::Idle => {}
            }
            if app.pending_editor.is_some()
                || app.pending_git.is_some()
                || app.pending_ci.is_some()
                || app.pending_clipboard.is_some()
            {
                break;
            }
        }
        if quit {
            break;
        }
    }
    events.abort();
    if let Some(mcp) = mcp {
        mcp.handle.abort();
        mcp::clear_endpoint(&app.review.repo_root, mcp.port);
    }
    drop(watcher);
    Ok(())
}

/// Spawn the MCP server when enabled and publish its endpoint for the stdio
/// proxy to discover. A startup failure disables MCP for the session; a
/// failed endpoint write only loses proxy discovery, so it surfaces as a
/// toast rather than aborting startup.
fn start_mcp(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) -> Option<mcp::McpHandle> {
    if !app.config.mcp.enabled {
        return None;
    }
    let handle = match mcp::spawn_mcp(tx.clone(), app.feedback_tx.subscribe(), app.config.mcp.port)
    {
        Ok(handle) => handle,
        Err(err) => {
            app.error(format!("mcp server failed to start: {err}"));
            return None;
        }
    };
    app.mcp_port = Some(handle.port);
    if let Err(err) = mcp::write_endpoint(&app.review.repo_root, handle.port) {
        // the proxy that agents connect through discovers the port from
        // this file; a write failure means it silently can't find diffler
        app.error(format!("failed to write mcp endpoint file: {err}"));
    }
    Some(handle)
}

/// Start the off-thread repo refresh when one is queued and none is running.
fn dispatch_refresh(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
    if app.refresh_state != app::RefreshState::Queued {
        return;
    }
    app.refresh_state = app::RefreshState::Running;
    let root = app.review.repo_root.clone();
    let context = app.config.ui.context_lines;
    let against = app.against_rev().map(str::to_owned);
    let tx = tx.clone();
    tokio::task::spawn_blocking(move || {
        let result =
            diffler_core::review::Review::compute_refresh(&root, context, against.as_deref())
                .map_err(|err| err.to_string());
        let _ = tx.send(AppEvent::RefreshDone(Box::new(result)));
    });
}

/// Load a requested file's text and blame off the main task, highlighting it
/// on the same worker so the screen opens fully rendered.
fn dispatch_file(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
    let Some(request) = app.pending_file.take() else {
        return;
    };
    let tx = tx.clone();
    let root = app.review.repo_root.clone();
    let highlighter = std::sync::Arc::clone(&app.highlighter);
    tokio::task::spawn_blocking(move || {
        let result = diffler_core::review::Review::compute_file(&root, &request.path)
            .map_err(|err| format!("{}: {err}", request.path))
            .map(|snapshot| {
                let highlights = highlighter.highlight(&snapshot.path, &snapshot.content);
                app::file::FileView::new(
                    snapshot.path,
                    &snapshot.content,
                    highlights,
                    snapshot.blame,
                    request.blame,
                )
            });
        let _ = tx.send(AppEvent::FileLoaded {
            result: Box::new(result),
            line: request.line,
            token: request.token,
        });
    });
}

/// Read the git attributes of the kinds sidebar's files off the main task: one
/// lookup walks the attribute files, so a whole diff's worth would stall the
/// loop.
fn dispatch_declared(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
    let Some(request) = app.pending_declared.take() else {
        return;
    };
    let tx = tx.clone();
    let root = app.review.repo_root.clone();
    tokio::task::spawn_blocking(move || {
        let kinds = diffler_core::review::Review::compute_declared(&root, &request.paths)
            .unwrap_or_default();
        let _ = tx.send(AppEvent::DeclaredKinds {
            kinds,
            token: request.token,
        });
    });
}

fn dispatch_workers(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
    dispatch_enrich(app, tx);
    dispatch_file(app, tx);
    dispatch_declared(app, tx);
    dispatch_pr_posts(app, tx);
    dispatch_refresh(app, tx);
}

/// The provider for one detected remote, with the app context every adapter
/// needs cloned in.
fn provider_for(
    remote: &diffler::app::CiRemote,
    repo_root: &Path,
    branch: Option<&str>,
    yaml_cache: ci::YamlCache,
    etags: ci::EtagCache,
) -> Box<dyn ci::ForgeProvider + Send> {
    ci::build_provider(
        &remote.detected,
        repo_root,
        branch,
        remote.url.as_deref(),
        yaml_cache,
        etags,
    )
}

/// Push each queued PR comment/reply to the forge via the primary remote's
/// provider; results return as events that stamp the forge ids.
fn dispatch_pr_posts(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
    if app.pending_pr_posts.is_empty() {
        return;
    }
    let Some(remote) = app.ci_remotes().first().cloned() else {
        app.drop_pending_pr_posts();
        return;
    };
    let repo_root = app.review.repo_root.clone();
    let branch = app.head.branch.clone();
    let yaml_cache = app.ci_yaml_cache.clone();
    let etags = app.ci_etags.clone();
    for post in std::mem::take(&mut app.pending_pr_posts) {
        let tx = tx.clone();
        let repo_root = repo_root.clone();
        let branch = branch.clone();
        let yaml_cache = yaml_cache.clone();
        let etags = etags.clone();
        let remote = remote.clone();
        tokio::spawn(async move {
            let provider = provider_for(&remote, &repo_root, branch.as_deref(), yaml_cache, etags);
            let result = match &post {
                app::pr::PrPost::Review { review, .. } => {
                    provider.submit_pr_review(review).await.map(|()| None)
                }
                app::pr::PrPost::Reply {
                    number,
                    parent_remote_id,
                    body,
                    ..
                } => provider
                    .reply_pr_comment(*number, parent_remote_id, body)
                    .await
                    .map(Some),
                app::pr::PrPost::Resolve {
                    number,
                    thread_id,
                    resolved,
                    ..
                } => provider
                    .resolve_pr_thread(*number, thread_id, *resolved)
                    .await
                    .map(|()| None),
                app::pr::PrPost::Edit {
                    number,
                    remote_id,
                    body,
                    ..
                } => provider
                    .update_pr_comment(*number, remote_id, body)
                    .await
                    .map(|()| None),
                app::pr::PrPost::Delete {
                    number, remote_id, ..
                } => provider
                    .delete_pr_comment(*number, remote_id)
                    .await
                    .map(|()| None),
            };
            let _ = tx.send(AppEvent::PrPosted {
                post: Box::new(post),
                result: result.map_err(|err| err.to_string()),
            });
        });
    }
}

/// Spawn a blocking-pool worker per queued enrichment job; results return as
/// events so the pane fills in without blocking input.
fn dispatch_enrich(app: &mut App, tx: &mpsc::UnboundedSender<AppEvent>) {
    for job in app.pending_enrich.drain(..) {
        let tx = tx.clone();
        let highlighter = std::sync::Arc::clone(&app.highlighter);
        tokio::task::spawn_blocking(move || {
            let outcome = app::enrich::run_enrich(&highlighter, job);
            let _ = tx.send(AppEvent::Enriched(Box::new(outcome)));
        });
    }
}

/// Service a CI provider request off the event loop, turning the result (or
/// error) into the matching [`AppEvent`] the loop folds back into the screen.
/// Spawn the off-thread provider call(s) for a CI request: `Runs` aggregates
/// every remote (tagging each run when more than one); detail/log/extras route
/// to the run's remote, `Pr` to the primary.
fn dispatch_ci(app: &App, request: CiRequest, tx: &mpsc::UnboundedSender<AppEvent>) {
    let remotes = app.ci_remotes();
    if remotes.is_empty() {
        return;
    }
    let repo_root = app.review.repo_root.clone();
    let branch = app.head.branch.clone();
    let yaml_cache = app.ci_yaml_cache.clone();
    let etags = app.ci_etags.clone();
    let tx = tx.clone();
    if matches!(request, CiRequest::Runs) {
        let multi = remotes.len() > 1;
        tokio::spawn(async move {
            let mut all = Vec::new();
            for remote in &remotes {
                let provider = provider_for(
                    remote,
                    &repo_root,
                    branch.as_deref(),
                    yaml_cache.clone(),
                    etags.clone(),
                );
                if let Ok(mut runs) = provider.list_runs(30).await {
                    if multi {
                        for run in &mut runs {
                            run.remote = Some(remote.name.clone());
                        }
                    }
                    all.extend(runs);
                }
            }
            all.sort_by_key(|run| std::cmp::Reverse(run.created));
            let _ = tx.send(AppEvent::CiRuns(all));
        });
        return;
    }
    let remote = match &request {
        CiRequest::Detail(_) | CiRequest::Extras(_) | CiRequest::Log { .. } => {
            app.ci_remote_for_open_run()
        }
        _ => remotes.first().cloned(),
    };
    if let Some(remote) = remote {
        let provider = provider_for(&remote, &repo_root, branch.as_deref(), yaml_cache, etags);
        tokio::spawn(async move {
            let _ = tx.send(run_ci_request(provider, request).await);
        });
    }
}

async fn run_ci_request(
    provider: Box<dyn crate::ci::ForgeProvider + Send>,
    request: CiRequest,
) -> AppEvent {
    match request {
        CiRequest::Runs => match provider.list_runs(30).await {
            Ok(runs) => AppEvent::CiRuns(runs),
            Err(err) => AppEvent::CiError(err.to_string()),
        },
        CiRequest::Pr => AppEvent::CiPr(provider.current_pr().await.unwrap_or(None)),
        CiRequest::CreatePr(new) => AppEvent::PrCreated(Box::new(
            provider
                .create_pr(&new)
                .await
                .map_err(|err| err.to_string()),
        )),
        CiRequest::Prs => match provider.list_prs().await {
            Ok(prs) => AppEvent::CiPrs(prs),
            Err(err) => AppEvent::CiPrsError(err.to_string()),
        },
        CiRequest::PrComments(number) => match provider.pr_comments(number).await {
            Ok(comments) => AppEvent::PrComments {
                number,
                comments,
                // best-effort: a failed lookup only skips force-push detection
                pr: provider.pr(number).await.ok(),
            },
            Err(err) => AppEvent::CiError(err.to_string()),
        },
        CiRequest::Detail(id) => match provider.run_detail(&id).await {
            Ok(detail) => AppEvent::CiRunDetail(detail),
            Err(err) => AppEvent::CiError(err.to_string()),
        },
        CiRequest::Extras(id) => match provider.run_extras(&id).await {
            Ok(extras) => AppEvent::CiExtras(extras),
            Err(err) => AppEvent::CiError(err.to_string()),
        },
        CiRequest::Log { run, job, offset } => match provider.job_log(&run, &job, offset).await {
            Ok(chunk) => AppEvent::CiLog {
                text: chunk.text,
                steps: chunk.steps,
                next_offset: chunk.next_offset,
                done: chunk.done,
            },
            Err(err) => AppEvent::CiError(err.to_string()),
        },
    }
}

/// Run a network git op as a child process in `repo_root`, capturing its
/// combined output. Runs on a blocking thread; the result is an [`AppEvent`]
/// the run loop folds back into the status bar. Shelling to the user's `git`
/// keeps credentials entirely out of diffler.
fn run_git(label: &str, argv: &[String], repo_root: &Path) -> AppEvent {
    let Some((program, rest)) = argv.split_first() else {
        return AppEvent::GitDone {
            label: label.to_owned(),
            ok: false,
            output: "empty command".to_owned(),
        };
    };
    match std::process::Command::new(program)
        .args(rest)
        .current_dir(repo_root)
        .output()
    {
        Ok(out) => {
            let mut output = String::from_utf8_lossy(&out.stderr).into_owned();
            output.push_str(&String::from_utf8_lossy(&out.stdout));
            AppEvent::GitDone {
                label: label.to_owned(),
                ok: out.status.success(),
                output,
            }
        }
        Err(err) => AppEvent::GitDone {
            label: label.to_owned(),
            ok: false,
            output: format!("cannot run {program}: {err}"),
        },
    }
}
