use crate::output::{explain, policy};
use crate::util::{find_repo_root, write_html_report};
use crate::{OutputFormat, OutputLevel, OutputMode};
use anyhow::Context;
use hotspots_core::delta::Delta;
use hotspots_core::gate::{check_gate, GateConfig, GateVerdict};
use hotspots_core::snapshot::{self, Snapshot};
use hotspots_core::TouchMode;
use hotspots_core::{analyze_with_progress, AnalysisOptions};
use hotspots_core::{delta, git};
use std::path::{Path, PathBuf};

pub(crate) struct AnalyzeArgs {
    pub path: PathBuf,
    pub format: OutputFormat,
    pub mode: Option<OutputMode>,
    pub policy: bool,
    pub top: Option<usize>,
    pub min_lrs: Option<f64>,
    pub config_path: Option<PathBuf>,
    pub output: Option<PathBuf>,
    pub explain: bool,
    pub force: bool,
    pub no_persist: bool,
    pub level: Option<OutputLevel>,
    pub per_function_touches: bool,
    pub all_functions: bool,
    pub include_models: bool,
    pub explain_patterns: bool,
    /// URL of the corresponding written analysis post, embedded as a banner in HTML output.
    pub source_url: Option<String>,
    /// Number of rayon worker threads; None = use all logical CPUs.
    pub jobs: Option<usize>,
    /// CLI override for callgraph_skip_above; None = use resolved config value.
    pub callgraph_skip_above: Option<usize>,
    /// When true, force-disable per-function touches regardless of config.
    pub no_per_function_touches: bool,
    /// When true, skip all touch metrics (no git log calls at all).
    pub skip_touch_metrics: bool,
    /// Hybrid touch threshold: file-level first, per-function for files with ≥N touches/30d.
    pub hybrid_touches: Option<usize>,
}

/// Validate flag combinations that are mode/format-specific.
pub(crate) fn validate_analyze_flags(args: &AnalyzeArgs) -> anyhow::Result<()> {
    let AnalyzeArgs {
        mode,
        format,
        policy,
        explain,
        per_function_touches,
        no_persist,
        force,
        level,
        all_functions,
        include_models,
        explain_patterns,
        ..
    } = args;
    if *policy && *mode != Some(OutputMode::Delta) {
        anyhow::bail!("--policy flag is only valid with --mode delta");
    }
    if *explain && *mode != Some(OutputMode::Snapshot) {
        anyhow::bail!("--explain flag is only valid with --mode snapshot");
    }
    if *per_function_touches && mode.is_none() {
        anyhow::bail!(
            "--per-function-touches is only valid with --mode snapshot, --mode delta, or --mode models"
        );
    }
    if *no_persist {
        if mode.is_none() {
            anyhow::bail!("--no-persist is only valid with --mode snapshot or --mode delta");
        }
        if *mode == Some(OutputMode::Models) {
            anyhow::bail!("--no-persist is only valid with --mode snapshot or --mode delta");
        }
        if *force {
            anyhow::bail!("--no-persist and --force are mutually exclusive");
        }
    }
    if level.is_some() {
        if *mode != Some(OutputMode::Snapshot) {
            anyhow::bail!("--level is only valid with --mode snapshot");
        }
        if !matches!(format, OutputFormat::Text) {
            anyhow::bail!("--level is only valid with --format text");
        }
        if *explain {
            anyhow::bail!("--level and --explain are mutually exclusive");
        }
    }
    if *all_functions
        && (*mode != Some(OutputMode::Snapshot) || !matches!(format, OutputFormat::Json))
    {
        anyhow::bail!("--all-functions is only valid with --mode snapshot --format json");
    }
    if *mode == Some(OutputMode::Models)
        && !matches!(format, OutputFormat::Text | OutputFormat::Json)
    {
        anyhow::bail!("--mode models supports --format text or --format json");
    }
    if *include_models
        && (*mode != Some(OutputMode::Snapshot)
            || !matches!(format, OutputFormat::Json | OutputFormat::Html))
    {
        anyhow::bail!("--include-models is only valid with --mode snapshot --format json/html");
    }
    if *explain_patterns && *mode != Some(OutputMode::Snapshot) && mode.is_some() {
        anyhow::bail!("--explain-patterns is only valid with --mode snapshot or without --mode");
    }
    if matches!(format, OutputFormat::Sarif) && *mode != Some(OutputMode::Snapshot) {
        anyhow::bail!("--format sarif requires --mode snapshot");
    }
    Ok(())
}

