//! AWS Bedrock model_provider using the Converse API.

use crate::traits::{
    ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
    ModelProvider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall, ToolsPayload,
};
use async_trait::async_trait;
use hmac::{Hmac, Mac};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
use zeroclaw_api::tool::ToolSpec;
#[cfg(windows)]
use zeroclaw_config::platform::native::windows_std_cmd_shell_command;

/// Hostname prefix for the Bedrock Runtime endpoint.
const ENDPOINT_PREFIX: &str = "bedrock-runtime";
/// SigV4 signing service name (AWS uses "bedrock", not "bedrock-runtime").
const SIGNING_SERVICE: &str = "bedrock";
const DEFAULT_REGION: &str = "us-east-1";

// ── Authentication ──────────────────────────────────────────────

/// Authentication method for Bedrock: either SigV4 (AKSK) or Bearer token.
enum BedrockAuth {
    SigV4(AwsCredentials),
    BearerToken(String),
}

// ── AWS Credentials ─────────────────────────────────────────────

/// Resolved AWS credentials for SigV4 signing.
#[derive(Clone)]
struct AwsCredentials {
    access_key_id: String,
    secret_access_key: String,
    session_token: Option<String>,
    region: String,
    /// Credential expiry (from `credential_process` `Expiration` field).
    /// `None` means no known expiry — treat as long-lived.
    expires_at: Option<chrono::DateTime<chrono::Utc>>,
}

impl AwsCredentials {
    /// Resolve credentials: first try environment variables, then EC2 IMDSv2.
    fn from_env() -> anyhow::Result<Self> {
        let access_key_id = env_required("AWS_ACCESS_KEY_ID")?;
        let secret_access_key = env_required("AWS_SECRET_ACCESS_KEY")?;

        let session_token = env_optional("AWS_SESSION_TOKEN");

        let region = env_optional("AWS_REGION")
            .or_else(|| env_optional("AWS_DEFAULT_REGION"))
            .unwrap_or_else(|| DEFAULT_REGION.to_string());

        Ok(Self {
            access_key_id,
            secret_access_key,
            session_token,
            region,
            expires_at: None,
        })
    }

    /// Parse `~/.aws/config` (or `$AWS_CONFIG_FILE`) and return the
    /// `credential_process` command and optional `region` for the active profile.
    fn parse_aws_config(content: &str, profile: &str) -> Option<(String, Option<String>)> {
        let target = if profile == "default" {
            "[default]".to_string()
        } else {
            format!("[profile {profile}]")
        };

        let mut in_section = false;
        let mut cred_process = None;
        let mut region = None;

        for line in content.lines() {
            let trimmed = line.trim();
            if trimmed.starts_with('[') {
                in_section = trimmed == target;
                continue;
            }
            if !in_section || trimmed.starts_with('#') || trimmed.starts_with(';') {
                continue;
            }
            if let Some((key, value)) = trimmed.split_once('=') {
                match key.trim() {
                    "credential_process" => cred_process = Some(value.trim().to_string()),
                    "region" => region = Some(value.trim().to_string()),
                    _ => {}
                }
            }
        }
        cred_process.map(|cmd| (cmd, region))
    }

    /// Resolve credentials via `credential_process` in `~/.aws/config`.
    fn from_credential_process() -> anyhow::Result<Self> {
        let config_path = std::env::var("AWS_CONFIG_FILE").unwrap_or_else(|_| {
            let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
            format!("{home}/.aws/config")
        });
        let content = std::fs::read_to_string(&config_path).map_err(|e| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "config_path": &config_path,
                        "error": format!("{}", e),
                    })),
                "bedrock: cannot read AWS config file"
            );
            anyhow::Error::msg(format!("Cannot read {config_path}: {e}"))
        })?;
        let profile = std::env::var("AWS_PROFILE").unwrap_or_else(|_| "default".to_string());
        let (cmd, config_region) = Self::parse_aws_config(&content, &profile).ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"profile": &profile})),
                "bedrock: no credential_process in AWS profile"
            );
            anyhow::Error::msg(format!("No credential_process in [{profile}]"))
        })?;

        let output = run_credential_process_command(&cmd).map_err(|e| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                "bedrock: failed to spawn credential_process"
            );
            anyhow::Error::msg(format!("Failed to run credential_process: {e}"))
        })?;
        anyhow::ensure!(
            output.status.success(),
            "credential_process exited with {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        );

        let json: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                "bedrock: credential_process output is not valid JSON"
            );
            anyhow::Error::msg(format!("credential_process output is not valid JSON: {e}"))
        })?;

        let access_key_id = json["AccessKeyId"]
            .as_str()
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({"missing": "AccessKeyId"})),
                    "bedrock: credential_process missing AccessKeyId"
                );
                anyhow::Error::msg("Missing AccessKeyId in credential_process output")
            })?
            .to_string();
        let secret_access_key = json["SecretAccessKey"]
            .as_str()
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({"missing": "SecretAccessKey"})),
                    "bedrock: credential_process missing SecretAccessKey"
                );
                anyhow::Error::msg("Missing SecretAccessKey in credential_process output")
            })?
            .to_string();
        let session_token = json["SessionToken"].as_str().map(|s| s.to_string());

        let expires_at = json["Expiration"]
            .as_str()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|dt| dt.with_timezone(&chrono::Utc));

        let region = env_optional("AWS_REGION")
            .or_else(|| env_optional("AWS_DEFAULT_REGION"))
            .or(config_region)
            .unwrap_or_else(|| DEFAULT_REGION.to_string());

        ::zeroclaw_log::record!(
            DEBUG,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
            "Loaded AWS credentials via credential_process"
        );

        Ok(Self {
            access_key_id,
            secret_access_key,
            session_token,
            region,
            expires_at,
        })
    }

    /// Fetch credentials from EC2 IMDSv2 instance metadata service.
    async fn from_imds() -> anyhow::Result<Self> {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(3))
            .build()?;

        // Step 1: get IMDSv2 token
        let token = client
            .put("http://169.254.169.254/latest/api/token")
            .header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
            .send()
            .await?
            .text()
            .await?;

        // Step 2: get IAM role name
        let role = client
            .get("http://169.254.169.254/latest/meta-data/iam/security-credentials/")
            .header("X-aws-ec2-metadata-token", &token)
            .send()
            .await?
            .text()
            .await?;
        let role = role.trim().to_string();
        anyhow::ensure!(!role.is_empty(), "No IAM role attached to this instance");

        // Step 3: get credentials for that role
        let creds_url = format!(
            "http://169.254.169.254/latest/meta-data/iam/security-credentials/{}",
            role
        );
        let creds_json: serde_json::Value = client
            .get(&creds_url)
            .header("X-aws-ec2-metadata-token", &token)
            .send()
            .await?
            .json()
            .await?;

        let access_key_id = creds_json["AccessKeyId"]
            .as_str()
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({
                            "source": "imds",
                            "missing": "AccessKeyId",
                        })),
                    "bedrock: IMDS response missing AccessKeyId"
                );
                anyhow::Error::msg("Missing AccessKeyId in IMDS response")
            })?
            .to_string();
        let secret_access_key = creds_json["SecretAccessKey"]
            .as_str()
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({
                            "source": "imds",
                            "missing": "SecretAccessKey",
                        })),
                    "bedrock: IMDS response missing SecretAccessKey"
                );
                anyhow::Error::msg("Missing SecretAccessKey in IMDS response")
            })?
            .to_string();
        let session_token = creds_json["Token"].as_str().map(|s| s.to_string());

        // Step 4: get region from instance identity document
        let region = match client
            .get("http://169.254.169.254/latest/meta-data/placement/region")
            .header("X-aws-ec2-metadata-token", &token)
            .send()
            .await
        {
            Ok(resp) => resp.text().await.unwrap_or_default(),
            Err(_) => String::new(),
        };
        let region = if region.trim().is_empty() {
            env_optional("AWS_REGION")
                .or_else(|| env_optional("AWS_DEFAULT_REGION"))
                .unwrap_or_else(|| DEFAULT_REGION.to_string())
        } else {
            region.trim().to_string()
        };

        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
            &format!(
                "Loaded AWS credentials from EC2 instance metadata (role: {})",
                role
            )
        );

        Ok(Self {
            access_key_id,
            secret_access_key,
            session_token,
            region,
            expires_at: None,
        })
    }

    /// Resolve credentials: env vars first, then credential_process, then EC2 IMDS.
    async fn resolve() -> anyhow::Result<Self> {
        if let Ok(creds) = Self::from_env() {
            return Ok(creds);
        }
        if let Ok(creds) = Self::from_credential_process() {
            return Ok(creds);
        }
        Self::from_imds().await
    }

    fn host(&self) -> String {
        format!("{ENDPOINT_PREFIX}.{}.amazonaws.com", self.region)
    }

    /// Returns `true` if credentials have a known expiry that has passed
    /// (with 60s skew to allow for clock drift and network latency).
    fn is_expired(&self) -> bool {
        match self.expires_at {
            Some(exp) => chrono::Utc::now() >= exp - chrono::Duration::seconds(60),
            None => false,
        }
    }
}

#[cfg(windows)]
fn run_credential_process_command(cmd: &str) -> std::io::Result<std::process::Output> {
    windows_std_cmd_shell_command(cmd).output()
}

#[cfg(not(windows))]
fn run_credential_process_command(cmd: &str) -> std::io::Result<std::process::Output> {
    std::process::Command::new("sh").args(["-c", cmd]).output()
}

fn env_required(name: &str) -> anyhow::Result<String> {
    std::env::var(name)
        .ok()
        .map(|v| v.trim().to_string())
        .filter(|v| !v.is_empty())
        .ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"env_var": name})),
                "bedrock: required environment variable is missing"
            );
            anyhow::Error::msg(format!(
                "Environment variable {name} is required for Bedrock"
            ))
        })
}

fn env_optional(name: &str) -> Option<String> {
    std::env::var(name)
        .ok()
        .map(|v| v.trim().to_string())
        .filter(|v| !v.is_empty())
}

// ── AWS SigV4 Signing ───────────────────────────────────────────

fn sha256_hex(data: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(data);
    hex::encode(hasher.finalize())
}

fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC can take key of any size");
    mac.update(data);
    mac.finalize().into_bytes().to_vec()
}

/// Derive the SigV4 signing key via HMAC chain.
fn derive_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
    let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes());
    let k_region = hmac_sha256(&k_date, region.as_bytes());
    let k_service = hmac_sha256(&k_region, service.as_bytes());
    hmac_sha256(&k_service, b"aws4_request")
}

