//! OpenInference / OTLP-compatible trace export (WOR-1419).
//!
//! A vendor-neutral way to project a run into the eval/observability market
//! (Braintrust, LangSmith, Arize, Galileo, Patronus) without bundling any
//! vendor SDK. The export is one JSON span per line (JSONL): a run span, a test
//! span per test, and a verdict span per judged test. Span names reuse the
//! pinned [`crate::otel`] constants so dashboards keyed on `mcptest.run` /
//! `mcptest.test` keep working, and ids are derived deterministically from the
//! run id so golden fixtures are stable.
//!
//! This is a render format, not a parallel trace model: it reads the canonical
//! [`Report`] and emits the same session -> test -> verdict shape the session
//! ledger and evidence pack already describe.

use std::io::Write;

use serde_json::{json, Value};
use sha2::{Digest, Sha256};

use crate::otel::{RUN_SPAN_NAME, TEST_SPAN_NAME};
use crate::report::{Report, Reporter, TestResult, TestStatus};

/// Schema id stamped on every exported span so a consumer can route by version.
pub const OPENINFERENCE_SCHEMA_VERSION: &str = "mcptest.dev/openinference/v1";

/// Emits an OpenInference-compatible JSONL trace from a run report.
#[derive(Default)]
pub struct OpeninferenceReporter;

impl Reporter for OpeninferenceReporter {
    fn name(&self) -> &'static str {
        "openinference"
    }

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        let trace_id = hex_id(&report.run_id, 32);
        let run_span = hex_id(&format!("{}:run", report.run_id), 16);

        writeln!(out, "{}", run_span_line(report, &trace_id, &run_span))?;

        for (index, test) in report.tests.iter().enumerate() {
            let test_span = hex_id(&format!("{}:test:{index}", report.run_id), 16);
            writeln!(
                out,
                "{}",
                test_span_line(test, &trace_id, &test_span, &run_span)
            )?;
            if test.consensus.is_some() {
                let verdict_span = hex_id(&format!("{}:verdict:{index}", report.run_id), 16);
                writeln!(
                    out,
                    "{}",
                    verdict_span_line(test, &trace_id, &verdict_span, &test_span)
                )?;
            }
        }
        Ok(())
    }
}

/// The run-level span: counts and timing for the whole execution.
fn run_span_line(report: &Report, trace_id: &str, span_id: &str) -> String {
    let s = &report.summary;
    let span = json!({
        "schema_version": OPENINFERENCE_SCHEMA_VERSION,
        "trace_id": trace_id,
        "span_id": span_id,
        "parent_span_id": Value::Null,
        "name": RUN_SPAN_NAME,
        // OTLP `span_kind` is the transport-level kind; the OpenInference
        // semantic kind (CHAIN/LLM/EVALUATOR) rides in the
        // `openinference.span.kind` attribute, which Arize/Phoenix and LangSmith
        // key their LLM views on.
        "span_kind": "INTERNAL",
        "status_code": if s.failed > 0 { "ERROR" } else { "OK" },
        "start_time": s.started_at,
        "end_time": s.ended_at,
        "duration_ms": s.duration_ms,
        "attributes": {
            "openinference.span.kind": "CHAIN",
            "mcptest.run.id": report.run_id,
            "mcptest.version": report.mcptest_version,
            "mcptest.run.total": s.total,
            "mcptest.run.passed": s.passed,
            "mcptest.run.failed": s.failed,
            "mcptest.run.skipped": s.skipped,
            "mcptest.run.inconclusive": s.inconclusive,
        }
    });
    span.to_string()
}

/// A per-test span, child of the run span.
fn test_span_line(test: &TestResult, trace_id: &str, span_id: &str, parent: &str) -> String {
    let span = json!({
        "schema_version": OPENINFERENCE_SCHEMA_VERSION,
        "trace_id": trace_id,
        "span_id": span_id,
        "parent_span_id": parent,
        "name": TEST_SPAN_NAME,
        "span_kind": "INTERNAL",
        "status_code": status_code(test.status),
        "start_time": test.started_at,
        "end_time": test.ended_at,
        "duration_ms": test.duration_ms,
        "attributes": {
            "openinference.span.kind": "CHAIN",
            "mcptest.test.name": test.name,
            "mcptest.test.status": status_label(test.status),
            "mcptest.test.cache_hit": test.cache_hit,
            "mcptest.test.file": test.file,
            "mcptest.test.line": test.line,
            "mcptest.test.score": test.score,
            "mcptest.test.rule_id": test.rule_id,
        }
    });
    span.to_string()
}