pub(crate) fn handle_analyze(args: AnalyzeArgs) -> anyhow::Result<()> {
    validate_analyze_flags(&args)?;

    let AnalyzeArgs {
        path,
        format,
        mode,
        policy,
        top,
        min_lrs,
        config_path,
        output,
        explain,
        force,
        no_persist,
        level,
        per_function_touches,
        no_per_function_touches,
        skip_touch_metrics,
        hybrid_touches,
        all_functions,
        include_models,
        explain_patterns,
        source_url,
        jobs,
        callgraph_skip_above,
    } = args;

    // Configure the global rayon thread pool before any parallel work begins.
    // Errors are ignored: build_global() fails if rayon was already initialized
    // (e.g. in tests), which is harmless.
    if let Some(n) = jobs {
        let _ = rayon::ThreadPoolBuilder::new()
            .num_threads(n)
            .build_global();
    }

    let normalized_path = if path.is_relative() {
        std::env::current_dir()?.join(&path)
    } else {
        path
    };

    if !normalized_path.exists() {
        anyhow::bail!("Path does not exist: {}", normalized_path.display());
    }

    let project_root = find_repo_root(&normalized_path).unwrap_or_else(|_| normalized_path.clone());
    let resolved_config =
        hotspots_core::config::load_and_resolve(&project_root, config_path.as_deref())
            .context("failed to load configuration")?;

    if let Some(ref p) = resolved_config.config_path {
        eprintln!("Using config: {}", p.display());
    }

    let effective_min_lrs = min_lrs.or(resolved_config.min_lrs);
    let effective_top = top.or(resolved_config.top_n);
    let touch_args = TouchArgs {
        no_per_function: no_per_function_touches,
        per_function: per_function_touches,
        hybrid: hybrid_touches,
        skip: skip_touch_metrics,
    };
    let effective_touch_mode = resolve_touch_mode(
        touch_args.no_per_function,
        touch_args.per_function,
        touch_args.hybrid.or(resolved_config.hybrid_touch_threshold),
        resolved_config.per_function_touches,
    );

    if let Some(output_mode) = mode {
        let result = handle_mode_output(
            &normalized_path,
            output_mode,
            &resolved_config,
            ModeOutputOptions {
                format,
                policy,
                top: effective_top,
                min_lrs: effective_min_lrs,
                output,
                explain,
                force,
                no_persist,
                level,
                touch_mode: effective_touch_mode,
                all_functions,
                include_models,
                explain_patterns,
                source_url,
                callgraph_skip_above,
                skip_touch_metrics: touch_args.skip,
            },
        );
        return result;
    }

    // If a trained ranker exists, promote to snapshot mode so activity_risk
    // fields are populated and the ranker can be applied. The ranker has no
    // effect in the default LRS-only path.
    let repo_root_for_ranker =
        find_repo_root(&normalized_path).unwrap_or_else(|_| normalized_path.clone());
    let ranker_path = snapshot::hotspots_dir(&repo_root_for_ranker).join("ranker.json");
    if ranker_path.exists() {
        let result = handle_mode_output(
            &normalized_path,
            OutputMode::Snapshot,
            &resolved_config,
            ModeOutputOptions {
                format,
                policy: false,
                top: effective_top,
                min_lrs: effective_min_lrs,
                output,
                explain: matches!(format, OutputFormat::Text),
                force,
                no_persist: true, // default analyze doesn't persist snapshots
                level,
                touch_mode: effective_touch_mode,
                all_functions: false,
                include_models: false,
                explain_patterns,
                source_url,
                callgraph_skip_above,
                skip_touch_metrics: touch_args.skip,
            },
        );
        return result;
    }

    // Default behavior (no --mode): simple text/JSON output
    handle_default_output(
        &normalized_path,
        format,
        explain_patterns,
        effective_min_lrs,
        effective_top,
        &resolved_config,
    )
}

struct TouchArgs {
    no_per_function: bool,
    per_function: bool,
    hybrid: Option<usize>,
    skip: bool,
}

fn handle_default_output(
    path: &Path,
    format: OutputFormat,
    explain_patterns: bool,
    min_lrs: Option<f64>,
    top: Option<usize>,
    resolved_config: &hotspots_core::ResolvedConfig,
) -> anyhow::Result<()> {
    let analysis_progress = make_analysis_progress();
    let explicit_top = top.or(resolved_config.top_n);
    // 0 is the sentinel for "show all"; otherwise default to 20 for text output
    let limit = match explicit_top {
        Some(0) => usize::MAX,
        Some(n) => n,
        None => 20,
    };
    let mut reports = analyze_with_progress(
        path,
        AnalysisOptions {
            min_lrs,
            top_n: if matches!(format, OutputFormat::Text) {
                Some(limit).filter(|&n| n != usize::MAX)
            } else {
                explicit_top.filter(|&n| n != 0)
            },
        },
        Some(resolved_config),
        Some(analysis_progress.as_ref()),
    )?;

    if explain_patterns {
        populate_pattern_details(&mut reports, resolved_config);
    }

    match format {
        OutputFormat::Text => {
            use is_terminal::IsTerminal;
            let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
            print!(
                "{}",
                hotspots_core::render_text_grouped(&reports, limit, color)
            );
        }
        OutputFormat::Json => println!("{}", hotspots_core::render_json(&reports)),
        OutputFormat::Html | OutputFormat::Jsonl => {
            anyhow::bail!("HTML/JSONL format requires --mode snapshot or --mode delta");
        }
        OutputFormat::Sarif => anyhow::bail!("SARIF format requires --mode snapshot"),
    }
    Ok(())
}

fn populate_pattern_details(
    reports: &mut [hotspots_core::FunctionRiskReport],
    resolved_config: &hotspots_core::ResolvedConfig,
) {
    for report in reports.iter_mut() {
        let t1 = hotspots_core::patterns::Tier1Input {
            cc: report.metrics.cc as usize,
            nd: report.metrics.nd as usize,
            fo: report.metrics.fo as usize,
            ns: report.metrics.ns as usize,
            loc: report.metrics.loc as usize,
        };
        let t2 = hotspots_core::patterns::Tier2Input {
            fan_in: None,
            scc_size: None,
            churn_lines: None,
            days_since_last_change: None,
            neighbor_churn: None,
            is_entrypoint: false,
        };
        report.pattern_details = Some(hotspots_core::patterns::classify_detailed(
            &t1,
            &t2,
            &resolved_config.pattern_thresholds,
        ));
    }
}

pub(crate) struct ModeOutputOptions {
    pub format: OutputFormat,
    pub policy: bool,
    pub top: Option<usize>,
    pub min_lrs: Option<f64>,
    pub output: Option<PathBuf>,
    pub explain: bool,
    pub force: bool,
    pub no_persist: bool,
    pub level: Option<OutputLevel>,
    pub touch_mode: TouchMode,
    pub all_functions: bool,
    pub include_models: bool,
    pub explain_patterns: bool,
    pub source_url: Option<String>,
    pub callgraph_skip_above: Option<usize>,
    pub skip_touch_metrics: bool,
}

