//! Failure-class mapping behavior tests (issue #15).
//!
//! Pins the [`FailureMapper`] surface defined in
//! `src/router/failure_mapping.rs`:
//!
//! * every [`FailureClass`] variant is producible by the mapper from
//!   *some* documented input shape (RouteError / ReceiptError /
//!   TransportError / NegotiationOutcome / placement decision);
//! * every [`RetryClass`] variant is the natural retry hint for at
//!   least one of those failure classes;
//! * the validation-layer `receipt.emitted` guard rejects a plan that
//!   would synthesize a receipt for a notification event;
//! * partial frame context and empty sentinel fields surface as
//!   `InvalidRequest`;
//! * the operator-required surface uses
//!   `(OperatorRequired, RetryAfterOperator)`;
//! * adapter-specific raw error patterns (Codex / Claude) are
//!   translated into the *shared* failure-class vocabulary by tiny
//!   per-adapter mapping fixtures, demonstrating the rule that no
//!   adapter introduces its own failure semantics into core types.

use std::collections::{BTreeMap, HashSet};

use lifeloop::router::{
    BuiltinAdapterRegistry, LifeloopFailureMapper, ReceiptError, RouteError, TransportError,
    classes_for_negotiation_outcome, failure_class_for_receipt_error,
    failure_class_for_route_error, failure_class_for_transport, retry_class_for, route,
    validate_receipt_eligible,
};
use lifeloop::{
    AdapterManifest, AdapterRole, CallbackRequest, ConformanceLevel, FailureClass, FrameClass,
    FrameContext, IntegrationMode, LifecycleEventKind, ManifestContextPressure,
    ManifestPlacementClass, ManifestPlacementSupport, ManifestReceipts, NegotiationOutcome,
    RetryClass, SCHEMA_VERSION, SupportState,
    router::{AdapterRegistry, AdapterResolution, FailureMapper},
};

// ---------------------------------------------------------------------------
// Fake adapter registry (mirrors tests/router.rs but kept self-contained so
// these tests don't depend on that file's helpers across the file boundary).
// ---------------------------------------------------------------------------

const FAKE_ADAPTER_ID: &str = "fake-adapter";
const FAKE_ADAPTER_VERSION: &str = "0.0.1";

fn fake_manifest() -> AdapterManifest {
    AdapterManifest {
        contract_version: SCHEMA_VERSION.to_string(),
        adapter_id: FAKE_ADAPTER_ID.into(),
        adapter_version: FAKE_ADAPTER_VERSION.into(),
        display_name: "Fake".into(),
        role: AdapterRole::PrimaryWorker,
        integration_modes: vec![IntegrationMode::NativeHook],
        lifecycle_events: BTreeMap::new(),
        placement: BTreeMap::from([(
            ManifestPlacementClass::PreSession,
            ManifestPlacementSupport {
                support: SupportState::Native,
                max_bytes: None,
            },
        )]),
        context_pressure: ManifestContextPressure {
            support: SupportState::Unavailable,
            evidence: None,
        },
        receipts: ManifestReceipts {
            native: false,
            lifeloop_synthesized: true,
            receipt_ledger: SupportState::Unavailable,
        },
        session_identity: None,
        session_rename: None,
        renewal: None,
        approval_surface: None,
        failure_modes: Vec::new(),
        telemetry_sources: Vec::new(),
        known_degradations: Vec::new(),
    }
}

struct FakeRegistry {
    manifest: AdapterManifest,
}

impl FakeRegistry {
    fn new() -> Self {
        Self {
            manifest: fake_manifest(),
        }
    }
}

impl AdapterRegistry for FakeRegistry {
    fn resolve(&self, adapter_id: &str, adapter_version: &str) -> AdapterResolution {
        if adapter_id != self.manifest.adapter_id {
            return AdapterResolution::UnknownId;
        }
        if adapter_version != self.manifest.adapter_version {
            return AdapterResolution::VersionMismatch {
                registered_version: self.manifest.adapter_version.clone(),
            };
        }
        AdapterResolution::Found(lifeloop::RegisteredAdapter {
            manifest: self.manifest.clone(),
            conformance: ConformanceLevel::PreConformance,
        })
    }
}

