//! Batched command runner used by `install` (bootstrap) and `up`.
//!
//! Jobs flagged `interactive` run first, one at a time, with stdio inherited so
//! the user can answer wizard prompts. The rest are executed concurrently with
//! per-line `indicatif` spinners — the mise-style parallel UI.

use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

use anyhow::Result;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};

use crate::ui::style;

use super::exec;

const TAIL_CAP: usize = 30;
const MSG_WIDTH: usize = 80;

pub struct Job {
    pub name: String,
    pub command: String,
    pub interactive: bool,
}

#[derive(Debug)]
pub struct Outcome {
    pub name: String,
    pub success: bool,
    pub tail: String,
    pub error: Option<String>,
    pub cancelled: bool,
}

static CANCELLED: AtomicBool = AtomicBool::new(false);
static CHILD_PIDS: Mutex<Vec<i32>> = Mutex::new(Vec::new());

/// Install a process-wide Ctrl-C handler. First press flips the cancel flag and
/// SIGTERMs every tracked child's process group; a second press escalates to
/// SIGKILL. Safe to call once per process — subsequent calls are a no-op.
pub fn install_cancel_handler() -> Result<()> {
    static INSTALLED: OnceLock<()> = OnceLock::new();
    if INSTALLED.get().is_some() {
        return Ok(());
    }
    ctrlc::set_handler(|| {
        let first = !CANCELLED.swap(true, Ordering::SeqCst);
        #[cfg(unix)]
        {
            let sig = if first { libc::SIGTERM } else { libc::SIGKILL };
            let pids: Vec<i32> = CHILD_PIDS.lock().map(|g| g.clone()).unwrap_or_default();
            for pid in pids {
                // Negative pid targets the whole process group, so grandchildren
                // spawned by installer scripts die with their shell.
                unsafe { libc::kill(-pid, sig) };
            }
            if first {
                // Some installers trap SIGTERM for cleanup. Give them 2s, then
                // SIGKILL stragglers so one Ctrl-C is enough in practice.
                thread::spawn(|| {
                    thread::sleep(Duration::from_secs(2));
                    let pids: Vec<i32> = CHILD_PIDS.lock().map(|g| g.clone()).unwrap_or_default();
                    for pid in pids {
                        unsafe { libc::kill(-pid, libc::SIGKILL) };
                    }
                });
            }
        }
        #[cfg(not(unix))]
        let _ = first;
    })?;
    INSTALLED.set(()).ok();
    Ok(())
}

pub fn is_cancelled() -> bool {
    CANCELLED.load(Ordering::SeqCst)
}

fn register_child(pid: u32) {
    if let Ok(mut v) = CHILD_PIDS.lock() {
        v.push(pid as i32);
    }
}

fn unregister_child(pid: u32) {
    if let Ok(mut v) = CHILD_PIDS.lock() {
        if let Some(i) = v.iter().position(|&p| p == pid as i32) {
            v.swap_remove(i);
        }
    }
}

fn cancelled_outcome(name: &str) -> Outcome {
    Outcome {
        name: name.to_string(),
        success: false,
        tail: String::new(),
        error: Some("cancelled".into()),
        cancelled: true,
    }
}

/// While alive, suppresses the tty's `^C` echo so a Ctrl-C during the parallel
/// UI doesn't scribble into the bar region and desync indicatif's row tracking.
/// SIGINT itself is unaffected — it still reaches our ctrlc handler. Unix-only;
/// a no-op elsewhere.
#[cfg(unix)]
struct EchoCtlGuard {
    restore: bool,
}

#[cfg(unix)]
impl EchoCtlGuard {
    fn disable() -> Self {
        use std::os::fd::AsRawFd;
        let fd = std::io::stdin().as_raw_fd();
        let mut t: libc::termios = unsafe { std::mem::zeroed() };
        if unsafe { libc::tcgetattr(fd, &mut t) } != 0 {
            return Self { restore: false };
        }
        if t.c_lflag & libc::ECHOCTL == 0 {
            return Self { restore: false };
        }
        t.c_lflag &= !libc::ECHOCTL;
        if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &t) } != 0 {
            return Self { restore: false };
        }
        Self { restore: true }
    }
}

#[cfg(unix)]
impl Drop for EchoCtlGuard {
    fn drop(&mut self) {
        if !self.restore {
            return;
        }
        use std::os::fd::AsRawFd;
        let fd = std::io::stdin().as_raw_fd();
        let mut t: libc::termios = unsafe { std::mem::zeroed() };
        if unsafe { libc::tcgetattr(fd, &mut t) } != 0 {
            return;
        }
        t.c_lflag |= libc::ECHOCTL;
        unsafe { libc::tcsetattr(fd, libc::TCSANOW, &t) };
    }
}