pub(crate) fn handle_mode_output(
    path: &Path,
    mode: OutputMode,
    resolved_config: &hotspots_core::ResolvedConfig,
    opts: ModeOutputOptions,
) -> anyhow::Result<()> {
    let repo_root = find_repo_root(path)?;
    let analysis_progress = make_analysis_progress();
    let reports = analyze_with_progress(
        path,
        AnalysisOptions {
            min_lrs: opts.min_lrs,
            top_n: None,
        },
        Some(resolved_config),
        Some(analysis_progress.as_ref()),
    )?;
    let pr_context = git::detect_pr_context();

    match mode {
        OutputMode::Snapshot => {
            handle_snapshot_mode(path, &repo_root, resolved_config, reports, pr_context, opts)
        }
        OutputMode::Delta => {
            handle_delta_mode(&repo_root, resolved_config, reports, pr_context, opts)
        }
        OutputMode::Models => handle_models_mode(path, &repo_root, resolved_config, reports, opts),
    }
}

fn handle_snapshot_mode(
    path: &Path,
    repo_root: &Path,
    resolved_config: &hotspots_core::ResolvedConfig,
    reports: Vec<hotspots_core::FunctionRiskReport>,
    pr_context: hotspots_core::git::PrContext,
    opts: ModeOutputOptions,
) -> anyhow::Result<()> {
    let ModeOutputOptions {
        format,
        explain,
        force,
        no_persist,
        level,
        touch_mode,
        all_functions,
        include_models,
        explain_patterns,
        source_url,
        callgraph_skip_above,
        skip_touch_metrics,
        top,
        output,
        ..
    } = opts;
    let mut snapshot = build_snapshot_via_db(
        repo_root,
        resolved_config,
        reports,
        touch_mode,
        callgraph_skip_above,
        skip_touch_metrics,
    )
    .context("failed to build enriched snapshot")?;

    snapshot.populate_patterns(&resolved_config.pattern_thresholds);
    if explain_patterns {
        snapshot.populate_pattern_details(&resolved_config.pattern_thresholds);
    }

    // Populate DC before persisting so the snapshot on disk includes the values,
    // and before apply_trained_ranker so the ranker reads real DC instead of 0.0.
    // Partner scores use make_rel so keys match the repo-relative paths that
    // compute_directed_coupling_for_repo produces from git log output.
    {
        use hotspots_core::trainer::{make_rel, repo_prefixes};
        let (prefix_can, prefix_raw) = repo_prefixes(repo_root);
        let partner_scores: std::collections::HashMap<String, f64> = snapshot
            .functions
            .iter()
            .map(|f| {
                let rel = make_rel(&f.file, &prefix_can, &prefix_raw);
                (rel, f.activity_risk.unwrap_or(f.lrs))
            })
            .collect();
        snapshot.populate_directed_coupling(repo_root, &partner_scores);
    }

    if !pr_context.is_pr && !no_persist {
        snapshot::persist_snapshot(repo_root, &snapshot, force)
            .context("failed to persist snapshot")?;
        snapshot::append_to_index(repo_root, &snapshot).context("failed to update index")?;
    }

    let ranker_applied = apply_trained_ranker(repo_root, &mut snapshot);

    // Re-run quadrant assignment now that activity_risk reflects trained RF scores.
    // This promotes debt→fire for functions with high predicted fix probability (≥0.7)
    // even if they haven't been touched in the last 30 days.
    if ranker_applied {
        snapshot.compute_quadrants(resolved_config.driver_threshold_percentile, true);
    }

    let total_function_count = snapshot.functions.len();

    // Suppression gate: check if the activity ranker is working on this repo.
    // Run on the full sorted snapshot (before top-N truncation) so calibration
    // sees a representative top-50.
    // Inconclusive is silent — it fires on greenfield repos or non-conventional
    // commit histories and is not actionable.
    let gate_verdict = check_gate(repo_root, &snapshot.functions, &GateConfig::default());
    if let GateVerdict::Suppressed {
        p_at_10, threshold, ..
    } = &gate_verdict
    {
        if ranker_applied {
            eprintln!(
                "hotspots: activity ranker P@10={p_at_10:.2} (< {threshold:.2}); using trained ranker instead."
            );
        } else {
            eprintln!(
                "hotspots: warning: activity ranker P@10={p_at_10:.2} < {threshold:.2} — rankings may be misleading."
            );
            eprintln!("          Run `hotspots train` to fit a local ranker from this repo's fix history.");
        }
    }

    apply_top_n(&mut snapshot, format, explain, level, top);

    emit_snapshot_output(
        &mut snapshot,
        SnapshotOutputOpts {
            format,
            explain,
            level,
            top,
            total_function_count,
            output,
            co_change_window_days: resolved_config.co_change_window_days,
            co_change_min_count: resolved_config.co_change_min_count,
            all_functions,
            include_models,
            source_url: source_url.clone(),
            risk_thresholds: hotspots_core::risk::RiskThresholds {
                moderate: resolved_config.moderate_threshold,
                high: resolved_config.high_threshold,
                critical: resolved_config.critical_threshold,
            },
        },
        repo_root,
        path,
    )
}

fn handle_delta_mode(
    repo_root: &Path,
    resolved_config: &hotspots_core::ResolvedConfig,
    reports: Vec<hotspots_core::FunctionRiskReport>,
    pr_context: hotspots_core::git::PrContext,
    opts: ModeOutputOptions,
) -> anyhow::Result<()> {
    let ModeOutputOptions {
        format,
        policy,
        output,
        source_url,
        touch_mode,
        callgraph_skip_above,
        skip_touch_metrics,
        ..
    } = opts;
    let snapshot = build_enriched_snapshot(
        repo_root,
        resolved_config,
        reports,
        touch_mode,
        callgraph_skip_above,
        skip_touch_metrics,
    )
    .context("failed to build enriched snapshot")?;

    let delta_val = if pr_context.is_pr {
        compute_pr_delta(repo_root, &snapshot)?
    } else {
        delta::compute_delta(repo_root, &snapshot)?
    };

    let delta_with_extras = enrich_delta(repo_root, resolved_config, &snapshot, delta_val, policy)?;

    if emit_delta_output(
        &delta_with_extras,
        format,
        policy,
        output,
        source_url.as_deref(),
    )? {
        std::process::exit(1);
    }
    Ok(())
}