/// A judge/jury verdict span, child of its test span. OpenInference marks a
/// scoring step with the `EVALUATOR` kind.
fn verdict_span_line(test: &TestResult, trace_id: &str, span_id: &str, parent: &str) -> String {
    let consensus = test.consensus.as_ref().expect("caller checked is_some");
    let jurors: Vec<Value> = consensus
        .jurors
        .iter()
        .map(|j| {
            json!({
                "model": j.model,
                "provider": j.provider,
                "verdict": verdict_label(j.verdict),
                "score": j.score,
                "cost_usd": j.cost_usd,
            })
        })
        .collect();
    // The first juror is the primary judge model surfaced under the llm.* /
    // gen_ai.* conventions; the full panel rides in `mcptest.jurors`.
    let primary = consensus.jurors.first();
    let span = json!({
        "schema_version": OPENINFERENCE_SCHEMA_VERSION,
        "trace_id": trace_id,
        "span_id": span_id,
        "parent_span_id": parent,
        "name": "mcptest.verdict",
        "span_kind": "INTERNAL",
        "status_code": if matches!(consensus.verdict, crate::report::Verdict::Fail) { "ERROR" } else { "OK" },
        "attributes": {
            // OpenInference marks a scoring step EVALUATOR; the judge is also an
            // LLM call, so surface the primary model under the gen_ai/llm
            // conventions Arize/Phoenix and LangSmith read.
            "openinference.span.kind": "EVALUATOR",
            "llm.model_name": primary.map(|j| j.model.clone()),
            "gen_ai.system": primary.map(|j| j.provider.clone()),
            "gen_ai.usage.cost_usd": consensus.total_cost_usd,
            "mcptest.verdict": verdict_label(consensus.verdict),
            "mcptest.verdict.method": consensus.method,
            "mcptest.verdict.score": consensus.score,
            "mcptest.verdict.cost_usd": consensus.total_cost_usd,
            "mcptest.verdict.escalate": consensus.escalate_for_review,
            "mcptest.jurors": jurors,
        }
    });
    span.to_string()
}

/// OTel status code for a test status.
fn status_code(status: TestStatus) -> &'static str {
    match status {
        TestStatus::Pass => "OK",
        TestStatus::Fail => "ERROR",
        TestStatus::Skip | TestStatus::Inconclusive => "UNSET",
    }
}

/// Stable lowercase label for a test status.
fn status_label(status: TestStatus) -> &'static str {
    match status {
        TestStatus::Pass => "pass",
        TestStatus::Fail => "fail",
        TestStatus::Skip => "skip",
        TestStatus::Inconclusive => "inconclusive",
    }
}

/// Stable lowercase label for a juror or consensus verdict.
fn verdict_label(verdict: crate::report::Verdict) -> &'static str {
    match verdict {
        crate::report::Verdict::Pass => "pass",
        crate::report::Verdict::Fail => "fail",
        crate::report::Verdict::Abstain => "abstain",
    }
}

/// A deterministic lowercase-hex id of `len` chars, derived from `seed`. Lets
/// the same run produce the same trace and span ids across machines, so golden
/// fixtures are stable and re-exports correlate.
fn hex_id(seed: &str, len: usize) -> String {
    let mut hasher = Sha256::new();
    hasher.update(seed.as_bytes());
    let hex = format!("{:x}", hasher.finalize());
    hex.chars().take(len).collect()
}

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

    fn render(report: &Report) -> Vec<Value> {
        let mut buf = Vec::new();
        OpeninferenceReporter
            .render(report, &mut buf)
            .expect("render");
        String::from_utf8(buf)
            .unwrap()
            .lines()
            .map(|l| serde_json::from_str(l).expect("each line is JSON"))
            .collect()
    }

    #[test]
    fn emits_a_run_span_then_one_span_per_test() {
        let report = Report::sample();
        let spans = render(&report);
        // One run span plus one span per test (sample has no jury verdicts).
        assert_eq!(spans[0]["name"], RUN_SPAN_NAME);
        assert_eq!(spans[0]["parent_span_id"], Value::Null);
        let test_spans = spans.iter().filter(|s| s["name"] == TEST_SPAN_NAME).count();
        assert_eq!(test_spans, report.tests.len());
    }

    #[test]
    fn every_span_is_versioned_and_well_formed() {
        let report = Report::sample();
        for span in render(&report) {
            assert_eq!(span["schema_version"], OPENINFERENCE_SCHEMA_VERSION);
            assert_eq!(
                span["trace_id"].as_str().unwrap().len(),
                32,
                "trace_id is 32 hex chars"
            );
            assert_eq!(
                span["span_id"].as_str().unwrap().len(),
                16,
                "span_id is 16 hex chars"
            );
        }
    }

    #[test]
    fn test_spans_parent_to_the_run_span() {
        let report = Report::sample();
        let spans = render(&report);
        let run_id = spans[0]["span_id"].clone();
        for span in spans.iter().filter(|s| s["name"] == TEST_SPAN_NAME) {
            assert_eq!(span["parent_span_id"], run_id);
        }
    }

    #[test]
    fn ids_are_deterministic_for_a_run_id() {
        let report = Report::sample();
        // Two renders of the same report yield identical ids.
        assert_eq!(render(&report), render(&report));
    }

    #[test]
    fn failed_run_reports_an_error_status() {
        let report = Report::sample();
        let spans = render(&report);
        // The sample carries a failing test, so the run span is ERROR.
        if report.summary.failed > 0 {
            assert_eq!(spans[0]["status_code"], "ERROR");
        }
    }

    #[test]
    fn spans_carry_an_otlp_kind_and_an_openinference_span_kind() {
        // WOR-1516: the OTLP `span_kind` is a valid OTLP kind, and the
        // OpenInference semantic kind (the one Arize/Phoenix key on) rides in
        // the `openinference.span.kind` attribute.
        let report = Report::sample();
        let spans = render(&report);
        for span in &spans {
            assert_eq!(span["span_kind"], "INTERNAL");
            assert!(span["attributes"]["openinference.span.kind"].is_string());
        }
        assert_eq!(spans[0]["attributes"]["openinference.span.kind"], "CHAIN");
    }
}
