#![allow(dead_code)] // WIP scaffolding not yet wired
//! Claude Code subscription inference provider.
//!
//! Speaks the Claude Code (claude.ai OAuth) wire protocol directly against
//! `api.anthropic.com`, using the OAuth credentials Claude Code already stored
//! at `~/.claude/.credentials.json`. Implements [`LlmProvider`] so it drops
//! into the existing provider registry alongside Bifrost and OpenAI OAuth.
//!
//! Internally it translates OpenAI chat-completion requests (Souveraine's
//! internal shape) to Anthropic `/v1/messages`, applies the subscription wire
//! shaping (attribution block + fingerprint, `metadata.user_id`, betas, identity
//! headers), refreshes the OAuth token under a mutex (writing it back so
//! `claude` stays in sync), and translates the Anthropic response back to
//! Souveraine's OpenAI-shaped `CompletionResult`.
//!
//! Wire constants and rules trace to the Claude Code subscription wire protocol.
//! On the wire the client presents as Claude Code because Anthropic's backend
//! validates the client fingerprint/salt against the first-party client to gate
//! subscription access. It uses YOUR token, from YOUR login, on YOUR machine.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use async_trait::async_trait;
use rand::RngCore;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tracing::{info, warn};

use super::bifrost::{
    ChatCompletionRequest, CompletionResult, InferenceStrain, Message, ParsedToolCall,
    ToolDefinition, Usage,
};
use super::provider::LlmProvider;

// ── Claude Code subscription wire constants ───────────────────────────────
const PROD_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
const ROLES_URL: &str = "https://api.anthropic.com/api/oauth/claude_cli/roles";
const OAUTH_BETA: &str = "oauth-2025-04-20";
const CC_PRODUCT_BETA: &str = "claude-code-20250219";
const SCOPES: &str =
    "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
const FINGERPRINT_SALT: &str = "59cf53e54c78";
const CC_ENTRYPOINT: &str = "cli";
const CC_PLATFORM: &str = "claude_code_cli";
const FALLBACK_CC_VERSION: &str = "2.1.196";
const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
const DEFAULT_MAX_TOKENS: u32 = 8192;

/// Direct Claude Code subscription inference provider.
pub struct ClaudeSubscriptionProvider {
    name: String,
    http: reqwest::Client,
    upstream_base: String,
    cc_version: String,
    user_agent: String,
    device_id: String,
    account_uuid: String,
    extra_metadata: Map<String, Value>,
    default_model: String,
    token: Arc<Mutex<TokenState>>,
}

#[derive(Clone)]
struct TokenState {
    access_token: Option<String>,
    refresh_token: Option<String>,
    expires_at_ms: Option<u64>,
    source_path: Option<PathBuf>,
}

