//
//  OpenRouterProvider.swift
//  oAI
//
//  OpenRouter AI provider implementation with SSE streaming
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.


import Foundation
import os

class OpenRouterProvider: AIProvider {
    let name = "OpenRouter"
    let capabilities = ProviderCapabilities(
        supportsStreaming: true,
        supportsVision: true,
        supportsTools: true,
        supportsOnlineSearch: true,
        maxContextLength: nil
    )
    
    private let apiKey: String
    private let baseURL = "https://openrouter.ai/api/v1"
    private let session: URLSession
    
    init(apiKey: String) {
        self.apiKey = apiKey
        
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 60
        config.timeoutIntervalForResource = 300
        self.session = URLSession(configuration: config)
    }
    
    // MARK: - List Models
    
    func listModels() async throws -> [ModelInfo] {
        Log.api.info("Fetching model list from OpenRouter")
        let url = URL(string: "\(baseURL)/models")!
        var request = URLRequest(url: url)
        request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")

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

        guard let httpResponse = response as? HTTPURLResponse else {
            Log.api.error("OpenRouter models: invalid response (not HTTP)")
            throw ProviderError.invalidResponse
        }

        guard httpResponse.statusCode == 200 else {
            if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
                Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
                throw ProviderError.unknown(errorResponse.error.message)
            }
            Log.api.error("OpenRouter models HTTP \(httpResponse.statusCode)")
            throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
        }
        
