use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Condvar, Mutex};
use std::time::{Duration, Instant};

use indicatif::{ProgressBar, ProgressStyle};
use tracing::{debug, info, warn};

const BRAILLE: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

// ── DirLock ──────────────────────────────────────────────────────────────────

/// Exclusive advisory lock on an index directory, held for the duration of
/// any command that writes into an already-existing index (building the
/// sibling annex, merging into a destination, filtering/selecting in place,
/// ...). Two such commands racing on the same directory can otherwise
/// corrupt each other's writes with no error from either side.
///
/// Only the directory actually being *written to* needs a lock — a command
/// like `merge` that reads several source indexes to build one destination
/// only needs to lock the destination.
///
/// Uses the OS's advisory file lock (`flock` on Unix, `LockFileEx` on
/// Windows) via `fs4`, not a hand-rolled PID file: the OS releases it
/// automatically on process exit, including a crash — no stale-lock cleanup
/// logic needed.
pub struct DirLock {
    _file: std::fs::File,
}

impl DirLock {
    /// Block until the exclusive lock on `dir` is acquired (creating `dir`
    /// and the lock file within it if needed). Logs once if the wait is
    /// non-trivial, so a blocked command doesn't look silently hung.
    pub fn acquire(dir: &std::path::Path) -> std::io::Result<Self> {
        use fs4::fs_std::FileExt;

        std::fs::create_dir_all(dir)?;
        let lock_path = dir.join(".obikmer.lock");
        let file = std::fs::OpenOptions::new()
            .create(true)
            .truncate(false)
            .write(true)
            .open(&lock_path)?;

        if file.try_lock_exclusive().is_err() {
            info!(dir = %dir.display(), "waiting for another obikmer process to release this index");
            file.lock_exclusive()?;
        }
        Ok(Self { _file: file })
    }
}

// ── TracedBar ──────────────────────────────────────────────────────────────────

/// Wrapper around `ProgressBar` that emits `tracing` events when stderr is not
/// a TTY (e.g. HPC job logs): every 10% for bounded bars, every ~10 s for
/// spinners (throttled on `set_message`).
pub struct TracedBar {
    pb: ProgressBar,
    label: String,
    unit: String,
    total: u64,             // 0 for spinners
    start: Instant,         // creation time, for spinner throttling
    last_pct: AtomicU64,    // last emitted 10%-bucket (1..=10), 0 = none yet
    last_log_ms: AtomicU64, // ms since `start` at last spinner log
}

impl TracedBar {
    pub fn inc(&self, delta: u64) {
        self.pb.inc(delta);
        if self.pb.is_hidden() && self.total > 0 {
            let pos = self.pb.position();
            let pct10 = (pos * 10) / self.total; // 0..=10
            let last = self.last_pct.load(Ordering::Relaxed);
            if pct10 > last
                && self
                    .last_pct
                    .compare_exchange(last, pct10, Ordering::Relaxed, Ordering::Relaxed)
                    .is_ok()
            {
                info!(
                    stage    = %self.label,
                    progress = format_args!("{}%", pct10 * 10),
                    "{}/{} {}",
                    pos, self.total, self.unit
                );
            }
        }
    }

    pub fn set_message(&self, msg: impl Into<String>) {
        let msg = msg.into();
        if self.pb.is_hidden() {
            if self.total > 0 {
                debug!(stage = %self.label, "{msg}");
            } else {
                // spinner: throttle to ~10 s
                let now_ms = self.start.elapsed().as_millis() as u64;
                let last = self.last_log_ms.load(Ordering::Relaxed);
                if now_ms >= last + 10_000
                    && self
                        .last_log_ms
                        .compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
                        .is_ok()
                {
                    info!(stage = %self.label, "{msg}");
                }
            }
        }
        self.pb.set_message(msg);
    }

    pub fn finish_and_clear(&self) {
        self.pb.finish_and_clear();
    }
}