impl ClaudeSubscriptionProvider {
    /// Build from a provider config entry. Reads `~/.claude/.credentials.json`
    /// (or the `credential_file` override), fetches `account_uuid` best-effort,
    /// and persists a device id.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        name: &str,
        base_url: &str,
        primary_model: &str,
        timeout_secs: u64,
        credential_file: Option<&str>,
        cc_version: Option<&str>,
        account_uuid: Option<&str>,
        device_id: Option<&str>,
        extra_metadata: Option<HashMap<String, Value>>,
    ) -> Result<Self> {
        let cc_version = cc_version
            .filter(|v| !v.is_empty())
            .map(str::to_string)
            .unwrap_or_else(resolve_cc_version);
        let user_agent = format!("claude-cli/{cc_version} (undefined, {CC_ENTRYPOINT})");

        let http = reqwest::Client::builder()
            .timeout(Duration::from_secs(timeout_secs))
            .user_agent(user_agent.clone())
            .build()
            .context("failed to build HTTP client")?;

        let cred_path = resolve_credential_path(credential_file).context(
            "no Claude Code credential file found (set credential_file or log in with `claude`)",
        )?;
        let token = load_credentials(&cred_path).with_context(|| {
            format!(
                "failed to read Claude Code credentials at {}",
                cred_path.display()
            )
        })?;
        if token.access_token.is_none() && token.refresh_token.is_none() {
            anyhow::bail!(
                "credential file has no access_token/refresh_token; log in with `claude` first"
            );
        }

        let device_id = match device_id.filter(|v| is_device_id(v)) {
            Some(id) => id.to_ascii_lowercase(),
            None => get_or_create_device_id()?,
        };

        let account_uuid = account_uuid
            .filter(|v| !v.is_empty())
            .unwrap_or_default()
            .to_string();

        let extra = extra_metadata
            .map(|m| m.into_iter().collect::<Map<String, Value>>())
            .unwrap_or_default();

        info!(
            "🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {}",
            primary_model,
            cc_version,
            cred_path.display()
        );

        Ok(Self {
            name: name.to_string(),
            http,
            upstream_base: base_url.trim_end_matches('/').to_string(),
            cc_version,
            user_agent,
            device_id,
            account_uuid,
            extra_metadata: extra,
            default_model: primary_model.to_string(),
            token: Arc::new(Mutex::new(token)),
        })
    }

    fn beta_header(&self) -> String {
        format!("{OAUTH_BETA},{CC_PRODUCT_BETA}")
    }

    /// Translate an OpenAI chat-completion request to a wire-shaped Anthropic
    /// `/v1/messages` body.
    fn shape_body(&self, model: &str, request: &ChatCompletionRequest) -> Result<Vec<u8>> {
        let (messages, system_text, first_user_text) = translate_messages(&request.messages);
        let mut body = json!({
            "model": model,
            "messages": messages,
            "max_tokens": request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
            "stream": false,
        });
        if let Some(t) = request.temperature {
            body["temperature"] = json!(t);
        }
        if let Some(system) = system_text {
            body["system"] = json!(system);
        }
        if let Some(tools) = &request.tools {
            if !tools.is_empty() {
                body["tools"] = json!(tools.iter().map(translate_tool).collect::<Vec<_>>());
            }
        }

        let obj = body.as_object_mut().unwrap();
        inject_attribution(obj, &self.cc_version, &first_user_text);
        inject_metadata(obj, self);

        Ok(serde_json::to_vec(&body)?)
    }

    /// Return a usable access token, refreshing under the mutex if it is
    /// missing or inside the proactive window.
    async fn current_access_token(&self) -> Result<String> {
        let mut token = self.token.lock().await;
        let access_empty = token.access_token.as_deref().is_none_or(str::is_empty);
        let expiring = token
            .expires_at_ms
            .is_none_or(|exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64);
        if access_empty || expiring {
            if let Err(e) = refresh_token(&self.http, &self.user_agent, &mut token).await {
                warn!("claude token refresh failed: {e}");
            }
        }
        token
            .access_token
            .clone()
            .filter(|t| !t.is_empty())
            .ok_or_else(|| {
                anyhow::anyhow!("no claude access_token available (refresh failed and none cached)")
            })
    }
}

#[async_trait]
impl LlmProvider for ClaudeSubscriptionProvider {
    fn id(&self) -> &str {
        &self.name
    }

    fn default_model(&self) -> &str {
        &self.default_model
    }

