import Foundation
import SwiftData

/// 「导出身体档案」的服务层。
///
/// 流程(对齐 spec §6):
///   prepare → extractingIntent → retrieving → generating → completed
///
/// 红线对齐:
/// - UI 只通过本服务调用 AI(§3.1)
/// - 两次 LLM 调用都进 `AIRuntime.shared` 的 actor 队列,与 CaptureService 串行(§3.1)
/// - 意图 JSON 解析失败 → 用 30 天 + 空关键词兜底,流程不中断(§3.2 / spec §9)
/// - 不引入云、不写密码学、不重构现有结构(§10)
@MainActor
struct HealthExportService {

    static let shared = HealthExportService()
    private init() {}

    // MARK: - Public types

    enum Phase: String, Sendable {
        case extractingIntent
        case retrieving
        case generating
        case completed

        var label: String {
            switch self {
            case .extractingIntent: return String(appLoc: "理解意图")
            case .retrieving:       return String(appLoc: "检索数据")
            case .generating:       return String(appLoc: "撰写报告")
            case .completed:        return String(appLoc: "已完成")
            }
        }
    }

    /// 检索结果摘要 —— 把「本地 RAG 找到了什么」拿给 UI 演出来(§12 卖点 3)。
    struct RetrievalSummary: Sendable, Equatable {
        var chips: [String]
        var indicatorCount: Int
        var reportCount: Int
        var symptomCount: Int
        var diaryCount: Int

        var totalCount: Int { indicatorCount + reportCount + symptomCount + diaryCount }

        /// 同名指标合并计数(保持检索的新→旧顺序),超出 cap 折叠成 "+N"。纯函数,单测覆盖。
        static func groupedChips(_ names: [String], cap: Int = 8) -> [String] {
            var order: [String] = []
            var counts: [String: Int] = [:]
            for n in names {
                if counts[n] == nil { order.append(n) }
                counts[n, default: 0] += 1
            }
            var chips = order.map { name -> String in
                let c = counts[name] ?? 1
                return c > 1 ? "\(name) ×\(c)" : name
            }
            if chips.count > cap {
                let overflow = chips.count - cap
                chips = Array(chips.prefix(cap)) + ["+\(overflow)"]
            }
            return chips
        }

        @MainActor
        static func from(snapshot: Snapshot) -> RetrievalSummary {
            var chips = groupedChips(snapshot.indicators.map(\.name), cap: 8)
            chips += snapshot.reports.prefix(3).map(\.title)
            chips += snapshot.symptoms.prefix(3).map(\.name)
            if !snapshot.diaries.isEmpty {
                chips.append(String(appLoc: "日记 ×\(snapshot.diaries.count)"))
            }
            return RetrievalSummary(
                chips: chips,
                indicatorCount: snapshot.indicators.count,
                reportCount: snapshot.reports.count,
                symptomCount: snapshot.symptoms.count,
                diaryCount: snapshot.diaries.count
            )
        }
    }

    enum Event {
        case phaseChanged(Phase)
        case retrieved(RetrievalSummary)
        case token(TokenChunk)
        case completed(persistentID: PersistentIdentifier)
        // .failed 走 stream throw,不在 Event 里
    }

    enum ServiceError: Error, LocalizedError {
        case modelNotReady
        case generationFailed(String)
        case cancelled

        var errorDescription: String? {
            switch self {
            case .modelNotReady:           return String(appLoc: "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。")
            case .generationFailed(let m): return String(appLoc: "生成失败:\(m)")
            case .cancelled:               return String(appLoc: "已取消")
            }
        }
    }

    // MARK: - Entry point

