//
//  AnthropicProvider.swift
//  Confab
//
//  Anthropic Messages API provider with SSE streaming and tool support
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.


import Foundation
import os

class AnthropicProvider: AIProvider {
    let name = "Anthropic"
    let capabilities = ProviderCapabilities(
        supportsStreaming: true,
        supportsVision: true,
        supportsTools: true,
        supportsOnlineSearch: false,
        maxContextLength: nil
    )

    enum AuthMode {
        case apiKey(String)
        case oauth
    }

    private let authMode: AuthMode
    private let baseURL = "https://api.anthropic.com/v1"
    private let apiVersion = "2023-06-01"
    private let session: URLSession

    /// Create with a standard API key
    init(apiKey: String) {
        self.authMode = .apiKey(apiKey)
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 180  // 3 minutes for initial response (tool use needs thinking time)
        config.timeoutIntervalForResource = 600  // 10 minutes total
        self.session = URLSession(configuration: config)
    }

    /// Create with OAuth (Pro/Max subscription)
    init(oauth: Bool) {
        self.authMode = .oauth
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 180  // 3 minutes for initial response (tool use needs thinking time)
        config.timeoutIntervalForResource = 600  // 10 minutes total
        self.session = URLSession(configuration: config)
    }

    /// Whether this provider is using OAuth authentication
    var isOAuth: Bool {
        if case .oauth = authMode { return true }
        return false
    }

    // MARK: - Models