    async fn list_models(&self) -> Result<Vec<String>> {
        let access_token = self.current_access_token().await?;
        let resp = self
            .http
            .get(format!("{}/v1/models", self.upstream_base))
            .header("authorization", format!("Bearer {access_token}"))
            .header("anthropic-version", "2023-06-01")
            .header("anthropic-beta", self.beta_header())
            .header("user-agent", &self.user_agent)
            .header("x-app", CC_ENTRYPOINT)
            .header("anthropic-client-platform", CC_PLATFORM)
            .send()
            .await?
            .error_for_status()?
            .json::<Value>()
            .await?;
        let models = resp
            .get("data")
            .and_then(Value::as_array)
            .map(|arr| {
                arr.iter()
                    .filter_map(|m| m.get("id").and_then(Value::as_str).map(String::from))
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();
        Ok(models)
    }

    async fn chat_completion_with_strain(
        &self,
        request: ChatCompletionRequest,
    ) -> Result<(CompletionResult, Vec<InferenceStrain>)> {
        let model = if request.model.is_empty() {
            self.default_model.clone()
        } else {
            request.model.clone()
        };
        let mut strain = Vec::new();
        let max_attempts = 4u32;

        let body = self.shape_body(&model, &request)?;

        for attempt in 0..max_attempts {
            let access_token = self.current_access_token().await?;
            let resp = self
                .http
                .post(format!("{}/v1/messages", self.upstream_base))
                .header("authorization", format!("Bearer {access_token}"))
                .header("content-type", "application/json")
                .header("anthropic-version", "2023-06-01")
                .header("anthropic-beta", self.beta_header())
                .header("user-agent", &self.user_agent)
                .header("x-app", CC_ENTRYPOINT)
                .header("anthropic-client-platform", CC_PLATFORM)
                .body(body.clone())
                .send()
                .await;

            let resp = match resp {
                Ok(r) => r,
                Err(e) if e.is_timeout() || e.is_connect() => {
                    if attempt + 1 == max_attempts {
                        anyhow::bail!(
                            "claude subscription unreachable after {max_attempts} attempts: {e}"
                        );
                    }
                    let delay = backoff(attempt);
                    warn!(
                        "claude subscription connect failed (attempt {}), retry in {:?}: {e}",
                        attempt + 1,
                        delay
                    );
                    strain.push(InferenceStrain::Transient {
                        attempt,
                        status: 0,
                        model: model.clone(),
                        delay_ms: delay.as_millis() as u64,
                    });
                    tokio::time::sleep(delay).await;
                    continue;
                }
                Err(e) => return Err(e.into()),
            };

            let status = resp.status();
            if status.is_success() {
                let text = resp.text().await.context("reading claude response body")?;
                return parse_completion(&text).map(|r| (r, strain));
            }

            let body_text = resp.text().await.unwrap_or_default();
            match classify_status(status) {
                ErrorClass::Transient if attempt + 1 < max_attempts => {
                    let delay = backoff(attempt);
                    warn!(
                        "claude subscription {status} on {model} (attempt {}), retry in {:?}",
                        attempt + 1,
                        delay
                    );
                    strain.push(InferenceStrain::Transient {
                        attempt,
                        status: status.as_u16(),
                        model: model.clone(),
                        delay_ms: delay.as_millis() as u64,
                    });
                    tokio::time::sleep(delay).await;
                }
                _ => {
                    strain.push(InferenceStrain::Exhausted {
                        attempts: attempt + 1,
                        status: status.as_u16(),
                        model: model.clone(),
                        body: body_text.chars().take(300).collect(),
                    });
                    anyhow::bail!(
                        "claude subscription returned {status} after {} attempt(s) on {model}: {}",
                        attempt + 1,
                        &body_text[..body_text.len().min(500)]
                    );
                }
            }
        }
        unreachable!("retry loop returns or bails")
    }
}

// ── token refresh ==========================================================

async fn refresh_token(
    http: &reqwest::Client,
    user_agent: &str,
    token: &mut TokenState,
) -> Result<()> {
    let refresh = token
        .refresh_token
        .clone()
        .ok_or_else(|| anyhow::anyhow!("no refresh_token"))?;
    let resp = http
        .post(TOKEN_URL)
        .header("content-type", "application/json")
        .header("user-agent", user_agent)
        .json(&json!({
            "grant_type": "refresh_token",
            "refresh_token": refresh,
            "client_id": PROD_CLIENT_ID,
            "scope": SCOPES,
        }))
        .send()
        .await?;
    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        anyhow::bail!("token endpoint returned {status}: {body}");
    }
    let value: Value = resp.json().await?;
    let new_access = value
        .get("access_token")
        .and_then(Value::as_str)
        .ok_or_else(|| anyhow::anyhow!("refresh response missing access_token"))?
        .to_string();
    let new_refresh = value
        .get("refresh_token")
        .and_then(Value::as_str)
        .map(str::to_string);
    let expires_in = value
        .get("expires_in")
        .and_then(Value::as_u64)
        .unwrap_or(3600);
    let new_expires = now_ms() + expires_in * 1000;

    if let Some(path) = token.source_path.clone() {
        if let Err(e) =
            write_back_credentials(&path, &new_access, new_refresh.as_deref(), new_expires)
        {
            warn!("credential write-back failed (continuing in-memory): {e}");
        }
    }
    token.access_token = Some(new_access);
    token.refresh_token = Some(new_refresh.unwrap_or(refresh));
    token.expires_at_ms = Some(new_expires);
    Ok(())
}

/// Best-effort `account_uuid` lookup via the roles/profile endpoint.
#[allow(dead_code)]
async fn fetch_account_uuid(http: &reqwest::Client, access_token: &str, user_agent: &str) -> String {
    match http
        .get(ROLES_URL)
        .bearer_auth(access_token)
        .header("user-agent", user_agent)
        .send()
        .await
    {
        Ok(r) if r.status().is_success() => match r.json::<Value>().await {
            Ok(v) => v
                .get("account")
                .and_then(|a| a.get("uuid"))
                .and_then(Value::as_str)
                .map(str::to_string)
                .unwrap_or_default(),
            Err(_) => String::new(),
        },
        _ => String::new(),
    }
}

// ── credentials file =======================================================

fn resolve_credential_path(configured: Option<&str>) -> Option<PathBuf> {
    if let Some(path) = configured.filter(|p| !p.is_empty()) {
        let p = PathBuf::from(shellexpand::tilde(path).into_owned());
        return Some(p);
    }
    let home = std::env::var("HOME").ok()?;
    let path = PathBuf::from(home).join(".claude/.credentials.json");
    path.exists().then_some(path)
}

/// `{ claudeAiOauth: { accessToken, refreshToken, expiresAt(ms), ... }, ... }`
fn load_credentials(path: &Path) -> Result<TokenState> {
    let raw =
        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    let value: Value = serde_json::from_str(&raw).context("parsing credentials json")?;
    let oauth = value
        .get("claudeAiOauth")
        .ok_or_else(|| anyhow::anyhow!("credential file missing `claudeAiOauth` object"))?;
    Ok(TokenState {
        access_token: oauth
            .get("accessToken")
            .and_then(Value::as_str)
            .map(str::to_string),
        refresh_token: oauth
            .get("refreshToken")
            .and_then(Value::as_str)
            .map(str::to_string),
        expires_at_ms: oauth.get("expiresAt").and_then(Value::as_u64),
        source_path: Some(path.to_path_buf()),
    })
}

/// Atomically write refreshed tokens back, preserving all other fields.
fn write_back_credentials(
    path: &Path,
    access: &str,
    new_refresh: Option<&str>,
    expires_at_ms: u64,
) -> Result<()> {
    let raw = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
    let mut value: Value = serde_json::from_str(&raw).unwrap_or_else(|_| json!({}));
    let oauth = value
        .as_object_mut()
        .context("cred file is not an object")?
        .entry("claudeAiOauth")
        .or_insert_with(|| json!({}));
    let object = oauth
        .as_object_mut()
        .context("claudeAiOauth is not an object")?;
    object.insert("accessToken".to_string(), json!(access));
    if let Some(refresh) = new_refresh {
        object.insert("refreshToken".to_string(), json!(refresh));
    }
    object.insert("expiresAt".to_string(), json!(expires_at_ms));

    let bytes = serde_json::to_vec_pretty(&value)?;
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, bytes)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
    }
    std::fs::rename(&tmp, path)?;
    Ok(())
}