/// Build the SigV4 `Authorization` header value.
/// `headers` must be sorted by lowercase header name.
fn build_authorization_header(
    credentials: &AwsCredentials,
    method: &str,
    canonical_uri: &str,
    query_string: &str,
    headers: &[(String, String)],
    payload: &[u8],
    timestamp: &chrono::DateTime<chrono::Utc>,
) -> String {
    let date_stamp = timestamp.format("%Y%m%d").to_string();
    let amz_date = timestamp.format("%Y%m%dT%H%M%SZ").to_string();

    let mut canonical_headers = String::new();
    for (k, v) in headers {
        canonical_headers.push_str(k);
        canonical_headers.push(':');
        canonical_headers.push_str(v);
        canonical_headers.push('\n');
    }

    let signed_headers: String = headers
        .iter()
        .map(|(k, _)| k.as_str())
        .collect::<Vec<_>>()
        .join(";");

    let payload_hash = sha256_hex(payload);

    let canonical_request = format!(
        "{method}\n{canonical_uri}\n{query_string}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
    );

    let credential_scope = format!(
        "{date_stamp}/{}/{SIGNING_SERVICE}/aws4_request",
        credentials.region
    );

    let string_to_sign = format!(
        "AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
        sha256_hex(canonical_request.as_bytes())
    );

    let signing_key = derive_signing_key(
        &credentials.secret_access_key,
        &date_stamp,
        &credentials.region,
        SIGNING_SERVICE,
    );

    let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));

    format!(
        "AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}",
        credentials.access_key_id
    )
}

// ── Converse API Types (Request) ────────────────────────────────

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ConverseRequest {
    messages: Vec<ConverseMessage>,
    #[serde(skip_serializing_if = "Option::is_none")]
    system: Option<Vec<SystemBlock>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    inference_config: Option<InferenceConfig>,
    #[serde(skip_serializing_if = "Option::is_none")]
    tool_config: Option<ToolConfig>,
    #[serde(skip_serializing_if = "Option::is_none")]
    additional_model_request_fields: Option<serde_json::Value>,
}

