//! Behavior tests for callback invocation (issue #14).
//!
//! Models the inline-fake-client pattern from `tests/fake_client.rs`
//! against the router's [`DefaultCallbackInvoker`] / [`FnClientCallback`]
//! seam. No CCD, no IO — just typed Lifeloop values.

use std::collections::BTreeMap;

use lifeloop::router::{
    AdapterRegistry, AdapterResolution, CallbackInvoker, DefaultCallbackInvoker, FnClientCallback,
    RoutingPlan, route,
};
use lifeloop::{
    AcceptablePlacement, AdapterManifest, AdapterRole, CallbackRequest, CallbackResponse,
    ConformanceLevel, FailureClass, FrameContext, IntegrationMode, LifecycleEventKind,
    ManifestContextPressure, ManifestPlacementClass, ManifestPlacementSupport, ManifestReceipts,
    PayloadEnvelope, PayloadRef, PlacementClass, ReceiptStatus, RegisteredAdapter,
    RequirementLevel, SCHEMA_VERSION, SupportState,
};

const FAKE_ID: &str = "fake-adapter";
const FAKE_VERSION: &str = "0.0.1";

fn manifest() -> AdapterManifest {
    let mut placement = BTreeMap::new();
    placement.insert(
        ManifestPlacementClass::PreFrameTrailing,
        ManifestPlacementSupport {
            support: SupportState::Native,
            max_bytes: None,
        },
    );
    AdapterManifest {
        contract_version: SCHEMA_VERSION.to_string(),
        adapter_id: FAKE_ID.into(),
        adapter_version: FAKE_VERSION.into(),
        display_name: "Fake".into(),
        role: AdapterRole::PrimaryWorker,
        integration_modes: vec![IntegrationMode::NativeHook],
        lifecycle_events: BTreeMap::new(),
        placement,
        context_pressure: ManifestContextPressure {
            support: SupportState::Native,
            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 Fixture(AdapterManifest);

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

fn frame_request() -> CallbackRequest {
    let mut metadata = serde_json::Map::new();
    metadata.insert("opaque_caller_key".into(), serde_json::json!("kept"));
    CallbackRequest {
        schema_version: SCHEMA_VERSION.to_string(),
        event: LifecycleEventKind::FrameOpening,
        event_id: "evt-cb-1".into(),
        adapter_id: FAKE_ID.into(),
        adapter_version: FAKE_VERSION.into(),
        integration_mode: IntegrationMode::NativeHook,
        invocation_id: "inv-cb-1".into(),
        harness_session_id: Some("sess-1".into()),
        harness_run_id: Some("run-1".into()),
        harness_task_id: Some("task-1".into()),
        frame_context: Some(FrameContext::top_level("frm-1")),
        capability_snapshot_ref: None,
        payload_refs: vec![PayloadRef {
            payload_id: "pay-1".into(),
            payload_kind: "instruction_frame".into(),
            content_digest: None,
            byte_size: Some(11),
        }],
        sequence: None,
        idempotency_key: Some("idem-cb-1".into()),
        metadata,
    }
}

fn build_plan() -> RoutingPlan {
    let fx = Fixture(manifest());
    route(&frame_request(), &fx).expect("plan builds")
}

fn build_plan_from(req: &CallbackRequest) -> RoutingPlan {
    let fx = Fixture(manifest());
    route(req, &fx).expect("plan builds")
}

fn opaque_payload(body: &str) -> PayloadEnvelope {
    PayloadEnvelope {
        schema_version: SCHEMA_VERSION.to_string(),
        payload_id: "pay-1".into(),
        client_id: "fake-client".into(),
        payload_kind: "instruction_frame".into(),
        format: "client-defined".into(),
        content_encoding: "utf8".into(),
        body: Some(body.into()),
        body_ref: None,
        byte_size: body.len() as u64,
        content_digest: None,
        acceptable_placements: vec![AcceptablePlacement {
            placement: PlacementClass::PrePromptFrame,
            requirement: RequirementLevel::Preferred,
        }],
        idempotency_key: None,
        expires_at_epoch_s: None,
        redaction: None,
        metadata: serde_json::Map::new(),
    }
}

#[test]
fn invoker_synthesizes_request_from_plan() {
    let plan = build_plan();
    let req = lifeloop::router::synthesize_request(&plan);
    assert_eq!(req.schema_version, SCHEMA_VERSION);
    assert_eq!(req.event, LifecycleEventKind::FrameOpening);
    assert_eq!(req.event_id, "evt-cb-1");
    assert_eq!(req.adapter_id, FAKE_ID);
    assert_eq!(req.adapter_version, FAKE_VERSION);
    assert_eq!(req.integration_mode, IntegrationMode::NativeHook);
    assert_eq!(req.invocation_id, "inv-cb-1");
    assert_eq!(req.harness_session_id.as_deref(), Some("sess-1"));
    assert_eq!(req.harness_run_id.as_deref(), Some("run-1"));
    assert_eq!(req.harness_task_id.as_deref(), Some("task-1"));
    assert_eq!(req.idempotency_key.as_deref(), Some("idem-cb-1"));
    assert_eq!(req.metadata["opaque_caller_key"], "kept");
    assert_eq!(req.payload_refs.len(), 1);
    assert_eq!(req.payload_refs[0].payload_id, "pay-1");
}

#[test]
fn invoker_preserves_request_metadata_from_plan() {
    let mut source = frame_request();
    source
        .metadata
        .insert("prompt".into(), serde_json::json!("carry this prompt"));
    source.metadata.insert(
        "hook_event_name".into(),
        serde_json::json!("UserPromptSubmit"),
    );

    let plan = build_plan_from(&source);
    let req = lifeloop::router::synthesize_request(&plan);

    assert_eq!(req.metadata, source.metadata);
}

#[test]
fn fake_client_roundtrip_returns_response_unmodified() {
    let plan = build_plan();
    let payload = opaque_payload("hello opaque");
    let payload_clone = payload.clone();

    let cb = FnClientCallback::<_, std::convert::Infallible>::new(
        move |req: &CallbackRequest, payloads: &[PayloadEnvelope]| {
            assert_eq!(req.event_id, "evt-cb-1");
            assert_eq!(req.metadata["opaque_caller_key"], "kept");
            assert_eq!(payloads.len(), 1);
            // Body opacity: invoker did not mutate the bytes.
            assert_eq!(payloads[0].body.as_deref(), Some("hello opaque"));
            Ok(CallbackResponse::ok(ReceiptStatus::Delivered))
        },
    );
    let invoker = DefaultCallbackInvoker::new(cb);
    let resp = invoker.invoke(&plan, &[payload_clone]).unwrap();
    assert_eq!(resp.status, ReceiptStatus::Delivered);

    // The original payload is unchanged after the call (opacity).
    assert_eq!(payload.body.as_deref(), Some("hello opaque"));
}

#[test]
fn fake_client_can_return_failure() {
    let plan = build_plan();
    let cb = FnClientCallback::<_, std::convert::Infallible>::new(
        |_req: &CallbackRequest, _payloads: &[PayloadEnvelope]| {
            Ok(CallbackResponse::failed(FailureClass::TransportError))
        },
    );
    let invoker = DefaultCallbackInvoker::new(cb);
    let resp = invoker.invoke(&plan, &[]).unwrap();
    assert_eq!(resp.status, ReceiptStatus::Failed);
    assert_eq!(resp.failure_class, Some(FailureClass::TransportError));
}

#[test]
fn fake_client_error_propagates_through_invoker() {
    #[derive(Debug, PartialEq)]
    struct ClientErr(&'static str);

    let plan = build_plan();
    let cb = FnClientCallback::<_, ClientErr>::new(|_, _| Err(ClientErr("simulated")));
    let invoker = DefaultCallbackInvoker::new(cb);
    let err = invoker.invoke(&plan, &[]).unwrap_err();
    assert_eq!(err, ClientErr("simulated"));
}