        let modelsResponse = try JSONDecoder().decode(OpenRouterModelsResponse.self, from: data)
        Log.api.info("OpenRouter loaded \(modelsResponse.data.count) models")
        return modelsResponse.data.map { modelData in
            let promptPrice = Double(modelData.pricing.prompt) ?? 0.0
            let completionPrice = Double(modelData.pricing.completion) ?? 0.0
            
            var info = ModelInfo(
                id: modelData.id,
                name: modelData.name,
                description: modelData.description,
                contextLength: modelData.contextLength,
                pricing: ModelInfo.Pricing(
                    prompt: promptPrice * 1_000_000, // Convert to per 1M tokens
                    completion: completionPrice * 1_000_000
                ),
                capabilities: ModelInfo.ModelCapabilities(
                    vision: {
                        let mod = modelData.architecture?.modality ?? ""
                        return mod == "multimodal" || mod.hasPrefix("text+image")
                    }(),
                    tools: modelData.supportedParameters?.contains("tools") ?? false,
                    online: {
                        // OpenRouter supports :online suffix for all text models
                        let mod = modelData.architecture?.modality ?? ""
                        if let arrow = mod.range(of: "->") {
                            return !mod[arrow.upperBound...].contains("image")
                        }
                        return true
                    }(),
                    imageGeneration: {
                        if let mod = modelData.architecture?.modality,
                           let arrow = mod.range(of: "->") {
                            let output = mod[arrow.upperBound...]
                            return output.contains("image")
                        }
                        return false
                    }(),
                    thinking: modelData.supportedParameters?.contains("reasoning") ?? false
                ),
                architecture: modelData.architecture.map { arch in
                    ModelInfo.Architecture(
                        tokenizer: arch.tokenizer,
                        instructType: arch.instructType,
                        modality: arch.modality
                    )
                },
                topProvider: modelData.id.components(separatedBy: "/").first
            )
            info.categories = ModelCategory.infer(
                name: modelData.name,
                id: modelData.id,
                description: modelData.description
            )
            return info
        }
    }
    
    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("OpenRouter chat request: model=\(request.model), messages=\(request.messages.count)")
        let apiRequest = try buildAPIRequest(from: request)
        let url = URL(string: "\(baseURL)/chat/completions")!
        
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
        urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
        urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
        urlRequest.httpBody = try JSONEncoder().encode(apiRequest)
        
        let (data, response) = try await session.data(for: urlRequest)
        
        guard let httpResponse = response as? HTTPURLResponse else {
            throw ProviderError.invalidResponse
        }
        
        guard httpResponse.statusCode == 200 else {
            if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
                Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
                throw ProviderError.unknown(errorResponse.error.message)
            }
            Log.api.error("OpenRouter chat HTTP \(httpResponse.statusCode)")
            throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
        }

        // Debug: log raw response for image gen models
        if request.imageGeneration, let rawStr = String(data: data, encoding: .utf8) {
            Log.api.debug("Image gen raw response (first 3000 chars): \(rawStr.prefix(3000))")
        }

        let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
        let chatResponse = try convertToChatResponse(apiResponse)
        if request.imageGeneration {
            Log.api.debug("Image gen decoded: content='\(chatResponse.content)', generatedImages=\(chatResponse.generatedImages?.count ?? 0)")
        }
        return chatResponse
    }

    // MARK: - Chat with raw tool messages

    /// Chat completion that accepts pre-encoded messages (for the tool call loop where
    /// message shapes vary: user, assistant+tool_calls, tool results).
    func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
        let url = URL(string: "\(baseURL)/chat/completions")!

        var body: [String: Any] = [
            "model": model,
            "messages": messages,
            "stream": false
        ]
        if let tools = tools {
            let toolsData = try JSONEncoder().encode(tools)
            body["tools"] = try JSONSerialization.jsonObject(with: toolsData)
            body["tool_choice"] = "auto"
        }
        if let maxTokens = maxTokens { body["max_tokens"] = maxTokens }
        if let temperature = temperature { body["temperature"] = temperature }

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
        urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
        urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
        urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)

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

        guard let httpResponse = response as? HTTPURLResponse else {
            throw ProviderError.invalidResponse
        }

        guard httpResponse.statusCode == 200 else {
            if let errorResponse = try? JSONDecoder().decode(OpenRouterErrorResponse.self, from: data) {
                Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode): \(errorResponse.error.message)")
                throw ProviderError.unknown(errorResponse.error.message)
            }
            Log.api.error("OpenRouter tool chat HTTP \(httpResponse.statusCode)")
            throw ProviderError.unknown("HTTP \(httpResponse.statusCode)")
        }

        let apiResponse = try JSONDecoder().decode(OpenRouterChatResponse.self, from: data)
        return try convertToChatResponse(apiResponse)
    }

    // MARK: - Streaming Chat
    
    func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
        Log.api.info("OpenRouter stream request: model=\(request.model), messages=\(request.messages.count)")
        return AsyncThrowingStream { continuation in
            Task {
                do {
                    var apiRequest = try buildAPIRequest(from: request)
                    apiRequest.stream = true
                    
                    let url = URL(string: "\(baseURL)/chat/completions")!
                    var urlRequest = URLRequest(url: url)
                    urlRequest.httpMethod = "POST"
                    urlRequest.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
                    urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
                    urlRequest.addValue("text/event-stream", forHTTPHeaderField: "Accept")
                    urlRequest.addValue("https://github.com/yourusername/oAI", forHTTPHeaderField: "HTTP-Referer")
                    urlRequest.addValue("oAI-Swift", forHTTPHeaderField: "X-Title")
                    urlRequest.httpBody = try JSONEncoder().encode(apiRequest)
                    
                    let (bytes, response) = try await session.bytes(for: urlRequest)
                    
                    guard let httpResponse = response as? HTTPURLResponse else {
                        Log.api.error("OpenRouter stream: invalid response (not HTTP)")
                        continuation.finish(throwing: ProviderError.invalidResponse)
                        return
                    }

                    guard httpResponse.statusCode == 200 else {
                        Log.api.error("OpenRouter stream HTTP \(httpResponse.statusCode)")
                        continuation.finish(throwing: ProviderError.unknown("HTTP \(httpResponse.statusCode)"))
                        return
                    }

                    var buffer = ""
                    
                    for try await line in bytes.lines {
                        if line.hasPrefix("data: ") {
                            let jsonString = String(line.dropFirst(6))
                            
                            if jsonString == "[DONE]" {
                                continuation.finish()
                                return
                            }
                            
                            buffer += jsonString
                            
                            if let jsonData = buffer.data(using: .utf8) {
                                do {
                                    let chunk = try JSONDecoder().decode(OpenRouterStreamChunk.self, from: jsonData)
                                    let streamChunk = try convertToStreamChunk(chunk)
                                    continuation.yield(streamChunk)
                                    buffer = ""
                                } catch {
                                    // Partial JSON, keep buffering
                                    continue
                                }
                            }
                        }
                    }
                    
                    continuation.finish()
                } catch {
                    continuation.finish(throwing: error)
                }
            }
        }
    }
    
    // MARK: - Credits
    
    func getCredits() async throws -> Credits? {
        Log.api.info("Fetching OpenRouter credits")
        let url = URL(string: "\(baseURL)/credits")!
        var request = URLRequest(url: url)
        request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")

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

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            return nil
        }

        let creditsResponse = try JSONDecoder().decode(OpenRouterCreditsResponse.self, from: data)
        let totalCredits = creditsResponse.data.totalCredits ?? 0
        let totalUsage = creditsResponse.data.totalUsage ?? 0
        let remaining = totalCredits - totalUsage

        return Credits(
            balance: remaining,
            currency: "USD",
            usage: totalUsage,
            limit: totalCredits
        )
    }
    
    // MARK: - Helper Methods
    
    private func buildAPIRequest(from request: ChatRequest) throws -> OpenRouterChatRequest {
        let apiMessages = request.messages.map { message -> OpenRouterChatRequest.APIMessage in

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

            let content: OpenRouterChatRequest.APIMessage.MessageContent

            if hasAttachments {
                // Use array format for messages with attachments
                var contentArray: [OpenRouterChatRequest.APIMessage.ContentItem] = []

                // Add main text content
                contentArray.append(.text(message.content))

                // Add attachments
                if let attachments = message.attachments {
                    for attachment in attachments {
                        guard let data = attachment.data else { continue }

                        switch attachment.type {
                        case .image, .pdf:
                            // Send as base64 data URL with correct MIME type
                            let base64String = data.base64EncodedString()
                            let dataURL = "data:\(attachment.mimeType);base64,\(base64String)"
                            let imageContent = OpenRouterChatRequest.APIMessage.ContentItem.ImageContent(
                                type: "image_url",
                                imageUrl: .init(url: dataURL)
                            )
                            contentArray.append(.image(imageContent))

                        case .text:
                            // Inline text file content
                            let filename = (attachment.path as NSString).lastPathComponent
                            let textContent = String(data: data, encoding: .utf8) ?? ""
                            contentArray.append(.text("File: \(filename)\n\n\(textContent)"))
                        }
                    }
                }

                content = .array(contentArray)
            } else {
                // Use simple string format for text-only messages
                content = .string(message.content)
            }

            return OpenRouterChatRequest.APIMessage(
                role: message.role.rawValue,
                content: content
            )
        }
        
        // Append :online suffix for web search when online mode is enabled
        let effectiveModel: String
        if request.onlineMode && !request.imageGeneration && !request.model.hasSuffix(":online") {
            effectiveModel = request.model + ":online"
        } else {
            effectiveModel = request.model
        }

        let reasoningConfig: ReasoningAPIConfig? = request.reasoning.map {
            ReasoningAPIConfig(effort: $0.effort, exclude: $0.exclude ? true : nil)
        }

        return OpenRouterChatRequest(
            model: effectiveModel,
            messages: apiMessages,
            stream: request.stream,
            maxTokens: request.maxTokens,
            temperature: request.temperature,
            topP: request.topP,
            tools: request.tools,
            toolChoice: request.tools != nil ? "auto" : nil,
            modalities: request.imageGeneration ? ["text", "image"] : nil,
            reasoning: reasoningConfig
        )
    }
    
    private func convertToChatResponse(_ apiResponse: OpenRouterChatResponse) throws -> ChatResponse {
        guard let choice = apiResponse.choices.first else {
            throw ProviderError.invalidResponse
        }

        let toolCalls: [ToolCallInfo]? = choice.message.toolCalls?.map { tc in
            ToolCallInfo(id: tc.id, type: tc.type, functionName: tc.function.name, arguments: tc.function.arguments)
        }

        let topLevelImages = choice.message.images.flatMap { decodeImageOutputs($0) } ?? []
        let blockImages = decodeImageOutputs(choice.message.contentBlockImages) ?? []
        let allImages = topLevelImages + blockImages
        let images: [Data]? = allImages.isEmpty ? nil : allImages

        return ChatResponse(
            id: apiResponse.id,
            model: apiResponse.model,
            content: choice.message.content ?? "",
            role: choice.message.role,
            finishReason: choice.finishReason,
            usage: apiResponse.usage.map { usage in
                ChatResponse.Usage(
                    promptTokens: usage.promptTokens,
                    completionTokens: usage.completionTokens,
                    totalTokens: usage.totalTokens
                )
            },
            created: Date(timeIntervalSince1970: TimeInterval(apiResponse.created)),
            toolCalls: toolCalls,
            generatedImages: images
        )
    }

    private func convertToStreamChunk(_ apiChunk: OpenRouterStreamChunk) throws -> StreamChunk {
        guard let choice = apiChunk.choices.first else {
            throw ProviderError.invalidResponse
        }

        // Merge images from both sources: top-level `images` field and content-block images
        let topLevelImages = choice.delta.images.flatMap { decodeImageOutputs($0) } ?? []
        let blockImages = decodeImageOutputs(choice.delta.contentBlockImages) ?? []
        let allImages = topLevelImages + blockImages
        let images: [Data]? = allImages.isEmpty ? nil : allImages

        return StreamChunk(
            id: apiChunk.id,
            model: apiChunk.model,
            delta: StreamChunk.Delta(
                content: choice.delta.content,
                role: choice.delta.role,
                images: images,
                thinking: choice.delta.reasoning
            ),
            finishReason: choice.finishReason,
            usage: apiChunk.usage.map { usage in
                ChatResponse.Usage(
                    promptTokens: usage.promptTokens,
                    completionTokens: usage.completionTokens,
                    totalTokens: usage.totalTokens
                )
            }
        )
    }

    /// Decode base64 data URL images from API response
    private func decodeImageOutputs(_ outputs: [OpenRouterChatResponse.ImageOutput]) -> [Data]? {
        let decoded = outputs.compactMap { output -> Data? in
            let url = output.imageUrl.url
            // Strip "data:image/...;base64," prefix
            guard let commaIndex = url.firstIndex(of: ",") else { return nil }
            let base64String = String(url[url.index(after: commaIndex)...])
            return Data(base64Encoded: base64String)
        }
        return decoded.isEmpty ? nil : decoded
    }
}