/// Spinner with the standard project look: `⠋ label — msg  0s`.
/// Caller updates the message with `pb.set_message(...)`.
pub fn spinner(label: &str) -> TracedBar {
    let pb = ProgressBar::new_spinner();
    pb.set_style(
        ProgressStyle::with_template(&format!("{{spinner}} {label} — {{msg}}  {{elapsed}}"))
            .unwrap()
            .tick_strings(BRAILLE),
    );
    pb.enable_steady_tick(Duration::from_millis(100));
    TracedBar {
        pb,
        label: label.to_string(),
        unit: String::new(),
        total: 0,
        start: Instant::now(),
        last_pct: AtomicU64::new(0),
        last_log_ms: AtomicU64::new(0),
    }
}

/// Progress bar with the standard project look:
/// `⠋ label — [████░░░░] pos/len unit  elapsed`.
pub fn progress_bar(label: &str, n: u64, unit: &str) -> TracedBar {
    let pb = ProgressBar::new(n);
    pb.set_style(
        ProgressStyle::with_template(&format!(
            "{{spinner}} {label} — {{bar:40.cyan/blue}} {{pos}}/{{len}} {unit}  {{elapsed}}"
        ))
        .unwrap()
        .tick_strings(BRAILLE),
    );
    pb.enable_steady_tick(Duration::from_millis(100));
    TracedBar {
        pb,
        label: label.to_string(),
        unit: unit.to_string(),
        total: n,
        start: Instant::now(),
        last_pct: AtomicU64::new(0),
        last_log_ms: AtomicU64::new(0),
    }
}

use libc::{RUSAGE_SELF, getrusage, rusage, timeval};
use sysinfo::System;

// ── Memory query ──────────────────────────────────────────────────────────────

/// Returns the number of bytes available for allocation in the current process context.
///
/// On Linux, cgroup memory limits (SLURM, containers) are checked first: the
/// process may be constrained to far less than the host's available RAM.
/// Returns `min(cgroup_available, host_available)` when a finite limit is found.
///
/// On macOS, `available_memory()` can return 0 when the memory compressor
/// inflates the page count; in that case we fall back to half of total memory.
/// Returns the process peak RSS (high-water mark since process start).
/// Monotonically increasing — use delta before/after a phase to measure its RAM cost.
pub fn peak_rss_bytes() -> u64 {
    rss_to_bytes(&get_rusage())
}

pub fn available_memory_bytes() -> u64 {
    let sys = System::new_all();
    let host_avail = match sys.available_memory() {
        0 => sys.total_memory() / 2,
        n => n,
    };
    #[cfg(target_os = "linux")]
    if let Some(cg) = cgroup_v2_available().or_else(cgroup_v1_available) {
        return cg.min(host_avail);
    }
    host_avail
}