fn valid_request() -> CallbackRequest {
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::SessionStarted,
        event_id: "evt-1".into(),
        adapter_id: FAKE_ADAPTER_ID.into(),
        adapter_version: FAKE_ADAPTER_VERSION.into(),
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: "inv-1".into(),
        harness_session_id: Some("sess-1".into()),
        harness_run_id: None,
        harness_task_id: None,
        frame_context: None,
        capability_snapshot_ref: None,
        payload_refs: Vec::new(),
        sequence: None,
        idempotency_key: None,
        metadata: serde_json::Map::new(),
    }
}

// ---------------------------------------------------------------------------
// Coverage: every FailureClass variant is producible by the mapper.
// ---------------------------------------------------------------------------

/// Returns a sample input that the mapper resolves to `target`.
///
/// Any variant of [`FailureClass`] that the mapper *can* return is
/// listed here. If a new variant is added to [`FailureClass`] this
/// table will fail the exhaustiveness assertion below.
fn sample_for(target: FailureClass) -> FailureClass {
    let mapper = LifeloopFailureMapper::new();
    match target {
        FailureClass::AdapterUnavailable => {
            mapper
                .map_route_error(&RouteError::AdapterIdNotFound {
                    adapter_id: "ghost".into(),
                })
                .0
        }
        FailureClass::CapabilityUnsupported => {
            classes_for_negotiation_outcome(NegotiationOutcome::Unsupported, None)
                .expect("unsupported pair")
                .0
        }
        FailureClass::CapabilityDegraded => {
            // CapabilityDegraded surfaces only via explicit caller
            // override on Unsupported. Validate the override path.
            classes_for_negotiation_outcome(
                NegotiationOutcome::Unsupported,
                Some(FailureClass::CapabilityDegraded),
            )
            .expect("override")
            .0
        }
        FailureClass::PlacementUnavailable => {
            // Surfaced by negotiation with explicit class override
            // (placement decisions promote to Unsupported and carry
            // the matching failure class through the same channel).
            classes_for_negotiation_outcome(
                NegotiationOutcome::Unsupported,
                Some(FailureClass::PlacementUnavailable),
            )
            .expect("override")
            .0
        }
        FailureClass::PayloadTooLarge => {
            classes_for_negotiation_outcome(
                NegotiationOutcome::Unsupported,
                Some(FailureClass::PayloadTooLarge),
            )
            .expect("override")
            .0
        }
        FailureClass::PayloadRejected => {
            classes_for_negotiation_outcome(
                NegotiationOutcome::Unsupported,
                Some(FailureClass::PayloadRejected),
            )
            .expect("override")
            .0
        }
        FailureClass::IdentityUnavailable => {
            classes_for_negotiation_outcome(
                NegotiationOutcome::Unsupported,
                Some(FailureClass::IdentityUnavailable),
            )
            .expect("override")
            .0
        }
        FailureClass::TransportError => {
            mapper
                .map_transport_error(&TransportError::Io("connection refused".into()))
                .0
        }
        FailureClass::Timeout => mapper.map_transport_error(&TransportError::Timeout).0,
        FailureClass::OperatorRequired => {
            classes_for_negotiation_outcome(NegotiationOutcome::RequiresOperator, None)
                .expect("operator-required pair")
                .0
        }
        FailureClass::StateConflict => {
            mapper
                .map_receipt_error(&ReceiptError::Conflict {
                    idempotency_key: "k".into(),
                })
                .0
        }
        FailureClass::InvalidRequest => {
            mapper
                .map_route_error(&RouteError::InvalidFrameContext {
                    detail: "missing".into(),
                })
                .0
        }
        FailureClass::InternalError => {
            mapper
                .map_transport_error(&TransportError::Internal("panic".into()))
                .0
        }
    }
}

