//! Schema-drift gate (ADR-043).
//!
//! Every [`klieo_core::Episode`] variant's serialized payload must validate
//! against its published JSON schema under `docs/schemas/runlog/`, and the
//! envelope schema's `kind` enum must enumerate exactly the known variants.
//!
//! A failing assertion means the Rust type and the published wire contract
//! have diverged. Resolve in the same change that alters the type: an
//! additive schema relaxation is a minor bump; a breaking shape change is a
//! SemVer-major bump. Never silence the gate by deleting a fixture.

use jsonschema::JSONSchema;
use klieo_core::{Episode, ToolResult};
use serde_json::{json, Value};
use std::path::PathBuf;

const RUNLOG_SCHEMA_DIR: &str = "docs/schemas/runlog";
const ENVELOPE_SCHEMA: &str = "episode.schema.json";

/// Every schema file the gate is responsible for: the envelope plus one
/// payload schema per `Episode` variant.
const ALL_SCHEMA_FILES: &[&str] = &[
    ENVELOPE_SCHEMA,
    "episode-started.schema.json",
    "episode-llm-call.schema.json",
    "episode-tool-call.schema.json",
    "episode-bus-publish.schema.json",
    "episode-bus-receive.schema.json",
    "episode-completed.schema.json",
    "episode-failed.schema.json",
    "episode-summary-checkpoint.schema.json",
    "episode-ops.schema.json",
    "episode-run-attributed.schema.json",
    "episode-run-origin.schema.json",
];

/// One serialized `Episode` variant paired with the payload schema its
/// wire shape must satisfy.
struct VariantFixture {
    kind: &'static str,
    schema_file: &'static str,
    episode: Episode,
}

fn fixture(kind: &'static str, schema_file: &'static str, episode: Episode) -> VariantFixture {
    VariantFixture {
        kind,
        schema_file,
        episode,
    }
}

/// Representative samples. Some variants appear twice to exercise shapes the
/// schema must all accept: `llm_call` fully populated vs the legacy all-`None`
/// form (optional fields serialize as JSON `null`), and `tool_call` carrying
/// each `ToolResult` arm (`Ok` payload vs `Err` message).
fn variant_fixtures() -> Vec<VariantFixture> {
    vec![
        fixture(
            "started",
            "episode-started.schema.json",
            Episode::Started {
                agent: "planner".into(),
            },
        ),
        fixture("llm_call", "episode-llm-call.schema.json", llm_call_full()),
        fixture(
            "llm_call",
            "episode-llm-call.schema.json",
            llm_call_legacy(),
        ),
        fixture(
            "tool_call",
            "episode-tool-call.schema.json",
            Episode::ToolCall {
                name: "search".into(),
                args: json!({ "query": "klieo" }),
                result: ToolResult::Ok {
                    value: json!({ "hits": 3 }),
                },
            },
        ),
        fixture(
            "tool_call",
            "episode-tool-call.schema.json",
            Episode::ToolCall {
                name: "search".into(),
                args: json!({ "query": "klieo" }),
                result: ToolResult::Err {
                    message: "tool failed".into(),
                },
            },
        ),
        fixture(
            "bus_publish",
            "episode-bus-publish.schema.json",
            Episode::BusPublish {
                subject: "agent.events".into(),
            },
        ),
        fixture(
            "bus_receive",
            "episode-bus-receive.schema.json",
            Episode::BusReceive {
                subject: "agent.events".into(),
            },
        ),
        fixture(
            "completed",
            "episode-completed.schema.json",
            Episode::Completed,
        ),
        fixture(
            "failed",
            "episode-failed.schema.json",
            Episode::Failed {
                error: "upstream timeout".into(),
            },
        ),
        fixture(
            "summary_checkpoint",
            "episode-summary-checkpoint.schema.json",
            Episode::SummaryCheckpoint {
                input_message_count: 12,
                summary_chars: 480,
                latency_ms: 90,
                tokens: 256,
            },
        ),
        fixture(
            "ops",
            "episode-ops.schema.json",
            Episode::Ops(json!({ "event": "scaled", "replicas": 3 })),
        ),
        fixture(
            "run_attributed",
            "episode-run-attributed.schema.json",
            Episode::RunAttributed {
                tenant_label: "0123456789abcdef".into(),
            },
        ),
        fixture(
            "run_origin",
            "episode-run-origin.schema.json",
            Episode::RunOrigin {
                parent_anchor: "sha256:0123456789abcdef".into(),
            },
        ),
    ]
}

fn llm_call_full() -> Episode {
    Episode::LlmCall {
        tokens: 1_200,
        latency_ms: 350,
        provider: Some("anthropic".into()),
        model: Some("claude-opus-4-8".into()),
        prompt_tokens: Some(900),
        completion_tokens: Some(300),
    }
}