    /// 主入口。返回事件流;UI 关闭 sheet → stream 取消 → Service 不入库。
    /// 调用方需在 MainActor。
    func export(prompt: String,
                in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
        AsyncThrowingStream { continuation in
            let task = Task { @MainActor in
                do {
                    // —— 预热模型(幂等) ——
                    do {
                        try await AIRuntime.shared.prepare()
                    } catch {
                        throw ServiceError.modelNotReady
                    }

                    // —— Phase 1: 抽意图 ——
                    continuation.yield(.phaseChanged(.extractingIntent))
                    let intent = await Self.extractIntent(userPrompt: prompt)
                    try Task.checkCancellation()

                    // —— Phase 2: 检索 ——
                    continuation.yield(.phaseChanged(.retrieving))
                    let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
                    continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
                    try Task.checkCancellation()

                    // —— Phase 3: 生成 ——
                    continuation.yield(.phaseChanged(.generating))
                    let dataJSON = Self.serializeData(snapshot: snapshot)

                    var generated = ""
                    var lastRate: Double = 0

                    if Self.isEffectivelyEmpty(snapshot) {
                        // 没有任何真实记录:跳过 LLM,直接产出确定性「无记录」摘要,
                        // 从根上杜绝小模型在空数据上编造病例(用户红线:严格按历史信息)。
                        generated = Self.fallbackReport(label: intent.labelCN, userPrompt: prompt)
                        continuation.yield(.token(TokenChunk(text: generated, decodeRate: 0)))
                    } else {
                        let genPrompt = HealthExportPrompts.reportGeneration(
                            userPrompt: prompt,
                            intentLabelCN: intent.labelCN,
                            dataJSON: dataJSON
                        )

                        // —— 流式去 <think>...</think> 兜底 ——
                        // Prompt 里已加 Qwen3 的 `/no_think`,但模型偶尔仍带 thinking。
                        // 用「全文累计 + 每 chunk 重清 + diff yield」:
                        // - thinking 阶段,UI 看到的 generated 始终为空
                        // - 看到 </think> 后,真实内容流式出现
                        var stripper = ThinkStripper()
                        let stream = await AIRuntime.shared.generate(
                            prompt: genPrompt,
                            maxTokens: 1024
                        )
                        for try await chunk in stream {
                            try Task.checkCancellation()
                            if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
                            let delta = stripper.feed(chunk.text)
                            if !delta.isEmpty {
                                continuation.yield(.token(TokenChunk(
                                    text: delta,
                                    decodeRate: chunk.decodeRate
                                )))
                            }
                        }
                        generated = stripper.output
                    }

                    guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
                        throw ServiceError.generationFailed("模型未输出任何内容")
                    }

                    // —— 追加确定性趋势段(不经 LLM,零编造) ——
                    let trendBlock = Self.trendSection(snapshot.trends)
                    if !trendBlock.isEmpty {
                        generated += trendBlock
                        continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
                    }

                    // —— Phase 4: 持久化 ——
                    let export = HealthExport(
                        prompt: prompt,
                        content: generated,
                        referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
                        referencedReportIDs:    snapshot.reports.map    { Self.idString($0.persistentModelID) },
                        referencedSymptomIDs:   snapshot.symptoms.map   { Self.idString($0.persistentModelID) },
                        referencedDiaryIDs:     snapshot.diaries.map    { Self.idString($0.persistentModelID) },
                        inferredTimeFromDate: snapshot.fromDate,
                        inferredTimeToDate: snapshot.toDate,
                        inferredIntent: intent.intent,
                        inferredLabelCN: intent.labelCN,
                        modelTag: ModelKind.llm.rawValue,   // 取实际加载的 LLM tag,而非写死默认值(本地推理凭证 §12#6)
                        decodeRate: lastRate
                    )
                    modelContext.insert(export)
                    do { try modelContext.save() } catch {
                        // 保存失败不阻塞 UI 显示文本;仅记日志(W6 可接 telemetry)
                        print("[HealthExportService] save failed: \(error)")
                    }
                    continuation.yield(.phaseChanged(.completed))
                    continuation.yield(.completed(persistentID: export.persistentModelID))
                    continuation.finish()
                } catch is CancellationError {
                    continuation.finish(throwing: ServiceError.cancelled)
                } catch let e as ServiceError {
                    continuation.finish(throwing: e)
                } catch {
                    continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
                }
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }

    /// 多轮导出页的单轮问答。只回答,不入库。
    /// 事件流:先 .retrieved(检索可视化),后 .token 流式正文;不发 .phaseChanged / .completed。
    func answer(question: String,
                conversation: [HealthExportDialogueTurn],
                in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
        AsyncThrowingStream { continuation in
            let task = Task { @MainActor in
                do {
                    do {
                        try await AIRuntime.shared.prepare()
                    } catch {
                        throw ServiceError.modelNotReady
                    }

                    let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
                    continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
                    let dataJSON = Self.serializeData(snapshot: snapshot)
                    let transcript = HealthExportDialogueTurn.transcript(from: conversation)
                    let prompt = HealthExportPrompts.dialogueAnswer(
                        latestQuestion: question,
                        transcript: transcript,
                        dataJSON: dataJSON
                    )

                    var stripper = ThinkStripper()
                    let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480)
                    for try await chunk in stream {
                        try Task.checkCancellation()
                        let delta = stripper.feed(chunk.text)
                        if !delta.isEmpty {
                            continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
                        }
                    }
                    let displayed = stripper.output

                    guard !displayed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
                        throw ServiceError.generationFailed("模型未输出任何内容")
                    }
                    continuation.finish()
                } catch is CancellationError {
                    continuation.finish(throwing: ServiceError.cancelled)
                } catch let e as ServiceError {
                    continuation.finish(throwing: e)
                } catch {
                    continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
                }
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }

    /// 多轮导出页的最终报告生成。保存为现有 HealthExport 历史。
    func export(conversation: [HealthExportDialogueTurn],
                in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
        AsyncThrowingStream { continuation in
            let task = Task { @MainActor in
                do {
                    do {
                        try await AIRuntime.shared.prepare()
                    } catch {
                        throw ServiceError.modelNotReady
                    }

                    continuation.yield(.phaseChanged(.retrieving))
                    let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
                    continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
                    let dataJSON = Self.serializeData(snapshot: snapshot)
                    let transcript = HealthExportDialogueTurn.transcript(from: conversation)
                    try Task.checkCancellation()

                    continuation.yield(.phaseChanged(.generating))
                    let genPrompt = HealthExportPrompts.dialogueReportGeneration(
                        transcript: transcript,
                        dataJSON: dataJSON
                    )

                    var lastRate: Double = 0
                    var stripper = ThinkStripper()
                    let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200)
                    for try await chunk in stream {
                        try Task.checkCancellation()
                        if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
                        let delta = stripper.feed(chunk.text)
                        if !delta.isEmpty {
                            continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
                        }
                    }
                    var generated = stripper.output

                    guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
                        throw ServiceError.generationFailed("模型未输出任何内容")
                    }

                    // —— 追加确定性趋势段(不经 LLM,零编造) ——
                    let trendBlock = Self.trendSection(snapshot.trends)
                    if !trendBlock.isEmpty {
                        generated += trendBlock
                        continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
                    }

                    let export = HealthExport(
                        prompt: transcript,
                        content: generated,
                        referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
                        referencedReportIDs: [],
                        referencedSymptomIDs: [],
                        referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
                        inferredTimeFromDate: snapshot.fromDate,
                        inferredTimeToDate: snapshot.toDate,
                        inferredIntent: "dialogue_export",
                        inferredLabelCN: "对话整理",
                        modelTag: ModelKind.llm.rawValue,
                        decodeRate: lastRate
                    )
                    modelContext.insert(export)
                    do { try modelContext.save() } catch {
                        print("[HealthExportService] save failed: \(error)")
                    }
                    continuation.yield(.phaseChanged(.completed))
                    continuation.yield(.completed(persistentID: export.persistentModelID))
                    continuation.finish()
                } catch is CancellationError {
                    continuation.finish(throwing: ServiceError.cancelled)
                } catch let e as ServiceError {
                    continuation.finish(throwing: e)
                } catch {
                    continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
                }
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }

    // MARK: - Phase 1: intent extraction

    struct Intent: Sendable {
        var timeRangeDays: Int
        var keywords: [String]
        var symptomKeywords: [String]
        var intent: String
        var labelCN: String

        /// 兜底:抽不出 → 30 天 + 空关键词。
        static let fallback = Intent(
            timeRangeDays: 30,
            keywords: [],
            symptomKeywords: [],
            intent: "general_review",
            labelCN: "近期健康摘要"
        )
    }

    /// 调一次 LLM 拿 JSON,失败用 `Intent.fallback`。
    /// 不流式 —— 直接拼成完整字符串再解析。
    private static func extractIntent(userPrompt: String) async -> Intent {
        let prompt = HealthExportPrompts.intentExtraction(userPrompt: userPrompt)
        var collected = ""
        do {
            let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200)
            for try await chunk in stream {
                collected += chunk.text
            }
        } catch {
            return .fallback
        }
        return parseIntent(collected) ?? .fallback
    }

