//! Opt-in performance counters. Activated by `MIR_TIMING=1`.
//!
//! Captures data we need to decide whether the pull-based refactor
//! (Phases 3–5 of `sequential-popping-parasol.md`) is justified: how often
//! `FileAnalyzer`'s post-body-analysis retry loop iterates, how much time each
//! iteration costs, and how many lazy loads it triggers.
//!
//! When `MIR_TIMING` is unset the counters are no-ops (an `AtomicBool` check
//! plus a branch). Safe to leave compiled in.

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::Instant;

static ENABLED: OnceLock<bool> = OnceLock::new();

fn enabled() -> bool {
    *ENABLED.get_or_init(|| {
        std::env::var("MIR_TIMING")
            .map(|v| v != "0" && !v.is_empty())
            .unwrap_or(false)
    })
}

/// Globally-aggregated counters. Logged on `dump()` (e.g. CLI end-of-run).
#[derive(Default)]
pub struct Counters {
    /// Number of `FileAnalyzer::analyze` invocations.
    pub file_analyses: AtomicU64,
    /// Number of body-analysis invocations summed across all analyses.
    pub body_analysis_runs: AtomicU64,
    /// Lazy loads attempted (one per unresolved FQCN passed to `load_class`).
    pub lazy_loads_attempted: AtomicU64,
    /// Lazy loads that resolved to a class (the call returned `Some`).
    pub lazy_loads_resolved: AtomicU64,
    /// Total body-analysis wall time in microseconds.
    pub body_analysis_micros: AtomicU64,

    /// `collect_and_ingest_file` calls that hit the on-disk stub cache.
    pub stub_cache_hits: AtomicU64,
    /// `collect_and_ingest_file` calls that missed and had to parse.
    pub stub_cache_misses: AtomicU64,

    // Failure-bucket counts for `AnalysisSession::load_class`. Sum of
    // these three == `lazy_loads_attempted - lazy_loads_resolved` (the
    // total failure count). Diagnoses *why* lazy-load fails on real
    // workloads — drives the Phase 3 decision in `docs/perf-baseline.md`.
    /// No resolver configured (`with_psr4` / `with_class_resolver` never
    /// called).
    pub ll_fail_no_resolver: AtomicU64,
    /// Resolver returned `None` for the FQCN (PSR-4 prefix / classmap
    /// didn't match).
    pub ll_fail_resolver_none: AtomicU64,
    /// Resolver mapped the FQCN to a path, but `SourceProvider::read`
    /// returned `None` (file unreadable / missing).
    pub ll_fail_source_unreadable: AtomicU64,
    /// Resolver mapped, source read, but after `ingest_file` the class is
    /// still not present in the index. Most interesting bucket — points at
    /// FQCN normalization mismatch, definition-collection collection gap, or
    /// resolver-points-at-wrong-file.
    pub ll_fail_ingest_then_missing: AtomicU64,

    /// Number of times `lookup_function_node_for_decl` fell through to the
    /// O(N) short-name scan over all workspace functions. Non-zero means we
    /// should build a short-name → FQN index.
    pub fn_short_name_scans: AtomicU64,

    // FlowState::branch() clone profiling — upper bound on COW savings.
    // "upper bound" because COW only helps branches that never write the field;
    // branches that do write still pay the clone (just deferred to make_mut).
    /// Total FlowState::branch() calls.
    pub flow_branch_calls: AtomicU64,
    /// Sum of read_vars.len() at each branch() call.
    pub flow_branch_read_vars_entries: AtomicU64,
    /// Sum of var_locations.len() at each branch() call.
    pub flow_branch_var_locs_entries: AtomicU64,
    /// Sum of last_write_locs.len() at each branch() call.
    pub flow_branch_last_write_entries: AtomicU64,
}

static COUNTERS: Counters = Counters {
    file_analyses: AtomicU64::new(0),
    body_analysis_runs: AtomicU64::new(0),
    lazy_loads_attempted: AtomicU64::new(0),
    lazy_loads_resolved: AtomicU64::new(0),
    body_analysis_micros: AtomicU64::new(0),
    stub_cache_hits: AtomicU64::new(0),
    stub_cache_misses: AtomicU64::new(0),
    ll_fail_no_resolver: AtomicU64::new(0),
    ll_fail_resolver_none: AtomicU64::new(0),
    ll_fail_source_unreadable: AtomicU64::new(0),
    ll_fail_ingest_then_missing: AtomicU64::new(0),
    fn_short_name_scans: AtomicU64::new(0),
    flow_branch_calls: AtomicU64::new(0),
    flow_branch_read_vars_entries: AtomicU64::new(0),
    flow_branch_var_locs_entries: AtomicU64::new(0),
    flow_branch_last_write_entries: AtomicU64::new(0),
};