fn llm_call_legacy() -> Episode {
    Episode::LlmCall {
        tokens: 1_200,
        latency_ms: 350,
        provider: None,
        model: None,
        prompt_tokens: None,
        completion_tokens: None,
    }
}

fn workspace_root() -> PathBuf {
    // CARGO_MANIFEST_DIR is <root>/crates/klieo-core; schemas live at the root.
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
}

fn load_schema(file: &str) -> Value {
    let path = workspace_root().join(RUNLOG_SCHEMA_DIR).join(file);
    let text = std::fs::read_to_string(&path)
        .unwrap_or_else(|err| panic!("read schema {}: {err}", path.display()));
    serde_json::from_str(&text)
        .unwrap_or_else(|err| panic!("parse schema {}: {err}", path.display()))
}

fn compile_schema(file: &str) -> JSONSchema {
    let value = load_schema(file);
    JSONSchema::compile(&value).unwrap_or_else(|err| panic!("compile schema {file}: {err}"))
}

/// Externally-tagged enums serialize struct/newtype variants as
/// `{"Variant": <payload>}` and unit variants as the bare string
/// `"Variant"`. The payload schemas describe the inner object, so a unit
/// variant maps to an empty object.
fn payload_of(episode: &Episode) -> Value {
    match serde_json::to_value(episode).expect("episode serializes to json") {
        Value::String(_) => json!({}),
        Value::Object(map) => map
            .into_values()
            .next()
            .expect("externally-tagged variant carries one key"),
        other => panic!("unexpected episode serialization shape: {other}"),
    }
}

fn schema_violations(schema: &JSONSchema, instance: &Value) -> Vec<String> {
    match schema.validate(instance) {
        Ok(()) => Vec::new(),
        Err(errors) => errors.map(|error| error.to_string()).collect(),
    }
}

#[test]
fn every_published_schema_compiles_as_valid_json_schema() {
    for file in ALL_SCHEMA_FILES {
        compile_schema(file);
    }
}

#[test]
fn every_episode_variant_payload_matches_published_schema() {
    for fixture in variant_fixtures() {
        let schema = compile_schema(fixture.schema_file);
        let payload = payload_of(&fixture.episode);
        let violations = schema_violations(&schema, &payload);
        assert!(
            violations.is_empty(),
            "kind `{}` payload violates {}: {violations:?}\npayload = {payload}",
            fixture.kind,
            fixture.schema_file,
        );
    }
}

#[test]
fn envelope_schema_accepts_a_representative_episode_envelope() {
    let schema = compile_schema(ENVELOPE_SCHEMA);
    let envelope = json!({
        "workspace_id": "ws_1",
        "run_id": "run_1",
        "seq": 0,
        "kind": "llm_call",
        "payload": { "tokens": 10, "latency_ms": 5 },
        "ts": "2026-06-10T12:00:00Z",
    });
    let violations = schema_violations(&schema, &envelope);
    assert!(
        violations.is_empty(),
        "envelope sample rejected: {violations:?}"
    );
}

/// Every fixture's declared kind must exist in the published envelope enum,
/// so a fixture can never validate against a kind the envelope rejects.
///
/// This guards fixture/schema consistency only. Completeness against the
/// Rust `Episode` type — a new variant being added without a fixture — is
/// enforced at compile time by `kind_discriminant` in `klieo-core`'s
/// `memory.rs` unit tests, where `#[non_exhaustive]` does not relax the match.
#[test]
fn every_fixture_kind_is_declared_in_the_envelope_enum() {
    let schema = load_schema(ENVELOPE_SCHEMA);
    let declared = schema["properties"]["kind"]["enum"]
        .as_array()
        .expect("envelope schema declares a kind enum");
    let published: Vec<&str> = declared
        .iter()
        .map(|value| value.as_str().expect("kind enum is strings"))
        .collect();

    for fixture in variant_fixtures() {
        assert!(
            published.contains(&fixture.kind),
            "fixture kind `{}` is absent from the published envelope enum {published:?}",
            fixture.kind,
        );
    }
}

#[test]
fn payload_schema_rejects_a_wrongly_typed_field() {
    let schema = compile_schema("episode-llm-call.schema.json");
    let malformed = json!({ "tokens": "not-a-number", "latency_ms": 5 });
    let violations = schema_violations(&schema, &malformed);
    assert!(
        !violations.is_empty(),
        "llm-call schema accepted a string `tokens`; required type is not enforced",
    );
}

#[test]
fn envelope_schema_rejects_a_missing_required_field() {
    let schema = compile_schema(ENVELOPE_SCHEMA);
    let missing_run_id = json!({
        "workspace_id": "ws_1",
        "seq": 0,
        "kind": "completed",
        "ts": "2026-06-10T12:00:00Z",
    });
    let violations = schema_violations(&schema, &missing_run_id);
    assert!(
        !violations.is_empty(),
        "envelope schema accepted a record missing the required `run_id`",
    );
}
