//! `GovernedLlmClient` per-tenant binding (Phase B B4).
//!
//! Confirms the wrapper routes acquires through `acquire_llm_scoped` so
//! the per-tenant LLM cap reaches every call, and that a `Saturated`
//! refusal surfaces as `LlmError::RateLimit` to the caller.

use async_trait::async_trait;
use klieo_bus_memory::MemoryBus;
use klieo_core::error::LlmError;
use klieo_core::ids::RunId;
use klieo_core::llm::{
    Capabilities, ChatRequest, ChatResponse, ChunkStream, Embedding, FinishReason, Message, Usage,
};
use klieo_core::test_utils::InMemoryEpisodic;
use klieo_core::LlmClient;
use klieo_ops::governor::{GovernedLlmClient, Governor, TokenBucketGovernor};
use klieo_ops::types::{BudgetScope, ProviderId, TenantId};
use std::sync::Arc;

const ANTHROPIC: &str = "anthropic";
const TENANT_NARROW: &str = "tenant_narrow";
const TENANT_NARROW_CAP: u32 = 1;
const PROVIDER_HEADROOM: u32 = 100;

/// Inner `LlmClient` stub that returns an empty `ChatResponse`. The
/// wrapper acquires the permit before delegating; the inner never
/// runs when the governor refuses.
struct EmptyLlm;

#[async_trait]
impl LlmClient for EmptyLlm {
    fn name(&self) -> &str {
        "stub"
    }

    fn capabilities(&self) -> &Capabilities {
        // Static default needs interior mutability or a lazy. Using a
        // const isn't viable because `Capabilities` has non-const builders;
        // a OnceLock is the smallest correct shape.
        use std::sync::OnceLock;
        static CAPS: OnceLock<Capabilities> = OnceLock::new();
        CAPS.get_or_init(Capabilities::default)
    }

    async fn complete(&self, _req: ChatRequest) -> Result<ChatResponse, LlmError> {
        Ok(ChatResponse::new(
            Message::assistant(String::new()),
            Usage::new(0, 0),
            FinishReason::Stop,
            "stub-model",
        ))
    }

    async fn stream(&self, _req: ChatRequest) -> Result<ChunkStream, LlmError> {
        unimplemented!("stream not exercised in this suite")
    }

    async fn embed(&self, _texts: &[String]) -> Result<Vec<Embedding>, LlmError> {
        unimplemented!("embed not exercised in this suite")
    }
}

#[tokio::test]
async fn tenant_saturation_returns_ratelimit() {
    let bus = MemoryBus::new();
    let governor: Arc<dyn Governor> = Arc::new(
        TokenBucketGovernor::builder(bus.kv.clone())
            .llm_limit(ProviderId(ANTHROPIC.into()), PROVIDER_HEADROOM)
            .tenant_llm_default_rps(TENANT_NARROW_CAP)
            .build(),
    );
    let episodic: Arc<InMemoryEpisodic> = Arc::new(InMemoryEpisodic::default());

    let client = GovernedLlmClient::new(
        Arc::new(EmptyLlm),
        governor.clone(),
        episodic.clone(),
        RunId::new(),
        ProviderId(ANTHROPIC.into()),
        Some(TenantId(TENANT_NARROW.into())),
    );

    // Hold every slot in the tenant bucket so the wrapper's acquire
    // races into refusal. The held permits are kept on the stack
    // until after the assert: drop releases capacity.
    let mut held = Vec::new();
    for _ in 0..TENANT_NARROW_CAP {
        held.push(
            governor
                .acquire_llm_scoped(
                    BudgetScope::TenantProvider(
                        TenantId(TENANT_NARROW.into()),
                        ProviderId(ANTHROPIC.into()),
                    ),
                    1,
                )
                .await
                .expect("seed acquire fits within the configured cap"),
        );
    }

    let refused = client.complete(ChatRequest::new(vec![])).await;
    drop(held);
    assert!(
        matches!(refused, Err(LlmError::RateLimit { .. })),
        "tenant-saturated permit must surface RateLimit (fail-fast, not hold), got {refused:?}"
    );
}