pub fn record_file_analysis() {
    if enabled() {
        COUNTERS.file_analyses.fetch_add(1, Ordering::Relaxed);
    }
}

pub fn record_body_analysis(duration_micros: u64) {
    if enabled() {
        COUNTERS.body_analysis_runs.fetch_add(1, Ordering::Relaxed);
        COUNTERS
            .body_analysis_micros
            .fetch_add(duration_micros, Ordering::Relaxed);
    }
}

pub fn record_lazy_load_attempt(resolved: bool) {
    if enabled() {
        COUNTERS
            .lazy_loads_attempted
            .fetch_add(1, Ordering::Relaxed);
        if resolved {
            COUNTERS.lazy_loads_resolved.fetch_add(1, Ordering::Relaxed);
        }
    }
}

pub fn record_stub_cache_hit() {
    if enabled() {
        COUNTERS.stub_cache_hits.fetch_add(1, Ordering::Relaxed);
    }
}

pub fn record_stub_cache_miss() {
    if enabled() {
        COUNTERS.stub_cache_misses.fetch_add(1, Ordering::Relaxed);
    }
}

/// Reason for a `load_class` failure. Variants align 1:1 with the
/// `ll_fail_*` counters; see [`Counters`] for semantics.
#[derive(Copy, Clone, Debug)]
pub enum LazyLoadFailure {
    NoResolver,
    ResolverNone,
    SourceUnreadable,
    IngestThenMissing,
}

/// Up to 40 sampled FQCNs per failure bucket. Diagnostic only — printed by
/// `dump()` when `MIR_TIMING=1`. Behind a `Mutex` because failures may
/// happen concurrently in parallel analysis paths.
static FAILURE_SAMPLES: std::sync::Mutex<FailureSamples> =
    std::sync::Mutex::new(FailureSamples::new());

struct FailureSamples {
    no_resolver: Vec<String>,
    resolver_none: Vec<String>,
    source_unreadable: Vec<String>,
    ingest_then_missing: Vec<String>,
}

impl FailureSamples {
    const fn new() -> Self {
        Self {
            no_resolver: Vec::new(),
            resolver_none: Vec::new(),
            source_unreadable: Vec::new(),
            ingest_then_missing: Vec::new(),
        }
    }

    fn push(bucket: &mut Vec<String>, fqcn: &str) {
        if bucket.len() < 40 && !bucket.iter().any(|s| s == fqcn) {
            bucket.push(fqcn.to_string());
        }
    }
}

pub fn record_fn_short_name_scan() {
    if enabled() {
        COUNTERS.fn_short_name_scans.fetch_add(1, Ordering::Relaxed);
    }
}

pub fn record_flow_branch(read_vars: usize, var_locs: usize, last_write: usize) {
    if enabled() {
        COUNTERS.flow_branch_calls.fetch_add(1, Ordering::Relaxed);
        COUNTERS
            .flow_branch_read_vars_entries
            .fetch_add(read_vars as u64, Ordering::Relaxed);
        COUNTERS
            .flow_branch_var_locs_entries
            .fetch_add(var_locs as u64, Ordering::Relaxed);
        COUNTERS
            .flow_branch_last_write_entries
            .fetch_add(last_write as u64, Ordering::Relaxed);
    }
}

pub fn record_lazy_load_failure(reason: LazyLoadFailure, fqcn: &str) {
    if !enabled() {
        return;
    }
    let counter = match reason {
        LazyLoadFailure::NoResolver => &COUNTERS.ll_fail_no_resolver,
        LazyLoadFailure::ResolverNone => &COUNTERS.ll_fail_resolver_none,
        LazyLoadFailure::SourceUnreadable => &COUNTERS.ll_fail_source_unreadable,
        LazyLoadFailure::IngestThenMissing => &COUNTERS.ll_fail_ingest_then_missing,
    };
    counter.fetch_add(1, Ordering::Relaxed);
    let mut samples = FAILURE_SAMPLES.lock().unwrap();
    let bucket = match reason {
        LazyLoadFailure::NoResolver => &mut samples.no_resolver,
        LazyLoadFailure::ResolverNone => &mut samples.resolver_none,
        LazyLoadFailure::SourceUnreadable => &mut samples.source_unreadable,
        LazyLoadFailure::IngestThenMissing => &mut samples.ingest_then_missing,
    };
    FailureSamples::push(bucket, fqcn);
}

fn render_samples() -> String {
    let s = FAILURE_SAMPLES.lock().unwrap();
    let mut out = String::new();
    let buckets = [
        ("no_resolver", &s.no_resolver),
        ("resolver_none", &s.resolver_none),
        ("source_unreadable", &s.source_unreadable),
        ("ingest_then_missing", &s.ingest_then_missing),
    ];
    for (name, b) in buckets {
        if b.is_empty() {
            continue;
        }
        out.push_str(&format!("\n  sample {} ({}):", name, b.len()));
        for fqcn in b.iter().take(20) {
            out.push_str(&format!("\n    {fqcn}"));
        }
    }
    out
}

