import Foundation
import AVFoundation
import Speech

/// A single piece of transcript text attributed to a channel and a time range
/// (offsets are in seconds from the start of the recording).
struct TranscriptSegment: Codable, Hashable {
    let speaker: String
    let startOffset: TimeInterval
    let endOffset: TimeInterval
    let text: String
}

/// Logical speaker labels used when merging per-channel transcripts.
enum TranscriptSpeaker: String {
    case microphone = "You"
    case system = "Meeting"
}

/// Progress snapshot for UI status updates.
struct MeetingTranscriptionProgress: Sendable {
    let totalChunks: Int
    let completedChunks: Int
}

enum MeetingTranscriptionError: Error, LocalizedError {
    case authorizationDenied
    case authorizationRestricted
    case recognizerUnavailable(locale: String)
    case noAudioToTranscribe

    var errorDescription: String? {
        switch self {
        case .authorizationDenied:
            return "Speech recognition permission denied. Enable it in System Settings and try again."
        case .authorizationRestricted:
            return "Speech recognition is restricted on this Mac."
        case .recognizerUnavailable(let locale):
            return "Speech recognizer is unavailable for \(locale)."
        case .noAudioToTranscribe:
            return "No audio was available to transcribe."
        }
    }
}

/// Transcribes meeting audio by running Apple Speech on per-channel files
/// in fixed-size chunks, falling back across a list of locales per chunk.
final class MeetingTranscriptionService {
    private struct ChunkPlan {
        let index: Int
        let startFrame: AVAudioFramePosition
        let frameCount: AVAudioFrameCount
        let startOffset: TimeInterval
        let endOffset: TimeInterval
    }

    /// Shared progress counter used across concurrent channels.
    private actor ProgressCounter {
        private let total: Int
        private var completed: Int = 0
        private let onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)?