// ── wire shaping ===========================================================

/// Inject the attribution block into `system[0]`, preserving the rest.
fn inject_attribution(body: &mut Map<String, Value>, cc_version: &str, first_user_text: &str) {
    let fingerprint = compute_fingerprint(first_user_text, cc_version);
    let block = json!({
        "type": "text",
        "text": format!(
            "x-anthropic-billing-header: cc_version={cc_version}.{fingerprint}; cc_entrypoint={CC_ENTRYPOINT};"
        ),
    });
    let system = body.remove("system");
    let next = match system {
        Some(Value::String(text)) if !text.is_empty() => {
            Value::Array(vec![block, json!({ "type": "text", "text": text })])
        }
        Some(Value::Array(items)) => {
            let rest = if items.first().is_some_and(is_attribution_block) {
                items.into_iter().skip(1).collect::<Vec<_>>()
            } else {
                items
            };
            let mut out = vec![block];
            out.extend(rest);
            Value::Array(out)
        }
        Some(other) => Value::Array(vec![block, other]),
        None => Value::Array(vec![block]),
    };
    body.insert("system".to_string(), next);
}

fn is_attribution_block(value: &Value) -> bool {
    value
        .get("text")
        .and_then(Value::as_str)
        .is_some_and(|t| t.starts_with("x-anthropic-billing-header:"))
}

