//! End-to-end regression suite for the operator-policy tool-error
//! termination path: `ToolErrorPolicyLayer` escalates classified
//! failures into `Error::ToolErrorTerminal`, the ReAct recipe
//! propagates it like `Error::Interrupted`, and `Agent::execute`
//! emits one `AgentEvent::Failed` carrying a typed
//! `FailureKind::ToolErrorTerminal`.
//!
//! Each test asserts the full chain so a regression in any one
//! component (classification, escalation, recipe branch, sink
//! emission, audit surface) breaks here.

#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::indexing_slicing,
    clippy::doc_markdown
)]

use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use parking_lot::Mutex;

use entelix_agents::{FailureKind, ReActState, create_react_agent};
use entelix_core::ir::{ContentPart, Message, Role};
use entelix_core::tools::{
    Tool, ToolErrorKind, ToolErrorPolicy, ToolErrorPolicyLayer, ToolMetadata,
};
use entelix_core::{
    AgentContext, AuditSink, AuditSinkHandle, ExecutionContext, Result, ToolRegistry,
};
use entelix_runnable::Runnable;

/// MockModel that emits one `ToolUse` then never gets called again
/// — the test asserts the loop exits before the model would be
/// re-invoked.
struct MockModel {
    script: StdMutex<Vec<Message>>,
    calls: Arc<AtomicUsize>,
}

impl MockModel {
    fn new(script: Vec<Message>) -> (Self, Arc<AtomicUsize>) {
        let calls = Arc::new(AtomicUsize::new(0));
        (
            Self {
                script: StdMutex::new(script),
                calls: calls.clone(),
            },
            calls,
        )
    }
}

#[async_trait]
impl Runnable<Vec<Message>, Message> for MockModel {
    async fn invoke(&self, _input: Vec<Message>, _ctx: &ExecutionContext) -> Result<Message> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        let mut script = self.script.lock().unwrap();
        if script.is_empty() {
            return Err(entelix_core::Error::invalid_request(
                "MockModel: script exhausted (loop continued past termination)",
            ));
        }
        Ok(script.remove(0))
    }
}

/// Tool whose `execute` always returns a fixed error. Counts
/// invocations so tests can assert the inner tool was invoked
/// exactly once (no retry on a terminal-class failure).
struct AlwaysFailsTool {
    metadata: ToolMetadata,
    err: Arc<dyn Fn() -> entelix_core::Error + Send + Sync>,
    calls: Arc<AtomicUsize>,
}

impl AlwaysFailsTool {
    fn new(
        name: &str,
        err: impl Fn() -> entelix_core::Error + Send + Sync + 'static,
    ) -> (Self, Arc<AtomicUsize>) {
        let calls = Arc::new(AtomicUsize::new(0));
        let metadata = ToolMetadata::function(
            name,
            "Test tool that always fails.",
            serde_json::json!({"type": "object"}),
        );
        (
            Self {
                metadata,
                err: Arc::new(err),
                calls: calls.clone(),
            },
            calls,
        )
    }
}

#[async_trait]
impl Tool for AlwaysFailsTool {
    fn metadata(&self) -> &ToolMetadata {
        &self.metadata
    }
    async fn execute(
        &self,
        _input: serde_json::Value,
        _ctx: &AgentContext<()>,
    ) -> Result<serde_json::Value> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        Err((self.err)())
    }
}

fn tool_use(id: &str, name: &str) -> Message {
    Message::new(
        Role::Assistant,
        vec![ContentPart::ToolUse {
            id: id.to_owned(),
            name: name.to_owned(),
            input: serde_json::json!({}),
            provider_echoes: Vec::new(),
        }],
    )
}

#[derive(Default)]
struct CapturingAuditSink {
    terminals: Mutex<Vec<(ToolErrorKind, String)>>,
}