    /// 解析 JSON。容错:抠出第一段 `{…}`,缺字段填默认值。
    /// 公开 (internal) 给单测调用。
    static func parseIntent(_ raw: String) -> Intent? {
        let jsonString = CaptureService.extractJSONObject(from: raw)
        guard let data = jsonString.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
              let dict = obj as? [String: Any] else {
            return nil
        }
        let days = clampDays(dict["time_range_days"])
        let keywords = stringArray(dict["keywords"])
        let symptomKeywords = stringArray(dict["symptom_keywords"])
        let intent = (dict["intent"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "general_review"
        let labelCN = (dict["intent_label_cn"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "近期健康摘要"
        return Intent(
            timeRangeDays: days,
            keywords: keywords,
            symptomKeywords: symptomKeywords,
            intent: intent.isEmpty ? "general_review" : intent,
            labelCN: labelCN.isEmpty ? "近期健康摘要" : labelCN
        )
    }

    private static func clampDays(_ raw: Any?) -> Int {
        if let n = raw as? Int { return max(1, min(365, n)) }
        if let n = raw as? Double { return max(1, min(365, Int(n))) }
        if let s = raw as? String, let n = Int(s) { return max(1, min(365, n)) }
        return 30
    }

    private static func stringArray(_ raw: Any?) -> [String] {
        guard let arr = raw as? [Any] else { return [] }
        return arr.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespaces) }
                  .filter { !$0.isEmpty }
    }

    // MARK: - Phase 2: retrieve

    struct Snapshot {
        var fromDate: Date
        var toDate: Date
        var indicators: [Indicator]
        var symptoms: [Symptom]
        var reports: [Report]
        var diaries: [DiaryEntry]
        var profile: UserProfile
        /// 药品库(用户「我有哪些药」清单)→ AI 背景 current_meds。空 → 不写该字段。
        var medications: [Medication] = []
        /// 相关指标的趋势行(确定性计算,不进 LLM)。空 → 不渲染「## 指标趋势」段。
        var trends: [ExportTrend] = []
    }

    /// 同步 SwiftData 查询。@MainActor。
    private static func retrieve(intent: Intent, ctx: ModelContext) -> Snapshot {
        let toDate = Date()
        let fromDate = Calendar.current.date(
            byAdding: .day, value: -intent.timeRangeDays, to: toDate
        ) ?? toDate.addingTimeInterval(-30 * 86400)

        // —— Indicators(时间窗 + 关键词软过滤) ——
        let indDesc = FetchDescriptor<Indicator>(
            predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
            sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
        )
        let allInWindow = (try? ctx.fetch(indDesc)) ?? []
        var indicators = allInWindow
        if !intent.keywords.isEmpty {
            let filtered = indicators.filter { ind in
                intent.keywords.contains { kw in
                    ind.name.localizedCaseInsensitiveContains(kw)
                }
            }
            // 关键词命中为主,但保留所有异常项(避免漏掉医生关心的)
            let abnormal = indicators.filter { $0.status != .normal }
            let combined = (filtered + abnormal).reduce(into: [Indicator]()) { acc, x in
                if !acc.contains(where: { $0.persistentModelID == x.persistentModelID }) {
                    acc.append(x)
                }
            }
            indicators = combined.isEmpty ? indicators : combined
        }
        indicators = Array(indicators.prefix(20))

        // —— Symptoms(时间窗有交叠) ——
        let symptomDesc = FetchDescriptor<Symptom>(
            sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
        )
        let allSymptoms = (try? ctx.fetch(symptomDesc)) ?? []
        let symptoms = Array(
            allSymptoms.filter { sym in
                let overlapsStart = sym.startedAt <= toDate
                let overlapsEnd = (sym.endedAt ?? Date.distantFuture) >= fromDate
                return overlapsStart && overlapsEnd
            }.prefix(10)
        )

        // —— Reports(时间窗) ——
        let reportDesc = FetchDescriptor<Report>(
            predicate: #Predicate { $0.reportDate >= fromDate && $0.reportDate <= toDate },
            sortBy: [SortDescriptor(\.reportDate, order: .reverse)]
        )
        let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))

        // —— Diary ——
        // 有具体症状词 → 按词过滤(targeted,保留隐私);
        // 无症状词(泛化请求,如「最近身体异常」)→ 纳入时间窗内最近 5 条日记。
        // 之前「无词即清空」会让真实记录完全不进 prompt → 数据为空 → 小模型编造,是本次 bug 主因之一。
        let diaryDesc = FetchDescriptor<DiaryEntry>(
            predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        let allDiaries = (try? ctx.fetch(diaryDesc)) ?? []
        let diaries: [DiaryEntry]
        if intent.symptomKeywords.isEmpty {
            diaries = Array(allDiaries.prefix(5))
        } else {
            diaries = Array(
                allDiaries.filter { d in
                    intent.symptomKeywords.contains { kw in
                        d.content.localizedCaseInsensitiveContains(kw)
                    }
                }.prefix(5)
            )
        }

        // —— Profile(单例) ——
        let profile = UserProfileStore.loadOrCreate(in: ctx)

        // —— 药品库(全量,作为 AI 背景 current_meds) ——
        let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []

        // —— 趋势(确定性,不进 LLM) ——
        // 用全量 in-window 还原完整序列;裁剪后的 indicators 决定哪些 series 相关。
        let trends = ExportTrendBuilder.build(
            allInWindow: allInWindow,
            relevant: indicators,
            profile: profile
        )

        return Snapshot(
            fromDate: fromDate,
            toDate: toDate,
            indicators: indicators,
            symptoms: symptoms,
            reports: reports,
            diaries: diaries,
            profile: profile,
            medications: medications,
            trends: trends
        )
    }

    /// 多轮导出使用全量指标 + 健康日记作为上下文。为控制 prompt 体积,日记正文在序列化阶段截断。
    static func retrieveDialogueSnapshot(ctx: ModelContext) -> Snapshot {
        let indicatorDesc = FetchDescriptor<Indicator>(
            sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
        )
        let diaryDesc = FetchDescriptor<DiaryEntry>(
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
        let diaries = (try? ctx.fetch(diaryDesc)) ?? []
        let profile = UserProfileStore.loadOrCreate(in: ctx)
        let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []

        let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
        let fromDate = dates.min() ?? Date()
        let toDate = dates.max() ?? Date()

        // 多轮导出用全量指标,全部视为相关。
        let trends = ExportTrendBuilder.build(
            allInWindow: indicators,
            relevant: indicators,
            profile: profile
        )

        return Snapshot(
            fromDate: fromDate,
            toDate: toDate,
            indicators: indicators,
            symptoms: [],
            reports: [],
            diaries: diaries,
            profile: profile,
            medications: medications,
            trends: trends
        )
    }

    // MARK: - Phase 3: serialize data for prompt

    /// 把 Snapshot 序列化成给 LLM 的精简 JSON。
    /// 不用 Codable —— 字段命名要保持 prompt 里描述的英文 key,顺序也要稳定。
    static func serializeData(snapshot: Snapshot) -> String {
        let df = DateFormatter()
        df.locale = Locale(identifier: "en_US_POSIX")
        df.dateFormat = "yyyy-MM-dd"

        let profile = snapshot.profile
        var root: [String: Any] = [:]

        // profile
        var profDict: [String: Any] = [:]
        if let age = profile.age { profDict["age"] = age }
        let sexLabel = profile.sex.label
        if profile.sex != .undisclosed { profDict["sex"] = sexLabel }
        if let h = profile.heightCM { profDict["height_cm"] = h }
        if let w = profile.weightKG {
            profDict["weight_kg"] = w.truncatingRemainder(dividingBy: 1) == 0
                ? Int(w) : Double(round(w * 10) / 10)
        }
        if !profile.bloodTypeRaw.isEmpty { profDict["blood_type"] = profile.bloodTypeRaw }
        if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
        if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
        if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
        // current_meds 改读药品库(Medication);旧 profile.currentMedications 已停用。
        let medNames = snapshot.medications.map { m in
            m.detailLine.isEmpty ? m.name : "\(m.name) \(m.detailLine)"
        }
        if !medNames.isEmpty { profDict["current_meds"] = medNames }
        root["profile"] = profDict

        // symptoms
        root["symptoms"] = snapshot.symptoms.map { s -> [String: Any] in
            var d: [String: Any] = [
                "name": s.name,
                "started": df.string(from: s.startedAt),
                "severity": s.severity,
                "ongoing": s.isOngoing
            ]
            if let ended = s.endedAt { d["ended"] = df.string(from: ended) }
            if let note = s.note, !note.isEmpty { d["note"] = note }
            return d
        }

        // indicators
        root["indicators"] = snapshot.indicators.map { i -> [String: Any] in
            [
                "name": i.name,
                "value": i.value,
                "unit": i.unit,
                "range": i.range,
                "status": i.status.rawValue,
                "date": df.string(from: i.capturedAt)
            ]
        }

        // reports
        root["reports"] = snapshot.reports.map { r -> [String: Any] in
            var d: [String: Any] = [
                "title": r.title,
                "type": r.type.label,
                "date": df.string(from: r.reportDate)
            ]
            if let inst = r.institution, !inst.isEmpty { d["institution"] = inst }
            if let sum = r.summary, !sum.isEmpty { d["summary"] = sum }
            return d
        }

        // diaries
        root["diaries"] = snapshot.diaries.map { d -> [String: Any] in
            let excerpt = String(d.content.prefix(80))
            return [
                "date": df.string(from: d.createdAt),
                "excerpt": excerpt
            ]
        }

        // 时间窗也给 LLM 看
        root["time_window"] = [
            "from": df.string(from: snapshot.fromDate),
            "to": df.string(from: snapshot.toDate)
        ]

        guard let data = try? JSONSerialization.data(
            withJSONObject: root,
            options: [.prettyPrinted, .sortedKeys]
        ),
              let str = String(data: data, encoding: .utf8) else {
            return "{}"
        }
        return str
    }

    // MARK: - 空数据兜底(杜绝编造)

    /// 检索结果是否「实质为空」:无症状/指标/报告/日记,且 profile 也没有任何可写字段。
    /// 为真时跳过 LLM,改用确定性「无记录」摘要,避免小模型凭先验编造病例。
    static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
        guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty,
              s.diaries.isEmpty, s.medications.isEmpty else {
            return false
        }
        let p = s.profile
        return p.age == nil
            && p.sex == .undisclosed
            && p.heightCM == nil
            && p.weightKG == nil
            && p.bloodTypeRaw.isEmpty
            && p.allergies.isEmpty
            && p.chronicConditions.isEmpty
            && p.familyHistory.isEmpty
    }

    /// 无真实记录时的确定性摘要:6 段全「无记录」,主诉仅照搬本人原话,不做任何推断。
    static func fallbackReport(label: String, userPrompt: String) -> String {
        let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)"
        let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
        let complaintLine = complaint.isEmpty ? "无记录" : complaint
        return """
        \(title)

        > 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据本人原话,未做任何推断。

        ## 主诉
        \(complaintLine)

        ## 本人背景
        无记录

        ## 近期症状(按时间倒序)
        无记录

        ## 关键指标(异常项优先)
        无记录

        ## 在服药与过敏
        无记录

        ## 本人疑问
        无记录
        """
    }