fn enrich_delta(
    repo_root: &Path,
    resolved_config: &hotspots_core::ResolvedConfig,
    snapshot: &Snapshot,
    delta_val: delta::Delta,
    policy: bool,
) -> anyhow::Result<delta::Delta> {
    let mut unique_files: Vec<String> = snapshot
        .functions
        .iter()
        .map(|f| f.file.clone())
        .collect::<std::collections::HashSet<_>>()
        .into_iter()
        .collect();
    unique_files.sort();
    let files_as_str: Vec<&str> = unique_files.iter().map(|s| s.as_str()).collect();
    let import_edges = hotspots_core::imports::resolve_file_deps(&files_as_str, repo_root);
    let mut current_co_change = hotspots_core::git::extract_co_change_pairs(
        repo_root,
        resolved_config.co_change_window_days,
        resolved_config.co_change_min_count,
    )
    .unwrap_or_default();
    hotspots_core::aggregates::annotate_static_deps(
        &mut current_co_change,
        &import_edges,
        repo_root,
    );

    let parent_sha = snapshot.commit.parents.first().cloned();
    let prev_co_change: Vec<hotspots_core::git::CoChangePair> = parent_sha
        .as_deref()
        .and_then(|sha| {
            hotspots_core::delta::load_parent_snapshot(repo_root, sha)
                .ok()
                .flatten()
        })
        .and_then(|s| s.aggregates)
        .map(|a| a.co_change)
        .unwrap_or_default();

    let mut enriched = delta_val.clone();
    if policy {
        let policy_results = hotspots_core::policy::evaluate_policies(
            &delta_val,
            snapshot,
            repo_root,
            resolved_config,
        )
        .context("failed to evaluate policies")?;
        if let Some(results) = policy_results {
            enriched.policy = Some(results);
        }
    }
    enriched.aggregates = Some(hotspots_core::aggregates::compute_delta_aggregates(
        &delta_val,
        &current_co_change,
        &prev_co_change,
    ));
    Ok(enriched)
}

fn handle_models_mode(
    path: &Path,
    repo_root: &Path,
    resolved_config: &hotspots_core::ResolvedConfig,
    reports: Vec<hotspots_core::FunctionRiskReport>,
    opts: ModeOutputOptions,
) -> anyhow::Result<()> {
    let ModeOutputOptions {
        format,
        top,
        touch_mode,
        callgraph_skip_above,
        skip_touch_metrics,
        ..
    } = opts;
    let snapshot = build_enriched_snapshot(
        repo_root,
        resolved_config,
        reports,
        touch_mode,
        callgraph_skip_above,
        skip_touch_metrics,
    )
    .context("failed to build enriched snapshot")?;
    let model_map = hotspots_core::models::compute_model_risk_map(path, repo_root, &snapshot, top)
        .context("failed to compute model risk map")?;
    match format {
        OutputFormat::Text => {
            print!(
                "{}",
                hotspots_core::models::render_model_risk_text(&model_map, top)
            );
        }
        OutputFormat::Json => {
            println!(
                "{}",
                hotspots_core::models::render_model_risk_json(&model_map)?
            );
        }
        OutputFormat::Html | OutputFormat::Jsonl | OutputFormat::Sarif => {
            unreachable!("validated by validate_analyze_flags")
        }
    }
    Ok(())
}

struct SnapshotOutputOpts {
    format: OutputFormat,
    explain: bool,
    level: Option<OutputLevel>,
    top: Option<usize>,
    total_function_count: usize,
    output: Option<PathBuf>,
    co_change_window_days: u64,
    co_change_min_count: usize,
    all_functions: bool,
    include_models: bool,
    source_url: Option<String>,
    risk_thresholds: hotspots_core::risk::RiskThresholds,
}

fn emit_snapshot_output(
    snapshot: &mut Snapshot,
    opts: SnapshotOutputOpts,
    repo_root: &Path,
    analysis_path: &Path,
) -> anyhow::Result<()> {
    match opts.format {
        OutputFormat::Json => emit_json_output(snapshot, repo_root, analysis_path, opts),
        OutputFormat::Jsonl => emit_jsonl_output(snapshot),
        OutputFormat::Text => emit_text_output(snapshot, repo_root, opts),
        OutputFormat::Html => emit_html_output(snapshot, repo_root, analysis_path, opts),
        OutputFormat::Sarif => emit_sarif_output(snapshot, repo_root, opts),
    }
}

fn emit_json_output(
    snapshot: &mut Snapshot,
    repo_root: &Path,
    analysis_path: &Path,
    opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
    let SnapshotOutputOpts {
        all_functions,
        include_models,
        co_change_window_days,
        co_change_min_count,
        output,
        ..
    } = opts;
    let aggregates = hotspots_core::aggregates::compute_snapshot_aggregates_with_models(
        snapshot,
        repo_root,
        co_change_window_days,
        co_change_min_count,
        include_models.then_some(analysis_path),
    );
    if all_functions {
        snapshot.aggregates = Some(aggregates);
        write_json_snapshot(snapshot, output)
    } else {
        let agent_output = hotspots_core::aggregates::compute_agent_snapshot_output(
            snapshot,
            &aggregates,
            repo_root,
        );
        write_json_agent(&agent_output, output)
    }
}

fn emit_jsonl_output(snapshot: &mut Snapshot) -> anyhow::Result<()> {
    let stdout = std::io::stdout();
    let mut out = std::io::BufWriter::new(stdout.lock());
    snapshot
        .write_jsonl_to(&mut out)
        .context("failed to write snapshot JSONL")
}