#[cfg(not(unix))]
struct EchoCtlGuard;

#[cfg(not(unix))]
impl EchoCtlGuard {
    fn disable() -> Self {
        Self
    }
}

/// Run every job. Interactive jobs run serially first, then the rest run in
/// parallel. Returned outcomes preserve the original `jobs` order.
pub fn run_batch(jobs: Vec<Job>) -> Vec<Outcome> {
    let mut order: Vec<String> = jobs.iter().map(|j| j.name.clone()).collect();
    let (interactive, parallel): (Vec<_>, Vec<_>) = jobs.into_iter().partition(|j| j.interactive);

    let mut by_name: std::collections::HashMap<String, Outcome> = std::collections::HashMap::new();

    if !interactive.is_empty() {
        println!("{}", style::bold("Interactive (serial):"));
        for job in interactive {
            let outcome = if is_cancelled() {
                println!("  {} {}", style::cancel_mark(), job.name);
                cancelled_outcome(&job.name)
            } else {
                run_inline(&job)
            };
            by_name.insert(outcome.name.clone(), outcome);
        }
    }

    if !parallel.is_empty() {
        if !by_name.is_empty() {
            println!();
        }
        println!("{}", style::bold("Parallel:"));
        for outcome in run_parallel(&parallel) {
            by_name.insert(outcome.name.clone(), outcome);
        }
    }

    order
        .drain(..)
        .map(|n| {
            by_name
                .remove(&n)
                .expect("every job emits exactly one outcome")
        })
        .collect()
}

fn run_inline(job: &Job) -> Outcome {
    println!("  {} {}", style::step_mark(), job.name);
    match exec::run_shell(&job.command) {
        Ok(()) => {
            println!("  {} {}", style::ok_mark(), job.name);
            Outcome {
                name: job.name.clone(),
                success: true,
                tail: String::new(),
                error: None,
                cancelled: false,
            }
        }
        Err(e) => {
            let cancelled = is_cancelled();
            let err = e.to_string();
            let mark = if cancelled {
                style::cancel_mark()
            } else {
                style::fail_mark()
            };
            eprintln!("  {} {} {err}", mark, job.name);
            Outcome {
                name: job.name.clone(),
                success: false,
                tail: String::new(),
                error: Some(err),
                cancelled,
            }
        }
    }
}

fn run_parallel(jobs: &[Job]) -> Vec<Outcome> {
    let _echoctl = EchoCtlGuard::disable();
    let mp = MultiProgress::new();
    let style = ProgressStyle::with_template("{spinner:.cyan} {prefix:<18.bold} {wide_msg}")
        .unwrap()
        .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✓"]);
    let prefix_width = jobs.iter().map(|j| j.name.len()).max().unwrap_or(0);

    let bars: Vec<ProgressBar> = jobs
        .iter()
        .map(|job| {
            let pb = mp.add(ProgressBar::new_spinner());
            pb.set_style(style.clone());
            pb.set_prefix(format!("{:<width$}", job.name, width = prefix_width));
            pb.set_message("queued");
            pb.enable_steady_tick(Duration::from_millis(80));
            pb
        })
        .collect();

    let (tx, rx) = mpsc::channel::<Event>();
    thread::scope(|s| {
        for (idx, job) in jobs.iter().enumerate() {
            let tx = tx.clone();
            s.spawn(move || {
                if is_cancelled() {
                    tx.send(Event::Done {
                        idx,
                        success: false,
                        tail: String::new(),
                        error: Some("cancelled".into()),
                        cancelled: true,
                    })
                    .ok();
                    return;
                }
                exec::log_command(&job.command);
                tx.send(Event::Start { idx }).ok();
                let res = stream_command(&job.command, idx, &tx);
                tx.send(Event::Done {
                    idx,
                    success: res.success,
                    tail: res.tail,
                    error: res.error,
                    cancelled: res.cancelled,
                })
                .ok();
            });
        }
        drop(tx);

        let mut outcomes: Vec<Option<Outcome>> = (0..jobs.len()).map(|_| None).collect();
        for ev in rx {
            match ev {
                Event::Start { idx } => bars[idx].set_message("running…"),
                Event::Line { idx, line } => bars[idx].set_message(truncate(&line, MSG_WIDTH)),
                Event::Done {
                    idx,
                    success,
                    tail,
                    error,
                    cancelled,
                } => {
                    let bar = &bars[idx];
                    if cancelled {
                        bar.finish_with_message("⊘ cancelled");
                    } else if success {
                        bar.finish_with_message("✔ done");
                    } else {
                        let err_msg = error.clone().unwrap_or_else(|| "failed".into());
                        bar.finish_with_message(format!("✘ {err_msg}"));
                    }
                    outcomes[idx] = Some(Outcome {
                        name: jobs[idx].name.clone(),
                        success,
                        tail,
                        error,
                        cancelled,
                    });
                }
            }
        }
        outcomes
            .into_iter()
            .map(|o| o.expect("every job sends Done"))
            .collect()
    })
}

