//
//  AnthropicProviderTests.swift
//  oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen

import Testing
import Foundation
@testable import Confab

@Suite("AnthropicProvider request/response conversion")
struct AnthropicProviderTests {

    private var provider: AnthropicProvider { AnthropicProvider(apiKey: "test-key") }

    private func decodeBody(_ urlRequest: URLRequest) throws -> [String: Any] {
        try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
    }

    // MARK: - buildURLRequest

    @Test("System messages are pulled out of the message list into a top-level system field")
    func systemMessageExtracted() throws {
        let messages = [
            Message(role: .system, content: "be concise"),
            Message(role: .user, content: "hi")
        ]
        let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
        let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
        let body = try decodeBody(urlRequest)

        let apiMessages = body["messages"] as? [[String: Any]]
        #expect(apiMessages?.count == 1) // system message removed from the array
        #expect(apiMessages?.first?["role"] as? String == "user")

        let system = body["system"] as? [[String: Any]]
        #expect(system?.first?["text"] as? String == "be concise")
        let cacheControl = system?.first?["cache_control"] as? [String: String]
        #expect(cacheControl?["type"] == "ephemeral")
    }

    @Test("The last message gets a cache_control breakpoint on its content block")
    func lastMessageGetsCacheBreakpoint() throws {
        let messages = [
            Message(role: .user, content: "first"),
            Message(role: .assistant, content: "second"),
            Message(role: .user, content: "third")
        ]
        let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
        let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
        let body = try decodeBody(urlRequest)
        let apiMessages = body["messages"] as? [[String: Any]]

        // Earlier messages keep plain string content (no cache breakpoint).
        #expect(apiMessages?[0]["content"] as? String == "first")
        #expect(apiMessages?[1]["content"] as? String == "second")

        // The last message's content becomes an array with a cache_control block.
        let lastContent = apiMessages?[2]["content"] as? [[String: Any]]
        #expect(lastContent?.first?["text"] as? String == "third")
        let cacheControl = lastContent?.first?["cache_control"] as? [String: String]
        #expect(cacheControl?["type"] == "ephemeral")
    }

    @Test("max_tokens defaults to 16000 when not specified on the request")
    func defaultMaxTokens() throws {
        let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5")
        let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
        let body = try decodeBody(urlRequest)
        #expect(body["max_tokens"] as? Int == 16000)
    }

    @Test("Online mode adds a web_search tool even with no explicit tools requested")
    func onlineModeAddsWebSearchTool() throws {
        let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5", onlineMode: true)
        let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
        let body = try decodeBody(urlRequest)
        let tools = body["tools"] as? [[String: Any]]
        #expect(tools?.first?["name"] as? String == "web_search")
        // tool_choice is only added when the caller supplied explicit tools.
        #expect(body["tool_choice"] == nil)
    }

    @Test("Empty message content is replaced with a placeholder rather than sent blank")
    func emptyContentGetsPlaceholder() throws {
        let request = ChatRequest(messages: [Message(role: .user, content: "   ")], model: "claude-sonnet-4-5")
        let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
        let body = try decodeBody(urlRequest)
        let apiMessages = body["messages"] as? [[String: Any]]
        // This is also the last message, so it gets wrapped in a cache-control array.
        let content = apiMessages?.first?["content"] as? [[String: Any]]
        #expect(content?.first?["text"] as? String == "[Image]")
    }

    // MARK: - parseResponse

    @Test("Parses text content, usage, and stop_reason from a well-formed response")
    func parsesWellFormedResponse() throws {
        let json: [String: Any] = [
            "id": "msg_123",
            "model": "claude-sonnet-4-5",
            "content": [["type": "text", "text": "hello there"]],
            "stop_reason": "end_turn",
            "usage": ["input_tokens": 10, "output_tokens": 5]
        ]
        let data = try JSONSerialization.data(withJSONObject: json)
        let response = try provider.parseResponse(data: data)
        #expect(response.id == "msg_123")
        #expect(response.content == "hello there")
        #expect(response.finishReason == "end_turn")
        #expect(response.usage?.promptTokens == 10)
        #expect(response.usage?.completionTokens == 5)
        #expect(response.toolCalls == nil)
    }