fn emit_text_output(
    snapshot: &mut Snapshot,
    repo_root: &Path,
    opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
    let SnapshotOutputOpts {
        level,
        explain,
        top,
        total_function_count,
        co_change_window_days,
        co_change_min_count,
        ..
    } = opts;
    let aggregates = hotspots_core::aggregates::compute_snapshot_aggregates(
        snapshot,
        repo_root,
        co_change_window_days,
        co_change_min_count,
    );
    if level == Some(OutputLevel::File) {
        explain::print_file_risk_output(&aggregates.file_risk, top)?;
    } else if level == Some(OutputLevel::Module) {
        explain::print_module_output(&aggregates.modules, top)?;
    } else if explain {
        use is_terminal::IsTerminal;
        let color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
        explain::print_explain_output(snapshot, total_function_count, color)?;
    } else {
        anyhow::bail!(
            "text format without --explain is not supported for snapshot mode (use --format json or add --explain)"
        );
    }
    Ok(())
}

fn emit_html_output(
    snapshot: &mut Snapshot,
    repo_root: &Path,
    analysis_path: &Path,
    opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
    let SnapshotOutputOpts {
        co_change_window_days,
        co_change_min_count,
        include_models,
        source_url,
        risk_thresholds,
        output,
        ..
    } = opts;
    let aggregates = hotspots_core::aggregates::compute_snapshot_aggregates_with_models(
        snapshot,
        repo_root,
        co_change_window_days,
        co_change_min_count,
        include_models.then_some(analysis_path),
    );
    snapshot.aggregates = Some(aggregates);
    let history: Vec<_> = hotspots_core::trends::load_snapshot_window(repo_root, 30)
        .unwrap_or_default()
        .into_iter()
        .filter_map(|s| s.summary.map(|sum| (s.commit, sum)))
        .collect();
    let html = hotspots_core::html::render_html_snapshot(
        snapshot,
        &history,
        source_url.as_deref(),
        &risk_thresholds,
    );
    let output_path = output.unwrap_or_else(|| PathBuf::from(".hotspots/report.html"));
    write_html_report(&output_path, &html)?;
    eprintln!("HTML report written to: {}", output_path.display());
    Ok(())
}

fn emit_sarif_output(
    snapshot: &mut Snapshot,
    repo_root: &Path,
    opts: SnapshotOutputOpts,
) -> anyhow::Result<()> {
    let sarif = hotspots_core::sarif::render_sarif(snapshot, repo_root);
    if let Some(output_path) = opts.output {
        if let Some(parent) = output_path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("failed to create directory: {}", parent.display()))?;
        }
        std::fs::write(&output_path, &sarif)
            .with_context(|| format!("failed to write SARIF to {}", output_path.display()))?;
        eprintln!("SARIF report written to: {}", output_path.display());
    } else {
        println!("{sarif}");
    }
    Ok(())
}

