//
// OpenRocky — Voice-first AI Agent
// https://github.com/openrocky
//
// Developed by everettjf with the assistance of Claude Code and Codex.
// Date: 2026-03-25
// Copyright (c) 2026 everettjf. All rights reserved.
//

import Foundation
@preconcurrency import SwiftOpenAI

@RealtimeActor
final class OpenRockyOpenAIRealtimeVoiceClient: OpenRockyRealtimeVoiceClient {
    let modelID: String

    private let configuration: OpenRockyProviderConfiguration
    private let realtimeConfiguration: OpenRockyRealtimeProviderConfiguration
    private var session: OpenAIRealtimeSession?
    private var receiverTask: Task<Void, Never>?
    private var eventSink: (@Sendable (OpenRockyRealtimeEvent) -> Void)?
    private var isReady = false

    private var soulInstructions: String
    private var realtimeTools: [OpenAIRealtimeSessionConfiguration.RealtimeTool]
    private let advancedSettings: OpenRockyRealtimeAdvancedSettings
    /// Pre-existing turns replayed into the session right after `session.updated`,
    /// so the model can pick up an in-progress conversation. Empty for fresh
    /// conversations. When non-empty, the per-character greeting is suppressed
    /// — opening with "hello, how can I help" after replaying 6 turns of context
    /// would feel like a memory wipe.
    private let primingItems: [OpenRockyVoicePrimingItem]

    /// ID of the assistant item currently streaming. Captured from
    /// `response.output_item.added` so we can target it in `conversation.item.truncate`
    /// when the user barges in. Cleared on `response.done`.
    private var currentAssistantItemID: String?

    /// True between `response.created` and `response.done`. Used to gate
    /// `response.cancel`, since cancelling when nothing is active makes the
    /// server return `response_cancel_not_active`.
    private var isResponseInProgress: Bool = false

    /// Set when the caller asks us to disconnect, so the receiver-loop exit
    /// path can distinguish a graceful close from a transport drop.
    private var isManualDisconnect: Bool = false

    /// True after the first `session.updated` is handled. Subsequent updates
    /// (e.g., from `updateSession()` hot-swapping the soul or tool whitelist)
    /// MUST NOT re-replay priming or re-fire the greeting — otherwise switching
    /// soul mid-conversation would replay history and re-greet the user.
    private var didReplayPriming: Bool = false

    /// Most recent server-side error text, captured from `.error` events so we
    /// can attach it to the eventual `.disconnected` emission. The bridge reads
    /// it to skip the retry loop on unrecoverable failures (auth/quota/model).
    /// Cleared on graceful disconnect.
    private var lastErrorText: String?

    init(
        configuration: OpenRockyProviderConfiguration,
        realtimeConfiguration: OpenRockyRealtimeProviderConfiguration,
        soulInstructions: String,
        realtimeTools: [OpenAIRealtimeSessionConfiguration.RealtimeTool],
        primingItems: [OpenRockyVoicePrimingItem] = []
    ) {
        self.configuration = configuration.normalized()
        self.realtimeConfiguration = realtimeConfiguration
        self.advancedSettings = realtimeConfiguration.effectiveAdvancedSettings
        self.modelID = self.advancedSettings.realtimeModel
        self.soulInstructions = soulInstructions
        self.realtimeTools = realtimeTools
        self.primingItems = primingItems
    }

    func connect(eventSink: @escaping @Sendable (OpenRockyRealtimeEvent) -> Void) async throws {
        self.eventSink = eventSink
        self.isManualDisconnect = false
        emit(.status("Connecting realtime session..."))

        let service = try await OpenRockyOpenAIServiceFactory.makeService(configuration: configuration)
        let config = sessionConfiguration()
        rlog.info("Connecting model=\(modelID) voice=\(config.audio?.output?.voice ?? "nil") vad=\(advancedSettings.turnDetection.mode.rawValue)", category: "Voice")
        let session = try await service.realtimeSession(
            model: modelID,
            configuration: config
        )

        self.session = session
        rlog.info("OpenAI realtime session created", category: "Voice")
        receiverTask?.cancel()
        receiverTask = Task { [weak self] in
            guard let self else { return }
            for await message in session.receiver {
                await self.handle(message)
            }
            // Receiver stream ended. If the caller didn't ask us to stop, the
            // transport dropped — surface a single .disconnected so the bridge
            // can attempt reconnect-with-backoff.
            self.notifyIfUnexpectedDisconnect()
        }
    }