/// RAII scope guard for measuring body-analysis wall time. Drop emits the record.
pub struct BodyAnalysisScope {
    start: Option<Instant>,
}

impl BodyAnalysisScope {
    pub fn new() -> Self {
        Self {
            start: if enabled() {
                Some(Instant::now())
            } else {
                None
            },
        }
    }
}

impl Default for BodyAnalysisScope {
    fn default() -> Self {
        Self::new()
    }
}

impl Drop for BodyAnalysisScope {
    fn drop(&mut self) {
        if let Some(t0) = self.start {
            record_body_analysis(t0.elapsed().as_micros() as u64);
        }
    }
}

/// Render counters as a human-readable block. Returns `None` if metrics are
/// disabled. Intended to be printed at end of batch (`ProjectAnalyzer`) or
/// session shutdown.
pub fn dump() -> Option<String> {
    if !enabled() {
        return None;
    }
    let analyses = COUNTERS.file_analyses.load(Ordering::Relaxed);
    let body_analysis_runs = COUNTERS.body_analysis_runs.load(Ordering::Relaxed);
    let attempts = COUNTERS.lazy_loads_attempted.load(Ordering::Relaxed);
    let resolved = COUNTERS.lazy_loads_resolved.load(Ordering::Relaxed);
    let body_analysis_micros = COUNTERS.body_analysis_micros.load(Ordering::Relaxed);
    let cache_hits = COUNTERS.stub_cache_hits.load(Ordering::Relaxed);
    let cache_misses = COUNTERS.stub_cache_misses.load(Ordering::Relaxed);
    let ll_no_resolver = COUNTERS.ll_fail_no_resolver.load(Ordering::Relaxed);
    let ll_resolver_none = COUNTERS.ll_fail_resolver_none.load(Ordering::Relaxed);
    let ll_source_unreadable = COUNTERS.ll_fail_source_unreadable.load(Ordering::Relaxed);
    let ll_ingest_missing = COUNTERS.ll_fail_ingest_then_missing.load(Ordering::Relaxed);
    let fn_short_scans = COUNTERS.fn_short_name_scans.load(Ordering::Relaxed);
    let branch_calls = COUNTERS.flow_branch_calls.load(Ordering::Relaxed);
    let branch_read_vars = COUNTERS
        .flow_branch_read_vars_entries
        .load(Ordering::Relaxed);
    let branch_var_locs = COUNTERS
        .flow_branch_var_locs_entries
        .load(Ordering::Relaxed);
    let branch_last_write = COUNTERS
        .flow_branch_last_write_entries
        .load(Ordering::Relaxed);

    let avg_pass2_us = body_analysis_micros
        .checked_div(body_analysis_runs)
        .unwrap_or(0);

    // Compute upper-bound clone bytes per run: entry counts × per-entry bytes.
    // Name = 4B (interned u32), map entry overhead ~50B for FxHash open-addressing.
    // These are UPPER bounds — COW only helps write-free branches.
    let entry_bytes: u64 = 54; // Name(4) + value(16 max) + ~34B hashmap overhead per slot
    let read_vars_ub_mb = branch_read_vars * entry_bytes / 1_000_000;
    let var_locs_ub_mb = branch_var_locs * entry_bytes * 2 / 1_000_000; // value=(u32,u16,u32,u16)=12B
    let last_write_ub_mb = branch_last_write * entry_bytes * 2 / 1_000_000;

    let samples = render_samples();
    Some(format!(
        "mir metrics:\n  \
         file analyses        : {analyses}\n  \
         body analysis runs   : {body_analysis_runs}\n  \
         body analysis time   : {body_analysis_micros} us  (avg/run: {avg_pass2_us} us)\n  \
         lazy load attempts   : {attempts}  resolved: {resolved}\n  \
         lazy load failures   : no_resolver={ll_no_resolver}  resolver_none={ll_resolver_none}  \
         source_unreadable={ll_source_unreadable}  ingest_then_missing={ll_ingest_missing}\n  \
         stub cache           : hits {cache_hits}  misses {cache_misses}\n  \
         fn short-name scans  : {fn_short_scans}\n  \
         flow branch()        : {branch_calls} calls\n  \
           read_vars          : {branch_read_vars} total entries  (~{read_vars_ub_mb} MB upper bound)\n  \
           var_locations      : {branch_var_locs} total entries  (~{var_locs_ub_mb} MB upper bound)\n  \
           last_write_locs    : {branch_last_write} total entries  (~{last_write_ub_mb} MB upper bound){samples}"
    ))
}