    /// 把趋势行拼成追加到 LLM 输出末尾的「## 指标趋势」段。空 → 返回空串(整段省略)。
    static func trendSection(_ trends: [ExportTrend]) -> String {
        guard !trends.isEmpty else { return "" }
        return "\n\n## 指标趋势\n" + trends.map { $0.line() }.joined(separator: "\n")
    }

    // MARK: - Helpers

    /// 把 SwiftData persistentModelID 编成稳定字符串。
    /// W3 引用回链跳源记录时,用这个字符串反查(暂未实现)。
    private static func idString(_ id: PersistentIdentifier) -> String {
        String(describing: id)
    }

    // MARK: - <think> 标签清理

    /// 在全文累计上做一次性清理,返回应展示给用户的干净文本。
    /// 用「累计 + 重清 + diff yield」方式调用,确保:
    /// - 配对 `<think>...</think>` 整段移除(包括空 think 块)
    /// - 未闭合 `<think>...`(还没等到闭标签)→ 全部暂存,等闭标签出现再放
    /// - Qwen3 偶尔只吐 `</think>` 闭标签 → 它之前的内容也当 thinking 丢弃
    /// - 头部空白 trim,避免 `## 标题` 前面有多余空行
    static func stripThinkBlocks(_ raw: String) -> String {
        var s = raw

        // 1. 反复删配对 <think>...</think>(包括 think 块体为空的情况)
        while let openR = s.range(of: "<think>"),
              let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
            s.removeSubrange(openR.lowerBound..<closeR.upperBound)
        }