/// `metadata.user_id` = JSON string of `{device_id, account_uuid, session_id, …extra}`.
fn inject_metadata(body: &mut Map<String, Value>, client: &ClaudeSubscriptionProvider) {
    let mut metadata = body
        .remove("metadata")
        .and_then(|v| v.as_object().cloned())
        .unwrap_or_default();
    let mut user_id = client.extra_metadata.clone();
    user_id.insert(
        "device_id".to_string(),
        Value::String(client.device_id.clone()),
    );
    user_id.insert(
        "session_id".to_string(),
        Value::String(uuid::Uuid::new_v4().to_string()),
    );
    user_id.insert(
        "account_uuid".to_string(),
        Value::String(client.account_uuid.clone()),
    );
    metadata.insert(
        "user_id".to_string(),
        Value::String(Value::Object(user_id).to_string()),
    );
    body.insert("metadata".to_string(), Value::Object(metadata));
}

/// `fingerprint = sha256( SALT + chars[4,7,20] + VERSION )[:3]`, JS UTF-16 indexing.
fn compute_fingerprint(first_user_text: &str, version: &str) -> String {
    let units: Vec<u16> = first_user_text.encode_utf16().collect();
    let mut chars = String::new();
    for &index in &[4usize, 7, 20] {
        match units.get(index) {
            Some(unit) => chars.push(char::from_u32(*unit as u32).unwrap_or('0')),
            None => chars.push('0'),
        }
    }
    let mut hasher = Sha256::new();
    hasher.update(FINGERPRINT_SALT.as_bytes());
    hasher.update(chars.as_bytes());
    hasher.update(version.as_bytes());
    hex::encode(hasher.finalize())[..3].to_string()
}

// ── OpenAI ↔ Anthropic translation =========================================

/// Translate OpenAI messages into Anthropic messages + a joined system string.
fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String) {
    let mut system_parts: Vec<String> = Vec::new();
    let mut merged: Vec<(String, Vec<Value>)> = Vec::new();

    for msg in openai {
        match msg.role.as_str() {
            "system" => {
                if !msg.content.is_empty() {
                    system_parts.push(msg.content.as_text());
                }
            }
            "tool" => {
                let block = json!({
                    "type": "tool_result",
                    "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
                    "content": msg.content.as_text(),
                });
                push_merge(&mut merged, "user", block);
            }
            "assistant" => {
                if !msg.content.is_empty() {
                    push_merge(
                        &mut merged,
                        "assistant",
                        json!({ "type": "text", "text": msg.content.as_text() }),
                    );
                }
                if let Some(calls) = &msg.tool_calls {
                    for call in calls {
                        let input: Value = serde_json::from_str(&call.function.arguments)
                            .unwrap_or(json!({}));
                        push_merge(
                            &mut merged,
                            "assistant",
                            json!({
                                "type": "tool_use",
                                "id": call.id,
                                "name": call.function.name,
                                "input": input,
                            }),
                        );
                    }
                }
            }
            _ => {
                push_merge(
                    &mut merged,
                    "user",
                    json!({ "type": "text", "text": msg.content.as_text() }),
                );
            }
        }
    }

    // Anthropic requires the first role to be "user".
    while merged.first().is_some_and(|(role, _)| role == "assistant") {
        merged.remove(0);
    }

    let messages: Vec<Value> = merged
        .into_iter()
        .map(|(role, blocks)| {
            let content = if blocks.len() == 1
                && blocks[0].get("type").and_then(Value::as_str) == Some("text")
            {
                Value::String(
                    blocks[0]
                        .get("text")
                        .and_then(Value::as_str)
                        .unwrap_or("")
                        .to_string(),
                )
            } else {
                Value::Array(blocks)
            };
            json!({ "role": role, "content": content })
        })
        .collect();

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

    let first_user_text = openai
        .iter()
        .find(|m| m.role == "user")
        .map(|m| m.content.as_text())
        .unwrap_or_default();

    (messages, system, first_user_text)
}

fn push_merge(merged: &mut Vec<(String, Vec<Value>)>, role: &str, block: Value) {
    if let Some(last) = merged.last_mut() {
        if last.0 == role {
            last.1.push(block);
            return;
        }
    }
    merged.push((role.to_string(), vec![block]));
}