#[test]
fn every_failure_class_variant_is_covered_by_mapper() {
    let mut seen = HashSet::new();
    for &fc in FailureClass::ALL {
        let observed = sample_for(fc);
        assert_eq!(observed, fc, "sample_for({fc:?}) returned {observed:?}");
        seen.insert(fc);
    }
    assert_eq!(
        seen.len(),
        FailureClass::ALL.len(),
        "FailureClass coverage incomplete: {seen:?}"
    );
}

#[test]
fn every_retry_class_variant_is_the_default_for_some_failure_class() {
    let mut seen: HashSet<RetryClass> = HashSet::new();
    for &fc in FailureClass::ALL {
        seen.insert(retry_class_for(fc));
    }
    for &rc in RetryClass::ALL {
        assert!(
            seen.contains(&rc),
            "RetryClass::{rc:?} is not produced by any FailureClass::default_retry()"
        );
    }
}

// ---------------------------------------------------------------------------
// Operator-required pairing
// ---------------------------------------------------------------------------

#[test]
fn operator_required_uses_operator_required_failure_and_retry_after_operator() {
    let pair = classes_for_negotiation_outcome(NegotiationOutcome::RequiresOperator, None);
    assert_eq!(
        pair,
        Some((
            FailureClass::OperatorRequired,
            RetryClass::RetryAfterOperator
        ))
    );
    // An override on RequiresOperator is intentionally ignored: this
    // outcome has a single canonical failure class. Pin that.
    let pair_with_override = classes_for_negotiation_outcome(
        NegotiationOutcome::RequiresOperator,
        Some(FailureClass::PayloadTooLarge),
    );
    assert_eq!(
        pair_with_override,
        Some((
            FailureClass::OperatorRequired,
            RetryClass::RetryAfterOperator
        )),
        "RequiresOperator must always pair OperatorRequired + RetryAfterOperator"
    );
}

// ---------------------------------------------------------------------------
// receipt.emitted: validation-layer guard
// ---------------------------------------------------------------------------

#[test]
fn validate_receipt_eligible_rejects_receipt_emitted_plan() {
    let reg = FakeRegistry::new();
    let mut req = valid_request();
    req.event = LifecycleEventKind::ReceiptEmitted;
    // ReceiptEmitted is not frame-scoped; no frame context required.
    req.frame_context = None;
    req.idempotency_key = None; // already rejected at route() level when present
    let plan = route(&req, &reg).expect("plan synthesizes");
    let err = validate_receipt_eligible(&plan).expect_err("must reject");
    assert!(
        matches!(err, RouteError::InvalidEventEnvelope { .. }),
        "expected InvalidEventEnvelope, got {err:?}"
    );
    // And: the failure class for that rejection is InvalidRequest, so a
    // receipt synthesized off this rejection records a deterministic
    // class (matching the emit-time guard's classification).
    let mapper = LifeloopFailureMapper::new();
    let (fc, rc) = mapper.map_route_error(&err);
    assert_eq!(fc, FailureClass::InvalidRequest);
    assert_eq!(rc, RetryClass::DoNotRetry);
}

#[test]
fn validate_receipt_eligible_accepts_non_receipt_emitted_events() {
    let reg = FakeRegistry::new();
    let req = valid_request();
    let plan = route(&req, &reg).expect("plan");
    validate_receipt_eligible(&plan).expect("non-notification event passes");
}

// ---------------------------------------------------------------------------
// Edge-case guards
// ---------------------------------------------------------------------------

