//! Pipeline output rendering.
//!
//! A pipeline run produces three audiences worth of output: the last step's
//! result for a human (pretty) or a downstream pipe (`--format json` full
//! trace), a per-step cost/token/duration breakdown that every reporter
//! surfaces so CI can chart spend, and the dry-run plan plus cost projection.
//! Keeping the rendering here keeps the executor focused on control flow.

use std::fmt::Write as _;

use serde_json::{json, Value};

use super::budget::BudgetTracker;
use super::executor::{RunResult, StepOutcome, StepStatus};
use super::plan::Projection;

/// Render the last step's output for the default (pretty) format.
///
/// This is what a human reading the terminal cares about: the final result.
/// The cost breakdown is printed separately so piping the result stays clean.
pub fn render_last_output_pretty(result: &RunResult) -> String {
    match &result.last_output {
        Some(value) => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
        None => "(no step produced output)".to_string(),
    }
}

/// Render the full execution trace as JSON for `--format json`.
///
/// Carries every step's status, output, and cost so a downstream tool or CI
/// job has the complete picture in one machine-readable object.
pub fn render_trace_json(result: &RunResult) -> Value {
    let steps: Vec<Value> = result.steps.iter().map(step_to_json).collect();
    json!({
        "steps": steps,
        "cost": {
            "total_usd": round6(result.budget.spent_usd()),
            "total_tokens": result.budget.spent_tokens(),
            "cap_usd": result.budget.cost_cap(),
            "cap_tokens": result.budget.token_cap(),
            "fraction_used": result.budget.cost_fraction_used().map(round6),
        },
        "breach": result.breach.as_ref().map(|(id, kind)| json!({
            "step": id,
            "kind": kind.label(),
        })),
        "last_output": result.last_output,
    })
}

fn step_to_json(step: &StepOutcome) -> Value {
    let status = match &step.status {
        StepStatus::Ok => json!("ok"),
        StepStatus::Skipped => json!("skipped"),
        StepStatus::FailedContinued(msg) => json!({ "failed": msg }),
    };
    json!({
        "id": step.id,
        "tool": step.tool,
        "status": status,
        "cost_usd": round6(step.cost.cost_usd),
        "input_tokens": step.cost.input_tokens,
        "output_tokens": step.cost.output_tokens,
        "duration_ms": step.cost.duration.as_millis() as u64,
        "output": step.output,
    })
}

/// Render the per-step cost/token/duration breakdown table.
///
/// Printed to stderr after a run so it never pollutes the piped result on
/// stdout. The trailing budget line shows the cap and percent consumed when a
/// cost cap was set.
pub fn render_cost_breakdown(budget: &BudgetTracker) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "Step           Cost       Tokens (in/out)    Duration");
    for step in budget.steps() {
        let tokens = if step.is_llm || step.total_tokens() > 0 {
            format!("{} / {}", step.input_tokens, step.output_tokens)
        } else {
            "-".to_string()
        };
        let _ = writeln!(
            out,
            "{:<14} ${:<9.4} {:<18} {}ms",
            truncate(&step.step_id, 14),
            step.cost_usd,
            tokens,
            step.duration.as_millis()
        );
    }
    let _ = writeln!(out, "────────────────────────────────────────────────────");
    let _ = writeln!(
        out,
        "{:<14} ${:<9.4} {:<18}",
        "Total",
        budget.spent_usd(),
        format!("{}", budget.spent_tokens())
    );
    if let Some(cap) = budget.cost_cap() {
        let pct = budget.cost_fraction_used().unwrap_or(0.0) * 100.0;
        let _ = writeln!(out, "Budget         ${cap:.2} ({pct:.0}% used)");
    }
    out
}

/// Render the dry-run plan and (optionally) the cost projection (,
/// ). The plan lists every step in order; with `estimate` set the
/// projection adds per-step dollar figures, a total, and a worst-case line.
pub fn render_dry_run(path: &str, projection: &Projection, estimate: bool) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "Pipeline: {path}");
    if estimate {
        for step in &projection.steps {
            let _ = writeln!(
                out,
                "  Step {:<10} : ~${:.4} ({})",
                truncate(&step.id, 10),
                step.cost_usd,
                step.basis
            );
        }
        let _ = writeln!(out, "                  ──────────");
        let _ = writeln!(out, "  Total estimate   : ~${:.4}", projection.total_usd);
        let _ = writeln!(
            out,
            "\n  Worst-case ({WORST_CASE_NOTE}): ~${:.4}",
            projection.worst_case_usd
        );
    } else {
        for step in &projection.steps {
            let _ = writeln!(out, "  Step {}", step.id);
        }
        let _ = writeln!(out, "(pass --estimate-cost to project spend)");
    }
    out
}

const WORST_CASE_NOTE: &str = "3 retries on each step, 1.5x token estimate";

fn round6(v: f64) -> f64 {
    (v * 1_000_000.0).round() / 1_000_000.0
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        format!("{}…", &s[..max.saturating_sub(1)])
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pipe::budget::{BudgetLimits, BudgetTracker, StepCost};
    use crate::pipe::plan::project;
    use crate::pipe::pricing::PricingTable;
    use std::time::Duration;

    fn tracker_with_steps() -> BudgetTracker {
        let mut t = BudgetTracker::new(BudgetLimits {
            max_cost: Some(0.50),
            ..Default::default()
        });
        t.record(StepCost {
            step_id: "find".into(),
            cost_usd: 0.001,
            input_tokens: 0,
            output_tokens: 0,
            duration: Duration::from_millis(120),
            is_llm: false,
        });
        t.record(StepCost {
            step_id: "summarize".into(),
            cost_usd: 0.082,
            input_tokens: 8240,
            output_tokens: 1980,
            duration: Duration::from_millis(3400),
            is_llm: true,
        });
        t
    }

    #[test]
    fn breakdown_shows_total_and_budget_line() {
        let out = render_cost_breakdown(&tracker_with_steps());
        assert!(out.contains("find"));
        assert!(out.contains("summarize"));
        assert!(out.contains("8240 / 1980"));
        assert!(out.contains("Total"));
        assert!(out.contains("Budget"));
        assert!(out.contains("% used"));
    }

    #[test]
    fn breakdown_uses_dash_for_non_llm_tokens() {
        let out = render_cost_breakdown(&tracker_with_steps());
        // The find step has no tokens, so its row carries a dash.
        let find_line = out.lines().find(|l| l.contains("find")).unwrap();
        assert!(find_line.contains('-'));
    }

    #[test]
    fn dry_run_with_estimate_prints_totals() {
        let pricing = PricingTable::bundled().unwrap();
        let p = crate::pipe::model::Pipeline::from_yaml_str(
            "pipe:\n  - id: a\n    tool: t\n  - id: b\n    tool: t\n",
            "<test>",
        )
        .unwrap();
        let projection = project(&p, &pricing, |_| None);
        let out = render_dry_run("my.yaml", &projection, true);
        assert!(out.contains("Total estimate"));
        assert!(out.contains("Worst-case"));
    }

    #[test]
    fn dry_run_without_estimate_lists_steps_only() {
        let pricing = PricingTable::bundled().unwrap();
        let p = crate::pipe::model::Pipeline::from_yaml_str(
            "pipe:\n  - id: a\n    tool: t\n",
            "<test>",
        )
        .unwrap();
        let projection = project(&p, &pricing, |_| None);
        let out = render_dry_run("my.yaml", &projection, false);
        assert!(out.contains("Step a"));
        assert!(!out.contains("Total estimate"));
    }
}