enum Event {
    Start {
        idx: usize,
    },
    Line {
        idx: usize,
        line: String,
    },
    Done {
        idx: usize,
        success: bool,
        tail: String,
        error: Option<String>,
        cancelled: bool,
    },
}

struct StreamResult {
    success: bool,
    tail: String,
    error: Option<String>,
    cancelled: bool,
}

fn stream_command(command: &str, idx: usize, tx: &mpsc::Sender<Event>) -> StreamResult {
    let shell = exec::shell_binary();
    let mut cmd = Command::new(&shell);
    cmd.arg("-c")
        .arg(command)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        // Put the child in its own pgid so we can signal the whole subtree
        // (shell → installer → grandchildren) without nuking aimgr itself.
        cmd.process_group(0);
    }
    let mut child = match cmd.spawn() {
        Ok(c) => c,
        Err(e) => {
            return StreamResult {
                success: false,
                tail: String::new(),
                error: Some(format!("spawn failed: {e}")),
                cancelled: false,
            };
        }
    };

    let pid = child.id();
    register_child(pid);
    // Cover the race where the Ctrl-C handler fired between is_cancelled() and spawn.
    if is_cancelled() {
        #[cfg(unix)]
        unsafe {
            libc::kill(-(pid as i32), libc::SIGTERM);
        }
    }

    let stdout = child.stdout.take().expect("piped stdout");
    let stderr = child.stderr.take().expect("piped stderr");

    let (line_tx, line_rx) = mpsc::channel::<String>();
    let txo = line_tx.clone();
    let txe = line_tx;

    let h_out = thread::spawn(move || {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if txo.send(line).is_err() {
                break;
            }
        }
    });
    let h_err = thread::spawn(move || {
        for line in BufReader::new(stderr).lines().map_while(Result::ok) {
            if txe.send(line).is_err() {
                break;
            }
        }
    });

    let mut tail: VecDeque<String> = VecDeque::with_capacity(TAIL_CAP);
    for line in line_rx {
        if tail.len() == TAIL_CAP {
            tail.pop_front();
        }
        tail.push_back(line.clone());
        tx.send(Event::Line { idx, line }).ok();
    }

    h_out.join().ok();
    h_err.join().ok();

    let wait = child.wait();
    unregister_child(pid);

    let tail_str = tail.into_iter().collect::<Vec<_>>().join("\n");
    let cancelled = is_cancelled();
    match wait {
        Ok(s) if s.success() => StreamResult {
            success: true,
            tail: tail_str,
            error: None,
            cancelled: false,
        },
        Ok(s) => {
            let err = if cancelled {
                "cancelled".into()
            } else {
                format!("exit {}", s.code().unwrap_or(-1))
            };
            StreamResult {
                success: false,
                tail: tail_str,
                error: Some(err),
                cancelled,
            }
        }
        Err(e) => StreamResult {
            success: false,
            tail: tail_str,
            error: Some(format!("wait failed: {e}")),
            cancelled,
        },
    }
}

/// Print the failure tails of any failed outcomes (interactive jobs skip this
/// since their output was already on the user's screen). Cancelled outcomes
/// are omitted — they're summarised separately by the caller.
pub fn print_failure_tails(outcomes: &[Outcome]) {
    for o in outcomes.iter().filter(|o| !o.success && !o.cancelled) {
        if o.tail.is_empty() && o.error.is_none() {
            continue;
        }
        eprintln!();
        eprintln!("{}", style::bold(format!("── {} ──", o.name)));
        if !o.tail.is_empty() {
            eprintln!("{}", style::dim(&o.tail));
        }
        if let Some(err) = &o.error {
            eprintln!("{} {err}", style::fail_mark());
        }
    }
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let head: String = s.chars().take(max.saturating_sub(1)).collect();
    format!("{head}…")
}

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

    #[test]
    fn truncate_shortens_long_messages() {
        let s = "x".repeat(200);
        let t = truncate(&s, 10);
        assert!(t.ends_with('…'));
        assert_eq!(t.chars().count(), 10);
    }
}