fn apply_top_n(
    snapshot: &mut Snapshot,
    format: OutputFormat,
    explain: bool,
    level: Option<OutputLevel>,
    top: Option<usize>,
) {
    let is_aggregate_level = level == Some(OutputLevel::File) || level == Some(OutputLevel::Module);
    let is_text = matches!(format, OutputFormat::Text);
    if !is_aggregate_level && (top.is_some() || (is_text && explain)) {
        snapshot.functions.sort_by(|a, b| {
            let a_score = a.activity_risk.unwrap_or(a.lrs);
            let b_score = b.activity_risk.unwrap_or(b.lrs);
            b_score
                .partial_cmp(&a_score)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        // 0 = show all; None in text+explain defaults to 20
        let limit = match top {
            Some(0) => usize::MAX,
            Some(n) => n,
            None if is_text => 20,
            None => usize::MAX,
        };
        if limit != usize::MAX {
            snapshot.functions.truncate(limit);
        }
    }
}

/// If `.hotspots/ranker.json` exists, overwrite each function's `activity_risk`
/// with the trained model's vote score.  Returns true if the ranker was applied.
/// Silent no-op (returns false) if the model is absent or fails to load.
fn apply_trained_ranker(repo_root: &Path, snapshot: &mut Snapshot) -> bool {
    let model_path = snapshot::hotspots_dir(repo_root).join("ranker.json");
    if !model_path.exists() {
        return false;
    }
    let model = match hotspots_core::trainer::RankerModel::load(&model_path) {
        Ok(m) => m,
        Err(e) => {
            eprintln!("hotspots: warning: failed to load ranker.json: {e}");
            return false;
        }
    };
    for func in &mut snapshot.functions {
        func.activity_risk = Some(hotspots_core::trainer::score(&model, func));
    }
    true
}

fn write_snapshot_json_file<F>(output_path: &Path, write: F) -> anyhow::Result<()>
where
    F: FnOnce(&mut std::io::BufWriter<std::fs::File>) -> anyhow::Result<()>,
{
    if let Some(parent) = output_path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
    }
    let file = std::fs::File::create(output_path)
        .with_context(|| format!("failed to create {}", output_path.display()))?;
    let mut out = std::io::BufWriter::new(file);
    write(&mut out)
}

fn write_json_snapshot(snapshot: &Snapshot, output: Option<PathBuf>) -> anyhow::Result<()> {
    if let Some(output_path) = output {
        write_snapshot_json_file(&output_path, |out| {
            snapshot
                .write_json_to(out)
                .context("failed to write snapshot JSON")
        })?;
        eprintln!("JSON report written to: {}", output_path.display());
    } else {
        let stdout = std::io::stdout();
        let mut out = std::io::BufWriter::new(stdout.lock());
        snapshot
            .write_json_to(&mut out)
            .context("failed to write snapshot JSON")?;
    }
    Ok(())
}

fn write_json_agent(
    agent_output: &hotspots_core::aggregates::AgentSnapshotOutput,
    output: Option<PathBuf>,
) -> anyhow::Result<()> {
    if let Some(output_path) = output {
        write_snapshot_json_file(&output_path, |out| {
            agent_output
                .write_json_to(out)
                .context("failed to write agent snapshot JSON")
        })?;
        eprintln!("JSON report written to: {}", output_path.display());
    } else {
        let stdout = std::io::stdout();
        let mut out = std::io::BufWriter::new(stdout.lock());
        agent_output
            .write_json_to(&mut out)
            .context("failed to write agent snapshot JSON")?;
    }
    Ok(())
}

/// Returns true if there are blocking policy failures (caller should exit non-zero).
fn emit_delta_output(
    delta_val: &Delta,
    format: OutputFormat,
    with_policy: bool,
    output: Option<PathBuf>,
    source_url: Option<&str>,
) -> anyhow::Result<bool> {
    let has_blocking_failures = delta_val
        .policy
        .as_ref()
        .map(|p| p.has_blocking_failures())
        .unwrap_or(false);

    match format {
        OutputFormat::Json => {
            println!("{}", delta_val.to_json()?);
        }
        OutputFormat::Jsonl => {
            anyhow::bail!("JSONL format is not supported for delta mode (use --mode snapshot)");
        }
        OutputFormat::Text => {
            emit_delta_text(delta_val, with_policy)?;
        }
        OutputFormat::Html => {
            emit_delta_html(delta_val, source_url, output)?;
        }
        OutputFormat::Sarif => {
            anyhow::bail!("SARIF format is not supported for delta mode (use --mode snapshot)");
        }
    }

    Ok(has_blocking_failures)
}

fn emit_delta_text(delta_val: &Delta, with_policy: bool) -> anyhow::Result<()> {
    if !with_policy {
        anyhow::bail!(
            "text format is not supported for delta mode without --policy (use --format json)"
        );
    }
    if let Some(ref policy_results) = delta_val.policy {
        policy::print_policy_text_output(delta_val, policy_results)?;
    } else {
        println!("Delta Analysis");
        println!("{}", "=".repeat(80));
        println!("Baseline delta (no parent snapshot) - policy evaluation skipped.");
        println!(
            "\nDelta contains {} function changes.",
            delta_val.deltas.len()
        );
    }
    Ok(())
}

fn emit_delta_html(
    delta_val: &Delta,
    source_url: Option<&str>,
    output: Option<PathBuf>,
) -> anyhow::Result<()> {
    let html = hotspots_core::html::render_html_delta(delta_val, source_url);
    let output_path = output.unwrap_or_else(|| PathBuf::from(".hotspots/report.html"));
    write_html_report(&output_path, &html)?;
    eprintln!("HTML report written to: {}", output_path.display());
    Ok(())
}

/// Compute delta for PR mode (compares vs merge-base).
fn compute_pr_delta(repo_root: &Path, snapshot: &Snapshot) -> anyhow::Result<delta::Delta> {
    let merge_base_sha = git::resolve_merge_base_auto();
    let fallback_sha = snapshot.commit.parents.first().map(|s| s.as_str());
    let parent = load_merge_base_or_fallback(repo_root, merge_base_sha.as_deref(), fallback_sha)?;
    delta::Delta::new(snapshot, parent.as_ref())
}

fn load_merge_base_or_fallback(
    repo_root: &Path,
    merge_base_sha: Option<&str>,
    fallback_sha: Option<&str>,
) -> anyhow::Result<Option<Snapshot>> {
    if let Some(sha) = merge_base_sha {
        match delta::load_parent_snapshot(repo_root, sha)? {
            Some(snap) => return Ok(Some(snap)),
            None => {
                eprintln!("Warning: merge-base snapshot not found, falling back to direct parent")
            }
        }
    } else {
        eprintln!("Warning: failed to resolve merge-base, falling back to direct parent");
    }
    if let Some(sha) = fallback_sha {
        delta::load_parent_snapshot(repo_root, sha)
    } else {
        Ok(None)
    }
}

/// Memory-efficient snapshot pipeline using SQLite as an in-process buffer.
///
/// Pipeline: insert reports → drop Vec → update churn → build call graph from DB →
/// update callgraph metrics → drop CallGraph → load FunctionSnapshot Vec → enrich → return.
///
/// Peak memory compared to `build_enriched_snapshot`:
///   Before: ~250 MB (reports + call graph + snapshot Vec all overlap)
///   After:  ~45 MB  (only call graph + SQLite overlap; reports freed before graph builds)
pub(crate) fn build_snapshot_via_db(
    repo_root: &Path,
    resolved_config: &hotspots_core::ResolvedConfig,
    reports: Vec<hotspots_core::FunctionRiskReport>,
    touch_mode: TouchMode,
    callgraph_skip_above: Option<usize>,
    skip_touch_metrics: bool,
) -> anyhow::Result<Snapshot> {
    use hotspots_core::db::TempDb;
    use hotspots_core::snapshot::{AnalysisInfo, CommitInfo, SNAPSHOT_SCHEMA_VERSION};

    let git_context =
        git::extract_git_context_at(repo_root).context("failed to extract git context")?;
    let merge_base = hotspots_core::git::find_merge_base(repo_root);

    let commit_info = CommitInfo::from(git_context.clone());
    let sha = commit_info.sha.clone();

    // Phase 1: write reports to DB, then free the Vec (~23 MB).
    let db = TempDb::new().context("failed to create pipeline TempDb")?;
    db.insert_reports(&commit_info, &reports)
        .context("failed to insert reports into pipeline DB")?;
    drop(reports);

    // Phase 2: churn (needed before callgraph so neighbor_churn can read it).
    if !git_context.parent_shas.is_empty() {
        match git::extract_commit_churn_at(repo_root, &sha) {
            Ok(churns) => {
                let churn_map: std::collections::HashMap<String, _> = churns
                    .into_iter()
                    .map(|c| {
                        let absolute_path = repo_root.join(&c.file);
                        let normalized_path = absolute_path.to_string_lossy().to_string();
                        (normalized_path, c)
                    })
                    .collect();
                db.update_churn(&sha, &churn_map)
                    .context("failed to update churn in pipeline DB")?;
            }
            Err(e) => eprintln!("Warning: failed to extract churn: {}", e),
        }
    }

    // Phase 3: build call graph from lean DB rows, run algorithms, write back, free graph.
    let effective_skip_above = callgraph_skip_above.unwrap_or(resolved_config.callgraph_skip_above);
    let function_count = db
        .function_count(&sha)
        .context("failed to count functions in pipeline DB")?;

    if function_count <= effective_skip_above {
        let call_graph = hotspots_core::build_call_graph_from_db(&db, &sha, repo_root)
            .context("failed to build call graph from DB")?;
        db.update_callgraph_metrics(
            &sha,
            &call_graph,
            resolved_config.betweenness_exact_threshold,
            resolved_config.betweenness_approx_k,
        )
        .context("failed to update callgraph metrics in pipeline DB")?;
        // call_graph dropped here, freeing ~25 MB.
    } else {
        eprintln!(
            "info: call graph skipped ({} functions > --callgraph-skip-above {})",
            function_count, effective_skip_above
        );
    }

    // Phase 4: load enriched functions from DB (churn + callgraph already set).
    let functions = db
        .load_snapshot_functions(&sha)
        .context("failed to load snapshot functions from pipeline DB")?;
    // SQLite connection dropped with `db` at end of scope; no longer needed.

    let total_functions = functions.len();
    let snapshot = Snapshot {
        schema_version: SNAPSHOT_SCHEMA_VERSION,
        commit: commit_info,
        analysis: AnalysisInfo {
            scope: "full".to_string(),
            tool_version: env!("CARGO_PKG_VERSION").to_string(),
        },
        functions,
        summary: None,
        aggregates: None,
    };

    // Phase 5: remaining enrichment (touch, activity risk, percentiles, driver, quadrant).
    let mut enricher = snapshot::SnapshotEnricher::new(snapshot).with_subsystems(repo_root);
    if !skip_touch_metrics {
        let needs_progress = matches!(
            touch_mode,
            TouchMode::PerFunction | TouchMode::Hybrid { .. }
        );
        let progress = if needs_progress {
            Some(make_progress_reporter(total_functions))
        } else {
            None
        };
        enricher = enricher.with_touch_metrics(repo_root, touch_mode, progress);
        enricher = enricher.with_branch_recency_adjustment(repo_root, merge_base.as_ref());
    }

    let result = enricher
        .enrich(
            Some(&resolved_config.scoring_weights),
            resolved_config.driver_threshold_percentile,
        )
        .build();
    Ok(result)
}

/// Run the full enrichment pipeline: git context, churn, touch metrics, call graph, activity risk.
///
/// `callgraph_skip_above` overrides `resolved_config.callgraph_skip_above` when `Some`.
/// `skip_touch_metrics` bypasses all git-log touch calls (file-level and per-function).
pub(crate) fn build_enriched_snapshot(
    repo_root: &Path,
    resolved_config: &hotspots_core::ResolvedConfig,
    mut reports: Vec<hotspots_core::FunctionRiskReport>,
    touch_mode: TouchMode,
    callgraph_skip_above: Option<usize>,
    skip_touch_metrics: bool,
) -> anyhow::Result<Snapshot> {
    let git_context =
        git::extract_git_context_at(repo_root).context("failed to extract git context")?;

    let merge_base = hotspots_core::git::find_merge_base(repo_root);

    let effective_skip_above = callgraph_skip_above.unwrap_or(resolved_config.callgraph_skip_above);
    let call_graph = if reports.len() > effective_skip_above {
        eprintln!(
            "info: call graph skipped ({} functions > --callgraph-skip-above {})",
            reports.len(),
            effective_skip_above
        );
        None
    } else {
        hotspots_core::build_call_graph(&reports, repo_root).ok()
    };

    for r in &mut reports {
        r.callees.clear();
        r.callees.shrink_to_fit();
    }

    let total_functions = reports.len();
    let mut enricher = snapshot::SnapshotEnricher::new(Snapshot::new(git_context.clone(), reports))
        .with_subsystems(repo_root);

    if !git_context.parent_shas.is_empty() {
        match git::extract_commit_churn_at(repo_root, &git_context.head_sha) {
            Ok(churns) => {
                let churn_map: std::collections::HashMap<String, _> = churns
                    .into_iter()
                    .map(|c| {
                        let absolute_path = repo_root.join(&c.file);
                        let normalized_path = absolute_path.to_string_lossy().to_string();
                        (normalized_path, c)
                    })
                    .collect();
                enricher = enricher.with_churn(&churn_map);
            }
            Err(e) => {
                eprintln!("Warning: failed to extract churn: {}", e);
            }
        }
    }

    if !skip_touch_metrics {
        let needs_progress = matches!(
            touch_mode,
            TouchMode::PerFunction | TouchMode::Hybrid { .. }
        );
        let progress = if needs_progress {
            Some(make_progress_reporter(total_functions))
        } else {
            None
        };
        enricher = enricher.with_touch_metrics(repo_root, touch_mode, progress);
        enricher = enricher.with_branch_recency_adjustment(repo_root, merge_base.as_ref());
    }

    if let Some(ref graph) = call_graph {
        enricher = enricher.with_callgraph(
            graph,
            resolved_config.betweenness_exact_threshold,
            resolved_config.betweenness_approx_k,
        );
    }

    Ok(enricher
        .enrich(
            Some(&resolved_config.scoring_weights),
            resolved_config.driver_threshold_percentile,
        )
        .build())
}

fn make_progress_reporter(total: usize) -> Box<dyn Fn(usize, usize)> {
    use std::io::IsTerminal;
    if total == 0 {
        return Box::new(|_i: usize, _total: usize| {});
    }
    if std::io::stderr().is_terminal() {
        use indicatif::{ProgressBar, ProgressStyle};
        let pb = ProgressBar::new(total as u64);
        pb.set_style(
            ProgressStyle::default_bar()
                .template("Building touch cache [{bar:40}] {pos}/{len}")
                .unwrap()
                .progress_chars("##-"),
        );
        Box::new(move |i: usize, t: usize| {
            pb.set_position(i as u64);
            if i >= t {
                pb.finish_and_clear();
            }
        })
    } else {
        let last_print = std::sync::Mutex::new(std::time::Instant::now());
        Box::new(move |i: usize, t: usize| {
            if i >= t {
                eprintln!("touch cache: {t}/{t} functions (100%) [done]");
                return;
            }
            if let Ok(mut last) = last_print.try_lock() {
                if last.elapsed().as_secs() >= 30 {
                    let pct = (i * 100).checked_div(t).unwrap_or(0);
                    eprintln!("touch cache: {i}/{t} functions ({pct}%)");
                    *last = std::time::Instant::now();
                }
            }
        })
    }
}

/// Analyze the source tree at `sha`, enrich it, persist it to `repo_root`, and
/// return the resulting snapshot.
///
/// Used by `hotspots diff --auto-analyze` to generate missing snapshots on
/// demand. A temporary git worktree is created at `sha` and torn down
/// automatically when this function returns (or on error).
///
/// Per-function touch metrics are skipped (the touch cache lives in
/// `repo_root/.hotspots/` and cannot be pre-warmed for historical refs; file-
/// level batched metrics are still computed as normal).
pub(crate) fn analyze_and_persist_at_ref(
    repo_root: &Path,
    sha: &str,
    resolved_config: &hotspots_core::ResolvedConfig,
) -> anyhow::Result<hotspots_core::snapshot::Snapshot> {
    let worktree = hotspots_core::git::create_worktree(repo_root, sha)
        .with_context(|| format!("failed to create worktree for {sha}"))?;

    let options = AnalysisOptions {
        min_lrs: None,
        top_n: None,
    };
    let progress = make_analysis_progress();
    let reports = analyze_with_progress(
        &worktree.path,
        options,
        Some(resolved_config),
        Some(progress.as_ref()),
    )
    .with_context(|| format!("analysis failed for ref {sha}"))?;

    // Build enriched snapshot using the worktree as the repo root so that
    // git context (HEAD, parents, timestamp) is read from the checked-out ref.
    // Touch cache is skipped (per_function_touches = false).
    // callgraph_skip_above = None: always use the config value for historical refs.
    let mut snapshot = build_enriched_snapshot(
        &worktree.path,
        resolved_config,
        reports,
        TouchMode::File,
        None,
        false,
    )
    .with_context(|| format!("enrichment failed for ref {sha}"))?;

    let worktree_prefix = worktree.path.to_string_lossy().replace('\\', "/");
    let repo_prefix = repo_root.to_string_lossy().replace('\\', "/");
    rewrite_worktree_paths(&mut snapshot, &worktree_prefix, &repo_prefix);

    snapshot.populate_patterns(&resolved_config.pattern_thresholds);

    // Persist into the *real* repo's .hotspots/ directory, not the worktree.
    hotspots_core::snapshot::persist_snapshot(repo_root, &snapshot, false)
        .with_context(|| format!("failed to persist snapshot for {sha}"))?;
    hotspots_core::snapshot::append_to_index(repo_root, &snapshot)
        .with_context(|| format!("failed to update index for {sha}"))?;

    // Drop of `worktree` runs `git worktree remove` here.
    Ok(snapshot)
}

fn rewrite_worktree_paths(snapshot: &mut Snapshot, worktree_prefix: &str, repo_prefix: &str) {
    for f in &mut snapshot.functions {
        if f.file.starts_with(worktree_prefix) {
            let rel = f.file[worktree_prefix.len()..].to_string();
            let old_file = f.file.clone();
            f.file = format!("{}{}", repo_prefix, rel);
            if let Some(symbol) = f.function_id.strip_prefix(&format!("{old_file}::")) {
                f.function_id = format!("{}::{}", f.file, symbol);
            }
        }
    }
}

fn resolve_touch_mode(
    no_per_function: bool,
    per_function: bool,
    hybrid_threshold: Option<usize>,
    config_per_function: bool,
) -> TouchMode {
    const DEFAULT_HYBRID_THRESHOLD: usize = 5;
    if no_per_function {
        TouchMode::File
    } else if let Some(threshold) = hybrid_threshold {
        TouchMode::Hybrid { threshold }
    } else if per_function || config_per_function {
        TouchMode::PerFunction
    } else {
        TouchMode::Hybrid {
            threshold: DEFAULT_HYBRID_THRESHOLD,
        }
    }
}

pub(crate) fn make_analysis_progress() -> Box<dyn Fn(usize, usize) + Send + Sync> {
    use std::io::IsTerminal;
    if !std::io::stderr().is_terminal() {
        let last_print = std::sync::Mutex::new(std::time::Instant::now());
        return Box::new(move |done: usize, total: usize| {
            if done == 0 {
                eprintln!("Analyzing: 0/{total} files");
                return;
            }
            if done >= total {
                eprintln!("Analyzing: {done}/{total} files [done]");
                return;
            }
            if let Ok(mut last) = last_print.try_lock() {
                if last.elapsed().as_secs() >= 30 {
                    let pct = (done as f64 / total as f64 * 100.0) as usize;
                    eprintln!("Analyzing: {done}/{total} files ({pct}%)");
                    *last = std::time::Instant::now();
                }
            }
        });
    }
    use indicatif::{ProgressBar, ProgressStyle};
    let pb = ProgressBar::new(0);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("Analyzing [{bar:40}] {pos}/{len} files  {elapsed} · ~{eta} remaining")
            .unwrap()
            .progress_chars("##-"),
    );
    Box::new(move |done: usize, total: usize| {
        if done == 0 {
            if total == 0 {
                pb.finish_and_clear();
                return;
            }
            pb.set_length(total as u64);
            pb.set_position(0);
        } else {
            pb.set_position(done as u64);
            if done >= total {
                pb.finish_and_clear();
            }
        }
    })
}