        init(total: Int, onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)?) {
            self.total = total
            self.onProgress = onProgress
        }

        func emitInitial() {
            onProgress?(MeetingTranscriptionProgress(totalChunks: total, completedChunks: 0))
        }

        func increment() {
            completed += 1
            onProgress?(MeetingTranscriptionProgress(totalChunks: total, completedChunks: completed))
        }
    }

    func requestAuthorization() async throws {
        switch SFSpeechRecognizer.authorizationStatus() {
        case .authorized:
            return
        case .notDetermined:
            let status: SFSpeechRecognizerAuthorizationStatus = await withCheckedContinuation { continuation in
                SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }
            }
            guard status == .authorized else { throw MeetingTranscriptionError.authorizationDenied }
        case .denied:
            throw MeetingTranscriptionError.authorizationDenied
        case .restricted:
            throw MeetingTranscriptionError.authorizationRestricted
        @unknown default:
            throw MeetingTranscriptionError.authorizationDenied
        }
    }

    /// Transcribes the mic and system channel audio (either may be nil) and
    /// returns a flat, time-ordered list of transcript segments labeled with
    /// the speaker channel.
    func transcribeMeeting(
        micURL: URL?,
        systemURL: URL?,
        chunkSeconds: TimeInterval = 7,
        overlapSeconds: TimeInterval = 1.2,
        locales: [Locale] = [],
        onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)? = nil
    ) async throws -> [TranscriptSegment] {
        try await requestAuthorization()

        let micPlan: (URL, [ChunkPlan])? = try micURL.flatMap { url -> (URL, [ChunkPlan])? in
            guard FileManager.default.fileExists(atPath: url.path) else { return nil }
            let chunks = try planChunks(for: url, chunkSeconds: chunkSeconds, overlapSeconds: overlapSeconds)
            return chunks.isEmpty ? nil : (url, chunks)
        }
        let systemPlan: (URL, [ChunkPlan])? = try systemURL.flatMap { url -> (URL, [ChunkPlan])? in
            guard FileManager.default.fileExists(atPath: url.path) else { return nil }
            let chunks = try planChunks(for: url, chunkSeconds: chunkSeconds, overlapSeconds: overlapSeconds)
            return chunks.isEmpty ? nil : (url, chunks)
        }

        let totalChunks = (micPlan?.1.count ?? 0) + (systemPlan?.1.count ?? 0)
        guard totalChunks > 0 else {
            throw MeetingTranscriptionError.noAudioToTranscribe
        }

        let counter = ProgressCounter(total: totalChunks, onProgress: onProgress)
        await counter.emitInitial()

        let effectiveLocales = locales.isEmpty ? preferredLocalesForGlobalRecognition() : locales

        async let micSegments: [TranscriptSegment] = {
            guard let plan = micPlan else { return [] }
            return try await self.transcribeChannel(
                url: plan.0,
                chunks: plan.1,
                speaker: .microphone,
                locales: effectiveLocales,
                counter: counter
            )
        }()
        async let systemSegments: [TranscriptSegment] = {
            guard let plan = systemPlan else { return [] }
            return try await self.transcribeChannel(
                url: plan.0,
                chunks: plan.1,
                speaker: .system,
                locales: effectiveLocales,
                counter: counter
            )
        }()

        let combined = try await micSegments + systemSegments
        return combined
            .filter { $0.text.isEmpty == false }
            .sorted { $0.startOffset < $1.startOffset }
    }

    // MARK: - Chunk planning

    private func planChunks(for url: URL, chunkSeconds: TimeInterval, overlapSeconds: TimeInterval) throws -> [ChunkPlan] {
        let audioFile = try AVAudioFile(forReading: url)
        let sampleRate = audioFile.processingFormat.sampleRate
        guard sampleRate > 0 else { return [] }
        let totalFrames = audioFile.length
        guard totalFrames > 0 else { return [] }

        let chunkSamples = AVAudioFramePosition(max(1, chunkSeconds * sampleRate))
        let overlapSamples = AVAudioFramePosition(max(0, overlapSeconds * sampleRate))
        let step = max(AVAudioFramePosition(1), chunkSamples - overlapSamples)

        var plans: [ChunkPlan] = []
        var start: AVAudioFramePosition = 0
        var index = 0
        while start < totalFrames {
            let end = min(start + chunkSamples, totalFrames)
            let frameCount = AVAudioFrameCount(end - start)
            let startOffset = Double(start) / sampleRate
            let endOffset = Double(end) / sampleRate
            plans.append(ChunkPlan(
                index: index,
                startFrame: start,
                frameCount: frameCount,
                startOffset: startOffset,
                endOffset: endOffset
            ))
            index += 1
            if end >= totalFrames { break }
            start += step
        }
        return plans
    }

    // MARK: - Per-channel transcription

    private func transcribeChannel(
        url: URL,
        chunks: [ChunkPlan],
        speaker: TranscriptSpeaker,
        locales: [Locale],
        counter: ProgressCounter
    ) async throws -> [TranscriptSegment] {
        var segments: [TranscriptSegment] = []
        segments.reserveCapacity(chunks.count)
        var preferredLocale: Locale?

        for plan in chunks {
            try Task.checkCancellation()
            let buffer = try readChunkBuffer(url: url, startFrame: plan.startFrame, frameCount: plan.frameCount)
            let result = await transcribeBufferWithLocaleFallback(
                buffer: buffer,
                locales: locales,
                preferredLocale: preferredLocale
            )
            await counter.increment()
            preferredLocale = result.locale ?? preferredLocale
            let trimmed = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
            if trimmed.isEmpty { continue }
            let textForSegment: String
            if let previous = segments.last {
                textForSegment = removeRepeatedPrefix(from: trimmed, previousText: previous.text)
            } else {
                textForSegment = trimmed
            }
            let normalized = textForSegment.trimmingCharacters(in: .whitespacesAndNewlines)
            if normalized.isEmpty { continue }
            segments.append(TranscriptSegment(
                speaker: speaker.rawValue,
                startOffset: plan.startOffset,
                endOffset: plan.endOffset,
                text: normalized
            ))
        }
        return segments
    }

    private func readChunkBuffer(url: URL, startFrame: AVAudioFramePosition, frameCount: AVAudioFrameCount) throws -> AVAudioPCMBuffer {
        let audioFile = try AVAudioFile(forReading: url)
        audioFile.framePosition = startFrame
        guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: frameCount) else {
            throw NSError(domain: "MeetingTranscriptionService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to allocate audio buffer."])
        }
        try audioFile.read(into: buffer, frameCount: frameCount)
        return buffer
    }

    private func transcribeBufferWithLocaleFallback(
        buffer: AVAudioPCMBuffer,
        locales: [Locale],
        preferredLocale: Locale?
    ) async -> (text: String, locale: Locale?) {
        var orderedLocales: [Locale] = []
        if let preferredLocale {
            orderedLocales.append(preferredLocale)
        }
        for locale in locales where orderedLocales.contains(where: { $0.identifier == locale.identifier }) == false {
            orderedLocales.append(locale)
        }

        var bestCandidate: (text: String, locale: Locale, score: Double)?
        for locale in orderedLocales {
            guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { continue }
            do {
                let text = try await transcribeBuffer(buffer: buffer, recognizer: recognizer)
                let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
                guard trimmed.isEmpty == false else { continue }
                let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
                if let current = bestCandidate {
                    if score > current.score {
                        bestCandidate = (trimmed, locale, score)
                    }
                } else {
                    bestCandidate = (trimmed, locale, score)
                }
            } catch {
                // One transient retry before moving on to the next locale.
                try? await Task.sleep(nanoseconds: 500_000_000)
                if let text = try? await transcribeBuffer(buffer: buffer, recognizer: recognizer) {
                    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
                    guard trimmed.isEmpty == false else { continue }
                    let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
                    if let current = bestCandidate {
                        if score > current.score {
                            bestCandidate = (trimmed, locale, score)
                        }
                    } else {
                        bestCandidate = (trimmed, locale, score)
                    }
                }
                continue
            }
        }
        if let bestCandidate {
            return (bestCandidate.text, bestCandidate.locale)
        }
        return ("", nil)
    }

    private func preferredLocalesForGlobalRecognition() -> [Locale] {
        let supported = SFSpeechRecognizer.supportedLocales()
        guard supported.isEmpty == false else {
            return [Locale.current, Locale(identifier: "en-US")]
        }

        let normalizedSupportedByIdentifier: [String: Locale] = Dictionary(
            uniqueKeysWithValues: supported.map { locale in
                (normalizeLocaleIdentifier(locale.identifier), locale)
            }
        )

        var ordered: [Locale] = []
        var seen = Set<String>()

        func appendIfSupportedIdentifier(_ identifier: String) {
            let normalized = normalizeLocaleIdentifier(identifier)
            guard seen.contains(normalized) == false else { return }
            guard let supportedLocale = normalizedSupportedByIdentifier[normalized] else { return }
            seen.insert(normalized)
            ordered.append(supportedLocale)
        }

        appendIfSupportedIdentifier(Locale.current.identifier)
        for identifier in Locale.preferredLanguages {
            appendIfSupportedIdentifier(identifier)
        }

        for locale in supported.sorted(by: { $0.identifier < $1.identifier }) {
            appendIfSupportedIdentifier(locale.identifier)
        }

        if ordered.isEmpty {
            return [Locale(identifier: "en-US")]
        }
        return ordered
    }

    private func normalizeLocaleIdentifier(_ identifier: String) -> String {
        identifier.replacingOccurrences(of: "_", with: "-").lowercased()
    }

    private func scoreTranscriptCandidate(_ text: String, locale: Locale, preferredLocale: Locale?) -> Double {
        let normalizedWords = text
            .components(separatedBy: CharacterSet.letters.inverted)
            .filter { $0.isEmpty == false }
        guard normalizedWords.isEmpty == false else { return 0 }

        let totalChars = max(1, text.count)
        let letterCount = text.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
        let letterRatio = Double(letterCount) / Double(totalChars)

        var repeatedWordPenalty = 0.0
        if normalizedWords.count >= 3 {
            var longestRun = 1
            var run = 1
            for index in 1..<normalizedWords.count {
                if normalizedWords[index].caseInsensitiveCompare(normalizedWords[index - 1]) == .orderedSame {
                    run += 1
                    longestRun = max(longestRun, run)
                } else {
                    run = 1
                }
            }
            if longestRun >= 3 {
                repeatedWordPenalty = Double(longestRun - 2) * 1.8
            }
        }

        // Bias toward continuity, but do not overpower quality score.
        let localeBias: Double
        if let preferredLocale,
           normalizeLocaleIdentifier(preferredLocale.identifier) == normalizeLocaleIdentifier(locale.identifier) {
            localeBias = 1.0
        } else {
            localeBias = 0.0
        }

        return Double(normalizedWords.count) + (letterRatio * 4.0) + localeBias - repeatedWordPenalty
    }

    /// Removes repeated leading words from a chunk that overlap with the
    /// trailing words of the previous chunk.
    private func removeRepeatedPrefix(from text: String, previousText: String) -> String {
        let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)
        let currentWords = text.components(separatedBy: separators).filter { $0.isEmpty == false }
        let previousWords = previousText.components(separatedBy: separators).filter { $0.isEmpty == false }
        guard currentWords.isEmpty == false, previousWords.isEmpty == false else { return text }

        let maxOverlap = min(12, currentWords.count, previousWords.count)
        guard maxOverlap > 0 else { return text }

        for overlap in stride(from: maxOverlap, through: 1, by: -1) {
            let previousSuffix = Array(previousWords.suffix(overlap)).map { $0.lowercased() }
            let currentPrefix = Array(currentWords.prefix(overlap)).map { $0.lowercased() }
            if previousSuffix == currentPrefix {
                let remainder = currentWords.dropFirst(overlap)
                return remainder.joined(separator: " ")
            }
        }
        return text
    }

    private func transcribeBuffer(buffer: AVAudioPCMBuffer, recognizer: SFSpeechRecognizer) async throws -> String {
        let request = SFSpeechAudioBufferRecognitionRequest()
        request.shouldReportPartialResults = false
        if #available(macOS 13.0, *) {
            request.addsPunctuation = true
        }

        return try await withCheckedThrowingContinuation { continuation in
            var hasResumed = false
            let lock = NSLock()
            func resumeOnce(with result: Result<String, Error>) {
                lock.lock()
                defer { lock.unlock() }
                if hasResumed { return }
                hasResumed = true
                switch result {
                case .success(let text):
                    continuation.resume(returning: text)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }

            let task = recognizer.recognitionTask(with: request) { result, error in
                if let error {
                    let nsError = error as NSError
                    // "No speech detected" is a normal empty-chunk outcome (code 203 in kafAssistant domain).
                    if nsError.domain == "kAFAssistantErrorDomain" && (nsError.code == 203 || nsError.code == 1110) {
                        resumeOnce(with: .success(""))
                        return
                    }
                    resumeOnce(with: .failure(error))
                    return
                }
                if let result, result.isFinal {
                    resumeOnce(with: .success(result.bestTranscription.formattedString))
                }
            }

            request.append(buffer)
            request.endAudio()
            _ = task
        }
    }
}