/// cgroup v2 (unified hierarchy): reads memory.max and memory.current for the
/// current process's cgroup. Returns None if unlimited or on any parse error.
#[cfg(target_os = "linux")]
fn cgroup_v2_available() -> Option<u64> {
    let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
    let rel = cgroup
        .lines()
        .find(|l| l.starts_with("0::"))?
        .strip_prefix("0::")?
        .trim();
    let base = format!("/sys/fs/cgroup{rel}");
    // "max" means no limit → parse::<u64>() fails → None
    let limit: u64 = std::fs::read_to_string(format!("{base}/memory.max"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    let used: u64 = std::fs::read_to_string(format!("{base}/memory.current"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    Some(limit.saturating_sub(used))
}

/// cgroup v1 (memory subsystem): reads memory.limit_in_bytes and
/// memory.usage_in_bytes. Returns None if unlimited or on any parse error.
#[cfg(target_os = "linux")]
fn cgroup_v1_available() -> Option<u64> {
    let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
    let path = cgroup
        .lines()
        .find(|l| l.contains(":memory:"))?
        .split(':')
        .nth(2)?;
    let base = format!("/sys/fs/cgroup/memory{path}");
    let limit: u64 = std::fs::read_to_string(format!("{base}/memory.limit_in_bytes"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    // Kernel uses 2^63 (rounded to page) as "no limit" sentinel
    if limit > (1u64 << 62) {
        return None;
    }
    let used: u64 = std::fs::read_to_string(format!("{base}/memory.usage_in_bytes"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    Some(limit.saturating_sub(used))
}

// ── CPU parallelism query ────────────────────────────────────────────────────

/// Returns the number of cores this process can actually use concurrently.
///
/// `std::thread::available_parallelism()` reads CPU affinity
/// (`sched_getaffinity`), not the container's CPU quota — a Docker/cgroup
/// container commonly reports the *host's* full core count this way while
/// actually being throttled (via `cpu.max`/`cpu.cfs_quota_us`) to a fraction
/// of a core. Sizing a thread/worker pool off the unthrottled count causes
/// severe oversubscription: dozens of threads contending for a sliver of
/// real CPU time, which can look indistinguishable from a hang for minutes
/// or hours (observed in CI). On Linux, this reads the cgroup CPU quota
/// first and returns `min(cgroup_quota, host_parallelism)` when a finite
/// quota is found; falls back to `available_parallelism()` otherwise (same
/// convention as [`available_memory_bytes`]).
pub fn effective_parallelism() -> usize {
    let host = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
    #[cfg(target_os = "linux")]
    {
        if let Some(quota) = cgroup_v2_cpu_quota() {
            let effective = quota.clamp(1, host);
            tracing::debug!(host, quota, effective, source = "cgroup v2", "effective_parallelism");
            return effective;
        }
        if let Some(quota) = cgroup_v1_cpu_quota() {
            let effective = quota.clamp(1, host);
            tracing::debug!(host, quota, effective, source = "cgroup v1", "effective_parallelism");
            return effective;
        }
    }
    tracing::debug!(host, effective = host, source = "available_parallelism (no cgroup quota found)", "effective_parallelism");
    host
}

/// cgroup v2 (unified hierarchy): reads `cpu.max` ("<quota> <period>", or
/// "max <period>" when unlimited) for the current process's cgroup, rounded
/// up to whole cores. Returns `None` if unlimited or on any parse error.
#[cfg(target_os = "linux")]
fn cgroup_v2_cpu_quota() -> Option<usize> {
    let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
    let rel = cgroup
        .lines()
        .find(|l| l.starts_with("0::"))?
        .strip_prefix("0::")?
        .trim();
    let base = format!("/sys/fs/cgroup{rel}");
    let raw = std::fs::read_to_string(format!("{base}/cpu.max")).ok()?;
    let mut parts = raw.split_whitespace();
    let quota_str = parts.next()?;
    let period: f64 = parts.next()?.parse().ok()?;
    if quota_str == "max" {
        return None; // unlimited
    }
    let quota: f64 = quota_str.parse().ok()?;
    Some((quota / period).ceil().max(1.0) as usize)
}

/// cgroup v1 (cpu subsystem): reads `cpu.cfs_quota_us`/`cpu.cfs_period_us`,
/// rounded up to whole cores. Returns `None` if unlimited (quota <= 0) or on
/// any parse error.
#[cfg(target_os = "linux")]
fn cgroup_v1_cpu_quota() -> Option<usize> {
    let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
    let path = cgroup
        .lines()
        .find(|l| l.contains(":cpu:") || l.contains(":cpu,cpuacct:"))?
        .split(':')
        .nth(2)?;
    let base = format!("/sys/fs/cgroup/cpu{path}");
    let quota: i64 = std::fs::read_to_string(format!("{base}/cpu.cfs_quota_us"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    if quota <= 0 {
        return None; // unlimited
    }
    let period: i64 = std::fs::read_to_string(format!("{base}/cpu.cfs_period_us"))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    if period <= 0 {
        return None;
    }
    Some(((quota as f64) / (period as f64)).ceil().max(1.0) as usize)
}

// ── raw helpers ───────────────────────────────────────────────────────────────

fn get_rusage() -> rusage {
    let mut ru = unsafe { std::mem::zeroed::<rusage>() };
    unsafe { getrusage(RUSAGE_SELF, &mut ru) };
    ru
}

fn tv_to_secs(tv: timeval) -> f64 {
    tv.tv_sec as f64 + tv.tv_usec as f64 * 1e-6
}

#[cfg(target_os = "macos")]
fn rss_to_bytes(ru: &rusage) -> u64 {
    ru.ru_maxrss as u64
}

#[cfg(not(target_os = "macos"))]
fn rss_to_bytes(ru: &rusage) -> u64 {
    ru.ru_maxrss as u64 * 1024
}

// Monotonically increasing counters — negative delta would be a kernel bug.
fn delta(end: i64, start: i64) -> u64 {
    (end - start).max(0) as u64
}

// ── CpuSample ─────────────────────────────────────────────────────────────────

/// Snapshot of process-wide CPU time + wall clock at a point in time.
/// Use [`cpu_efficiency`](Self::cpu_efficiency) to measure the fraction of
/// available cores used since the snapshot was taken.
pub struct CpuSample {
    wall: Instant,
    user_secs: f64,
    sys_secs: f64,
    previous: f64,
}

impl CpuSample {
    pub fn now() -> Self {
        let ru = get_rusage();
        Self {
            wall: Instant::now(),
            user_secs: tv_to_secs(ru.ru_utime),
            sys_secs: tv_to_secs(ru.ru_stime),
            previous: 0.0,
        }
    }

    /// (user_delta + sys_delta) / (wall_delta × n_cores) since this snapshot.
    /// Returns 0.0 if less than 100 ms have elapsed (too noisy).
    pub fn cpu_efficiency(&self, n_cores: usize) -> f64 {
        let ru = get_rusage();
        let wall = self.wall.elapsed().as_secs_f64();
        if wall < 0.1 {
            return 0.0;
        }
        let cpu =
            (tv_to_secs(ru.ru_utime) - self.user_secs) + (tv_to_secs(ru.ru_stime) - self.sys_secs);
        cpu / (wall * n_cores as f64)
    }

    pub fn do_i_activate(&mut self, threshold: f64) -> bool {
        let delta_wall = self.wall.elapsed().as_secs_f64();
        if delta_wall < 0.1 {
            // Window too short to be meaningful — leave state untouched so it
            // keeps accumulating until a real sample can be taken.
            return false;
        }

        let n = CpuSample::now();
        let delta_ru = (n.user_secs - self.user_secs) + (n.sys_secs - self.sys_secs);

        let efficiency = delta_ru / delta_wall;
        let activate = 0f64.max(efficiency - self.previous) >= threshold;

        debug!(
            "Do I activate : {} -> {} = {} Activate: {}",
            self.previous,
            efficiency,
            0f64.max(efficiency - self.previous),
            activate
        );
        self.previous = efficiency;
        self.user_secs = n.user_secs;
        self.sys_secs = n.sys_secs;
        self.wall = n.wall;

        activate
    }
}

// ── IoSample ──────────────────────────────────────────────────────────────────

/// Snapshot of process-wide block I/O (bytes read + written) + wall clock.
///
/// Same activation protocol as [`CpuSample`], but the growth check in
/// [`do_i_activate`](Self::do_i_activate) is *relative* rather than absolute:
/// raw I/O throughput has no portable scale across storage devices, unlike a
/// core count.
pub struct IoSample {
    wall: Instant,
    bytes: u64,
    previous_rate: f64,
}

impl IoSample {
    pub fn now() -> Self {
        Self {
            wall: Instant::now(),
            bytes: Self::read_bytes(),
            previous_rate: 0.0,
        }
    }

    /// Bytes actually submitted to the block layer (read + write), summed
    /// process-wide. Returns 0 if unavailable — degrades gracefully to a
    /// signal that never triggers activation (CPU-only heuristic).
    #[cfg(target_os = "linux")]
    fn read_bytes() -> u64 {
        let Ok(io) = std::fs::read_to_string("/proc/self/io") else {
            return 0;
        };
        io.lines()
            .filter_map(|l| {
                l.strip_prefix("read_bytes: ")
                    .or_else(|| l.strip_prefix("write_bytes: "))
            })
            .filter_map(|v| v.trim().parse::<u64>().ok())
            .sum()
    }

    #[cfg(target_os = "macos")]
    fn read_bytes() -> u64 {
        use libc::{RUSAGE_INFO_V4, getpid, proc_pid_rusage, rusage_info_v4};
        let mut info: rusage_info_v4 = unsafe { std::mem::zeroed() };
        let ret =
            unsafe { proc_pid_rusage(getpid(), RUSAGE_INFO_V4, &mut info as *mut _ as *mut _) };
        if ret != 0 {
            return 0;
        }
        info.ri_diskio_bytesread + info.ri_diskio_byteswritten
    }

    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
    fn read_bytes() -> u64 {
        0
    }

    /// Same protocol as [`CpuSample::do_i_activate`] (0.1 s minimum window,
    /// state untouched on early return), but growth is measured relative to
    /// the previous rate. `threshold` is a fraction, e.g. `0.2` for a 20 %
    /// increase in throughput since the last real sample.
    pub fn do_i_activate(&mut self, threshold: f64) -> bool {
        let elapsed = self.wall.elapsed().as_secs_f64();
        if elapsed < 0.1 {
            return false;
        }

        let n = Self::read_bytes();
        let rate = n.saturating_sub(self.bytes) as f64 / elapsed;
        let activate = if self.previous_rate == 0.0 {
            rate > 0.0 // bootstrap: any measured throughput is signal enough
        } else {
            (rate - self.previous_rate) / self.previous_rate >= threshold
        };

        debug!(
            "Do I activate (I/O) : {} -> {} Activate: {}",
            self.previous_rate, rate, activate
        );
        self.previous_rate = rate;
        self.bytes = n;
        self.wall = Instant::now();

        activate
    }
}

// ── public API ────────────────────────────────────────────────────────────────

/// Snapshot taken at the start of a pipeline stage.
#[must_use = "call .stop() to record the stage"]
pub struct Stage {
    label: String,
    wall: Instant,
    ru: rusage,
}

impl Stage {
    pub fn start(label: impl Into<String>) -> Self {
        let label = label.into();
        info!(stage = %label, "started");
        Self {
            label,
            wall: Instant::now(),
            ru: get_rusage(),
        }
    }

    pub fn stop(self) -> StageStats {
        let wall_secs = self.wall.elapsed().as_secs_f64();
        let end = get_rusage();
        let stats = StageStats {
            label: self.label,
            wall_secs,
            user_secs: tv_to_secs(end.ru_utime) - tv_to_secs(self.ru.ru_utime),
            sys_secs: tv_to_secs(end.ru_stime) - tv_to_secs(self.ru.ru_stime),
            max_rss_bytes: rss_to_bytes(&end),
            minor_faults: delta(end.ru_minflt as i64, self.ru.ru_minflt as i64),
            major_faults: delta(end.ru_majflt as i64, self.ru.ru_majflt as i64),
            vol_ctx: delta(end.ru_nvcsw as i64, self.ru.ru_nvcsw as i64),
            invol_ctx: delta(end.ru_nivcsw as i64, self.ru.ru_nivcsw as i64),
            in_blocks: delta(end.ru_inblock as i64, self.ru.ru_inblock as i64),
            out_blocks: delta(end.ru_oublock as i64, self.ru.ru_oublock as i64),
            swaps: delta(end.ru_nswap as i64, self.ru.ru_nswap as i64),
        };
        info!(
            stage      = %stats.label,
            wall_secs  = format_args!("{:.3}", stats.wall_secs),
            rss        = %fmt_bytes(stats.max_rss_bytes),
            swaps      = stats.swaps,
            "done"
        );
        if stats.swaps > 0 {
            warn!(
                stage = %stats.label,
                swaps = stats.swaps,
                "working set exceeds available RAM"
            );
        }
        stats
    }
}

/// Per-stage efficiency metrics collected from `getrusage(RUSAGE_SELF)` deltas.
pub struct StageStats {
    pub label: String,
    pub wall_secs: f64,
    pub user_secs: f64,
    pub sys_secs: f64,
    /// Peak RSS at end of stage (bytes). ru_maxrss is a process-lifetime maximum,
    /// so this reflects the high-water mark up to and including this stage.
    pub max_rss_bytes: u64,
    pub minor_faults: u64,
    pub major_faults: u64,
    pub vol_ctx: u64,    // voluntary context switches
    pub invol_ctx: u64,  // involuntary context switches
    pub in_blocks: u64,  // filesystem block reads (after page cache)
    pub out_blocks: u64, // filesystem block writes
    pub swaps: u64,
}

impl StageStats {
    /// (user + sys) / wall — effective thread count utilisation.
    pub fn parallelism(&self) -> f64 {
        if self.wall_secs > 1e-9 {
            (self.user_secs + self.sys_secs) / self.wall_secs
        } else {
            0.0
        }
    }

    /// parallelism / n_cores — fraction of available CPU power used (0..1+).
    pub fn efficiency(&self, n_cores: usize) -> f64 {
        self.parallelism() / n_cores as f64
    }
}

/// Accumulates stage stats and prints a human-readable summary table.
#[derive(Default)]
pub struct Reporter {
    stages: Vec<StageStats>,
}

impl Reporter {
    pub fn new() -> Self {
        Self::default()
    }
    pub fn push(&mut self, stats: StageStats) {
        self.stages.push(stats);
    }
    pub fn stages(&self) -> &[StageStats] {
        &self.stages
    }
    /// Print the summary to stderr.
    pub fn print(&self) {
        eprint!("{self}");
    }
}

// ── diagnosis ─────────────────────────────────────────────────────────────────

struct Diagnosis {
    tag: &'static str,
    detail: Option<String>,
}

// Thresholds are intentionally conservative to avoid false positives.
fn diagnose(s: &StageStats, n_cores: usize) -> Diagnosis {
    let eff = s.efficiency(n_cores);
    let cpu_pct = eff * 100.0;
    let io_ops = s.in_blocks + s.out_blocks;

    // swaps > 0 is the only reliable cross-platform indicator of true RAM exhaustion.
    // ru_majflt is intentionally excluded: on macOS it counts all file-backed mmap
    // page-ins (even from page cache), making it useless as a memory-pressure signal
    // for mmap-heavy code. On Linux it is more meaningful, but swaps covers the
    // severe case on both platforms.
    if s.swaps > 0 {
        return Diagnosis {
            tag: "swapping",
            detail: Some(format!(
                "swapped {} time(s) — working set exceeds available RAM",
                s.swaps,
            )),
        };
    }
    if eff < 0.3 && io_ops > 100 {
        return Diagnosis {
            tag: "disk I/O",
            detail: Some(format!(
                "{} block reads + {} writes — CPU at {:.0}%, stage is I/O-bound",
                s.in_blocks, s.out_blocks, cpu_pct,
            )),
        };
    }
    if eff < 0.3 && s.vol_ctx > 200 {
        return Diagnosis {
            tag: "contention",
            detail: Some(format!(
                "{} voluntary context switches — CPU at {:.0}%, possible lock contention or I/O wait",
                s.vol_ctx, cpu_pct,
            )),
        };
    }
    Diagnosis {
        tag: "—",
        detail: None,
    }
}

// ── display helpers ───────────────────────────────────────────────────────────

fn fmt_secs(s: f64) -> String {
    if s >= 100.0 {
        format!("{:.0}s", s)
    } else if s >= 10.0 {
        format!("{:.1}s", s)
    } else if s >= 1.0 {
        format!("{:.2}s", s)
    } else {
        format!("{:.0}ms", s * 1000.0)
    }
}

fn fmt_bytes(b: u64) -> String {
    if b >= 1 << 30 {
        format!("{:.1} GB", b as f64 / (1u64 << 30) as f64)
    } else if b >= 1 << 20 {
        format!("{:.0} MB", b as f64 / (1u64 << 20) as f64)
    } else {
        format!("{:.0} KB", b as f64 / 1024.0)
    }
}

fn fmt_efficiency(par: f64, n_cores: usize) -> String {
    format!(
        "{:.1}×/{} ({:.0}%)",
        par,
        n_cores,
        par / n_cores as f64 * 100.0
    )
}

// ── Display ───────────────────────────────────────────────────────────────────

// ── MemoryBudget ──────────────────────────────────────────────────────────────

struct BudgetInner {
    remaining: u64,
    active: usize,
    peak_active: usize,
}

/// Counting semaphore that limits total concurrent estimated memory usage.
///
/// Each worker acquires a cost (bytes) before starting and releases it on
/// completion. Non-deadlock guarantee: when no worker is active the next
/// acquire always succeeds regardless of cost vs. remaining budget.
pub struct MemoryBudget {
    total: u64,
    inner: Mutex<BudgetInner>,
    condvar: Condvar,
}

impl MemoryBudget {
    pub fn new(total: u64) -> Self {
        Self {
            total,
            inner: Mutex::new(BudgetInner {
                remaining: total,
                active: 0,
                peak_active: 0,
            }),
            condvar: Condvar::new(),
        }
    }

    pub fn acquire(&self, cost: u64) {
        let mut g = self.inner.lock().unwrap();
        loop {
            if g.active == 0 || g.remaining >= cost {
                g.remaining = g.remaining.saturating_sub(cost);
                g.active += 1;
                g.peak_active = g.peak_active.max(g.active);
                return;
            }
            g = self.condvar.wait(g).unwrap();
        }
    }

    pub fn release(&self, cost: u64) {
        let mut g = self.inner.lock().unwrap();
        g.remaining = (g.remaining + cost).min(self.total);
        g.active -= 1;
        self.condvar.notify_all();
    }

    pub fn total(&self) -> u64 {
        self.total
    }
    pub fn active(&self) -> usize {
        self.inner.lock().unwrap().active
    }
    pub fn remaining(&self) -> u64 {
        self.inner.lock().unwrap().remaining
    }
    pub fn peak_active(&self) -> usize {
        self.inner.lock().unwrap().peak_active
    }
}

// ── Display ───────────────────────────────────────────────────────────────────

impl fmt::Display for Reporter {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.stages.is_empty() {
            return Ok(());
        }

        let n_cores = effective_parallelism();

        // column widths
        let nw = self
            .stages
            .iter()
            .map(|s| s.label.len())
            .max()
            .unwrap_or(5)
            .max(5);
        // efficiency col: worst-case width for this run's n_cores value
        let ew = format!("{:.1}×/{} (100%)", 99.9f64, n_cores).len();

        let sep_w = nw + 2 + 7 + 2 + ew + 2 + 8 + 2 + 12;
        let sep = "─".repeat(sep_w);

        // header
        writeln!(
            f,
            "{:<nw$}  {:>7}  {:>ew$}  {:>8}  status",
            "stage", "wall", "efficiency", "peak RSS"
        )?;
        writeln!(f, "{sep}")?;

        // compute all diagnoses up front (needed for both table and footnotes)
        let diagnoses: Vec<Diagnosis> = self.stages.iter().map(|s| diagnose(s, n_cores)).collect();

        // per-stage rows
        for (s, d) in self.stages.iter().zip(diagnoses.iter()) {
            writeln!(
                f,
                "{:<nw$}  {:>7}  {:>ew$}  {:>8}  {}",
                s.label,
                fmt_secs(s.wall_secs),
                fmt_efficiency(s.parallelism(), n_cores),
                fmt_bytes(s.max_rss_bytes),
                d.tag,
            )?;
        }

        // totals
        let tw = self.stages.iter().map(|s| s.wall_secs).sum::<f64>();
        let tu = self.stages.iter().map(|s| s.user_secs).sum::<f64>();
        let ts = self.stages.iter().map(|s| s.sys_secs).sum::<f64>();
        let trss = self
            .stages
            .iter()
            .map(|s| s.max_rss_bytes)
            .max()
            .unwrap_or(0);
        let tpar = if tw > 1e-9 { (tu + ts) / tw } else { 0.0 };

        writeln!(f, "{sep}")?;
        writeln!(
            f,
            "{:<nw$}  {:>7}  {:>ew$}  {:>8}",
            "TOTAL",
            fmt_secs(tw),
            fmt_efficiency(tpar, n_cores),
            fmt_bytes(trss),
        )?;

        // bottleneck footnotes (only if at least one anomaly detected)
        let bottlenecks: Vec<(&str, &str)> = self
            .stages
            .iter()
            .zip(diagnoses.iter())
            .filter_map(|(s, d)| d.detail.as_deref().map(|det| (s.label.as_str(), det)))
            .collect();

        if !bottlenecks.is_empty() {
            writeln!(f, "\nBottlenecks:")?;
            for (label, detail) in &bottlenecks {
                writeln!(f, "  {label} — {detail}")?;
            }
        }

        Ok(())
    }
}