    func disconnect() async {
        rlog.info("OpenAI realtime disconnecting", category: "Voice")
        isManualDisconnect = true
        receiverTask?.cancel()
        receiverTask = nil
        session?.disconnect()
        session = nil
        isReady = false
        currentAssistantItemID = nil
        didReplayPriming = false
        lastErrorText = nil
    }

    private func notifyIfUnexpectedDisconnect() {
        guard !isManualDisconnect else { return }
        rlog.warning("OpenAI realtime receiver ended unexpectedly", category: "Voice")
        isReady = false
        currentAssistantItemID = nil
        isResponseInProgress = false
        // Attach the last server-side error so the bridge can classify it
        // (unrecoverable failures skip the retry loop). lastErrorText is set
        // by the `.error` case in handle(); nil means "transport just dropped".
        emit(.disconnected(lastError: lastErrorText))
    }

    func sendText(_ text: String) async throws {
        guard let session else {
            throw OpenRockyRealtimeVoiceClientError.notConnected
        }
        await session.sendMessage(
            OpenAIRealtimeConversationItemCreate(
                item: .init(role: "user", text: text)
            )
        )
        await session.sendMessage(OpenAIRealtimeResponseCreate())
        emit(.status("Typed input sent into the live runtime."))
    }

    func sendAudioChunk(base64Audio: String) async throws {
        guard let session, isReady else { return }
        await session.sendMessage(OpenAIRealtimeInputAudioBufferAppend(audio: base64Audio))
    }

    func sendToolOutput(callID: String, output: String) async throws {
        guard let session else {
            throw OpenRockyRealtimeVoiceClientError.notConnected
        }
        await session.sendMessage(OpenRockyRealtimeFunctionCallOutput(callID: callID, output: output))
        await session.sendMessage(OpenAIRealtimeResponseCreate())
    }

    func cancelResponse() async throws {
        guard let session, isResponseInProgress else { return }
        rlog.info("Sending response.cancel", category: "Voice")
        await session.sendMessage(OpenRockyRealtimeResponseCancel())
    }

    func truncateAssistantAudio(audioEndMs: Int) async throws {
        guard let session, let itemID = currentAssistantItemID else { return }
        rlog.info("Sending conversation.item.truncate item=\(itemID) audioEndMs=\(audioEndMs)", category: "Voice")
        await session.sendMessage(
            OpenRockyRealtimeConversationItemTruncate(itemID: itemID, audioEndMs: audioEndMs)
        )
        // Once truncated, the item is no longer "the current streaming response".
        currentAssistantItemID = nil
    }

    func clearInputAudioBuffer() async throws {
        guard let session else { return }
        rlog.debug("Sending input_audio_buffer.clear", category: "Voice")
        await session.sendMessage(OpenRockyRealtimeInputAudioBufferClear())
    }

    func updateSession(
        soulInstructions: String,
        realtimeTools: [OpenAIRealtimeSessionConfiguration.RealtimeTool]
    ) async throws {
        guard let session, isReady else {
            // No live session, or initial session.updated hasn't landed yet —
            // the queued init update already carries the latest cached values
            // (the bridge updates them before calling us), so we'd be redundant.
            self.soulInstructions = soulInstructions
            self.realtimeTools = realtimeTools
            return
        }
        self.soulInstructions = soulInstructions
        self.realtimeTools = realtimeTools
        rlog.info("Hot-updating realtime session (soul/tools refreshed)", category: "Voice")
        // GA session.update is a merge — we re-send the full config since we
        // already build it consistently. didReplayPriming gates the .sessionUpdated
        // handler so this won't re-replay priming or re-fire the greeting.
        await session.sendMessage(OpenAIRealtimeSessionUpdate(session: sessionConfiguration()))
    }