extension Array where Element == TranscriptSegment {
    /// Renders segments as a human-readable timeline like:
    /// `[00:12] Hello everyone.`
    func renderedTimelineText() -> String {
        let formatter: (TimeInterval) -> String = { seconds in
            let total = Int(seconds.rounded(.down))
            let h = total / 3600
            let m = (total % 3600) / 60
            let s = total % 60
            if h > 0 {
                return String(format: "%02d:%02d:%02d", h, m, s)
            }
            return String(format: "%02d:%02d", m, s)
        }
        return self.map { segment in
            "[\(formatter(segment.startOffset))] \(segment.text)"
        }.joined(separator: "\n")
    }
}

enum MeetingNotesError: Error, LocalizedError {
    case missingAPIKey
    case invalidResponse
    case httpStatus(Int, String)
    case emptyNotes

    var errorDescription: String? {
        switch self {
        case .missingAPIKey:
            return "OpenAI API key is missing. Set OPENAI_API_KEY in the environment or app key vault."
        case .invalidResponse:
            return "Notes generation returned an invalid response."
        case let .httpStatus(code, body):
            return "Notes generation failed (\(code)): \(body)"
        case .emptyNotes:
            return "Notes generation returned empty text."
        }
    }
}

/// Generates concise meeting notes from transcript text.
final class MeetingNotesService {
    private let session: URLSession
    private enum InAppKeyVault {
        private static let chunkOrder = [3, 9, 0, 7, 2, 10, 5, 1, 8, 4, 11, 6]
        private static let chunkSeeds: [UInt8] = [41, 77, 113, 149, 185, 221, 17, 53, 89, 125, 161, 197]
        private static let encryptedChunks: [[UInt8]] = [
            [75, 2, 114, 12, 39, 47, 198, 240, 254, 150, 129, 137, 150, 83],
            [117, 115, 43, 206, 197, 204, 221, 174, 164, 167, 158, 125, 44, 102],
            [2, 233, 190, 215, 195, 165, 182, 139, 154, 126, 54, 65, 115, 29],
            [193, 200, 230, 130, 237, 157, 171, 69, 73, 113, 102, 50, 14, 31],
            [192, 140, 136, 193, 169, 70, 105, 115, 46, 106, 91, 4, 221, 160],
            [130, 129, 174, 117, 116, 88, 112, 14, 11, 17, 208, 249, 221, 213],
            [69, 110, 30, 16, 30, 46, 29, 204, 210, 230, 238, 147, 234, 222],
            [102, 127, 49, 41, 74, 184, 171, 216, 210, 248, 136, 152, 84, 104],
            [9, 3, 35, 217, 247, 152, 220, 179, 175, 192, 83, 94, 71, 120],
            [23, 222, 174, 247, 153, 139, 173, 189, 104, 91, 20, 97, 56, 107],
            [238, 255, 240, 187, 150, 195, 79, 119, 106, 11, 40, 47, 44],
            [188, 130, 212, 186, 101, 120, 64, 122, 7, 22, 54, 236, 227, 229]
        ]