#[test]
fn empty_sentinel_strings_are_rejected_and_classify_as_invalid_request() {
    let reg = FakeRegistry::new();

    // Cover every required-non-empty path the route() validator
    // exposes. Each must surface as EmptySentinel and map to
    // InvalidRequest.
    type Mutator = Box<dyn Fn(&mut CallbackRequest)>;
    let cases: Vec<(Mutator, &'static str)> = vec![
        (Box::new(|r| r.event_id = String::new()), "request.event_id"),
        (
            Box::new(|r| r.adapter_id = String::new()),
            "request.adapter_id",
        ),
        (
            Box::new(|r| r.adapter_version = String::new()),
            "request.adapter_version",
        ),
        (
            Box::new(|r| r.invocation_id = String::new()),
            "request.invocation_id",
        ),
        (
            Box::new(|r| r.harness_session_id = Some(String::new())),
            "request.harness_session_id",
        ),
        (
            Box::new(|r| r.harness_run_id = Some(String::new())),
            "request.harness_run_id",
        ),
        (
            Box::new(|r| r.harness_task_id = Some(String::new())),
            "request.harness_task_id",
        ),
        (
            Box::new(|r| r.capability_snapshot_ref = Some(String::new())),
            "request.capability_snapshot_ref",
        ),
        (
            Box::new(|r| r.idempotency_key = Some(String::new())),
            "request.idempotency_key",
        ),
    ];

    let mapper = LifeloopFailureMapper::new();
    for (mutate, expected_field) in cases {
        let mut req = valid_request();
        mutate(&mut req);
        let err = route(&req, &reg).unwrap_err();
        match &err {
            RouteError::EmptySentinel { field } => assert_eq!(*field, expected_field),
            other => panic!("expected EmptySentinel for {expected_field}, got {other:?}"),
        }
        assert_eq!(mapper.map_route_error(&err).0, FailureClass::InvalidRequest);
    }
}

#[test]
fn partial_frame_context_is_rejected_and_classifies_as_invalid_request() {
    let reg = FakeRegistry::new();
    let mapper = LifeloopFailureMapper::new();

    // Case 1: top_level frame must NOT carry parent_frame_id.
    let mut req = valid_request();
    req.event = LifecycleEventKind::FrameOpening;
    req.frame_context = Some(FrameContext {
        frame_id: "f-1".into(),
        parent_frame_id: Some("p-1".into()),
        frame_class: FrameClass::TopLevel,
    });
    let err = route(&req, &reg).unwrap_err();
    assert!(matches!(err, RouteError::InvalidFrameContext { .. }));
    assert_eq!(mapper.map_route_error(&err).0, FailureClass::InvalidRequest);

    // Case 2: subcall frame MUST carry parent_frame_id.
    let mut req = valid_request();
    req.event = LifecycleEventKind::FrameOpening;
    req.frame_context = Some(FrameContext {
        frame_id: "f-1".into(),
        parent_frame_id: None,
        frame_class: FrameClass::Subcall,
    });
    let err = route(&req, &reg).unwrap_err();
    assert!(matches!(err, RouteError::InvalidFrameContext { .. }));
    assert_eq!(mapper.map_route_error(&err).0, FailureClass::InvalidRequest);

    // Case 3: empty frame_id.
    let mut req = valid_request();
    req.event = LifecycleEventKind::FrameOpening;
    req.frame_context = Some(FrameContext {
        frame_id: String::new(),
        parent_frame_id: None,
        frame_class: FrameClass::TopLevel,
    });
    let err = route(&req, &reg).unwrap_err();
    assert!(matches!(err, RouteError::InvalidFrameContext { .. }));
    assert_eq!(mapper.map_route_error(&err).0, FailureClass::InvalidRequest);

    // Case 4: empty parent_frame_id when supplied.
    let mut req = valid_request();
    req.event = LifecycleEventKind::FrameOpening;
    req.frame_context = Some(FrameContext {
        frame_id: "f-1".into(),
        parent_frame_id: Some(String::new()),
        frame_class: FrameClass::Subcall,
    });
    let err = route(&req, &reg).unwrap_err();
    assert!(matches!(err, RouteError::InvalidFrameContext { .. }));
    assert_eq!(mapper.map_route_error(&err).0, FailureClass::InvalidRequest);

    // Case 5: frame.* event without a frame_context at all.
    let mut req = valid_request();
    req.event = LifecycleEventKind::FrameEnded;
    req.frame_context = None;
    let err = route(&req, &reg).unwrap_err();
    assert!(matches!(err, RouteError::InvalidFrameContext { .. }));
}