    private func handle(_ message: OpenAIRealtimeMessage) async {
        switch message {
        case .responseAudioDelta, .responseTranscriptDelta:
            break
        default:
            rlog.debug("OpenAI event: \(message)", category: "Voice")
        }
        switch message {
        case .sessionCreated:
            emit(.status("Realtime session created."))
        case .sessionUpdated:
            isReady = true
            emit(.sessionReady(model: modelID))
            emit(.status("Realtime session is ready."))
            // Priming + greeting are a one-shot, gated on `didReplayPriming`.
            // session.updated can fire again later for hot config swaps
            // (updateSession()); replaying history then would re-greet the user
            // and re-inject turns the model has already seen.
            if !didReplayPriming, let session = self.session {
                didReplayPriming = true
                let priming = self.primingItems
                let greeting = realtimeConfiguration.characterGreeting ?? ""
                let shouldGreet = priming.isEmpty && !greeting.isEmpty
                if !priming.isEmpty || shouldGreet {
                    Task {
                        for item in priming {
                            await session.sendMessage(
                                OpenAIRealtimeConversationItemCreate(
                                    item: .init(role: item.role.rawValue, text: item.text)
                                )
                            )
                        }
                        if shouldGreet {
                            await session.sendMessage(
                                OpenAIRealtimeConversationItemCreate(item: .init(role: "user", text: greeting))
                            )
                            await session.sendMessage(OpenAIRealtimeResponseCreate())
                        }
                    }
                }
            }
        case .responseCreated:
            isReady = true
            isResponseInProgress = true
        case .responseOutputItemAdded(let itemId, let type):
            // Only assistant content items are valid truncate targets. Other types
            // (function_call, etc.) shouldn't be truncated.
            if type == "message" {
                currentAssistantItemID = itemId
            }
        case .inputAudioBufferSpeechStarted:
            emit(.inputSpeechStarted)
            emit(.status("Listening..."))
        case .inputAudioTranscriptionDelta(let text):
            emit(.userTranscriptDelta(text))
        case .inputAudioTranscriptionCompleted(let text):
            emit(.userTranscriptFinal(text))
        case .inputAudioBufferTranscript(let text):
            emit(.userTranscriptFinal(text))
        case .responseTranscriptDelta(let text):
            emit(.assistantTranscriptDelta(text))
        case .responseTranscriptDone(let text):
            emit(.assistantTranscriptFinal(text))
        case .responseTextDelta(let text):
            emit(.assistantTranscriptDelta(text))
        case .responseTextDone(let text):
            emit(.assistantTranscriptFinal(text))
        case .responseAudioDelta(let audio):
            emit(.assistantAudioChunk(audio))
        case .responseAudioDone:
            emit(.assistantAudioDone)
        case .responseFunctionCallArgumentsDone(let name, let arguments, let callID):
            rlog.info("OpenAI tool call: \(name) callID=\(callID)", category: "Voice")
            emit(.toolCallRequested(name: name, arguments: arguments, callID: callID))
        case .error(let text):
            let resolved = text ?? "Realtime session failed."
            rlog.error("OpenAI realtime error: \(resolved)", category: "Voice")
            lastErrorText = resolved
            emit(.error(resolved))
        case .responseDone(let status, let details):
            // Server-reported token usage is in details["usage"]; pull it out and
            // surface so SessionRuntime can record real metrics instead of estimating.
            if let details, let usage = details["usage"] as? [String: Any] {
                let prompt = (usage["input_tokens"] as? Int) ?? 0
                let completion = (usage["output_tokens"] as? Int) ?? 0
                let total = (usage["total_tokens"] as? Int) ?? (prompt + completion)
                if total > 0 {
                    emit(.usageReported(promptTokens: prompt, completionTokens: completion, totalTokens: total))
                }
            }
            currentAssistantItemID = nil
            isResponseInProgress = false
            if status != "completed" {
                let detail = details.map(Self.statusDetailsText) ?? ""
                rlog.warning("OpenAI response incomplete: status=\(status) \(detail)", category: "Voice")
                emit(.status("Realtime response status: \(status) \(detail)".trimmingCharacters(in: .whitespaces)))
            }
        default:
            break
        }
    }