        static func resolveKey() -> String? {
            guard encryptedChunks.count == chunkOrder.count, chunkSeeds.count == chunkOrder.count else {
                return nil
            }

            var rebuiltByOriginalIndex = Array(repeating: [UInt8](), count: chunkOrder.count)
            for (scrambledIndex, encryptedChunk) in encryptedChunks.enumerated() {
                let seed = Int(chunkSeeds[scrambledIndex])
                let decrypted = encryptedChunk.enumerated().map { offset, byte in
                    byte ^ UInt8((seed + (offset * 17)) & 0xFF)
                }
                let originalIndex = chunkOrder[scrambledIndex]
                guard originalIndex < rebuiltByOriginalIndex.count else { return nil }
                rebuiltByOriginalIndex[originalIndex] = decrypted
            }

            let joined = rebuiltByOriginalIndex.flatMap { $0 }
            guard joined.isEmpty == false else { return nil }
            return String(decoding: joined, as: UTF8.self)
        }
    }

    init(session: URLSession = .shared) {
        self.session = session
    }

    private enum APIKeySource: String {
        case argument
        case environment
        case inAppVault
    }

    func resolveAPIKey() -> String? {
        let env = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"])
        if let env, env.isEmpty == false { return env }
        let vault = normalizedAPIKey(from: InAppKeyVault.resolveKey())
        if let vault, vault.isEmpty == false { return vault }
        return nil
    }