    @Test("Parses a tool_use block into a ToolCallInfo with JSON-encoded arguments")
    func parsesToolUseBlock() throws {
        let json: [String: Any] = [
            "id": "msg_1",
            "model": "claude-sonnet-4-5",
            "content": [
                ["type": "tool_use", "id": "tool_1", "name": "get_weather", "input": ["city": "Oslo"]]
            ],
            "usage": ["input_tokens": 1, "output_tokens": 1]
        ]
        let data = try JSONSerialization.data(withJSONObject: json)
        let response = try provider.parseResponse(data: data)
        #expect(response.toolCalls?.count == 1)
        #expect(response.toolCalls?.first?.functionName == "get_weather")
        #expect(response.toolCalls?.first?.arguments.contains("Oslo") == true)
    }

    @Test("Throws invalidResponse for non-JSON data")
    func throwsOnInvalidJSON() {
        let data = "not json".data(using: .utf8)!
        #expect(throws: (any Error).self) {
            try provider.parseResponse(data: data)
        }
    }

    // MARK: - convertParametersToDict

    @Test("Converts tool parameters to Anthropic's input_schema dict shape")
    func convertsParametersToInputSchema() {
        let params = Tool.Function.Parameters(
            type: "object",
            properties: [
                "city": Tool.Function.Parameters.Property(type: "string", description: "City name"),
                "unit": Tool.Function.Parameters.Property(type: "string", description: "Unit", enum: ["celsius", "fahrenheit"])
            ],
            required: ["city"]
        )
        let dict = provider.convertParametersToDict(params)
        #expect(dict["type"] as? String == "object")
        #expect(dict["required"] as? [String] == ["city"])
        let properties = dict["properties"] as? [String: Any]
        let cityProp = properties?["city"] as? [String: Any]
        #expect(cityProp?["type"] as? String == "string")
        let unitProp = properties?["unit"] as? [String: Any]
        #expect(unitProp?["enum"] as? [String] == ["celsius", "fahrenheit"])
    }

    @Test("Array-typed properties include a nested items schema")
    func convertsArrayItemsSchema() {
        let params = Tool.Function.Parameters(
            type: "object",
            properties: [
                "tags": Tool.Function.Parameters.Property(type: "array", description: "Tags", items: .init(type: "string"))
            ],
            required: nil
        )
        let dict = provider.convertParametersToDict(params)
        let properties = dict["properties"] as? [String: Any]
        let tagsProp = properties?["tags"] as? [String: Any]
        let items = tagsProp?["items"] as? [String: Any]
        #expect(items?["type"] as? String == "string")
        #expect(dict["required"] == nil)
    }

    // MARK: - resolveFallbackPricing

    @Test("Exact-length single prefix match returns that tier's pricing")
    func fallbackPricingSingleMatch() {
        let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-haiku-4-5-20251001")
        #expect(pricing.prompt == 0.80)
        #expect(pricing.completion == 4.0)
    }

    @Test("Longest matching prefix wins when multiple prefixes could match")
    func fallbackPricingLongestPrefixWins() {
        let fallback: [(prefix: String, prompt: Double, completion: Double)] = [
            ("claude", 1.0, 2.0),
            ("claude-sonnet", 3.0, 15.0),
        ]
        let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-sonnet-5", fallback: fallback)
        #expect(pricing.prompt == 3.0)
        #expect(pricing.completion == 15.0)
    }

    @Test("An unmatched model ID prices at zero rather than guessing")
    func fallbackPricingNoMatch() {
        let pricing = AnthropicProvider.resolveFallbackPricing(for: "some-future-unknown-model")
        #expect(pricing.prompt == 0)
        #expect(pricing.completion == 0)
    }
}