#[derive(Debug, Serialize, Deserialize)]
struct ConverseMessage {
    role: String,
    content: Vec<ContentBlock>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum ContentBlock {
    Text(TextBlock),
    ToolUse(ToolUseWrapper),
    ToolResult(ToolResultWrapper),
    CachePointBlock(CachePointWrapper),
    Image(ImageWrapper),
    /// Thinking block for round-tripping extended thinking in conversation
    /// history. Required when thinking is enabled and assistant messages
    /// contain tool_use blocks.
    #[serde(rename = "reasoningContent")]
    ReasoningContent(ReasoningContentOutWrapper),
}

/// Outgoing reasoning content block for request messages.
/// Serializes as `{"reasoningContent": {"reasoningText": {"text": "..."}}}`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReasoningContentOutWrapper {
    reasoning_content: ReasoningContentOutBlock,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReasoningContentOutBlock {
    reasoning_text: ReasoningTextOutField,
}

#[derive(Debug, Serialize, Deserialize)]
struct ReasoningTextOutField {
    text: String,
    /// Signature for integrity verification — round-tripped from the
    /// original thinking block returned by the model.
    #[serde(skip_serializing_if = "Option::is_none")]
    signature: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct ImageWrapper {
    image: ImageBlock,
}

#[derive(Debug, Serialize, Deserialize)]
struct ImageBlock {
    format: String,
    source: ImageSource,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImageSource {
    bytes: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct TextBlock {
    text: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolUseWrapper {
    tool_use: ToolUseBlock,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolUseBlock {
    tool_use_id: String,
    name: String,
    input: serde_json::Value,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolResultWrapper {
    tool_result: ToolResultBlock,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolResultBlock {
    tool_use_id: String,
    content: Vec<ToolResultContent>,
    status: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CachePointWrapper {
    cache_point: CachePoint,
}

#[derive(Debug, Serialize, Deserialize)]
struct ToolResultContent {
    text: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct CachePoint {
    #[serde(rename = "type")]
    cache_type: String,
}

impl CachePoint {
    fn default_cache() -> Self {
        Self {
            cache_type: "default".to_string(),
        }
    }
}

/// System prompt blocks: either `{"text": "..."}` or `{"cachePoint": {...}}`.
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum SystemBlock {
    Text(TextBlock),
    CachePoint(CachePointWrapper),
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct InferenceConfig {
    max_tokens: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    temperature: Option<f64>,
}

fn bedrock_model_supports_native_thinking(model: &str) -> bool {
    !model.contains("claude-opus-4-7")
}

fn bedrock_model_supports_prompt_caching(model: &str) -> bool {
    let model = model.to_ascii_lowercase();
    model.contains("claude") || model.contains("nova")
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ToolConfig {
    tools: Vec<ToolDefinition>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ToolDefinition {
    tool_spec: ToolSpecDef,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ToolSpecDef {
    name: String,
    description: String,
    input_schema: InputSchema,
}

#[derive(Debug, Serialize)]
struct InputSchema {
    /// `Arc`-shared with the tool registry's stored schema — serialized
    /// transparently, never deep-cloned per request
    json: Arc<serde_json::Value>,
}

// ── Converse API Types (Response) ───────────────────────────────

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConverseResponse {
    #[serde(default)]
    output: Option<ConverseOutput>,
    #[serde(default)]
    #[allow(dead_code)]
    stop_reason: Option<String>,
    #[serde(default)]
    usage: Option<BedrockUsage>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BedrockUsage {
    #[serde(default)]
    input_tokens: Option<u64>,
    #[serde(default)]
    output_tokens: Option<u64>,
}

#[derive(Debug, Deserialize)]
struct ConverseOutput {
    #[serde(default)]
    message: Option<ConverseOutputMessage>,
}

#[derive(Debug, Deserialize)]
struct ConverseOutputMessage {
    #[allow(dead_code)]
    role: String,
    content: Vec<ResponseContentBlock>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ResponseContentBlock {
    ToolUse(ResponseToolUseWrapper),
    ReasoningContent(ReasoningContentWrapper),
    Text(TextBlock),
    Other(#[allow(dead_code)] serde_json::Value),
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReasoningContentWrapper {
    reasoning_content: ReasoningContentBlock,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReasoningContentBlock {
    #[serde(default)]
    reasoning_text: Option<ReasoningTextField>,
}

#[derive(Debug, Deserialize)]
struct ReasoningTextField {
    #[serde(default)]
    text: Option<String>,
    /// Signature for integrity verification — must be round-tripped
    /// when sending thinking blocks back in conversation history.
    #[serde(default)]
    signature: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResponseToolUseWrapper {
    tool_use: ToolUseBlock,
}

// ── BedrockModelProvider ─────────────────────────────────────────────

pub struct BedrockModelProvider {
    /// `[providers.models.<family>.<alias>]` config-key alias.
    alias: String,
    auth: Option<BedrockAuth>,
    max_tokens: u32,
    /// Cached SigV4 credentials from `credential_process` (with expiry).
    cred_cache: Mutex<Option<AwsCredentials>>,
}

/// Typed builder for [`BedrockModelProvider`].
///
/// `alias` is the only positional argument. Whether the built provider
/// probes ambient AWS credentials (`BEDROCK_API_KEY` env, then
/// `AwsCredentials::from_env`, then `AwsCredentials::from_credential_process`)
/// depends on whether [`Self::bearer_token`] was called: setting an
/// explicit token bypasses every probe, and specifically avoids
/// spawning the `credential_process` command from `~/.aws/config`.
/// Leaving it unset lets `build()` walk the standard AWS resolution
/// chain — the shape long-standing callers rely on when they wire
/// Bedrock through the AWS environment.
#[must_use]
pub struct BedrockBuilder {
    alias: String,
    bearer_token: Option<String>,
    max_tokens: Option<u32>,
}

impl BedrockBuilder {
    /// Set an explicit Bearer token. When set, [`Self::build`] uses it
    /// directly and skips every AWS credential probe (including the
    /// `credential_process` command that would otherwise spawn a shell).
    pub fn bearer_token(mut self, token: &str) -> Self {
        self.bearer_token = Some(token.to_string());
        self
    }

    /// Override the maximum output tokens for API requests. Defaults to
    /// [`zeroclaw_api::model_provider::BASELINE_MAX_TOKENS`] when unset.
    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
        self.max_tokens = Some(max_tokens);
        self
    }

    /// Finalize the provider synchronously.
    ///
    /// If `bearer_token` was set, uses it as-is with no ambient probe.
    /// Otherwise walks the standard chain (`BEDROCK_API_KEY` env →
    /// `AwsCredentials::from_env` → `AwsCredentials::from_credential_process`),
    /// which can spawn the `credential_process` command from
    /// `~/.aws/config`. Use [`Self::build_async`] if you need the
    /// IMDS-aware resolver (which requires an async context).
    pub fn build(self) -> BedrockModelProvider {
        let auth = if let Some(token) = self.bearer_token {
            Some(BedrockAuth::BearerToken(token))
        } else {
            resolve_ambient_auth_blocking()
        };
        BedrockModelProvider {
            alias: self.alias,
            auth,
            max_tokens: self
                .max_tokens
                .unwrap_or(zeroclaw_api::model_provider::BASELINE_MAX_TOKENS),
            cred_cache: Mutex::new(None),
        }
    }

    /// Finalize the provider through the async credential resolver
    /// (which additionally probes EC2 IMDSv2). Same bearer-token
    /// short-circuit as [`Self::build`].
    pub async fn build_async(self) -> BedrockModelProvider {
        let auth = if let Some(token) = self.bearer_token {
            Some(BedrockAuth::BearerToken(token))
        } else {
            resolve_ambient_auth_async().await
        };
        BedrockModelProvider {
            alias: self.alias,
            auth,
            max_tokens: self
                .max_tokens
                .unwrap_or(zeroclaw_api::model_provider::BASELINE_MAX_TOKENS),
            cred_cache: Mutex::new(None),
        }
    }
}

/// Walk the standard ambient AWS credential chain synchronously:
/// `BEDROCK_API_KEY` env → `AwsCredentials::from_env` →
/// `AwsCredentials::from_credential_process`. The last step can spawn
/// the `credential_process` command from `~/.aws/config`.
fn resolve_ambient_auth_blocking() -> Option<BedrockAuth> {
    if let Some(token) = env_optional("BEDROCK_API_KEY") {
        return Some(BedrockAuth::BearerToken(token));
    }
    AwsCredentials::from_env()
        .or_else(|_| AwsCredentials::from_credential_process())
        .ok()
        .map(BedrockAuth::SigV4)
}

/// Async counterpart of [`resolve_ambient_auth_blocking`] that also
/// probes EC2 IMDSv2 via [`AwsCredentials::resolve`].
async fn resolve_ambient_auth_async() -> Option<BedrockAuth> {
    if let Some(token) = env_optional("BEDROCK_API_KEY") {
        return Some(BedrockAuth::BearerToken(token));
    }
    AwsCredentials::resolve().await.ok().map(BedrockAuth::SigV4)
}

impl BedrockModelProvider {
    /// Entry point. Only `alias` is required; use
    /// [`BedrockBuilder::bearer_token`] to pin an explicit token
    /// (skipping every ambient probe) or call [`BedrockBuilder::build`]
    /// with no token to walk the AWS credential chain.
    pub fn builder(alias: &str) -> BedrockBuilder {
        BedrockBuilder {
            alias: alias.to_string(),
            bearer_token: None,
            max_tokens: None,
        }
    }

    fn http_client(&self) -> Client {
        zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
            "model_provider.bedrock",
            120,
            10,
        )
    }

    /// Percent-encode the model ID for URL path: only encode `:` to `%3A`.
    /// Colons in model IDs (e.g. `v1:0`) must be encoded because `reqwest::Url`
    /// may misparse them. Dots, hyphens, and alphanumerics are safe.
    fn encode_model_path(model_id: &str) -> String {
        model_id.replace(':', "%3A")
    }

    /// Resolve the AWS region from environment variables.
    fn resolve_region() -> String {
        env_optional("AWS_REGION")
            .or_else(|| env_optional("AWS_DEFAULT_REGION"))
            .unwrap_or_else(|| DEFAULT_REGION.to_string())
    }

    /// Build the actual request URL. Uses raw model ID (reqwest sends colons as-is).
    fn endpoint_url(region: &str, model_id: &str) -> String {
        format!("https://{ENDPOINT_PREFIX}.{region}.amazonaws.com/model/{model_id}/converse")
    }

    /// Build the canonical URI for SigV4 signing. Must URI-encode the path
    /// per SigV4 spec: colons become `%3A`. AWS verifies the signature against
    /// the encoded form even though the wire request uses raw colons.
    fn canonical_uri(model_id: &str) -> String {
        let encoded = Self::encode_model_path(model_id);
        format!("/model/{encoded}/converse")
    }

    /// Check the credential cache for unexpired credentials.
    fn cached_credentials(&self) -> Option<AwsCredentials> {
        let cache = self.cred_cache.lock().ok()?;
        let creds = cache.as_ref()?;
        if creds.is_expired() {
            return None;
        }
        Some(creds.clone())
    }

    /// Store credentials in the cache.
    fn cache_credentials(&self, creds: &AwsCredentials) {
        if let Ok(mut cache) = self.cred_cache.lock() {
            *cache = Some(creds.clone());
        }
    }

    /// Resolve auth: use cached if available, otherwise try env vars then IMDS.
    async fn resolve_auth(&self) -> anyhow::Result<BedrockAuth> {
        // If we already have auth cached, re-resolve from the same source.
        if let Some(ref auth) = self.auth {
            match auth {
                BedrockAuth::BearerToken(token) => {
                    return Ok(BedrockAuth::BearerToken(token.clone()));
                }
                BedrockAuth::SigV4(_) => {
                    if let Some(creds) = self.cached_credentials() {
                        return Ok(BedrockAuth::SigV4(creds));
                    }
                }
            }
        }
        // Check Bearer token first.
        if let Some(token) = env_optional("BEDROCK_API_KEY") {
            return Ok(BedrockAuth::BearerToken(token));
        }
        // Fall back to SigV4.
        if let Ok(creds) = AwsCredentials::from_env() {
            return Ok(BedrockAuth::SigV4(creds));
        }
        if let Ok(creds) = AwsCredentials::from_credential_process() {
            self.cache_credentials(&creds);
            return Ok(BedrockAuth::SigV4(creds));
        }
        Ok(BedrockAuth::SigV4(AwsCredentials::from_imds().await?))
    }

    // ── Cache heuristics (same thresholds as AnthropicModelProvider) ──

    /// Cache system prompts larger than ~1024 tokens (3KB of text).
    fn should_cache_system(text: &str) -> bool {
        text.len() > 3072
    }

    /// Cache conversations with more than 4 messages (excluding system).
    fn should_cache_conversation(messages: &[ChatMessage]) -> bool {
        messages.iter().filter(|m| m.role != "system").count() > 4
    }

    // ── Message conversion ──────────────────────────────────────

    fn convert_messages(
        messages: &[ChatMessage],
    ) -> (Option<Vec<SystemBlock>>, Vec<ConverseMessage>) {
        let mut system_blocks = Vec::new();
        let mut converse_messages = Vec::new();

        for msg in messages {
            match msg.role.as_str() {
                "system" => {
                    if system_blocks.is_empty() {
                        system_blocks.push(SystemBlock::Text(TextBlock {
                            text: msg.content.clone(),
                        }));
                    }
                }
                "assistant" => {
                    if let Some(blocks) = Self::parse_assistant_tool_call_message(&msg.content) {
                        converse_messages.push(ConverseMessage {
                            role: "assistant".to_string(),
                            content: blocks,
                        });
                    } else {
                        // Guard: never send an empty text block to Bedrock.
                        // This can happen when a daemon restart interrupts a
                        // streaming response, leaving a partially-persisted
                        // assistant message with empty content.
                        let text = if msg.content.trim().is_empty() {
                            "(empty response)".to_string()
                        } else {
                            msg.content.clone()
                        };
                        converse_messages.push(ConverseMessage {
                            role: "assistant".to_string(),
                            content: vec![ContentBlock::Text(TextBlock { text })],
                        });
                    }
                }
                "tool" => {
                    let tool_result_msg = Self::parse_tool_result_message(&msg.content)
                        .unwrap_or_else(|| {
                            // Fallback: always emit a toolResult block so the
                            // Bedrock API contract (every toolUse needs a matching
                            // toolResult) is never violated.
                            let tool_use_id = Self::extract_tool_call_id(&msg.content)
                                .or_else(|| Self::last_pending_tool_use_id(&converse_messages))
                                .unwrap_or_else(|| "unknown".to_string());

                            ::zeroclaw_log::record!(
                                WARN,
                                ::zeroclaw_log::Event::new(
                                    module_path!(),
                                    ::zeroclaw_log::Action::Note
                                )
                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                                &format!(
                                    "Failed to parse tool result message, creating error \
                                 toolResult for tool_use_id={}",
                                    tool_use_id
                                )
                            );

                            ConverseMessage {
                                role: "user".to_string(),
                                content: vec![ContentBlock::ToolResult(ToolResultWrapper {
                                    tool_result: ToolResultBlock {
                                        tool_use_id,
                                        content: vec![ToolResultContent {
                                            text: msg.content.clone(),
                                        }],
                                        status: "error".to_string(),
                                    },
                                })],
                            }
                        });

                    // Merge consecutive tool results into a single user message.
                    // Bedrock requires all toolResult blocks for a multi-tool-call
                    // turn to appear in one user message.
                    if let Some(last) = converse_messages.last_mut()
                        && last.role == "user"
                        && last
                            .content
                            .iter()
                            .all(|b| matches!(b, ContentBlock::ToolResult(_)))
                    {
                        last.content.extend(tool_result_msg.content);
                        continue;
                    }
                    converse_messages.push(tool_result_msg);
                }
                _ => {
                    let content_blocks = Self::parse_user_content_blocks(&msg.content);
                    converse_messages.push(ConverseMessage {
                        role: "user".to_string(),
                        content: content_blocks,
                    });
                }
            }
        }

        let system = if system_blocks.is_empty() {
            None
        } else {
            Some(system_blocks)
        };
        (system, converse_messages)
    }

    fn sanitize_empty_content_blocks(messages: &mut [ConverseMessage]) {
        for msg in messages.iter_mut() {
            msg.content.retain(|block| match block {
                ContentBlock::Text(tb) => !tb.text.trim().is_empty(),
                _ => true,
            });
            if msg.content.is_empty() {
                msg.content.push(ContentBlock::Text(TextBlock {
                    text: "(empty)".to_string(),
                }));
            }
        }
    }

    fn strip_orphaned_tool_uses(messages: &mut [ConverseMessage]) {
        use std::collections::HashSet;

        let answered_ids: HashSet<String> = messages
            .iter()
            .flat_map(|m| m.content.iter())
            .filter_map(|b| match b {
                ContentBlock::ToolResult(w) => Some(w.tool_result.tool_use_id.clone()),
                _ => None,
            })
            .collect();

        for msg in messages.iter_mut() {
            if msg.role != "assistant" {
                continue;
            }
            let before = msg.content.len();
            msg.content.retain(|block| match block {
                ContentBlock::ToolUse(w) => answered_ids.contains(&w.tool_use.tool_use_id),
                _ => true,
            });
            let removed = before - msg.content.len();
            if removed > 0 {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({ "removed": removed })),
                    "bedrock: converter stripped orphaned toolUse block(s) from an assistant \
                     message — upstream history pruning likely missed a case"
                );
                if msg.content.is_empty() {
                    msg.content.push(ContentBlock::Text(TextBlock {
                        text: "(tool call omitted — no matching result)".to_string(),
                    }));
                }
            }
        }
    }

    /// Try to extract a tool_call_id from partially-valid JSON content.
    fn extract_tool_call_id(content: &str) -> Option<String> {
        let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
        value
            .get("tool_call_id")
            .or_else(|| value.get("tool_use_id"))
            .or_else(|| value.get("toolUseId"))
            .and_then(serde_json::Value::as_str)
            .map(String::from)
    }

    fn last_pending_tool_use_id(converse_messages: &[ConverseMessage]) -> Option<String> {
        let last_assistant = converse_messages
            .iter()
            .rev()
            .find(|m| m.role == "assistant")?;

        let tool_use_ids: Vec<&str> = last_assistant
            .content
            .iter()
            .filter_map(|b| match b {
                ContentBlock::ToolUse(wrapper) => Some(wrapper.tool_use.tool_use_id.as_str()),
                _ => None,
            })
            .collect();

        let answered_ids: Vec<&str> = converse_messages
            .iter()
            .rev()
            .take_while(|m| m.role == "user")
            .flat_map(|m| m.content.iter())
            .filter_map(|b| match b {
                ContentBlock::ToolResult(wrapper) => Some(wrapper.tool_result.tool_use_id.as_str()),
                _ => None,
            })
            .collect();

        tool_use_ids
            .into_iter()
            .find(|id| !answered_ids.contains(id))
            .map(String::from)
    }

    /// Parse user message content, extracting [IMAGE:data:...] markers into image blocks.
    fn parse_user_content_blocks(content: &str) -> Vec<ContentBlock> {
        let mut blocks: Vec<ContentBlock> = Vec::new();
        let mut remaining = content;
        let has_image = content.contains("[IMAGE:");
        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
            &format!(
                "parse_user_content_blocks called, len={}, has_image={}",
                content.len(),
                has_image
            )
        );

        while let Some(start) = remaining.find("[IMAGE:") {
            // Add any text before the marker
            let text_before = &remaining[..start];
            if !text_before.trim().is_empty() {
                blocks.push(ContentBlock::Text(TextBlock {
                    text: text_before.to_string(),
                }));
            }

            let after = &remaining[start + 7..]; // skip "[IMAGE:"
            if let Some(end) = after.find(']') {
                let src = &after[..end];
                remaining = &after[end + 1..];

                // Only handle data URIs (base64 encoded images)
                if let Some(rest) = src.strip_prefix("data:")
                    && let Some(semi) = rest.find(';')
                {
                    let mime = &rest[..semi];
                    let after_semi = &rest[semi + 1..];
                    if let Some(b64) = after_semi.strip_prefix("base64,") {
                        let format = match mime {
                            "image/png" => "png",
                            "image/gif" => "gif",
                            "image/webp" => "webp",
                            _ => "jpeg",
                        };
                        blocks.push(ContentBlock::Image(ImageWrapper {
                            image: ImageBlock {
                                format: format.to_string(),
                                source: ImageSource {
                                    bytes: b64.to_string(),
                                },
                            },
                        }));
                        continue;
                    }
                }
                // Non-data-uri image: just include as text reference
                blocks.push(ContentBlock::Text(TextBlock {
                    text: format!("[image: {}]", src),
                }));
            } else {
                // No closing bracket, treat rest as text
                blocks.push(ContentBlock::Text(TextBlock {
                    text: remaining.to_string(),
                }));
                break;
            }
        }

        // Add any remaining text
        if !remaining.trim().is_empty() {
            blocks.push(ContentBlock::Text(TextBlock {
                text: remaining.to_string(),
            }));
        }

        if blocks.is_empty() {
            let fallback = if content.trim().is_empty() {
                "(empty)".to_string()
            } else {
                content.to_string()
            };
            blocks.push(ContentBlock::Text(TextBlock { text: fallback }));
        }

        blocks
    }

    /// Parse assistant message containing structured tool calls.
    fn parse_assistant_tool_call_message(content: &str) -> Option<Vec<ContentBlock>> {
        let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
        let tool_calls = value
            .get("tool_calls")
            .and_then(|v| serde_json::from_value::<Vec<ProviderToolCall>>(v.clone()).ok())?;

        let mut blocks = Vec::new();

        // When extended thinking is enabled, assistant messages must start
        // with reasoning content blocks (including signatures) before any
        // tool_use blocks. The reasoning_content field stores JSON-encoded
        // thinking blocks from the original response.
        if let Some(reasoning) = value
            .get("reasoning_content")
            .and_then(serde_json::Value::as_str)
            .filter(|r| !r.is_empty())
        {
            // reasoning_content may contain multiple JSON blocks joined by \n
            for part in reasoning.split('\n') {
                if let Ok(block) = serde_json::from_str::<serde_json::Value>(part) {
                    let text = block
                        .get("text")
                        .and_then(|t| t.as_str())
                        .unwrap_or("")
                        .to_string();
                    let signature = block
                        .get("signature")
                        .and_then(|s| s.as_str())
                        .filter(|s| !s.is_empty())
                        .map(|s| s.to_string());
                    blocks.push(ContentBlock::ReasoningContent(ReasoningContentOutWrapper {
                        reasoning_content: ReasoningContentOutBlock {
                            reasoning_text: ReasoningTextOutField { text, signature },
                        },
                    }));
                }
            }
        }

        if let Some(text) = value
            .get("content")
            .and_then(serde_json::Value::as_str)
            .map(str::trim)
            .filter(|t| !t.is_empty())
        {
            blocks.push(ContentBlock::Text(TextBlock {
                text: text.to_string(),
            }));
        }
        for call in tool_calls {
            let input = serde_json::from_str::<serde_json::Value>(&call.arguments)
                .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
            blocks.push(ContentBlock::ToolUse(ToolUseWrapper {
                tool_use: ToolUseBlock {
                    tool_use_id: call.id,
                    name: call.name,
                    input,
                },
            }));
        }
        Some(blocks)
    }

    /// Parse tool result message into a user message with ToolResult block.
    fn parse_tool_result_message(content: &str) -> Option<ConverseMessage> {
        let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
        let tool_use_id = value
            .get("tool_call_id")
            .or_else(|| value.get("tool_use_id"))
            .or_else(|| value.get("toolUseId"))
            .and_then(serde_json::Value::as_str)?
            .to_string();
        let result = value
            .get("content")
            .map(Self::tool_result_content_text)
            .unwrap_or_default();
        Some(ConverseMessage {
            role: "user".to_string(),
            content: vec![ContentBlock::ToolResult(ToolResultWrapper {
                tool_result: ToolResultBlock {
                    tool_use_id,
                    content: vec![ToolResultContent { text: result }],
                    status: "success".to_string(),
                },
            })],
        })
    }

    // Bedrock toolResult content is text-only. Preserve string tool output as-is;
    // encode structured output compactly so object/array results are not dropped.
    fn tool_result_content_text(value: &serde_json::Value) -> String {
        value
            .as_str()
            .map(ToString::to_string)
            .unwrap_or_else(|| value.to_string())
    }

    // ── Tool conversion ─────────────────────────────────────────

    fn convert_tools_to_converse(tools: Option<&[ToolSpec]>) -> Option<ToolConfig> {
        let items = tools?;
        if items.is_empty() {
            return None;
        }
        let tool_defs: Vec<ToolDefinition> = items
            .iter()
            .map(|tool| ToolDefinition {
                tool_spec: ToolSpecDef {
                    name: tool.name.clone(),
                    description: tool.description.clone(),
                    input_schema: InputSchema {
                        json: Arc::clone(&tool.parameters),
                    },
                },
            })
            .collect();
        Some(ToolConfig { tools: tool_defs })
    }

    // ── Response parsing ────────────────────────────────────────

    fn parse_converse_response(response: ConverseResponse) -> ProviderChatResponse {
        let mut text_parts = Vec::new();
        let mut thinking_parts = Vec::new();
        let mut tool_calls = Vec::new();

        let usage = response.usage.map(|u| TokenUsage {
            input_tokens: u.input_tokens,
            output_tokens: u.output_tokens,
            cached_input_tokens: None,
        });

        if let Some(output) = response.output
            && let Some(message) = output.message
        {
            for block in message.content {
                match block {
                    ResponseContentBlock::Text(tb) => {
                        let trimmed = tb.text.trim().to_string();
                        if !trimmed.is_empty() {
                            text_parts.push(trimmed);
                        }
                    }
                    ResponseContentBlock::ReasoningContent(wrapper) => {
                        if let Some(reasoning_text) = wrapper.reasoning_content.reasoning_text {
                            // Store as JSON with signature for round-tripping.
                            let block = serde_json::json!({
                                "text": reasoning_text.text.as_deref().unwrap_or(""),
                                "signature": reasoning_text.signature.as_deref().unwrap_or(""),
                            });
                            thinking_parts.push(block.to_string());
                        }
                    }
                    ResponseContentBlock::ToolUse(wrapper) => {
                        if !wrapper.tool_use.name.is_empty() {
                            tool_calls.push(ProviderToolCall {
                                id: wrapper.tool_use.tool_use_id,
                                name: wrapper.tool_use.name,
                                arguments: wrapper.tool_use.input.to_string(),
                                extra_content: None,
                            });
                        }
                    }
                    ResponseContentBlock::Other(_) => {}
                }
            }
        }

        let reasoning_content = if thinking_parts.is_empty() {
            None
        } else {
            Some(thinking_parts.join("\n"))
        };

        ProviderChatResponse {
            text: if text_parts.is_empty() {
                None
            } else {
                Some(text_parts.join("\n"))
            },
            tool_calls,
            usage,
            reasoning_content,
        }
    }

    // ── HTTP request ────────────────────────────────────────────

    async fn send_converse_request(
        &self,
        auth: &BedrockAuth,
        model: &str,
        request_body: &ConverseRequest,
    ) -> anyhow::Result<ConverseResponse> {
        let payload = serde_json::to_vec(request_body)?;

        // Debug: log image blocks in payload (truncated)
        if let Ok(debug_val) = serde_json::from_slice::<serde_json::Value>(&payload)
            && let Some(msgs) = debug_val.get("messages").and_then(|m| m.as_array())
        {
            for msg in msgs {
                if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
                    for block in content {
                        if block.get("image").is_some() {
                            let mut b = block.clone();
                            if let Some(img) = b.get_mut("image")
                                && let Some(src) = img.get_mut("source")
                                && let Some(bytes) = src.get_mut("bytes")
                                && let Some(s) = bytes.as_str()
                            {
                                *bytes = serde_json::json!(format!("<base64 {} chars>", s.len()));
                            }
                            ::zeroclaw_log::record!(
                                INFO,
                                ::zeroclaw_log::Event::new(
                                    module_path!(),
                                    ::zeroclaw_log::Action::Note
                                ),
                                &format!(
                                    "Bedrock image block: {}",
                                    serde_json::to_string(&b).unwrap_or_default()
                                )
                            );
                        }
                    }
                }
            }
        }

        let response: reqwest::Response = match auth {
            BedrockAuth::BearerToken(token) => {
                let region = Self::resolve_region();
                let url = Self::endpoint_url(&region, model);

                self.http_client()
                    .post(&url)
                    .header("content-type", "application/json")
                    .header("Authorization", format!("Bearer {token}"))
                    .body(payload)
                    .send()
                    .await?
            }
            BedrockAuth::SigV4(credentials) => {
                let url = Self::endpoint_url(&credentials.region, model);
                let canonical_uri = Self::canonical_uri(model);
                let now = chrono::Utc::now();
                let host = credentials.host();
                let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();

                let mut headers_to_sign = vec![
                    ("content-type".to_string(), "application/json".to_string()),
                    ("host".to_string(), host),
                    ("x-amz-date".to_string(), amz_date.clone()),
                ];
                if let Some(ref session_token) = credentials.session_token {
                    headers_to_sign
                        .push(("x-amz-security-token".to_string(), session_token.clone()));
                }
                headers_to_sign.sort_by(|a, b| a.0.cmp(&b.0));

                let authorization = build_authorization_header(
                    credentials,
                    "POST",
                    &canonical_uri,
                    "",
                    &headers_to_sign,
                    &payload,
                    &now,
                );

                let mut request = self
                    .http_client()
                    .post(&url)
                    .header("content-type", "application/json")
                    .header("x-amz-date", &amz_date)
                    .header("authorization", &authorization);

                if let Some(ref session_token) = credentials.session_token {
                    request = request.header("x-amz-security-token", session_token);
                }

                request.body(payload).send().await?
            }
        };

        if !response.status().is_success() {
            return Err(super::api_error("Bedrock", response).await);
        }

        let converse_response: ConverseResponse = response.json().await?;
        Ok(converse_response)
    }
}

// ── ModelProvider trait implementation ───────────────────────────────

#[async_trait]
impl ModelProvider for BedrockModelProvider {
    fn capabilities(&self) -> ProviderCapabilities {
        ProviderCapabilities {
            native_tool_calling: true,
            vision: true,
            prompt_caching: false,
            extended_thinking: true,
        }
    }

    fn supports_native_tools(&self) -> bool {
        true
    }

    fn convert_tools(&self, tools: &[ToolSpec]) -> ToolsPayload {
        let tool_values: Vec<serde_json::Value> = tools
            .iter()
            .map(|t| {
                serde_json::json!({
                    "toolSpec": {
                        "name": t.name,
                        "description": t.description,
                        "inputSchema": { "json": t.parameters }
                    }
                })
            })
            .collect();
        ToolsPayload::Anthropic { tools: tool_values }
    }

    async fn chat_with_system(
        &self,
        system_prompt: Option<&str>,
        message: &str,
        model: &str,
        temperature: Option<f64>,
    ) -> anyhow::Result<String> {
        let auth = self.resolve_auth().await?;

        let supports_caching = bedrock_model_supports_prompt_caching(model);
        let system = system_prompt.map(|text| {
            let mut blocks = vec![SystemBlock::Text(TextBlock {
                text: text.to_string(),
            })];
            if supports_caching && Self::should_cache_system(text) {
                blocks.push(SystemBlock::CachePoint(CachePointWrapper {
                    cache_point: CachePoint::default_cache(),
                }));
            }
            blocks
        });

        let mut messages = vec![ConverseMessage {
            role: "user".to_string(),
            content: Self::parse_user_content_blocks(message),
        }];
        Self::sanitize_empty_content_blocks(&mut messages);

        let request = ConverseRequest {
            system,
            messages,
            inference_config: Some(InferenceConfig {
                max_tokens: self.max_tokens,
                temperature,
            }),
            tool_config: None,
            additional_model_request_fields: None,
        };

        let response = self.send_converse_request(&auth, model, &request).await?;

        Self::parse_converse_response(response).text.ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
                "bedrock: empty text in response"
            );
            anyhow::Error::msg("No response from Bedrock")
        })
    }

    async fn chat(
        &self,
        request: ProviderChatRequest<'_>,
        model: &str,
        temperature: Option<f64>,
    ) -> anyhow::Result<ProviderChatResponse> {
        let auth = self.resolve_auth().await?;

        let (system_blocks, mut converse_messages) = Self::convert_messages(request.messages);

        // Strip empty text ContentBlocks that would cause Bedrock 400 errors.
        Self::sanitize_empty_content_blocks(&mut converse_messages);

        // Strip orphaned toolUse blocks (no matching toolResult) that would
        // otherwise trigger "Expected toolResult blocks at messages.N.content
        // for the following Ids: tooluse_*". The runtime history pruner is the
        // primary defence; this is the converter-side backstop.
        Self::strip_orphaned_tool_uses(&mut converse_messages);

        // Prompt caching (cachePoint) is only accepted by Claude/Nova models;
        // sending it to e.g. Qwen or Llama returns a 400. Gate all cachePoint
        // insertion on model support.
        let supports_caching = bedrock_model_supports_prompt_caching(model);

        // Apply cachePoint to system if large.
        let system = system_blocks.map(|mut blocks| {
            let has_large_system = blocks
                .iter()
                .any(|b| matches!(b, SystemBlock::Text(tb) if Self::should_cache_system(&tb.text)));
            if supports_caching && has_large_system {
                blocks.push(SystemBlock::CachePoint(CachePointWrapper {
                    cache_point: CachePoint::default_cache(),
                }));
            }
            blocks
        });

        // Apply cachePoint to last message if conversation is long.
        if supports_caching
            && Self::should_cache_conversation(request.messages)
            && let Some(last_msg) = converse_messages.last_mut()
        {
            last_msg
                .content
                .push(ContentBlock::CachePointBlock(CachePointWrapper {
                    cache_point: CachePoint::default_cache(),
                }));
        }

        let tool_config = Self::convert_tools_to_converse(request.tools);

        // Native thinking forces temperature=1.0 (Anthropic API requirement).
        // Otherwise the caller's Option<f64> flows through verbatim; None
        // omits the field via skip_serializing_if.
        let (effective_temperature, additional_fields, effective_max_tokens) = match request
            .thinking
        {
            Some(params) if bedrock_model_supports_native_thinking(model) => {
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"budget_tokens": params.budget_tokens})),
                    "Bedrock native extended thinking enabled; forcing temperature=1.0"
                );
                let fields = serde_json::json!({
                    "thinking": {
                        "type": "enabled",
                        "budget_tokens": params.budget_tokens
                    }
                });
                let min_required = params.budget_tokens + 1;
                let max_tokens = self.max_tokens.max(min_required);
                (Some(1.0), Some(fields), max_tokens)
            }
            Some(_) => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"model": model})),
                    "Native extended thinking requested but model only supports adaptive thinking; falling back to prompt-based reasoning"
                );
                (temperature, None, self.max_tokens)
            }
            None => (temperature, None, self.max_tokens),
        };

        let converse_request = ConverseRequest {
            system,
            messages: converse_messages,
            inference_config: Some(InferenceConfig {
                max_tokens: effective_max_tokens,
                temperature: effective_temperature,
            }),
            tool_config,
            additional_model_request_fields: additional_fields,
        };

        let response = self
            .send_converse_request(&auth, model, &converse_request)
            .await?;

        Ok(Self::parse_converse_response(response))
    }