    func generateNotes(
        from transcript: String,
        preferredLanguageIdentifier1: String? = nil,
        preferredLanguageIdentifier2: String? = nil,
        apiKey: String? = nil
    ) async throws -> String {
        let keys = resolveAPIKeyCandidates(apiKey: apiKey)
        guard keys.isEmpty == false else { throw MeetingNotesError.missingAPIKey }

        struct Message: Encodable {
            let role: String
            let content: String
        }
        struct Body: Encodable {
            let model: String
            let messages: [Message]
            let temperature: Double
        }

        var lastError: MeetingNotesError?
        for (index, candidate) in keys.enumerated() {
            do {
                let preference = preferredNotesLanguage(
                    primary: preferredLanguageIdentifier1,
                    secondary: preferredLanguageIdentifier2
                )
                let notesLanguage: DominantLanguage
                if let preference {
                    notesLanguage = preference
                } else {
                    notesLanguage = try await detectDominantLanguage(in: transcript, apiKey: candidate.key)
                }

                let notesPrompt = buildNotesPrompt(
                    transcript: transcript,
                    dominantLanguage: notesLanguage,
                    preferredLanguage: preference
                )
                let body = Body(
                    model: "gpt-4.1-mini",
                    messages: [
                        Message(role: "system", content: "You create practical meeting notes from imperfect transcripts."),
                        Message(role: "user", content: notesPrompt)
                    ],
                    temperature: 0.2
                )
                return try await requestChatCompletionText(body: body, apiKey: candidate.key)
            } catch let error as MeetingNotesError {
                switch error {
                case .httpStatus(let code, _):
                    lastError = error
                    if code == 401, index < (keys.count - 1) {
                        // Try next configured source when one key is stale/revoked.
                        continue
                    }
                    throw error
                default:
                    throw error
                }
            }
        }

        throw lastError ?? MeetingNotesError.invalidResponse
    }