#[test]
fn unknown_event_wire_name_rejected_at_deserialize_boundary() {
    // Serde rejects unknown enum wire names. Pin that, since this is
    // the load-bearing rejection for the UnknownEventName failure
    // class on the deserialize path.
    let raw = serde_json::json!({
        "schema_version": SCHEMA_VERSION,
        "event": "session.bogus",
        "event_id": "e",
        "adapter_id": FAKE_ADAPTER_ID,
        "adapter_version": FAKE_ADAPTER_VERSION,
        "integration_mode": "native_hook",
        "invocation_id": "i",
    });
    let res: Result<CallbackRequest, _> = serde_json::from_value(raw);
    assert!(res.is_err(), "deserialize must reject unknown event name");
}

#[test]
fn unknown_receipt_status_rejected_at_deserialize_boundary() {
    let raw = serde_json::json!({
        "schema_version": SCHEMA_VERSION,
        "status": "vibes",
        "client_payloads": [],
    });
    let res: Result<lifeloop::CallbackResponse, _> = serde_json::from_value(raw);
    assert!(
        res.is_err(),
        "deserialize must reject unknown receipt status"
    );
}

#[test]
fn invalid_payload_ref_classifies_as_invalid_request() {
    let reg = FakeRegistry::new();
    let mapper = LifeloopFailureMapper::new();
    let mut req = valid_request();
    req.payload_refs = vec![lifeloop::PayloadRef {
        payload_id: String::new(),
        payload_kind: "k".into(),
        content_digest: None,
        byte_size: None,
    }];
    let err = route(&req, &reg).unwrap_err();
    assert!(matches!(err, RouteError::InvalidPayloadRef { .. }));
    assert_eq!(mapper.map_route_error(&err).0, FailureClass::InvalidRequest);
}

#[test]
fn adapter_version_mismatch_against_builtin_classifies_as_adapter_unavailable() {
    let req = CallbackRequest {
        adapter_id: "codex".into(),
        adapter_version: "9.9.9".into(),
        ..valid_request()
    };
    let err = route(&req, &BuiltinAdapterRegistry).unwrap_err();
    assert!(matches!(err, RouteError::AdapterVersionMismatch { .. }));
    let mapper = LifeloopFailureMapper::new();
    assert_eq!(
        mapper.map_route_error(&err).0,
        FailureClass::AdapterUnavailable
    );
}

// ---------------------------------------------------------------------------
// Adapter mapping fixtures (Codex and Claude).
//
// These fixtures translate raw, adapter-emitted error patterns onto
// the SHARED FailureClass vocabulary. They do not introduce
// adapter-specific failure semantics into core types.
// ---------------------------------------------------------------------------

/// Fixture for the Codex adapter: matches well-known raw error
/// substrings to the shared failure class. The mapping is intentionally
/// case-sensitive — the substrings are the literal markers a Codex
/// transport surfaces.
fn codex_raw_to_shared(raw: &str) -> FailureClass {
    if raw.contains("ETIMEDOUT") || raw.contains("deadline_exceeded") {
        FailureClass::Timeout
    } else if raw.contains("ECONNREFUSED") || raw.contains("EPIPE") || raw.contains("broken_pipe") {
        FailureClass::TransportError
    } else if raw.contains("payload_too_large") || raw.contains("413 Payload Too Large") {
        FailureClass::PayloadTooLarge
    } else if raw.contains("invalid_request") || raw.contains("400 Bad Request") {
        FailureClass::InvalidRequest
    } else {
        FailureClass::InternalError
    }
}