    async fn warmup(&self) -> anyhow::Result<()> {
        let region = match self.auth {
            Some(BedrockAuth::SigV4(ref creds)) => creds.region.clone(),
            Some(BedrockAuth::BearerToken(_)) => Self::resolve_region(),
            None => return Ok(()),
        };
        let url = format!("https://{ENDPOINT_PREFIX}.{region}.amazonaws.com/");
        let _ = self.http_client().get(&url).send().await;
        Ok(())
    }
}

// ── Tests ───────────────────────────────────────────────────────

impl ::zeroclaw_api::attribution::Attributable for BedrockModelProvider {
    fn role(&self) -> ::zeroclaw_api::attribution::Role {
        ::zeroclaw_api::attribution::Role::Provider(
            ::zeroclaw_api::attribution::ProviderKind::Model(
                ::zeroclaw_api::attribution::ModelProviderKind::Bedrock,
            ),
        )
    }
    fn alias(&self) -> &str {
        &self.alias
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_util::{EnvGuard, env_lock};
    use crate::traits::ChatMessage;

    // ── SigV4 signing tests ─────────────────────────────────────

    #[test]
    fn sha256_hex_empty_string() {
        // Known SHA-256 of empty input
        assert_eq!(
            sha256_hex(b""),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }

    #[test]
    fn sha256_hex_known_input() {
        // SHA-256 of "hello"
        assert_eq!(
            sha256_hex(b"hello"),
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
        );
    }

    /// AWS documentation example key for SigV4 test vectors (not a real credential).
    const TEST_VECTOR_SECRET: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";

    #[test]
    fn hmac_sha256_known_input() {
        let test_key: &[u8] = b"key";
        let result = hmac_sha256(test_key, b"message");
        assert_eq!(
            hex::encode(&result),
            "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
        );
    }

    #[test]
    fn derive_signing_key_structure() {
        // Verify the key derivation produces a 32-byte key (SHA-256 output).
        let key = derive_signing_key(TEST_VECTOR_SECRET, "20150830", "us-east-1", "iam");
        assert_eq!(key.len(), 32);
    }

    #[test]
    fn derive_signing_key_known_test_vector() {
        // AWS SigV4 test vector from documentation.
        let key = derive_signing_key(TEST_VECTOR_SECRET, "20150830", "us-east-1", "iam");
        assert_eq!(
            hex::encode(&key),
            "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9"
        );
    }

    #[test]
    fn build_authorization_header_format() {
        let credentials = AwsCredentials {
            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
            secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
            session_token: None,
            region: "us-east-1".to_string(),
            expires_at: None,
        };

        let timestamp = chrono::DateTime::parse_from_rfc3339("2024-01-15T12:00:00Z")
            .unwrap()
            .with_timezone(&chrono::Utc);

        let headers = vec![
            ("content-type".to_string(), "application/json".to_string()),
            (
                "host".to_string(),
                "bedrock-runtime.us-east-1.amazonaws.com".to_string(),
            ),
            ("x-amz-date".to_string(), "20240115T120000Z".to_string()),
        ];

        let auth = build_authorization_header(
            &credentials,
            "POST",
            "/model/anthropic.claude-3-sonnet/converse",
            "",
            &headers,
            b"{}",
            &timestamp,
        );

        // Verify structure
        assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/"));
        assert!(auth.contains("SignedHeaders=content-type;host;x-amz-date"));
        assert!(auth.contains("Signature="));
        assert!(auth.contains("/us-east-1/bedrock/aws4_request"));
    }

    #[test]
    fn build_authorization_header_includes_security_token_in_signed_headers() {
        let credentials = AwsCredentials {
            access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
            secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(),
            session_token: Some("session-token-value".to_string()),
            region: "us-east-1".to_string(),
            expires_at: None,
        };

        let timestamp = chrono::DateTime::parse_from_rfc3339("2024-01-15T12:00:00Z")
            .unwrap()
            .with_timezone(&chrono::Utc);

        let headers = vec![
            ("content-type".to_string(), "application/json".to_string()),
            (
                "host".to_string(),
                "bedrock-runtime.us-east-1.amazonaws.com".to_string(),
            ),
            ("x-amz-date".to_string(), "20240115T120000Z".to_string()),
            (
                "x-amz-security-token".to_string(),
                "session-token-value".to_string(),
            ),
        ];

        let auth = build_authorization_header(
            &credentials,
            "POST",
            "/model/test-model/converse",
            "",
            &headers,
            b"{}",
            &timestamp,
        );

        assert!(auth.contains("x-amz-security-token"));
    }

    // ── Credential tests ────────────────────────────────────────

    #[test]
    fn credentials_host_formats_correctly() {
        let creds = AwsCredentials {
            access_key_id: "AKID".to_string(),
            secret_access_key: "secret".to_string(),
            session_token: None,
            region: "us-west-2".to_string(),
            expires_at: None,
        };
        assert_eq!(creds.host(), "bedrock-runtime.us-west-2.amazonaws.com");
    }

    // ── ModelProvider construction tests ─────────────────────────────

    #[test]
    fn creates_without_credentials() {
        // ModelProvider should construct even without env vars.
        let _provider = BedrockModelProvider::builder("test").build();
    }

    #[tokio::test]
    #[allow(clippy::await_holding_lock)]
    async fn chat_fails_without_credentials() {
        let _env_lock = env_lock();
        let _ak = EnvGuard::set("AWS_ACCESS_KEY_ID", None);
        let _sk = EnvGuard::set("AWS_SECRET_ACCESS_KEY", None);
        let _bearer = EnvGuard::set("BEDROCK_API_KEY", None);
        let _config = EnvGuard::set("AWS_CONFIG_FILE", Some("/dev/null"));
        let model_provider = BedrockModelProvider {
            alias: "test".to_string(),
            auth: None,
            max_tokens: zeroclaw_api::model_provider::BASELINE_MAX_TOKENS,
            cred_cache: Mutex::new(None),
        };
        let result = model_provider
            .chat_with_system(None, "hello", "anthropic.claude-sonnet-4-6", Some(0.7))
            .await;
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("credentials not set")
                || err.contains("169.254.169.254")
                || err.to_lowercase().contains("credential")
                || err.to_lowercase().contains("builder error"),
            "Expected missing-credentials style error, got: {err}"
        );
    }

    // ── Bearer token tests ──────────────────────────────────────

    #[test]
    fn creates_with_bearer_token() {
        let model_provider = BedrockModelProvider::builder("test")
            .bearer_token("test-api-key")
            .build();
        assert!(model_provider.auth.is_some());
        assert!(
            matches!(model_provider.auth, Some(BedrockAuth::BearerToken(ref t)) if t == "test-api-key")
        );
    }

    /// Regression for the factory-side explicit-API-key path.
    ///
    /// The pre-refactor code used `BedrockModelProvider::with_bearer_token(alias, api_key)`,
    /// which directly constructed a bearer-token provider and skipped
    /// every ambient AWS credential probe. An earlier iteration of this
    /// refactor routed the same path through `new(alias).with_bearer_token(...)`,
    /// which meant `new(alias)` would still walk `BEDROCK_API_KEY` env → SigV4
    /// env → `credential_process` (spawning a shell command from
    /// `~/.aws/config`) before the bearer-token override took effect —
    /// an observable security-boundary regression.
    ///
    /// This test proves the current builder-based explicit-key path
    /// bypasses every ambient probe: with the AWS config file pointed at
    /// `/dev/null` (so any `credential_process` walk would either err out
    /// or, if the parser were ever changed, spawn `/dev/null` as a
    /// command) and with `BEDROCK_API_KEY` deliberately set to a
    /// distractor value, the resulting `auth` must be exactly the
    /// caller-supplied bearer token.
    #[test]
    fn bearer_token_builder_skips_ambient_credential_probe() {
        let _env_lock = env_lock();
        // A stale ambient bearer would beat the explicit one if the
        // builder probed env before applying the caller's token.
        let _bedrock_env = EnvGuard::set("BEDROCK_API_KEY", Some("distractor-ambient-token"));
        // Point the AWS config file at /dev/null so any accidental
        // credential_process walk fails loudly instead of silently
        // reading from the developer's real ~/.aws/config.
        let _aws_config = EnvGuard::set("AWS_CONFIG_FILE", Some("/dev/null"));
        // Also clear the SigV4 env vars so from_env cannot silently
        // succeed and mask an unintended probe.
        let _ak = EnvGuard::set("AWS_ACCESS_KEY_ID", None);
        let _sk = EnvGuard::set("AWS_SECRET_ACCESS_KEY", None);

        let model_provider = BedrockModelProvider::builder("test")
            .bearer_token("explicit-caller-token")
            .build();

        assert!(
            matches!(
                model_provider.auth,
                Some(BedrockAuth::BearerToken(ref t)) if t == "explicit-caller-token"
            ),
            "explicit bearer_token() must skip BEDROCK_API_KEY probe and win over any ambient value"
        );
    }

    #[test]
    fn bearer_token_from_env() {
        let _env_lock = env_lock();
        let _guard = EnvGuard::set("BEDROCK_API_KEY", Some("env-bearer-token"));
        // Clear SigV4 vars to ensure Bearer is chosen.
        let _ak_guard = EnvGuard::set("AWS_ACCESS_KEY_ID", None);
        let _sk_guard = EnvGuard::set("AWS_SECRET_ACCESS_KEY", None);

        let model_provider = BedrockModelProvider::builder("test").build();
        assert!(matches!(
            model_provider.auth,
            Some(BedrockAuth::BearerToken(ref t)) if t == "env-bearer-token"
        ));
    }

    #[test]
    fn bearer_token_precedence() {
        let _env_lock = env_lock();
        let _bearer_guard = EnvGuard::set("BEDROCK_API_KEY", Some("bearer-key"));
        let _ak_guard = EnvGuard::set("AWS_ACCESS_KEY_ID", Some("AKIAEXAMPLE"));
        let _sk_guard = EnvGuard::set("AWS_SECRET_ACCESS_KEY", Some("secret"));

        let model_provider = BedrockModelProvider::builder("test").build();
        // Bearer token should take priority over SigV4 credentials.
        assert!(matches!(
            model_provider.auth,
            Some(BedrockAuth::BearerToken(ref t)) if t == "bearer-key"
        ));
    }

    // ── Endpoint URL tests ──────────────────────────────────────

    #[test]
    fn endpoint_url_formats_correctly() {
        let url = BedrockModelProvider::endpoint_url("us-east-1", "anthropic.claude-sonnet-4-6");
        assert_eq!(
            url,
            "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-sonnet-4-6/converse"
        );
    }

    #[test]
    fn endpoint_url_keeps_raw_colon() {
        // Endpoint URL uses raw colon so reqwest sends `:` on the wire.
        let url = BedrockModelProvider::endpoint_url(
            "us-west-2",
            "anthropic.claude-3-5-haiku-20241022-v1:0",
        );
        assert!(url.contains("/model/anthropic.claude-3-5-haiku-20241022-v1:0/converse"));
    }

    #[test]
    fn canonical_uri_encodes_colon() {
        // Canonical URI must encode `:` as `%3A` for SigV4 signing.
        let uri = BedrockModelProvider::canonical_uri("anthropic.claude-3-5-haiku-20241022-v1:0");
        assert_eq!(
            uri,
            "/model/anthropic.claude-3-5-haiku-20241022-v1%3A0/converse"
        );
    }

    #[test]
    fn canonical_uri_no_colon_unchanged() {
        let uri = BedrockModelProvider::canonical_uri("anthropic.claude-sonnet-4-6");
        assert_eq!(uri, "/model/anthropic.claude-sonnet-4-6/converse");
    }

    // ── Message conversion tests ────────────────────────────────

    #[test]
    fn convert_messages_system_extracted() {
        let messages = vec![
            ChatMessage::system("You are helpful"),
            ChatMessage::user("Hello"),
        ];
        let (system, msgs) = BedrockModelProvider::convert_messages(&messages);
        assert!(system.is_some());
        let system_blocks = system.unwrap();
        assert_eq!(system_blocks.len(), 1);
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0].role, "user");
    }