        // 2. 未闭合的开标签:开标签之后的全部当未完成的思考,先不显示
        if let openR = s.range(of: "<think>") {
            s = String(s[..<openR.lowerBound])
        }

        // 3. 孤立闭标签(Qwen3 偶尔无开标签):闭标签之前全部当思考丢弃
        if let closeR = s.range(of: "</think>") {
            s = String(s[closeR.upperBound...])
        }

        // 4. 顶部空白 trim
        while let first = s.first, first.isWhitespace {
            s.removeFirst()
        }
        return s
    }
}

/// 流式 `<think>` 去除器:逐 chunk 喂入,返回这次应增量 yield 的 delta。
///
/// 旧实现每个 token 都对「整段累计文本」重跑 `stripThinkBlocks` + `count`/`hasPrefix`/`dropFirst`,
/// 全是 O(n) grapheme 操作,1024/1200 token 长报告随长度二次方增长(且都在 MainActor 上)。
/// 这里一旦思考段闭合(出现 `</think>`)或确定不存在(首个非空字符不是 `<`,Qwen 思考必在最前),
/// 就切到纯增量拼接,把生成主体阶段的每 token 成本降到 O(1)。最坏情况退化为旧行为,无正确性风险。
private struct ThinkStripper {
    private var rawAccum = ""
    private(set) var output = ""
    private var resolved = false

    mutating func feed(_ piece: String) -> String {
        rawAccum += piece
        if resolved {
            output += piece        // 快路径:思考段已解析,直接增量
            return piece
        }
        let clean = HealthExportService.stripThinkBlocks(rawAccum)
        var delta = ""
        if clean.count > output.count, clean.hasPrefix(output) {
            delta = String(clean.dropFirst(output.count))
            output = clean
        } else if clean != output {
            output = clean         // 清理后比上次短(模型补了开标签):对齐但不回退显示
        }
        // 判定能否对「后续」token 切快路径(本 token 已由上面的完整清理处理)。
        if rawAccum.contains("</think>") {
            resolved = true                                                   // 思考段已闭合,其后纯增量
        } else if let c = rawAccum.first(where: { !$0.isWhitespace }), c != "<" {
            resolved = true                                                   // 开头非 '<' → 不存在 <think>
        }
        return delta
    }
}