fn translate_tool(tool: &ToolDefinition) -> Value {
    json!({
        "name": tool.function.name,
        "description": tool.function.description,
        "input_schema": tool.function.parameters,
    })
}

/// Anthropic non-stream response → Souveraine's OpenAI-shaped `CompletionResult`.
fn parse_completion(text: &str) -> Result<CompletionResult> {
    let value: Value =
        serde_json::from_str(text).context("parsing claude messages response")?;
    let content_parts: Vec<String> = value
        .get("content")
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
                .filter_map(|b| b.get("text").and_then(Value::as_str).map(str::to_string))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let content = content_parts.concat();

    let reasoning = value
        .get("content")
        .and_then(Value::as_array)
        .and_then(|arr| {
            let r: Vec<String> = arr
                .iter()
                .filter(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
                .filter_map(|b| {
                    b.get("thinking")
                        .and_then(Value::as_str)
                        .map(str::to_string)
                })
                .collect();
            if r.is_empty() {
                None
            } else {
                Some(r.concat())
            }
        });

    let tool_calls = value
        .get("content")
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
                .filter_map(|b| {
                    Some(ParsedToolCall {
                        id: b.get("id").and_then(Value::as_str)?.to_string(),
                        name: b.get("name").and_then(Value::as_str)?.to_string(),
                        arguments: b.get("input").cloned().unwrap_or(json!({})),
                    })
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    let finish_reason = value
        .get("stop_reason")
        .and_then(Value::as_str)
        .map(map_stop_reason)
        .or(Some("stop".to_string()));

    let usage = value.get("usage").map(|u| {
        let input = u.get("input_tokens").and_then(Value::as_u64).unwrap_or(0) as u32;
        let output = u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0) as u32;
        Usage {
            prompt_tokens: input,
            completion_tokens: output,
            total_tokens: input + output,
        }
    });

    Ok(CompletionResult {
        content,
        reasoning,
        tool_calls,
        finish_reason,
        usage,
    })
}

fn map_stop_reason(reason: &str) -> String {
    match reason {
        "end_turn" | "stop_sequence" => "stop".to_string(),
        "max_tokens" => "length".to_string(),
        "tool_use" => "tool_calls".to_string(),
        other => other.to_string(),
    }
}

// ── misc helpers ===========================================================

fn resolve_cc_version() -> String {
    if let Ok(output) = std::process::Command::new("claude")
        .arg("--version")
        .output()
    {
        if output.status.success() {
            let text = String::from_utf8_lossy(&output.stdout);
            if let Some(version) = parse_version(&text) {
                return version;
            }
        }
    }
    FALLBACK_CC_VERSION.to_string()
}

fn parse_version(text: &str) -> Option<String> {
    let first = text.split_whitespace().next()?;
    let core: String = first
        .chars()
        .take_while(|c| c.is_ascii_digit() || *c == '.')
        .collect();
    (!core.is_empty() && core.matches('.').count() >= 2).then_some(core)
}

fn get_or_create_device_id() -> Result<String> {
    let state_dir = match std::env::var("SOUVERAINE_STATE_DIR") {
        Ok(v) => PathBuf::from(v),
        Err(_) => dirs::state_dir()
            .or_else(dirs::home_dir)
            .unwrap_or_else(|| PathBuf::from("."))
            .join("souveraine"),
    };
    std::fs::create_dir_all(&state_dir)?;
    let path = state_dir.join("claude-subscription-device-id");
    if let Ok(existing) = std::fs::read_to_string(&path) {
        let existing = existing.trim();
        if is_device_id(existing) {
            return Ok(existing.to_ascii_lowercase());
        }
    }
    let mut bytes = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut bytes);
    let id = hex::encode(bytes);
    std::fs::write(&path, format!("{id}\n"))?;
    Ok(id)
}

fn is_device_id(value: &str) -> bool {
    value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
}

fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}

