import Foundation

/// 「长按 + 语音直达」可路由到的新建入口。rawValue 与 IntentPrompts 的分类 token 一致。
enum VoiceIntent: String, CaseIterable, Sendable {
    case diary, medication, symptom, indicator, archive, export, reminder
}

/// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。
/// 关键词路兜底默认 diary(日记是最常见、最自由的入口),只有明确命中其它意图才离开 diary。
/// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。
/// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。
nonisolated enum VoiceIntentService {

    static func classify(_ utterance: String) async -> VoiceIntent? {
        let text = utterance.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else { return nil }
        // 模型冷启动可能要载入十几秒,语音直达等不起:6s 拿不到就走关键词。
        if let intent = try? await withTimeout(seconds: 6, operation: {
            try await classifyWithLLM(text)
        }) {
            return intent
        }
        return keywordMatch(text)
    }

    // MARK: - LLM 分类

    private static func classifyWithLLM(_ text: String) async throws -> VoiceIntent {
        try await AIRuntime.shared.prepare()
        let stream = await AIRuntime.shared.generate(prompt: IntentPrompts.classify(text),
                                                     maxTokens: 48)
        var collected = ""
        for try await chunk in stream {
            collected += chunk.text
        }
        guard let intent = parseIntent(from: collected) else {
            throw CaptureError.parseFailed("intent")
        }
        return intent
    }

    /// 从模型输出抠 `{"intent":"…"}`。容错:think 块、围栏、裸词。"unknown"/未知值返回 nil。
    static func parseIntent(from raw: String) -> VoiceIntent? {
        let cleaned = CaptureService.stripThink(raw)
        let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: cleaned))
        if let data = jsonString.data(using: .utf8),
           let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
           let token = obj["intent"] as? String {
            return VoiceIntent(rawValue: token.trimmingCharacters(in: .whitespaces).lowercased())
        }
        // 兜底:模型偶尔只吐裸词(diary / symptom …)
        let bare = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
            .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`。."))
            .lowercased()
        return VoiceIntent(rawValue: bare)
    }

    // MARK: - 关键词回退(纯函数,单测覆盖)

    /// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。
    /// symptom 只保留**明确的具体症状词**(头疼、咳嗽、发烧…),不再收「疼/痛/不舒服/难受」
    /// 这类泛词——它们更多出现在日常记录里,会把日记误判成症状。都不中时默认 diary。
    static func keywordMatch(_ text: String) -> VoiceIntent {
        let t = text.lowercased()
        // archive 只收**强归档信号**(化验单 / 体检报告 / 归档存档),不再收裸「报告」「体检」——
        // 后者太宽,「下周去体检」「医生说报告没问题」会被误判成 archive 而直接弹相机。
        let rules: [(VoiceIntent, [String])] = [
            (.reminder,   ["提醒", "别忘", "闹钟"]),
            (.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
            (.archive,    ["化验单", "化验报告", "检查报告", "检验报告", "体检报告", "归档", "存档"]),
            (.export,     ["身体档案", "给医生", "健康总结", "导出"]),
            (.indicator,  ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
                           "高压", "低压"]),
            (.symptom,    ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼",
                           "咳嗽", "发烧", "发热", "头晕", "恶心", "拉肚子"]),
        ]
        for (intent, keys) in rules {
            for key in keys where t.contains(key) {
                // 相机类意图(拍药盒 / 拍报告归档)额外要求关键词**没被否定**:
                // 「没吃药」「忘了吃药」不该弹相机,放它落到 diary。表单类意图不设此限。
                if intent == .medication || intent == .archive, isNegated(t, keyword: key) {
                    continue
                }
                return intent
            }
        }
        // 兜底默认日记:语音直达最常见的就是随口记一句今天的状态。
        return .diary
    }

    /// 关键词命中点前两个字若含否定/遗忘词,视为「这事没真发生」,不命中。
    /// 仅用于相机类意图,避免「没吃药」「忘了吃药」「不用吃药」误弹相机。
    private static let negationMarkers: Set<Character> = ["没", "不", "别", "忘", "甭", "未", "免"]

    static func isNegated(_ text: String, keyword: String) -> Bool {
        guard let range = text.range(of: keyword) else { return false }
        let preceding = text[..<range.lowerBound].suffix(2)
        return preceding.contains { negationMarkers.contains($0) }
    }
}

/// 简单超时竞速:operation 与 sleep 赛跑,超时抛 CancellationError 并取消未完成方。
nonisolated private func withTimeout<T: Sendable>(
    seconds: Double,
    operation: @escaping @Sendable () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
            throw CancellationError()
        }
        guard let result = try await group.next() else { throw CancellationError() }
        group.cancelAll()
        return result
    }
}