    private func sessionConfiguration() -> OpenAIRealtimeSessionConfiguration {
        var personaPrefix = ""
        if let name = realtimeConfiguration.characterName, !name.isEmpty {
            personaPrefix += "Your name is \(name). "
        }
        if let style = realtimeConfiguration.characterSpeakingStyle, !style.isEmpty {
            personaPrefix += "Speaking style: \(style). "
        }

        let voice = realtimeConfiguration.openaiVoice ?? OpenRockyOpenAIVoice.alloy.rawValue
        // GA: output_modalities is mutually exclusive — pick one. `.audio`
        // already includes transcript events on the data channel, so we don't
        // need to also list `.text`.
        let outputModalities: [OpenAIRealtimeSessionConfiguration.OutputModality] =
            advancedSettings.allowTextOnly ? [.text] : [.audio]

        let audio = OpenAIRealtimeSessionConfiguration.Audio(
            input: .init(
                format: .pcm24k,
                transcription: .init(
                    model: advancedSettings.transcriptionModel.rawValue,
                    language: advancedSettings.inputLanguage
                ),
                turnDetection: turnDetectionFromSettings()
            ),
            output: .init(
                format: .pcm24k,
                voice: voice,
                speed: Float(advancedSettings.speed)
            )
        )

        return OpenAIRealtimeSessionConfiguration(
            audio: audio,
            instructions: personaPrefix + soulInstructions + """

Voice-specific rules:
- Keep spoken replies short and natural. Do not read markdown formatting aloud.
- When you need to call tools, do NOT narrate the process. Do NOT say things like "let me check" or "I'm looking up". Just call the tool silently.
- After receiving tool results, directly tell the user the final answer. Do not describe what tool you used or how you got the result.
- Be concise: give the answer in one or two sentences when possible.
""",
            maxOutputTokens: .int(advancedSettings.maxOutputTokens),
            outputModalities: outputModalities,
            reasoning: .init(effort: reasoningEffortForSession()),
            tools: realtimeTools,
            toolChoice: .auto
        )
    }

    private func reasoningEffortForSession() -> OpenAIRealtimeSessionConfiguration.Reasoning.Effort {
        switch advancedSettings.reasoningEffort {
        case .minimal: .minimal
        case .low: .low
        case .medium: .medium
        case .high: .high
        case .xhigh: .xhigh
        }
    }

    private func turnDetectionFromSettings() -> OpenAIRealtimeSessionConfiguration.Audio.Input.TurnDetection {
        switch advancedSettings.turnDetection {
        case .semanticVAD(let eagerness):
            let mapped: OpenAIRealtimeSessionConfiguration.Audio.Input.TurnDetection.DetectionType.Eagerness
            switch eagerness {
            case .low: mapped = .low
            case .medium: mapped = .medium
            case .high: mapped = .high
            }
            return .init(type: .semanticVAD(eagerness: mapped))
        case .serverVAD(let prefix, let silence, let threshold):
            return .init(type: .serverVAD(
                prefixPaddingMs: prefix,
                silenceDurationMs: silence,
                threshold: threshold
            ))
        }
    }

    private func emit(_ event: OpenRockyRealtimeEvent) {
        eventSink?(event)
    }

    private static func statusDetailsText(_ details: [String: Any]) -> String {
        details
            .map { "\($0.key)=\($0.value)" }
            .sorted()
            .joined(separator: ", ")
    }
}

enum OpenRockyRealtimeVoiceClientError: LocalizedError {
    case notConnected

    var errorDescription: String? {
        switch self {
        case .notConnected:
            "Realtime voice session is not connected."
        }
    }
}