    #[test]
    fn convert_messages_user_and_assistant() {
        let messages = vec![
            ChatMessage::user("Hello"),
            ChatMessage::assistant("Hi there"),
        ];
        let (system, msgs) = BedrockModelProvider::convert_messages(&messages);
        assert!(system.is_none());
        assert_eq!(msgs.len(), 2);
        assert_eq!(msgs[0].role, "user");
        assert_eq!(msgs[1].role, "assistant");
    }

    #[test]
    fn convert_messages_tool_role_to_tool_result() {
        let tool_json = r#"{"tool_call_id": "call_123", "content": "Result data"}"#;
        let messages = vec![ChatMessage::tool(tool_json)];
        let (_, msgs) = BedrockModelProvider::convert_messages(&messages);
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0].role, "user");
        assert!(matches!(msgs[0].content[0], ContentBlock::ToolResult(_)));
    }

    #[test]
    fn convert_messages_assistant_tool_calls_parsed() {
        let tool_call_json = r#"{"content": "Let me check", "tool_calls": [{"id": "call_1", "name": "shell", "arguments": "{\"command\":\"ls\"}"}]}"#;
        let messages = vec![ChatMessage::assistant(tool_call_json)];
        let (_, msgs) = BedrockModelProvider::convert_messages(&messages);
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0].role, "assistant");
        assert_eq!(msgs[0].content.len(), 2);
        assert!(matches!(msgs[0].content[0], ContentBlock::Text(_)));
        assert!(matches!(msgs[0].content[1], ContentBlock::ToolUse(_)));
    }

    #[test]
    fn strip_orphaned_tool_uses_removes_unanswered_tool_use() {
        // Belt-and-suspenders: if an orphaned tool_use slips past the runtime
        // history pruner, the Bedrock converter must strip it so AWS doesn't
        // reject with "Expected toolResult blocks at messages.N.content".
        let tool_call_json = r#"{"content": "Let me check", "tool_calls": [{"id": "call_ORPHAN", "name": "shell", "arguments": "{\"command\":\"ls\"}"}]}"#;
        let messages = vec![ChatMessage::assistant(tool_call_json)];
        let (_, mut msgs) = BedrockModelProvider::convert_messages(&messages);
        // Pre-condition: the converter produced an (orphaned) ToolUse block.
        assert!(
            msgs[0]
                .content
                .iter()
                .any(|b| matches!(b, ContentBlock::ToolUse(_)))
        );

        BedrockModelProvider::strip_orphaned_tool_uses(&mut msgs);

        assert_eq!(msgs[0].role, "assistant");
        assert!(
            !msgs[0]
                .content
                .iter()
                .any(|b| matches!(b, ContentBlock::ToolUse(_))),
            "orphaned ToolUse must be stripped"
        );
        // Content must not be empty (Bedrock rejects blank content).
        assert!(!msgs[0].content.is_empty());
    }

    #[test]
    fn strip_orphaned_tool_uses_retains_answered_tool_use() {
        let tool_call_json = r#"{"content": "Let me check", "tool_calls": [{"id": "call_OK", "name": "shell", "arguments": "{\"command\":\"ls\"}"}]}"#;
        let tool_result_json = r#"{"content":"ls output","tool_call_id":"call_OK"}"#;
        let messages = vec![
            ChatMessage::assistant(tool_call_json),
            ChatMessage::tool(tool_result_json),
        ];
        let (_, mut msgs) = BedrockModelProvider::convert_messages(&messages);
        BedrockModelProvider::strip_orphaned_tool_uses(&mut msgs);
        assert!(
            msgs[0]
                .content
                .iter()
                .any(|b| matches!(b, ContentBlock::ToolUse(_))),
            "a tool_use with a matching tool_result must be retained"
        );
    }

    #[test]
    fn convert_messages_plain_assistant_text() {
        let messages = vec![ChatMessage::assistant("Just text")];
        let (_, msgs) = BedrockModelProvider::convert_messages(&messages);
        assert_eq!(msgs.len(), 1);
        assert!(matches!(msgs[0].content[0], ContentBlock::Text(_)));
    }

    // ── Cache tests ─────────────────────────────────────────────

    #[test]
    fn should_cache_system_small_prompt() {
        assert!(!BedrockModelProvider::should_cache_system("Short prompt"));
    }

    #[test]
    fn should_cache_system_large_prompt() {
        let large = "a".repeat(3073);
        assert!(BedrockModelProvider::should_cache_system(&large));
    }

    #[test]
    fn should_cache_system_boundary() {
        assert!(!BedrockModelProvider::should_cache_system(
            &"a".repeat(3072)
        ));
        assert!(BedrockModelProvider::should_cache_system(&"a".repeat(3073)));
    }

    #[test]
    fn should_cache_conversation_short() {
        let messages = vec![
            ChatMessage::system("System"),
            ChatMessage::user("Hello"),
            ChatMessage::assistant("Hi"),
        ];
        assert!(!BedrockModelProvider::should_cache_conversation(&messages));
    }

    #[test]
    fn should_cache_conversation_long() {
        let mut messages = vec![ChatMessage::system("System")];
        for i in 0..5 {
            messages.push(ChatMessage {
                role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
                content: format!("Message {i}"),
            });
        }
        assert!(BedrockModelProvider::should_cache_conversation(&messages));
    }

    // ── Tool conversion tests ───────────────────────────────────

    #[test]
    fn convert_tools_to_converse_formats_correctly() {
        let tools = vec![ToolSpec::new(
            "shell",
            "Run commands",
            serde_json::json!({"type": "object", "properties": {"command": {"type": "string"}}}),
        )];
        let config = BedrockModelProvider::convert_tools_to_converse(Some(&tools));
        assert!(config.is_some());
        let config = config.unwrap();
        assert_eq!(config.tools.len(), 1);
        assert_eq!(config.tools[0].tool_spec.name, "shell");
    }

    #[test]
    fn convert_tools_to_converse_empty_returns_none() {
        assert!(BedrockModelProvider::convert_tools_to_converse(Some(&[])).is_none());
        assert!(BedrockModelProvider::convert_tools_to_converse(None).is_none());
    }

    // ── Serde tests ─────────────────────────────────────────────

    #[test]
    fn converse_request_serializes_without_system() {
        let req = ConverseRequest {
            system: None,
            messages: vec![ConverseMessage {
                role: "user".to_string(),
                content: vec![ContentBlock::Text(TextBlock {
                    text: "Hello".to_string(),
                })],
            }],
            inference_config: Some(InferenceConfig {
                max_tokens: 4096,
                temperature: Some(0.7),
            }),
            tool_config: None,
            additional_model_request_fields: None,
        };
        let json = serde_json::to_string(&req).unwrap();
        assert!(!json.contains("system"));
        assert!(json.contains("Hello"));
        assert!(json.contains("maxTokens"));
    }

    #[test]
    fn bedrock_model_supports_native_thinking_excludes_opus_4_7() {
        // Per AWS Bedrock model card, Opus 4.7 only supports adaptive thinking;
        // fixed-budget native thinking returns a 400.
        assert!(!bedrock_model_supports_native_thinking(
            "us.anthropic.claude-opus-4-7"
        ));
        assert!(!bedrock_model_supports_native_thinking(
            "anthropic.claude-opus-4-7-v1:0"
        ));
    }

    #[test]
    fn bedrock_model_supports_native_thinking_allows_other_models() {
        assert!(bedrock_model_supports_native_thinking(
            "us.anthropic.claude-opus-4-6-v1"
        ));
        assert!(bedrock_model_supports_native_thinking(
            "us.anthropic.claude-sonnet-4-6-v1"
        ));
        assert!(bedrock_model_supports_native_thinking(
            "us.anthropic.claude-haiku-4-5-v1"
        ));
    }

    #[test]
    fn prompt_caching_supported_for_claude_and_nova() {
        for model in [
            "anthropic.claude-3-5-sonnet-20241022-v2:0",
            "us.anthropic.claude-sonnet-4-6-v1",
            "anthropic.claude-3-7-sonnet-20250219-v1:0",
            "amazon.nova-pro-v1:0",
            "us.amazon.nova-lite-v1:0",
        ] {
            assert!(
                bedrock_model_supports_prompt_caching(model),
                "expected prompt caching support for {model}"
            );
        }
    }

    #[test]
    fn prompt_caching_unsupported_for_other_families() {
        // Qwen (and other non-Claude/Nova families) reject
        // cachePoint blocks, so caching must be disabled for them.
        for model in [
            "qwen.qwen3-coder-next",
            "meta.llama3-1-70b-instruct-v1:0",
            "mistral.mistral-large-2407-v1:0",
            "deepseek.r1-v1:0",
        ] {
            assert!(
                !bedrock_model_supports_prompt_caching(model),
                "expected NO prompt caching support for {model}"
            );
        }
    }

    #[test]
    fn prompt_caching_match_is_case_insensitive() {
        assert!(bedrock_model_supports_prompt_caching("ANTHROPIC.CLAUDE-X"));
        assert!(bedrock_model_supports_prompt_caching("Amazon.Nova-Pro"));
        assert!(!bedrock_model_supports_prompt_caching("QWEN.qwen3"));
    }

    #[test]
    fn inference_config_serializes_without_temperature_when_none() {
        let cfg = InferenceConfig {
            max_tokens: 4096,
            temperature: None,
        };
        let json = serde_json::to_string(&cfg).unwrap();
        assert!(json.contains("maxTokens"));
        assert!(
            !json.contains("temperature"),
            "expected temperature to be omitted, got: {json}"
        );
    }

    #[test]
    fn inference_config_serializes_with_temperature_when_some() {
        let cfg = InferenceConfig {
            max_tokens: 4096,
            temperature: Some(0.7),
        };
        let json = serde_json::to_string(&cfg).unwrap();
        assert!(json.contains("maxTokens"));
        assert!(
            json.contains("temperature"),
            "expected temperature to be present, got: {json}"
        );
    }

    #[test]
    fn converse_response_deserializes_text() {
        let json = r#"{
            "output": {
                "message": {
                    "role": "assistant",
                    "content": [{"text": "Hello from Bedrock"}]
                }
            },
            "stopReason": "end_turn"
        }"#;
        let resp: ConverseResponse = serde_json::from_str(json).unwrap();
        let parsed = BedrockModelProvider::parse_converse_response(resp);
        assert_eq!(parsed.text.as_deref(), Some("Hello from Bedrock"));
        assert!(parsed.tool_calls.is_empty());
    }

    #[test]
    fn converse_response_deserializes_tool_use() {
        let json = r#"{
            "output": {
                "message": {
                    "role": "assistant",
                    "content": [
                        {"toolUse": {"toolUseId": "call_1", "name": "shell", "input": {"command": "ls"}}}
                    ]
                }
            },
            "stopReason": "tool_use"
        }"#;
        let resp: ConverseResponse = serde_json::from_str(json).unwrap();
        let parsed = BedrockModelProvider::parse_converse_response(resp);
        assert!(parsed.text.is_none());
        assert_eq!(parsed.tool_calls.len(), 1);
        assert_eq!(parsed.tool_calls[0].name, "shell");
        assert_eq!(parsed.tool_calls[0].id, "call_1");
    }

    #[test]
    fn converse_response_empty_output() {
        let json = r#"{"output": null, "stopReason": null}"#;
        let resp: ConverseResponse = serde_json::from_str(json).unwrap();
        let parsed = BedrockModelProvider::parse_converse_response(resp);
        assert!(parsed.text.is_none());
        assert!(parsed.tool_calls.is_empty());
    }

    #[test]
    fn content_block_text_serializes_as_flat_string() {
        let block = ContentBlock::Text(TextBlock {
            text: "Hello".to_string(),
        });
        let json = serde_json::to_string(&block).unwrap();
        // Must be {"text":"Hello"}, NOT {"text":{"text":"Hello"}}
        assert_eq!(json, r#"{"text":"Hello"}"#);
    }

    #[test]
    fn content_block_tool_use_serializes_with_nested_object() {
        let block = ContentBlock::ToolUse(ToolUseWrapper {
            tool_use: ToolUseBlock {
                tool_use_id: "call_1".to_string(),
                name: "shell".to_string(),
                input: serde_json::json!({"command": "ls"}),
            },
        });
        let json = serde_json::to_string(&block).unwrap();
        assert!(json.contains(r#""toolUse""#));
        assert!(json.contains(r#""toolUseId":"call_1""#));
    }

    #[test]
    fn content_block_cache_point_serializes() {
        let block = ContentBlock::CachePointBlock(CachePointWrapper {
            cache_point: CachePoint::default_cache(),
        });
        let json = serde_json::to_string(&block).unwrap();
        assert_eq!(json, r#"{"cachePoint":{"type":"default"}}"#);
    }

    #[test]
    fn content_block_text_round_trips() {
        let original = ContentBlock::Text(TextBlock {
            text: "Hello".to_string(),
        });
        let json = serde_json::to_string(&original).unwrap();
        let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
        assert!(matches!(deserialized, ContentBlock::Text(tb) if tb.text == "Hello"));
    }

    #[test]
    fn cache_point_serializes() {
        let cp = CachePoint::default_cache();
        let json = serde_json::to_string(&cp).unwrap();
        assert_eq!(json, r#"{"type":"default"}"#);
    }

    #[tokio::test]
    async fn warmup_without_credentials_is_noop() {
        let model_provider = BedrockModelProvider {
            alias: "test".to_string(),
            auth: None,
            max_tokens: zeroclaw_api::model_provider::BASELINE_MAX_TOKENS,
            cred_cache: Mutex::new(None),
        };
        let result = model_provider.warmup().await;
        assert!(result.is_ok());
    }

    #[test]
    fn capabilities_reports_native_tool_calling() {
        let model_provider = BedrockModelProvider {
            alias: "test".to_string(),
            auth: None,
            max_tokens: zeroclaw_api::model_provider::BASELINE_MAX_TOKENS,
            cred_cache: Mutex::new(None),
        };
        let caps = model_provider.capabilities();
        assert!(caps.native_tool_calling);
    }

    #[test]
    fn converse_response_parses_usage() {
        let json = r#"{
            "output": {"message": {"role": "assistant", "content": [{"text": {"text": "Hello"}}]}},
            "usage": {"inputTokens": 500, "outputTokens": 100}
        }"#;
        let resp: ConverseResponse = serde_json::from_str(json).unwrap();
        let usage = resp.usage.unwrap();
        assert_eq!(usage.input_tokens, Some(500));
        assert_eq!(usage.output_tokens, Some(100));
    }

    #[test]
    fn converse_response_parses_without_usage() {
        let json = r#"{"output": {"message": {"role": "assistant", "content": []}}}"#;
        let resp: ConverseResponse = serde_json::from_str(json).unwrap();
        assert!(resp.usage.is_none());
    }

    // ── Tool result fallback & merge tests ───────────────────────

    #[test]
    fn fallback_tool_result_emits_tool_result_block_not_text() {
        // When tool message content is not valid JSON, we should still get
        // a toolResult block (not a plain text user message).
        let messages = vec![
            ChatMessage::user("do something"),
            ChatMessage::assistant(
                r#"{"content":"","tool_calls":[{"id":"tool_1","name":"shell","arguments":"{}"}]}"#,
            ),
            ChatMessage {
                role: "tool".to_string(),
                content: "not valid json".to_string(),
            },
        ];
        let (_, msgs) = BedrockModelProvider::convert_messages(&messages);
        let tool_msg = &msgs[2];
        assert_eq!(tool_msg.role, "user");
        assert!(
            matches!(&tool_msg.content[0], ContentBlock::ToolResult(_)),
            "Expected ToolResult block, got {:?}",
            tool_msg.content[0]
        );
    }

    #[test]
    fn fallback_recovers_tool_use_id_from_assistant() {
        let messages = vec![
            ChatMessage::user("run it"),
            ChatMessage::assistant(
                r#"{"content":"","tool_calls":[{"id":"tool_abc","name":"shell","arguments":"{}"}]}"#,
            ),
            ChatMessage {
                role: "tool".to_string(),
                content: "raw output with no json".to_string(),
            },
        ];
        let (_, msgs) = BedrockModelProvider::convert_messages(&messages);
        if let ContentBlock::ToolResult(ref wrapper) = msgs[2].content[0] {
            assert_eq!(wrapper.tool_result.tool_use_id, "tool_abc");
            assert_eq!(wrapper.tool_result.status, "error");
        } else {
            panic!("Expected ToolResult block");
        }
    }

    #[test]
    fn consecutive_tool_results_merged_into_single_message() {
        let messages = vec![
            ChatMessage::user("do two things"),
            ChatMessage::assistant(
                r#"{"content":"","tool_calls":[{"id":"t1","name":"a","arguments":"{}"},{"id":"t2","name":"b","arguments":"{}"}]}"#,
            ),
            ChatMessage::tool(r#"{"tool_call_id":"t1","content":"result 1"}"#),
            ChatMessage::tool(r#"{"tool_call_id":"t2","content":"result 2"}"#),
        ];
        let (_, msgs) = BedrockModelProvider::convert_messages(&messages);
        // Should be: user, assistant, user (merged tool results)
        assert_eq!(msgs.len(), 3, "Expected 3 messages, got {}", msgs.len());
        assert_eq!(msgs[2].role, "user");
        assert_eq!(
            msgs[2].content.len(),
            2,
            "Expected 2 tool results in one message"
        );
        assert!(matches!(&msgs[2].content[0], ContentBlock::ToolResult(_)));
        assert!(matches!(&msgs[2].content[1], ContentBlock::ToolResult(_)));
    }

    #[test]
    fn extract_tool_call_id_tries_multiple_field_names() {
        assert_eq!(
            BedrockModelProvider::extract_tool_call_id(r#"{"tool_call_id":"a"}"#),
            Some("a".to_string())
        );
        assert_eq!(
            BedrockModelProvider::extract_tool_call_id(r#"{"tool_use_id":"b"}"#),
            Some("b".to_string())
        );
        assert_eq!(
            BedrockModelProvider::extract_tool_call_id(r#"{"toolUseId":"c"}"#),
            Some("c".to_string())
        );
        assert_eq!(
            BedrockModelProvider::extract_tool_call_id("not json at all"),
            None
        );
    }

    #[test]
    fn parse_tool_result_accepts_alternate_id_fields() {
        let msg = BedrockModelProvider::parse_tool_result_message(
            r#"{"tool_use_id":"x","content":"ok"}"#,
        );
        assert!(msg.is_some());
        if let ContentBlock::ToolResult(ref wrapper) = msg.unwrap().content[0] {
            assert_eq!(wrapper.tool_result.tool_use_id, "x");
            assert_eq!(wrapper.tool_result.content[0].text, "ok");
        } else {
            panic!("Expected ToolResult");
        }
    }

    #[test]
    fn parse_tool_result_preserves_structured_content_as_json_text() {
        let object_msg = BedrockModelProvider::parse_tool_result_message(
            r#"{"tool_call_id":"obj","content":{"ok":true,"count":2}}"#,
        )
        .expect("object content should parse");
        if let ContentBlock::ToolResult(ref wrapper) = object_msg.content[0] {
            assert_eq!(wrapper.tool_result.tool_use_id, "obj");
            assert_eq!(
                wrapper.tool_result.content[0].text,
                r#"{"count":2,"ok":true}"#
            );
        } else {
            panic!("Expected ToolResult");
        }

        let array_msg = BedrockModelProvider::parse_tool_result_message(
            r#"{"tool_call_id":"arr","content":["alpha",{"beta":1}]}"#,
        )
        .expect("array content should parse");
        if let ContentBlock::ToolResult(ref wrapper) = array_msg.content[0] {
            assert_eq!(wrapper.tool_result.tool_use_id, "arr");
            assert_eq!(
                wrapper.tool_result.content[0].text,
                r#"["alpha",{"beta":1}]"#
            );
        } else {
            panic!("Expected ToolResult");
        }
    }

    #[test]
    fn sanitize_removes_empty_text_blocks() {
        let mut messages = vec![ConverseMessage {
            role: "assistant".to_string(),
            content: vec![ContentBlock::Text(TextBlock {
                text: String::new(),
            })],
        }];
        BedrockModelProvider::sanitize_empty_content_blocks(&mut messages);
        assert_eq!(messages.len(), 1);
        if let ContentBlock::Text(ref tb) = messages[0].content[0] {
            assert_eq!(tb.text, "(empty)");
        } else {
            panic!("Expected Text block with placeholder");
        }
    }

    #[test]
    fn sanitize_preserves_non_empty_text_blocks() {
        let mut messages = vec![ConverseMessage {
            role: "user".to_string(),
            content: vec![ContentBlock::Text(TextBlock {
                text: "Hello".to_string(),
            })],
        }];
        BedrockModelProvider::sanitize_empty_content_blocks(&mut messages);
        if let ContentBlock::Text(ref tb) = messages[0].content[0] {
            assert_eq!(tb.text, "Hello");
        } else {
            panic!("Expected preserved Text block");
        }
    }

    #[test]
    fn convert_messages_empty_assistant_gets_placeholder() {
        let messages = vec![
            ChatMessage::user("Hello"),
            ChatMessage {
                role: "assistant".to_string(),
                content: String::new(),
            },
            ChatMessage::user("Continue"),
        ];
        let (_, converse) = BedrockModelProvider::convert_messages(&messages);
        let assistant_msg = &converse[1];
        assert_eq!(assistant_msg.role, "assistant");
        if let ContentBlock::Text(ref tb) = assistant_msg.content[0] {
            assert!(!tb.text.is_empty(), "Assistant text should not be empty");
        } else {
            panic!("Expected Text block for assistant message");
        }
    }

    // ── credential_process tests ────────────────────────────────

    #[test]
    fn parse_aws_config_default_profile() {
        let config = "\
[default]
region=us-west-2
credential_process=ada credentials print --account=123 --provider=conduit --role=MyRole
";
        let result = AwsCredentials::parse_aws_config(config, "default");
        assert!(result.is_some());
        let (cmd, region) = result.unwrap();
        assert_eq!(
            cmd,
            "ada credentials print --account=123 --provider=conduit --role=MyRole"
        );
        assert_eq!(region.as_deref(), Some("us-west-2"));
    }

    #[test]
    fn parse_aws_config_named_profile() {
        let config = "\
[default]
region=us-east-1

[profile myprofile]
region=eu-west-1
credential_process=aws sso get-role-credentials --profile myprofile
";
        let result = AwsCredentials::parse_aws_config(config, "myprofile");
        assert!(result.is_some());
        let (cmd, region) = result.unwrap();
        assert!(cmd.contains("myprofile"));
        assert_eq!(region.as_deref(), Some("eu-west-1"));
    }

    #[test]
    fn parse_aws_config_missing_credential_process() {
        let config = "\
[default]
region=us-west-2
";
        let result = AwsCredentials::parse_aws_config(config, "default");
        assert!(result.is_none());
    }

    #[test]
    fn parse_aws_config_ignores_comments() {
        let config = "\
[default]
# credential_process=should-be-ignored
; credential_process=also-ignored
credential_process=real-command
";
        let result = AwsCredentials::parse_aws_config(config, "default");
        assert!(result.is_some());
        assert_eq!(result.unwrap().0, "real-command");
    }

    #[test]
    fn parse_aws_config_nonexistent_profile() {
        let config = "\
[default]
credential_process=some-command
";
        let result = AwsCredentials::parse_aws_config(config, "nonexistent");
        assert!(result.is_none());
    }

    #[test]
    fn from_credential_process_parses_json_output() {
        let credential_json =
            r#"{"Version":1,"AccessKeyId":"AKIA","SecretAccessKey":"secret","SessionToken":"tok"}"#;
        #[cfg(windows)]
        let credential_command = format!("echo {credential_json}");
        #[cfg(not(windows))]
        let credential_command = format!("printf '%s\\n' '{credential_json}'");
        let config =
            format!("[default]\ncredential_process={credential_command}\nregion=ap-southeast-1\n");

        let (cmd, region) = AwsCredentials::parse_aws_config(&config, "default").unwrap();
        assert_eq!(cmd, credential_command);
        assert_eq!(region.as_deref(), Some("ap-southeast-1"));

        let output = run_credential_process_command(&cmd).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
        assert_eq!(json["AccessKeyId"].as_str(), Some("AKIA"));
        assert_eq!(json["SecretAccessKey"].as_str(), Some("secret"));
        assert_eq!(json["SessionToken"].as_str(), Some("tok"));
    }

    #[test]
    fn env_vars_take_precedence_over_credential_process() {
        let _env_lock = env_lock();
        let _ak = EnvGuard::set("AWS_ACCESS_KEY_ID", Some("FROM_ENV"));
        let _sk = EnvGuard::set("AWS_SECRET_ACCESS_KEY", Some("secret_from_env"));

        let creds = AwsCredentials::from_env();
        assert!(creds.is_ok());
        assert_eq!(creds.unwrap().access_key_id, "FROM_ENV");
    }

    // ── credential cache tests ──────────────────────────────────

    fn make_creds(expires_at: Option<chrono::DateTime<chrono::Utc>>) -> AwsCredentials {
        AwsCredentials {
            access_key_id: "AKIA".to_string(),
            secret_access_key: "secret".to_string(),
            session_token: Some("tok".to_string()),
            region: "us-west-2".to_string(),
            expires_at,
        }
    }

    #[test]
    fn is_expired_returns_false_when_no_expiry() {
        let creds = make_creds(None);
        assert!(!creds.is_expired());
    }

    #[test]
    fn is_expired_returns_false_when_future() {
        let future = chrono::Utc::now() + chrono::Duration::hours(1);
        let creds = make_creds(Some(future));
        assert!(!creds.is_expired());
    }

    #[test]
    fn is_expired_returns_true_when_past() {
        let past = chrono::Utc::now() - chrono::Duration::hours(1);
        let creds = make_creds(Some(past));
        assert!(creds.is_expired());
    }

    #[test]
    fn is_expired_returns_true_within_skew_window() {
        // 30 seconds from now is within the 60s skew — should be treated as expired.
        let soon = chrono::Utc::now() + chrono::Duration::seconds(30);
        let creds = make_creds(Some(soon));
        assert!(creds.is_expired());
    }

    #[test]
    fn cached_credentials_returns_none_when_empty() {
        let model_provider = BedrockModelProvider {
            alias: "test".to_string(),
            auth: None,
            max_tokens: zeroclaw_api::model_provider::BASELINE_MAX_TOKENS,
            cred_cache: Mutex::new(None),
        };
        assert!(model_provider.cached_credentials().is_none());
    }

    #[test]
    fn cached_credentials_returns_some_when_valid() {
        let future = chrono::Utc::now() + chrono::Duration::hours(1);
        let model_provider = BedrockModelProvider {
            alias: "test".to_string(),
            auth: None,
            max_tokens: zeroclaw_api::model_provider::BASELINE_MAX_TOKENS,
            cred_cache: Mutex::new(Some(make_creds(Some(future)))),
        };
        let cached = model_provider.cached_credentials();
        assert!(cached.is_some());
        assert_eq!(cached.unwrap().access_key_id, "AKIA");
    }

    #[test]
    fn cached_credentials_returns_none_when_expired() {
        let past = chrono::Utc::now() - chrono::Duration::hours(1);
        let model_provider = BedrockModelProvider {
            alias: "test".to_string(),
            auth: None,
            max_tokens: zeroclaw_api::model_provider::BASELINE_MAX_TOKENS,
            cred_cache: Mutex::new(Some(make_creds(Some(past)))),
        };
        assert!(model_provider.cached_credentials().is_none());
    }

    #[test]
    fn cache_credentials_stores_and_retrieves() {
        let future = chrono::Utc::now() + chrono::Duration::hours(1);
        let model_provider = BedrockModelProvider {
            alias: "test".to_string(),
            auth: None,
            max_tokens: zeroclaw_api::model_provider::BASELINE_MAX_TOKENS,
            cred_cache: Mutex::new(None),
        };
        assert!(model_provider.cached_credentials().is_none());
        model_provider.cache_credentials(&make_creds(Some(future)));
        assert!(model_provider.cached_credentials().is_some());
    }
}