    private func requestChatCompletionText(body: Encodable, apiKey: String) async throws -> String {
        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        if let project = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_PROJECT_ID"])
            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIProjectID") as? String) {
            request.setValue(project, forHTTPHeaderField: "OpenAI-Project")
        }
        if let org = normalizedConfigValue(from: ProcessInfo.processInfo.environment["OPENAI_ORG_ID"])
            ?? normalizedConfigValue(from: Bundle.main.object(forInfoDictionaryKey: "OpenAIOrganizationID") as? String) {
            request.setValue(org, forHTTPHeaderField: "OpenAI-Organization")
        }
        request.httpBody = try JSONEncoder().encode(AnyEncodable(body))

        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw MeetingNotesError.invalidResponse }
        guard (200..<300).contains(http.statusCode) else {
            let bodyText = String(data: data, encoding: .utf8) ?? "<no body>"
            throw MeetingNotesError.httpStatus(http.statusCode, bodyText)
        }

        struct ChoiceMessage: Decodable {
            let content: String?
        }
        struct Choice: Decodable {
            let message: ChoiceMessage
        }
        struct ChatResponse: Decodable {
            let choices: [Choice]
        }

        let decoded = try JSONDecoder().decode(ChatResponse.self, from: data)
        let notes = decoded.choices.first?.message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        guard notes.isEmpty == false else { throw MeetingNotesError.emptyNotes }
        return notes
    }

    private struct DominantLanguage: Sendable {
        let tag: String
        let name: String
    }

    private func preferredNotesLanguage(primary: String?, secondary: String?) -> DominantLanguage? {
        // Notes should follow Preferred Language 1 when set (user intent),
        // because dominant-language detection can confuse similar scripts (e.g., Urdu vs Hindi).
        let cleaned = [
            primary?.trimmingCharacters(in: .whitespacesAndNewlines),
            secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
        ]
            .compactMap { $0 }
            .filter { $0.isEmpty == false }

        guard let identifier = cleaned.first else { return nil }
        let tag = identifier.replacingOccurrences(of: "_", with: "-")
        let english = Locale(identifier: "en")
        let languageName = english.localizedString(forIdentifier: identifier)
            ?? english.localizedString(forIdentifier: tag)
            ?? identifier
        return DominantLanguage(tag: tag, name: languageName)
    }

    private func detectDominantLanguage(in transcript: String, apiKey: String) async throws -> DominantLanguage {
        struct DetectResponse: Decodable {
            let tag: String?
            let name: String?
        }

        let detectPrompt = """
        Identify the dominant language used in the transcript (the language used most overall).
        If multiple languages are present, still choose only ONE dominant language.

        Return ONLY valid JSON with this exact schema:
        {"tag":"<BCP-47 language tag>","name":"<language name in English>"}

        Transcript:
        \(transcript)
        """

        struct Message: Encodable {
            let role: String
            let content: String
        }
        struct Body: Encodable {
            let model: String
            let messages: [Message]
            let temperature: Double
        }

        let body = Body(
            model: "gpt-4.1-mini",
            messages: [
                Message(role: "system", content: "You detect languages and reply with strict JSON only."),
                Message(role: "user", content: detectPrompt)
            ],
            temperature: 0.0
        )

        let raw = try await requestChatCompletionText(body: body, apiKey: apiKey)
        let data = Data(raw.utf8)
        let decoded = try JSONDecoder().decode(DetectResponse.self, from: data)
        let tag = (decoded.tag ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        let name = (decoded.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)

        if tag.isEmpty == false, name.isEmpty == false {
            return DominantLanguage(tag: tag, name: name)
        }

        // Fallback: if decoding fails partially, still force non-English when possible.
        if tag.isEmpty == false {
            return DominantLanguage(tag: tag, name: tag)
        }
        return DominantLanguage(tag: "und", name: "the dominant language of the transcript")
    }

    private func buildNotesPrompt(
        transcript: String,
        dominantLanguage: DominantLanguage,
        preferredLanguage: DominantLanguage?
    ) -> String {
        let preferenceLine: String = {
            guard let preferredLanguage else { return "" }
            return "\nPreferred language selected by the user: \(preferredLanguage.name) (\(preferredLanguage.tag)). Follow this preference."
        }()

        return """
        You are a meeting assistant. Generate structured notes from the transcript.
        Important: the transcript may have missing words, dropped sentences, or minor recognition errors.
        Infer likely intent conservatively and produce useful notes without inventing specific facts.
        \(preferenceLine)

        Output language:
        - Write ALL notes (including headings) strictly in \(dominantLanguage.name) (\(dominantLanguage.tag)).
        - Do NOT respond in English unless \(dominantLanguage.name) is English.
        - Keep proper nouns, people names, product names, acronyms, URLs, and code tokens exactly as-is.

        Output sections (translate these section headings into \(dominantLanguage.name)):
        1) Summary (3-5 bullets)
        2) Decisions
        3) Action Items (include owner if identifiable; otherwise write the equivalent of "Unassigned" in \(dominantLanguage.name))
        4) Risks / Open Questions

        Transcript:
        \(transcript)
        """
    }

    private func resolveAPIKeyCandidates(apiKey: String?) -> [(source: APIKeySource, key: String)] {
        var candidates: [(APIKeySource, String)] = []
        if let value = normalizedAPIKey(from: apiKey) {
            candidates.append((.argument, value))
        }
        if let value = normalizedAPIKey(from: ProcessInfo.processInfo.environment["OPENAI_API_KEY"]) {
            candidates.append((.environment, value))
        }
        if let value = normalizedAPIKey(from: InAppKeyVault.resolveKey()) {
            candidates.append((.inAppVault, value))
        }

        var unique: [(APIKeySource, String)] = []
        var seen = Set<String>()
        for candidate in candidates where seen.contains(candidate.1) == false {
            seen.insert(candidate.1)
            unique.append(candidate)
        }
        return unique
    }

    private func normalizedConfigValue(from value: String?) -> String? {
        guard var cleaned = value?.trimmingCharacters(in: .whitespacesAndNewlines), cleaned.isEmpty == false else {
            return nil
        }
        cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
        return cleaned.isEmpty ? nil : cleaned
    }

    private func normalizedAPIKey(from value: String?) -> String? {
        guard var cleaned = normalizedConfigValue(from: value) else { return nil }
        cleaned = cleaned.replacingOccurrences(of: "—", with: "-")
        cleaned = cleaned.replacingOccurrences(of: "–", with: "-")
        cleaned = cleaned.replacingOccurrences(of: " ", with: "")
        cleaned = cleaned.replacingOccurrences(of: "\u{200B}", with: "")
        cleaned = cleaned.replacingOccurrences(of: "\u{FEFF}", with: "")
        return cleaned.isEmpty ? nil : cleaned
    }
}

private struct AnyEncodable: Encodable {
    private let encodeBlock: (Encoder) throws -> Void

    init(_ wrapped: Encodable) {
        self.encodeBlock = wrapped.encode(to:)
    }

    func encode(to encoder: Encoder) throws {
        try encodeBlock(encoder)
    }
}