fn backoff(attempt: u32) -> Duration {
    let base = 400u64 * 2u64.pow(attempt);
    Duration::from_millis(base.min(8_000))
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum ErrorClass {
    Transient,
    Permanent,
}

fn classify_status(status: reqwest::StatusCode) -> ErrorClass {
    match status.as_u16() {
        429 | 500 | 502 | 503 | 504 | 408 => ErrorClass::Transient,
        _ => ErrorClass::Permanent,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bridge::bifrost::{
        ContentValue, MessageToolCall, MessageToolCallFunction, ToolFunction,
    };

    #[test]
    fn fingerprint_matches_spec_worked_example() {
        assert_eq!(compute_fingerprint("hello", "2.1.196"), "68b");
    }

    #[test]
    fn attribution_has_no_invented_fields() {
        let mut body = json!({ "messages": [{"role":"user","content":"hi"}] })
            .as_object_mut()
            .unwrap()
            .clone();
        inject_attribution(&mut body, "2.1.196", "hello");
        let text = body["system"].as_array().unwrap()[0]
            .get("text")
            .and_then(Value::as_str)
            .unwrap();
        assert!(text.starts_with("x-anthropic-billing-header:"));
        assert!(text.contains("cc_version=2.1.196."));
    }

    #[test]
    fn translates_user_assistant_tool_roundtrip() {
        let msgs = vec![
            Message::text("user", "please read /etc/hostname"),
            Message {
                role: "assistant".into(),
                content: ContentValue::Text(String::new()),
                tool_calls: Some(vec![MessageToolCall {
                    id: "call_1".into(),
                    tool_type: "function".into(),
                    function: MessageToolCallFunction {
                        name: "read".into(),
                        arguments: r#"{"path":"/etc/hostname"}"#.into(),
                    },
                }]),
                tool_call_id: None,
                name: None,
            },
            Message::tool_result("call_1", "read", "example-host"),
            Message::text("user", "thanks"),
        ];
        let (messages, system, first_user) = translate_messages(&msgs);
        assert!(system.is_none());
        let roles: Vec<&str> = messages.iter().map(|m| m["role"].as_str().unwrap()).collect();
        assert_eq!(roles[0], "user");
        assert!(messages.iter().any(|m| {
            m["role"] == "user"
                && m["content"].is_array()
                && m["content"]
                    .as_array()
                    .unwrap()
                    .iter()
                    .any(|b| b["type"] == "tool_result")
        }));
        assert_eq!(first_user, "please read /etc/hostname");
    }

    #[test]
    fn translates_system_message_into_system_field() {
        let msgs = vec![
            Message::text("system", "you are souvie"),
            Message::text("user", "hi"),
        ];
        let (_, system, _) = translate_messages(&msgs);
        assert_eq!(system.as_deref(), Some("you are souvie"));
    }

    #[test]
    fn parses_anthropic_text_response() {
        let raw = r#"{
            "id":"msg_1","type":"message","role":"assistant","stop_reason":"end_turn",
            "content":[{"type":"text","text":"pong"}],
            "usage":{"input_tokens":16,"output_tokens":4}
        }"#;
        let r = parse_completion(raw).unwrap();
        assert_eq!(r.content, "pong");
        assert_eq!(r.finish_reason.as_deref(), Some("stop"));
        assert_eq!(r.usage.unwrap().prompt_tokens, 16);
        assert!(r.tool_calls.is_empty());
    }

    #[test]
    fn parses_anthropic_tool_use_response() {
        let raw = r#"{
            "id":"msg_2","stop_reason":"tool_use",
            "content":[
                {"type":"text","text":"reading it"},
                {"type":"tool_use","id":"call_9","name":"read","input":{"path":"/etc/hostname"}}
            ],
            "usage":{"input_tokens":10,"output_tokens":8}
        }"#;
        let r = parse_completion(raw).unwrap();
        assert_eq!(r.content, "reading it");
        assert_eq!(r.finish_reason.as_deref(), Some("tool_calls"));
        assert_eq!(r.tool_calls.len(), 1);
        assert_eq!(r.tool_calls[0].name, "read");
        assert_eq!(r.tool_calls[0].arguments["path"], "/etc/hostname");
    }

    #[test]
    fn translates_openai_tool_definition_to_anthropic_schema() {
        let tool = ToolDefinition {
            tool_type: "function".to_string(),
            function: ToolFunction {
                name: "read".to_string(),
                description: "read a file".to_string(),
                parameters: json!({"type":"object","properties":{"path":{"type":"string"}}}),
            },
        };
        let v = translate_tool(&tool);
        assert_eq!(v["name"], "read");
        assert_eq!(v["input_schema"]["type"], "object");
    }
}