    /// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
    /// Entries are matched by exact ID first; if no exact match is found, the enrichment step
    /// falls back to prefix matching so newly-released model variants (e.g. "claude-sonnet-4-6-20260301")
    /// still inherit the correct pricing tier.
    private static let knownModels: [ModelInfo] = [
        // Claude Fable 5
        ModelInfo(
            id: "claude-fable-5",
            name: "Claude Fable 5",
            description: "Anthropic's creative and storytelling model",
            contextLength: 200_000,
            pricing: .init(prompt: 10.0, completion: 50.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        // Claude 4.x series
        ModelInfo(
            id: "claude-opus-4-6",
            name: "Claude Opus 4.6",
            description: "Most capable and intelligent model",
            contextLength: 200_000,
            pricing: .init(prompt: 15.0, completion: 75.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-sonnet-4-6",
            name: "Claude Sonnet 4.6",
            description: "Best balance of speed and capability",
            contextLength: 200_000,
            pricing: .init(prompt: 3.0, completion: 15.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-haiku-4-6",
            name: "Claude Haiku 4.6",
            description: "Fastest and most affordable",
            contextLength: 200_000,
            pricing: .init(prompt: 0.80, completion: 4.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        // Claude 4.5 series
        ModelInfo(
            id: "claude-opus-4-5",
            name: "Claude Opus 4.5",
            description: "Previous generation Opus",
            contextLength: 200_000,
            pricing: .init(prompt: 15.0, completion: 75.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-opus-4-5-20251101",
            name: "Claude Opus 4.5",
            description: "Previous generation Opus",
            contextLength: 200_000,
            pricing: .init(prompt: 15.0, completion: 75.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-sonnet-4-5",
            name: "Claude Sonnet 4.5",
            description: "Best balance of speed and capability",
            contextLength: 200_000,
            pricing: .init(prompt: 3.0, completion: 15.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-sonnet-4-5-20250929",
            name: "Claude Sonnet 4.5",
            description: "Best balance of speed and capability",
            contextLength: 200_000,
            pricing: .init(prompt: 3.0, completion: 15.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-haiku-4-5",
            name: "Claude Haiku 4.5",
            description: "Fastest and most affordable",
            contextLength: 200_000,
            pricing: .init(prompt: 0.80, completion: 4.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-haiku-4-5-20251001",
            name: "Claude Haiku 4.5",
            description: "Fastest and most affordable",
            contextLength: 200_000,
            pricing: .init(prompt: 0.80, completion: 4.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        // Claude 3.x series
        ModelInfo(
            id: "claude-3-7-sonnet-20250219",
            name: "Claude 3.7 Sonnet",
            description: "Previous generation Sonnet",
            contextLength: 200_000,
            pricing: .init(prompt: 3.0, completion: 15.0),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
        ModelInfo(
            id: "claude-3-haiku-20240307",
            name: "Claude 3 Haiku",
            description: "Previous generation Haiku",
            contextLength: 200_000,
            pricing: .init(prompt: 0.25, completion: 1.25),
            capabilities: .init(vision: true, tools: true, online: true)
        ),
    ]

    /// Pricing tiers used for fuzzy fallback matching on unknown model IDs.
    /// Keyed by model name prefix (longest match wins).
    private static let pricingFallback: [(prefix: String, prompt: Double, completion: Double)] = [
        ("claude-fable",  10.0, 50.0),
        ("claude-opus",   15.0, 75.0),
        ("claude-sonnet",  3.0, 15.0),
        ("claude-haiku",   0.80, 4.0),
    ]

    /// Fuzzy pricing lookup for a model ID not found in `knownModels`: finds the
    /// longest matching prefix in `fallback` (longest match wins when several
    /// prefixes match, e.g. a future "claude-sonnet-mini" against both
    /// "claude-sonnet" and a shorter unrelated prefix). Unmatched IDs price at zero.
    static func resolveFallbackPricing(
        for modelId: String,
        fallback: [(prefix: String, prompt: Double, completion: Double)] = pricingFallback
    ) -> ModelInfo.Pricing {
        let match = fallback
            .filter { modelId.hasPrefix($0.prefix) }
            .max(by: { $0.prefix.count < $1.prefix.count })
        return match.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) }
            ?? ModelInfo.Pricing(prompt: 0, completion: 0)
    }

    /// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata.
    /// Falls back to knownModels if the request fails (no key, offline, etc.).
    func listModels() async throws -> [ModelInfo] {
        guard let url = URL(string: "\(baseURL)/models") else { return Self.knownModels }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        do {
            try await applyAuth(to: &request)
        } catch {
            Log.api.warning("Anthropic listModels: auth failed, using fallback — \(error.localizedDescription)")
            return Self.knownModels
        }

        do {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
                let code = (response as? HTTPURLResponse)?.statusCode ?? 0
                Log.api.warning("Anthropic listModels: HTTP \(code), using fallback")
                return Self.knownModels
            }

            guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                  let items = json["data"] as? [[String: Any]] else {
                Log.api.warning("Anthropic listModels: unexpected JSON shape, using fallback")
                return Self.knownModels
            }

            let enrichment = Dictionary(uniqueKeysWithValues: Self.knownModels.map { ($0.id, $0) })

            let models: [ModelInfo] = items.compactMap { item in
                guard let id = item["id"] as? String,
                      id.hasPrefix("claude-") else { return nil }
                let displayName = item["display_name"] as? String ?? id
                // Exact match first
                if let known = enrichment[id] { return known }
                // Fuzzy fallback: find the longest prefix that matches
                let pricing = Self.resolveFallbackPricing(for: id)
                return ModelInfo(
                    id: id,
                    name: displayName,
                    description: item["description"] as? String ?? "",
                    contextLength: 200_000,
                    pricing: pricing,
                    capabilities: .init(vision: true, tools: true, online: false)
                )
            }

            Log.api.info("Anthropic listModels: fetched \(models.count) model(s) from API")
            return models.isEmpty ? Self.knownModels : models

        } catch {
            Log.api.warning("Anthropic listModels: network error (\(error.localizedDescription)), using fallback")
            return Self.knownModels
        }
    }

    func getModel(_ id: String) async throws -> ModelInfo? {
        let models = try await listModels()
        return models.first { $0.id == id }
    }

    // MARK: - Chat Completion

    func chat(request: ChatRequest) async throws -> ChatResponse {
        Log.api.info("Anthropic chat request: model=\(request.model), messages=\(request.messages.count)")
        var (urlRequest, _) = try buildURLRequest(from: request, stream: false)
        try await applyAuth(to: &urlRequest)
        let (data, response) = try await session.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse else {
            Log.api.error("Anthropic chat: invalid response (not HTTP)")
            throw ProviderError.invalidResponse
        }
        guard httpResponse.statusCode == 200 else {
            if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let error = errorObj["error"] as? [String: Any],
               let message = error["message"] as? String {
                Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode): \(message)")
                throw ProviderError.unknown(message)
            }
            Log.api.error("Anthropic chat HTTP \(httpResponse.statusCode)")
            throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
        }

        return try parseResponse(data: data)
    }

    // MARK: - Chat with raw tool messages

    func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
        Log.api.info("Anthropic tool chat: model=\(model), messages=\(messages.count)")
        let url = messagesURL

        // Separate system message from conversation messages
        var systemText: String? = nil
        var conversationMessages: [[String: Any]] = []

        for msg in messages {
            let role = msg["role"] as? String ?? ""
            if role == "system" {
                systemText = msg["content"] as? String
            } else if role == "tool" {
                // Convert OpenAI tool result format to Anthropic tool_result format
                let toolCallId = msg["tool_call_id"] as? String ?? ""
                let content = msg["content"] as? String ?? ""
                conversationMessages.append([
                    "role": "user",
                    "content": [
                        ["type": "tool_result", "tool_use_id": toolCallId, "content": content]
                    ]
                ])
            } else if role == "assistant" {
                // Check for tool_calls — convert to Anthropic content blocks
                if let toolCalls = msg["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty {
                    var contentBlocks: [[String: Any]] = []
                    if let text = msg["content"] as? String, !text.isEmpty {
                        contentBlocks.append(["type": "text", "text": text])
                    }
                    for tc in toolCalls {
                        let fn = tc["function"] as? [String: Any] ?? [:]
                        let name = fn["name"] as? String ?? ""
                        let argsStr = fn["arguments"] as? String ?? "{}"
                        let argsObj = (try? JSONSerialization.jsonObject(with: Data(argsStr.utf8))) ?? [:]
                        contentBlocks.append([
                            "type": "tool_use",
                            "id": tc["id"] as? String ?? UUID().uuidString,
                            "name": name,
                            "input": argsObj
                        ])
                    }
                    conversationMessages.append(["role": "assistant", "content": contentBlocks])
                } else {
                    conversationMessages.append(["role": "assistant", "content": msg["content"] as? String ?? ""])
                }
            } else {
                // Content may be a String (plain text) or an Array (image/multipart blocks)
                if let contentArray = msg["content"] as? [[String: Any]] {
                    // Convert image_url blocks (OpenAI format) to Anthropic image blocks
                    let anthropicBlocks: [[String: Any]] = contentArray.compactMap { block in
                        let type = block["type"] as? String ?? ""
                        if type == "text" {
                            return block
                        } else if type == "image_url",
                                  let imageUrl = block["image_url"] as? [String: Any],
                                  let url = imageUrl["url"] as? String,
                                  url.hasPrefix("data:") {
                            // data:<mediaType>;base64,<data>
                            let withoutData = url.dropFirst(5) // strip "data:"
                            guard let semicolon = withoutData.firstIndex(of: ";"),
                                  let comma = withoutData.firstIndex(of: ",") else { return nil }
                            let mediaType = String(withoutData[withoutData.startIndex..<semicolon])
                            let base64Data = String(withoutData[withoutData.index(after: comma)...])
                            return [
                                "type": "image",
                                "source": [
                                    "type": "base64",
                                    "media_type": mediaType,
                                    "data": base64Data
                                ]
                            ]
                        }
                        return nil
                    }
                    if !anthropicBlocks.isEmpty {
                        conversationMessages.append(["role": role, "content": anthropicBlocks])
                    }
                } else {
                    let content = (msg["content"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
                    conversationMessages.append(["role": role, "content": content.isEmpty ? "[Image]" : content])
                }
            }
        }

        // Mark the last message with a cache breakpoint so the next loop
        // iteration (or next turn) can reuse everything up through this one.
        if var lastMessage = conversationMessages.popLast() {
            if let content = lastMessage["content"] as? String {
                lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
            } else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
                lastBlock["cache_control"] = ["type": "ephemeral"]
                blocks.append(lastBlock)
                lastMessage["content"] = blocks
            }
            conversationMessages.append(lastMessage)
        }

        var body: [String: Any] = [
            "model": model,
            "messages": conversationMessages,
            "max_tokens": maxTokens ?? 16000,
            "stream": false
        ]
        if let systemText = systemText {
            // Array form carries a cache breakpoint; also covers tools, which
            // render before system in Anthropic's prefix order.
            body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
        }
        if let temperature = temperature {
            body["temperature"] = temperature
        }
        if let tools = tools {
            body["tools"] = tools.map { tool -> [String: Any] in
                [
                    "name": tool.function.name,
                    "description": tool.function.description,
                    "input_schema": convertParametersToDict(tool.function.parameters)
                ]
            }
            body["tool_choice"] = ["type": "auto"]
        }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
        urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
        try await applyAuth(to: &urlRequest)

        let (data, response) = try await session.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse else {
            Log.api.error("Anthropic tool chat: invalid response (not HTTP)")
            throw ProviderError.invalidResponse
        }
        guard httpResponse.statusCode == 200 else {
            if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let error = errorObj["error"] as? [String: Any],
               let message = error["message"] as? String {
                Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode): \(message)")
                throw ProviderError.unknown(message)
            }
            Log.api.error("Anthropic tool chat HTTP \(httpResponse.statusCode)")
            throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
        }

        return try parseResponse(data: data)
    }

    // MARK: - Streaming Chat

    func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
        Log.api.info("Anthropic stream request: model=\(request.model), messages=\(request.messages.count)")
        return AsyncThrowingStream { continuation in
            Task {
                do {
                    var (urlRequest, _) = try buildURLRequest(from: request, stream: true)
                    try await self.applyAuth(to: &urlRequest)
                    let (bytes, response) = try await session.bytes(for: urlRequest)

                    guard let httpResponse = response as? HTTPURLResponse else {
                        Log.api.error("Anthropic stream: invalid response (not HTTP)")
                        continuation.finish(throwing: ProviderError.invalidResponse)
                        return
                    }
                    guard httpResponse.statusCode == 200 else {
                        Log.api.error("Anthropic stream HTTP \(httpResponse.statusCode)")
                        continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
                        return
                    }

                    var currentId = ""
                    var currentModel = request.model
                    var inputTokens = 0
                    var cacheCreationTokens: Int? = nil
                    var cacheReadTokens: Int? = nil

                    for try await line in bytes.lines {
                        // Anthropic SSE: "event: ..." and "data: {...}"
                        guard line.hasPrefix("data: ") else { continue }
                        let jsonString = String(line.dropFirst(6))

                        guard let jsonData = jsonString.data(using: .utf8),
                              let event = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
                              let eventType = event["type"] as? String else {
                            continue
                        }

                        switch eventType {
                        case "message_start":
                            if let message = event["message"] as? [String: Any] {
                                currentId = message["id"] as? String ?? ""
                                currentModel = message["model"] as? String ?? request.model
                                if let usageDict = message["usage"] as? [String: Any] {
                                    inputTokens = usageDict["input_tokens"] as? Int ?? 0
                                    cacheCreationTokens = usageDict["cache_creation_input_tokens"] as? Int
                                    cacheReadTokens = usageDict["cache_read_input_tokens"] as? Int
                                    if cacheCreationTokens != nil || cacheReadTokens != nil {
                                        Log.api.info("Anthropic stream cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
                                    }
                                }
                            }

                        case "content_block_delta":
                            if let delta = event["delta"] as? [String: Any],
                               let deltaType = delta["type"] as? String,
                               deltaType == "text_delta",
                               let text = delta["text"] as? String {
                                continuation.yield(StreamChunk(
                                    id: currentId,
                                    model: currentModel,
                                    delta: .init(content: text, role: nil, images: nil),
                                    finishReason: nil,
                                    usage: nil
                                ))
                            }

                        case "message_delta":
                            let delta = event["delta"] as? [String: Any]
                            let stopReason = delta?["stop_reason"] as? String
                            var usage: ChatResponse.Usage? = nil
                            if let usageDict = event["usage"] as? [String: Any] {
                                let outputTokens = usageDict["output_tokens"] as? Int ?? 0
                                usage = ChatResponse.Usage(
                                    promptTokens: inputTokens,
                                    completionTokens: outputTokens,
                                    totalTokens: inputTokens + outputTokens,
                                    cacheCreationInputTokens: cacheCreationTokens,
                                    cacheReadInputTokens: cacheReadTokens
                                )
                            }
                            continuation.yield(StreamChunk(
                                id: currentId,
                                model: currentModel,
                                delta: .init(content: nil, role: nil, images: nil),
                                finishReason: stopReason,
                                usage: usage
                            ))

                        case "message_stop":
                            continuation.finish()
                            return

                        default:
                            break
                        }
                    }

                    continuation.finish()
                } catch {
                    continuation.finish(throwing: error)
                }
            }
        }
    }

    // MARK: - Credits

    func getCredits() async throws -> Credits? {
        // Anthropic doesn't have a public credits API
        return nil
    }

    // MARK: - Auth Helpers

    /// Apply auth headers based on mode (API key or OAuth Bearer)
    private func applyAuth(to request: inout URLRequest) async throws {
        switch authMode {
        case .apiKey(let key):
            request.addValue(key, forHTTPHeaderField: "x-api-key")
            request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")

        case .oauth:
            let token = try await AnthropicOAuthService.shared.getValidAccessToken()
            request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
            request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
            request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta")
        }
    }

    /// Build the messages endpoint URL, appending ?beta=true for OAuth
    private var messagesURL: URL {
        switch authMode {
        case .apiKey:
            return URL(string: "\(baseURL)/messages")!
        case .oauth:
            return URL(string: "\(baseURL)/messages?beta=true")!
        }
    }

    // MARK: - Request Building

    func buildURLRequest(from request: ChatRequest, stream: Bool) throws -> (URLRequest, Data) {
        let url = messagesURL

        // Separate system message
        var systemText: String? = request.systemPrompt
        var apiMessages: [[String: Any]] = []

        for msg in request.messages {
            if msg.role == .system {
                systemText = msg.content
                continue
            }

            let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false

            if hasAttachments, let attachments = msg.attachments {
                var contentBlocks: [[String: Any]] = []
                if !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                    contentBlocks.append(["type": "text", "text": msg.content])
                }

                for attachment in attachments {
                    guard let data = attachment.data else { continue }
                    switch attachment.type {
                    case .image, .pdf:
                        let base64 = data.base64EncodedString()
                        contentBlocks.append([
                            "type": "image",
                            "source": [
                                "type": "base64",
                                "media_type": attachment.mimeType,
                                "data": base64
                            ]
                        ])
                    case .text:
                        let filename = (attachment.path as NSString).lastPathComponent
                        let textContent = String(data: data, encoding: .utf8) ?? ""
                        contentBlocks.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"])
                    }
                }
                apiMessages.append(["role": msg.role.rawValue, "content": contentBlocks])
            } else {
                let content = msg.content.trimmingCharacters(in: .whitespacesAndNewlines)
                apiMessages.append(["role": msg.role.rawValue, "content": content.isEmpty ? "[Image]" : content])
            }
        }

        // Mark the last message with a cache breakpoint so the next turn can
        // reuse everything up through this one as a cached prefix.
        if var lastMessage = apiMessages.popLast() {
            if let content = lastMessage["content"] as? String {
                lastMessage["content"] = [["type": "text", "text": content, "cache_control": ["type": "ephemeral"]]]
            } else if var blocks = lastMessage["content"] as? [[String: Any]], var lastBlock = blocks.popLast() {
                lastBlock["cache_control"] = ["type": "ephemeral"]
                blocks.append(lastBlock)
                lastMessage["content"] = blocks
            }
            apiMessages.append(lastMessage)
        }

        var body: [String: Any] = [
            "model": request.model,
            "messages": apiMessages,
            "max_tokens": request.maxTokens ?? 16000,
            "stream": stream
        ]

        if let systemText = systemText {
            // Array form (rather than a plain string) carries a cache breakpoint.
            // Per Anthropic's render order (tools -> system -> messages), this
            // single breakpoint caches the tool definitions too.
            body["system"] = [["type": "text", "text": systemText, "cache_control": ["type": "ephemeral"]]]
        }
        if let temperature = request.temperature {
            body["temperature"] = temperature
        }
        var toolsArray: [[String: Any]] = []
        if let tools = request.tools {
            toolsArray += tools.map { tool -> [String: Any] in
                [
                    "name": tool.function.name,
                    "description": tool.function.description,
                    "input_schema": convertParametersToDict(tool.function.parameters)
                ]
            }
        }
        if request.onlineMode {
            toolsArray.append([
                "type": "web_search_20250305",
                "name": "web_search",
                "max_uses": 5
            ])
        }
        if !toolsArray.isEmpty {
            body["tools"] = toolsArray
            if request.tools != nil {
                body["tool_choice"] = ["type": "auto"]
            }
        }

        let bodyData = try JSONSerialization.data(withJSONObject: body)

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
        if stream {
            urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
        }
        urlRequest.httpBody = bodyData

        // Auth is applied async in the caller (chat/streamChat)
        return (urlRequest, bodyData)
    }

    func parseResponse(data: Data) throws -> ChatResponse {
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            throw ProviderError.invalidResponse
        }

        let id = json["id"] as? String ?? ""
        let model = json["model"] as? String ?? ""
        let contentBlocks = json["content"] as? [[String: Any]] ?? []

        var textContent = ""
        var toolCalls: [ToolCallInfo] = []

        for block in contentBlocks {
            let blockType = block["type"] as? String ?? ""
            switch blockType {
            case "text":
                textContent += block["text"] as? String ?? ""
            case "tool_use":
                let tcId = block["id"] as? String ?? UUID().uuidString
                let tcName = block["name"] as? String ?? ""
                let tcInput = block["input"] ?? [:]
                let argsData = try JSONSerialization.data(withJSONObject: tcInput)
                let argsStr = String(data: argsData, encoding: .utf8) ?? "{}"
                toolCalls.append(ToolCallInfo(id: tcId, type: "function", functionName: tcName, arguments: argsStr))
            default:
                break
            }
        }

        let usageDict = json["usage"] as? [String: Any]
        let inputTokens = usageDict?["input_tokens"] as? Int ?? 0
        let outputTokens = usageDict?["output_tokens"] as? Int ?? 0
        let cacheCreationTokens = usageDict?["cache_creation_input_tokens"] as? Int
        let cacheReadTokens = usageDict?["cache_read_input_tokens"] as? Int
        if cacheCreationTokens != nil || cacheReadTokens != nil {
            Log.api.info("Anthropic cache usage: input=\(inputTokens), created=\(cacheCreationTokens ?? 0), read=\(cacheReadTokens ?? 0)")
        }

        return ChatResponse(
            id: id,
            model: model,
            content: textContent,
            role: "assistant",
            finishReason: json["stop_reason"] as? String,
            usage: ChatResponse.Usage(
                promptTokens: inputTokens,
                completionTokens: outputTokens,
                totalTokens: inputTokens + outputTokens,
                cacheCreationInputTokens: cacheCreationTokens,
                cacheReadInputTokens: cacheReadTokens
            ),
            created: Date(),
            toolCalls: toolCalls.isEmpty ? nil : toolCalls
        )
    }

    func convertParametersToDict(_ params: Tool.Function.Parameters) -> [String: Any] {
        var props: [String: Any] = [:]
        for (key, prop) in params.properties {
            var propDict: [String: Any] = [
                "type": prop.type,
                "description": prop.description
            ]
            if let enumVals = prop.enum {
                propDict["enum"] = enumVals
            }
            if let items = prop.items {
                propDict["items"] = ["type": items.type]
            }
            props[key] = propDict
        }
        var dict: [String: Any] = [
            "type": params.type,
            "properties": props
        ]
        if let required = params.required {
            dict["required"] = required
        }
        return dict
    }
}