impl AuditSink for CapturingAuditSink {
    fn record_sub_agent_invoked(&self, _agent_id: &str, _sub_thread_id: &str) {}
    fn record_agent_handoff(&self, _from: Option<&str>, _to: &str) {}
    fn record_interrupted(
        &self,
        _kind: &entelix_core::InterruptionKind,
        _payload: &serde_json::Value,
    ) {
    }
    fn record_resumed(&self, _from_checkpoint: &str) {}
    fn record_memory_recall(&self, _tier: &str, _namespace_key: &str, _hits: usize) {}
    fn record_usage_limit_exceeded(&self, _breach: &entelix_core::UsageLimitBreach) {}
    fn record_context_compacted(&self, _dropped_chars: usize, _retained_chars: usize) {}
    fn record_tool_error_terminal(&self, kind: ToolErrorKind, tool_name: &str) {
        self.terminals.lock().push((kind, tool_name.to_owned()));
    }
}

#[tokio::test]
async fn auth_failure_terminates_react_loop_without_model_retry() {
    // ReAct's first model turn returns a tool-use; the tool fails
    // with a 401, `operator_safe` policy classifies as `Auth` ∈
    // `terminate_on`, the loop returns `ToolErrorTerminal` and the
    // model is NOT re-invoked.
    let (model, model_calls) = MockModel::new(vec![tool_use("tu-1", "fail")]);
    let (tool, tool_calls) =
        AlwaysFailsTool::new("fail", || entelix_core::Error::provider_http(401, "no"));
    let registry = ToolRegistry::new()
        .register(Arc::new(tool))
        .unwrap()
        .layer(ToolErrorPolicyLayer::new(ToolErrorPolicy::operator_safe()));
    let graph = create_react_agent(model, registry).unwrap();

    let err = graph
        .invoke(ReActState::from_user("call fail"), &ExecutionContext::new())
        .await
        .expect_err("loop must terminate");

    match &err {
        entelix_core::Error::ToolErrorTerminal {
            kind, tool_name, ..
        } => {
            assert_eq!(*kind, ToolErrorKind::Auth);
            assert_eq!(tool_name, "fail");
        }
        other => panic!("expected ToolErrorTerminal, got {other:?}"),
    }
    // Model invoked exactly once (the turn that emitted the tool
    // call). Without termination, the loop would feed the rendered
    // error back and re-invoke the model.
    assert_eq!(
        model_calls.load(Ordering::SeqCst),
        1,
        "model must NOT be re-invoked after terminal tool failure"
    );
    assert_eq!(
        tool_calls.load(Ordering::SeqCst),
        1,
        "tool must be invoked exactly once"
    );
}

#[tokio::test]
async fn validation_failure_loops_back_through_model_under_operator_safe() {
    // `Validation` is NOT in `operator_safe` — the model authored
    // the bad input, so the loop must feed the error back. We use
    // a two-step script: first turn = tool-use, second turn =
    // final text reply.
    let (model, model_calls) = MockModel::new(vec![
        tool_use("tu-1", "fail"),
        Message::assistant("ok I'll stop"),
    ]);
    let (tool, _) =
        AlwaysFailsTool::new("fail", || entelix_core::Error::invalid_request("bad shape"));
    let registry = ToolRegistry::new()
        .register(Arc::new(tool))
        .unwrap()
        .layer(ToolErrorPolicyLayer::new(ToolErrorPolicy::operator_safe()));
    let graph = create_react_agent(model, registry).unwrap();

    let result = graph
        .invoke(ReActState::from_user("call fail"), &ExecutionContext::new())
        .await
        .expect("Validation should NOT terminate under operator_safe");

    // Model invoked twice: tool-use turn + final-reply turn.
    assert_eq!(model_calls.load(Ordering::SeqCst), 2);
    assert!(matches!(
        result.messages.last().unwrap().role,
        Role::Assistant
    ));
}