/// Fixture for the Claude adapter.
fn claude_raw_to_shared(raw: &str) -> FailureClass {
    if raw.contains("rate_limit_error") || raw.contains("overloaded_error") {
        // Claude's rate-limit / overload signals are transport-shaped
        // failures: the request never reached steady-state processing.
        FailureClass::TransportError
    } else if raw.contains("invalid_request_error") {
        FailureClass::InvalidRequest
    } else if raw.contains("not_found_error") {
        FailureClass::AdapterUnavailable
    } else if raw.contains("api_timeout") {
        FailureClass::Timeout
    } else if raw.contains("permission_error") {
        FailureClass::OperatorRequired
    } else {
        FailureClass::InternalError
    }
}

#[test]
fn codex_adapter_fixture_maps_raw_strings_to_shared_classes() {
    assert_eq!(codex_raw_to_shared("ETIMEDOUT"), FailureClass::Timeout);
    assert_eq!(
        codex_raw_to_shared("ECONNREFUSED upstream"),
        FailureClass::TransportError
    );
    assert_eq!(
        codex_raw_to_shared("413 Payload Too Large"),
        FailureClass::PayloadTooLarge
    );
    assert_eq!(
        codex_raw_to_shared("400 Bad Request: invalid_request"),
        FailureClass::InvalidRequest
    );
    assert_eq!(
        codex_raw_to_shared("kernel panic"),
        FailureClass::InternalError
    );
    // Round-trip into retry-class so the fixture also exercises the
    // shared retry rule.
    assert_eq!(
        retry_class_for(codex_raw_to_shared("ETIMEDOUT")),
        RetryClass::SafeRetry
    );
}

#[test]
fn claude_adapter_fixture_maps_raw_strings_to_shared_classes() {
    assert_eq!(
        claude_raw_to_shared("rate_limit_error"),
        FailureClass::TransportError
    );
    assert_eq!(
        claude_raw_to_shared("overloaded_error"),
        FailureClass::TransportError
    );
    assert_eq!(
        claude_raw_to_shared("invalid_request_error: missing field"),
        FailureClass::InvalidRequest
    );
    assert_eq!(
        claude_raw_to_shared("not_found_error: model unknown"),
        FailureClass::AdapterUnavailable
    );
    assert_eq!(claude_raw_to_shared("api_timeout"), FailureClass::Timeout);
    assert_eq!(
        claude_raw_to_shared("permission_error: operator must approve"),
        FailureClass::OperatorRequired
    );
    assert_eq!(
        retry_class_for(claude_raw_to_shared("permission_error")),
        RetryClass::RetryAfterOperator
    );
}

// ---------------------------------------------------------------------------
// Free-helper / From conversion sanity
// ---------------------------------------------------------------------------

#[test]
fn from_impls_match_free_helpers() {
    let r = RouteError::SchemaVersionMismatch {
        expected: "a".into(),
        found: "b".into(),
    };
    let via_helper = failure_class_for_route_error(&r);
    let via_from: FailureClass = (&r).into();
    assert_eq!(via_helper, via_from);

    let rc = ReceiptError::Conflict {
        idempotency_key: "k".into(),
    };
    let via_helper = failure_class_for_receipt_error(&rc);
    let via_from: FailureClass = (&rc).into();
    assert_eq!(via_helper, via_from);

    let t = TransportError::Timeout;
    let via_helper = failure_class_for_transport(&t);
    let via_from: FailureClass = (&t).into();
    assert_eq!(via_helper, via_from);
}

#[test]
fn transport_error_display_strings_include_variant_context() {
    assert_eq!(
        TransportError::Io("EPIPE".into()).to_string(),
        "transport io error: EPIPE"
    );
    assert_eq!(TransportError::Timeout.to_string(), "transport timeout");
    assert_eq!(
        TransportError::Internal("panic".into()).to_string(),
        "transport internal error: panic"
    );
}