#[tokio::test]
async fn empty_policy_preserves_legacy_passthrough_behaviour() {
    // Default policy (`terminate_on = empty`) is the no-op — every
    // failure flows through. Same scenario as the auth test above,
    // but the model is re-invoked after the tool's error rendering
    // reaches it. The mock script provides a second turn so the
    // loop converges.
    let (model, model_calls) = MockModel::new(vec![
        tool_use("tu-1", "fail"),
        Message::assistant("the tool failed, stopping"),
    ]);
    let (tool, _) = AlwaysFailsTool::new("fail", || entelix_core::Error::provider_http(401, "no"));
    let registry = ToolRegistry::new()
        .register(Arc::new(tool))
        .unwrap()
        .layer(ToolErrorPolicyLayer::new(ToolErrorPolicy::new())); // empty
    let graph = create_react_agent(model, registry).unwrap();

    let _ = graph
        .invoke(ReActState::from_user("call fail"), &ExecutionContext::new())
        .await
        .expect("empty policy preserves loop");

    // Model invoked twice — the auth failure was fed back as a
    // ToolResult and the model produced a final assistant turn.
    assert_eq!(model_calls.load(Ordering::SeqCst), 2);
}

#[tokio::test]
async fn audit_sink_records_termination() {
    let (model, _) = MockModel::new(vec![tool_use("tu-1", "fail")]);
    let (tool, _) = AlwaysFailsTool::new("fail", || entelix_core::Error::provider_http(401, "no"));
    let registry = ToolRegistry::new()
        .register(Arc::new(tool))
        .unwrap()
        .layer(ToolErrorPolicyLayer::new(ToolErrorPolicy::operator_safe()));
    let graph = create_react_agent(model, registry).unwrap();

    let sink = Arc::new(CapturingAuditSink::default());
    let ctx = ExecutionContext::new().with_audit_sink(AuditSinkHandle::new(sink.clone()));
    let _ = graph
        .invoke(ReActState::from_user("call fail"), &ctx)
        .await
        .expect_err("loop must terminate");

    // Snapshot under the guard, assert on owned data — the lock
    // releases at the end of this binding so the assertions never
    // hold the mutex.
    let terminals: Vec<(ToolErrorKind, String)> = sink.terminals.lock().clone();
    assert_eq!(terminals.len(), 1, "exactly one record_tool_error_terminal");
    assert_eq!(terminals[0], (ToolErrorKind::Auth, "fail".to_owned()));
}

#[tokio::test]
async fn failure_kind_classifies_tool_error_terminal_correctly() {
    // Direct classifier test — the `Agent::execute` failure path
    // populates `AgentEvent::Failed::kind` via this function.
    let inner = entelix_core::Error::provider_http(401, "no");
    let terminal = entelix_core::Error::tool_error_terminal(ToolErrorKind::Auth, "fail", inner);
    let kind = FailureKind::from_error(&terminal);
    match kind {
        FailureKind::ToolErrorTerminal {
            kind: tek,
            tool_name,
        } => {
            assert_eq!(tek, ToolErrorKind::Auth);
            assert_eq!(tool_name, "fail");
        }
        other => panic!("expected FailureKind::ToolErrorTerminal, got {other:?}"),
    }
    assert_eq!(
        FailureKind::ToolErrorTerminal {
            kind: ToolErrorKind::Auth,
            tool_name: "fail".into()
        }
        .wire_id(),
        "tool_error_terminal"
    );
}

#[tokio::test]
async fn terminal_error_envelope_delegates_to_source() {
    // ToolErrorTerminal carries the source error's wire identity.
    // Dashboards see the original cause's wire_code; the terminal
    // flag rides on the typed event channel (`FailureKind`).
    let inner = entelix_core::Error::provider_http(401, "no");
    let inner_env = inner.envelope();
    let terminal = entelix_core::Error::tool_error_terminal(ToolErrorKind::Auth, "fail", inner);
    let terminal_env = terminal.envelope();
    assert_eq!(terminal_env.wire_code, inner_env.wire_code);
    assert_eq!(terminal_env.wire_class, inner_env.wire_class);
    assert_eq!(terminal_env.provider_status, inner_env.provider_status);
}
