import SwiftUI
import SwiftData

/// 「导出身体档案」全屏 sheet。
/// 状态机:多轮问答 → running(retrieving → generating)→ completed / failed
struct HealthExportSheet: View {
    @Environment(\.modelContext) private var ctx
    @Environment(\.dismiss) private var dismiss

    /// 可选:从历史「重新生成」时传入(暂时未启用,W3 接)。
    let initialPrompt: String

    @State private var turns: [HealthExportDialogueTurn] = []
    @State private var draftQuestion: String = ""
    @State private var phase: HealthExportService.Phase?
    @State private var content: String = ""
    @State private var rate: Double = 0
    @State private var task: Task<Void, Never>?
    @State private var error: Error?
    @State private var completed: Bool = false
    @State private var copiedFlash: Bool = false
    @State private var lastScrollAt: Date = .distantPast   // 流式滚动节流时间戳
    @State private var answeringTurnID: UUID?
    @State private var retrieval: HealthExportService.RetrievalSummary?
    @State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
    @FocusState private var questionFocused: Bool

    // 快捷问答
    @State private var promptStore = QuickPromptStore.shared
    @State private var showAddPrompt = false
    @State private var newPromptText = ""

    init(initialPrompt: String = "") {
        self.initialPrompt = initialPrompt
    }

    private var isGeneratingReport: Bool { phase != nil && !completed && error == nil }
    private var isAnswering: Bool { answeringTurnID != nil }
    private var canAsk: Bool {
        !isAnswering &&
        !isGeneratingReport &&
        !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }
    /// 已有有效用户对话内容。
    private var hasUserContent: Bool {
        turns.contains(where: { $0.role == .user && !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
    }

    /// 可生成报告:有对话内容,或输入框里有文字(允许跳过多轮对话直接生成)。
    private var canGenerateReport: Bool {
        !isAnswering &&
        !isGeneratingReport &&
        (hasUserContent || !draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
    }

    var body: some View {
        VStack(spacing: 0) {
            header
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(alignment: .leading, spacing: 18) {
                        introSection

                        ForEach(turns) { turn in
                            dialogueBubble(turn)
                        }

                        if isGeneratingReport { phaseIndicator }

                        if !content.isEmpty {
                            reportCard
                        }

                        if let err = error { errorRow(err) }
                        Color.clear.frame(height: 1).id("bottom")
                    }
                    .padding(.horizontal, 20)
                    .padding(.vertical, 16)
                }
                // 流式期间 content / turns 每 token 都变,逐次动画滚动会造成布局抖动;
                // 节流到 ~8Hz,并在生成完成时补一次滚动确保停在底部。
                .onChange(of: content) { _, _ in throttledScrollToBottom(proxy) }
                .onChange(of: turns) { _, _ in throttledScrollToBottom(proxy) }
                .onChange(of: completed) { _, done in
                    if done { scrollToBottom(proxy) }
                }
                .onChange(of: answeringTurnID) { _, id in
                    if id == nil { scrollToBottom(proxy) }
                }
            }
            if completed {
                actionRow
            } else {
                composer
            }
        }
        .background(Tj.Palette.sand.ignoresSafeArea())
        .onAppear {
            if !initialPrompt.isEmpty, draftQuestion.isEmpty, turns.isEmpty {
                draftQuestion = initialPrompt
            }
            questionFocused = true
        }
        .onDisappear { task?.cancel() }
        .alert("添加快捷问答", isPresented: $showAddPrompt) {
            TextField("输入一句常用问题…", text: $newPromptText)
            Button("取消", role: .cancel) { newPromptText = "" }
            Button("添加") {
                promptStore.add(prompt: newPromptText)
                newPromptText = ""
            }
        } message: {
            Text("保存后点一下,就能把这句话填进输入框")
        }
    }

    // MARK: - 快捷问答

    /// 内置 + 自定义快捷问答 chip 行;点 chip 填入输入框,末尾「＋ 自定义」追加,长按自定义删除。
    private var quickPromptRow: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 8) {
                ForEach(promptStore.all) { p in
                    quickPromptChip(p)
                }
                addQuickPromptChip
            }
            .padding(.vertical, 1)   // 给 chip 描边留出像素,避免被 ScrollView 裁切
        }
    }

    private func quickPromptChip(_ p: QuickPrompt) -> some View {
        Button {
            draftQuestion = p.prompt
            questionFocused = true
        } label: {
            Text(p.title)
                .font(.tjScaled( 12, weight: .medium))
                .foregroundStyle(Tj.Palette.text)
                .lineLimit(1)
                .padding(.horizontal, 12)
                .padding(.vertical, 7)
                .background(Capsule().fill(Tj.Palette.sand2))
                .overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
        }
        .buttonStyle(.plain)
        .contextMenu {
            if !p.isBuiltin {
                Button(role: .destructive) {
                    promptStore.delete(p)
                } label: {
                    Label("删除", systemImage: "trash")
                }
            }
        }
    }

    private var addQuickPromptChip: some View {
        Button { showAddPrompt = true } label: {
            Label("自定义", systemImage: "plus")
                .font(.tjScaled( 12, weight: .medium))
                .foregroundStyle(Tj.Palette.text2)
                .padding(.horizontal, 12)
                .padding(.vertical, 7)
                .background(Capsule().fill(Tj.Palette.paper))
                .overlay(
                    Capsule().strokeBorder(
                        Tj.Palette.line,
                        style: StrokeStyle(lineWidth: 1, dash: [3])
                    )
                )
        }
        .buttonStyle(.plain)
    }

    // MARK: - Header

    private var header: some View {
        HStack(alignment: .center, spacing: 12) {
            Button { close() } label: {
                Image(systemName: "xmark")
                    .font(.tjScaled( 16, weight: .semibold))
                    .foregroundStyle(Tj.Palette.text)
                    .frame(width: 32, height: 32)
                    .background(Circle().fill(Tj.Palette.sand2))
            }
            VStack(alignment: .leading, spacing: 2) {
                Text("身体档案")
                    .font(.tjH2())
                    .foregroundStyle(Tj.Palette.text)
                Text("先问清楚,再整理给医生")
                    .font(.tjScaled( 11))
                    .foregroundStyle(Tj.Palette.text3)
            }
            Spacer()
            TjLockChip()
        }
        .padding(.horizontal, 20)
        .padding(.vertical, 14)
        .background(Tj.Palette.sand)
        .overlay(alignment: .bottom) {
            Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
        }
    }

    // MARK: - Dialogue

    private var introSection: some View {
        VStack(alignment: .leading, spacing: 14) {
            Text("围绕你的指标和健康日记提问")
                .font(.tjScaled( 13, weight: .semibold))
                .foregroundStyle(Tj.Palette.text2)

            quickPromptRow

            Text("上下文:全部记录指标 + 健康日记 · 本地 RAG · 不上传任何数据")
                .font(.tjScaled( 11))
                .foregroundStyle(Tj.Palette.text3)
        }
        .padding(14)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
                .fill(Tj.Palette.paper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
                .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
        )
    }

    private func dialogueBubble(_ turn: HealthExportDialogueTurn) -> some View {
        let isUser = turn.role == .user
        return HStack(alignment: .top, spacing: 8) {
            if isUser { Spacer(minLength: 44) }
            VStack(alignment: .leading, spacing: 6) {
                Text(turn.role.transcriptLabel)
                    .font(.tjScaled( 11, weight: .semibold))
                    .foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
                if !isUser, let summary = turnRetrievals[turn.id] {
                    RetrievalChipsView(summary: summary)
                }
                if turn.id == answeringTurnID && turn.text.isEmpty {
                    VStack(alignment: .leading, spacing: 8) {
                        Text(turnRetrievals[turn.id] == nil
                             ? "正在查看本地记录…"
                             : "正在根据这些记录回答…")
                            .font(.tjScaled( 13))
                            .foregroundStyle(Tj.Palette.text3)
                        AIFlowBar()
                    }
                } else {
                    Text(turn.text)
                        .font(.tjScaled( 14))
                        .lineSpacing(3)
                        .foregroundStyle(isUser ? Tj.Palette.paper : Tj.Palette.text)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
            .padding(12)
            .frame(maxWidth: 300, alignment: .leading)
            .background(
                RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
                    .fill(isUser ? Tj.Palette.ink : Tj.Palette.paper)
            )
            .overlay(
                RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
                    .strokeBorder(isUser ? Color.clear : Tj.Palette.lineSoft, lineWidth: 1)
            )
            if !isUser { Spacer(minLength: 44) }
        }
    }

    private var reportCard: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("整理好的报告")
                .font(.tjScaled( 13, weight: .semibold))
                .foregroundStyle(Tj.Palette.text2)
            MarkdownView(text: content)

            if completed {
                Divider().background(Tj.Palette.lineSoft)
                AIDisclaimerFooter()
            }
        }
        .padding(16)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
                .fill(Tj.Palette.paper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
                .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
        )
    }

    // MARK: - Phase indicator

    private var phaseIndicator: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(spacing: 10) {
                phasePill(.extractingIntent)
                arrow
                phasePill(.retrieving)
                arrow
                phasePill(.generating)
            }
            if let retrieval {
                RetrievalChipsView(summary: retrieval)
            }
            if phase == .generating && rate > 0 {
                Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
                    .font(.tjScaled( 11, design: .monospaced))
                    .foregroundStyle(Tj.Palette.leaf)
            } else {
                Text(phase?.label ?? "")
                    .font(.tjScaled( 11))
                    .foregroundStyle(Tj.Palette.text3)
            }

            // AI 计算中:多彩流光线(与日记 AI 辅助同一组件)
            AIFlowBar().padding(.top, 2)
        }
    }

    private func phasePill(_ p: HealthExportService.Phase) -> some View {
        let active = (p == phase)
        let done = phaseOrder(p) < phaseOrder(phase ?? .extractingIntent)
        let fill = active ? Tj.Palette.ink : (done ? Tj.Palette.leaf : Tj.Palette.sand2)
        let fg = (active || done) ? Tj.Palette.paper : Tj.Palette.text3
        return Text(p.label)
            .font(.tjScaled( 11, weight: active ? .semibold : .regular))
            .foregroundStyle(fg)
            .padding(.horizontal, 10)
            .padding(.vertical, 5)
            .background(Capsule().fill(fill))
    }

    private var arrow: some View {
        Image(systemName: "chevron.right")
            .font(.tjScaled( 10, weight: .semibold))
            .foregroundStyle(Tj.Palette.text3)
    }

    private func phaseOrder(_ p: HealthExportService.Phase) -> Int {
        switch p {
        case .extractingIntent: return 0
        case .retrieving:       return 1
        case .generating:       return 2
        case .completed:        return 3
        }
    }

    // MARK: - Error

    private func errorRow(_ err: Error) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            HStack(spacing: 8) {
                Image(systemName: "exclamationmark.triangle.fill")
                    .foregroundStyle(Tj.Palette.brick)
                Text(friendlyMessage(for: err))
                    .font(.tjScaled( 13))
                    .foregroundStyle(Tj.Palette.text)
            }
            Button { reset() } label: { Text("返回修改") }
                .buttonStyle(TjGhostButton(height: 40, fontSize: 13))
        }
        .padding(14)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                .fill(Tj.Palette.brickSoft.opacity(0.6))
        )
    }

    // MARK: - Action row (completed)

    private var actionRow: some View {
        HStack(spacing: 10) {
            Button { copy() } label: {
                Label(copiedFlash ? "已复制" : "复制", systemImage: copiedFlash ? "checkmark" : "doc.on.doc")
            }
            .buttonStyle(TjGhostButton(height: 44, fontSize: 13, horizontalPadding: 14))

            ShareLink(item: AIDisclaimer.appended(to: content)) {
                Label("分享", systemImage: "square.and.arrow.up")
                    .font(.tjScaled( 13, weight: .semibold))
                    .tracking(1)
                    .foregroundStyle(Tj.Palette.ink)
                    .padding(.horizontal, 14)
                    .frame(height: 44)
                    .background(Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1))
                    .contentShape(Capsule())   // 纯描边胶囊:内边距区也可点
            }

            Spacer()
            Button { regenerate() } label: {
                Label("重新整理", systemImage: "arrow.clockwise")
            }
            .buttonStyle(TjPrimaryButton(height: 44, fontSize: 13, horizontalPadding: 16))
        }
        .padding(.horizontal, 20)
        .padding(.vertical, 12)
        .background(Tj.Palette.paper)
        .overlay(alignment: .top) {
            Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
        }
    }

    private var composer: some View {
        VStack(spacing: 10) {
            HStack(spacing: 8) {
                TextField("写下要整理什么,或先提问补充情况…", text: $draftQuestion, axis: .vertical)
                    .font(.tjScaled( 14))
                    .lineLimit(1...4)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 10)
                    .background(
                        RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
                            .fill(Tj.Palette.sand2)
                    )
                    .focused($questionFocused)
                    .disabled(isAnswering || isGeneratingReport)

                Button { sendQuestion() } label: {
                    Image(systemName: "arrow.up")
                        .font(.tjScaled( 15, weight: .bold))
                        .foregroundStyle(Tj.Palette.paper)
                        .frame(width: 40, height: 40)
                        .background(Circle().fill(canAsk ? Tj.Palette.ink : Tj.Palette.line))
                }
                .disabled(!canAsk)
                .accessibilityLabel("发送问题")
            }

            if isGeneratingReport {
                Button { stopGeneration() } label: {
                    Label("停止生成", systemImage: "stop.fill")
                        .font(.tjScaled( 14, weight: .semibold))
                        .foregroundStyle(Tj.Palette.brick)
                        .frame(maxWidth: .infinity)
                        .frame(height: 44)
                        .background(
                            RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                                .strokeBorder(Tj.Palette.brick, lineWidth: 1)
                        )
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
            } else {
                Button { startReportGeneration() } label: {
                    Label("生成整理报告", systemImage: "doc.text.below.ecg")
                }
                .buttonStyle(TjPrimaryButton(height: 44, fontSize: 14))
                .disabled(!canGenerateReport)
                .opacity(canGenerateReport ? 1 : 0.45)
            }
        }
        .padding(.horizontal, 20)
        .padding(.vertical, 12)
        .background(Tj.Palette.paper)
        .overlay(alignment: .top) {
            Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
        }
    }

    // MARK: - Actions

    private func sendQuestion() {
        let question = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !question.isEmpty, !isAnswering, !isGeneratingReport else { return }
        draftQuestion = ""
        questionFocused = false

        let userTurn = HealthExportDialogueTurn.user(question)
        let assistantTurn = HealthExportDialogueTurn.assistant("")
        turns.append(userTurn)
        turns.append(assistantTurn)
        answeringTurnID = assistantTurn.id

        let conversationForPrompt = turns.filter { $0.id != assistantTurn.id }
        let stream = HealthExportService.shared.answer(
            question: question,
            conversation: conversationForPrompt,
            in: ctx
        )
        task?.cancel()
        task = Task { @MainActor in
            do {
                for try await event in stream {
                    switch event {
                    case .retrieved(let summary):
                        withAnimation(.snappy(duration: 0.25)) {
                            turnRetrievals[assistantTurn.id] = summary
                        }
                    case .token(let chunk):
                        appendToTurn(id: assistantTurn.id, text: chunk.text)
                        if chunk.decodeRate > 0 { rate = chunk.decodeRate }
                    case .phaseChanged, .completed:
                        break
                    }
                }
                answeringTurnID = nil
                questionFocused = true
            } catch {
                answeringTurnID = nil
                questionFocused = true
                if error is CancellationError { return }
                #if DEBUG
                print("[HealthExport] answer failed: \(error)")
                #endif
                // 已有部分回答就保留;否则给一句友好兜底,绝不把技术异常当成 AI 的回答展示。
                if let idx = turns.firstIndex(where: { $0.id == assistantTurn.id }),
                   turns[idx].text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                    turns[idx].text = "这次没能回答上来,请换个说法再试一次。"
                }
            }
        }
    }

    private func appendToTurn(id: UUID, text: String) {
        guard let idx = turns.firstIndex(where: { $0.id == id }) else { return }
        turns[idx].text += text
    }

    private func startReportGeneration() {
        guard canGenerateReport else { return }
        questionFocused = false

        // 直接生成:输入框里有文字(快捷问答/手输)就把它作为一条诉求追加进对话,
        // 不必先走多轮问答 —— 用户点一下「生成报告」即可。
        let draft = draftQuestion.trimmingCharacters(in: .whitespacesAndNewlines)
        if !draft.isEmpty {
            turns.append(.user(draft))
            draftQuestion = ""
        }

        content = ""
        rate = 0                 // 重新生成时清零,避免旧 tok/s 残留显示
        error = nil
        completed = false
        retrieval = nil
        phase = .retrieving

        let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
        task?.cancel()
        task = Task { @MainActor in
            do {
                for try await event in stream {
                    switch event {
                    case .phaseChanged(let ph):
                        phase = ph
                    case .retrieved(let summary):
                        withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
                    case .token(let chunk):
                        content += chunk.text
                        if chunk.decodeRate > 0 { rate = chunk.decodeRate }
                    case .completed:
                        completed = true
                    }
                }
            } catch {
                if error is CancellationError { return }
                #if DEBUG
                print("[HealthExport] export failed: \(error)")
                #endif
                self.error = error
                self.phase = nil
            }
        }
    }

    private func regenerate() {
        completed = false
        startReportGeneration()
    }

    /// 停止正在进行的报告生成:取消推理任务,回到可重新生成的干净态(已写的诉求保留在对话里)。
    private func stopGeneration() {
        task?.cancel()
        task = nil
        phase = nil
        rate = 0
        completed = false
        content = ""
        retrieval = nil
    }

    private func reset() {
        task?.cancel()
        task = nil
        phase = nil
        content = ""
        rate = 0
        error = nil
        completed = false
        answeringTurnID = nil
        retrieval = nil
        turnRetrievals = [:]
        questionFocused = true
    }

    private func copy() {
        UIPasteboard.general.string = AIDisclaimer.appended(to: content)
        copiedFlash = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
            copiedFlash = false
        }
    }

    private func close() {
        task?.cancel()
        dismiss()
    }

    // MARK: - 滚动

    private func scrollToBottom(_ proxy: ScrollViewProxy) {
        lastScrollAt = Date()
        withAnimation(.easeOut(duration: 0.12)) {
            proxy.scrollTo("bottom", anchor: .bottom)
        }
    }

    /// 节流滚动:流式 token 高频触发,限制到约 8Hz,避免每 token 一次动画布局。
    private func throttledScrollToBottom(_ proxy: ScrollViewProxy) {
        guard Date().timeIntervalSince(lastScrollAt) > 0.12 else { return }
        scrollToBottom(proxy)
    }

    /// 把推理过程中抛出的技术异常翻译成用户能看懂的一句话(取消不算错误)。
    private func friendlyMessage(for error: Error) -> String {
        if error is CancellationError { return "" }
        return "这次没能生成成功,请稍后重试。"
    }
}

// MARK: - 检索结果 chips(本地 RAG 可视化)

/// 生成开始前先把「本地 RAG 找到了什么」演出来:N 条记录 + 记录名 chips。
/// 结构化检索(不用 embedding)的天然优势 —— 每条命中都可解释、可展示(§12 卖点 3)。
private struct RetrievalChipsView: View {
    let summary: HealthExportService.RetrievalSummary

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            if summary.totalCount == 0 {
                Text("本地档案中暂无相关记录,将仅按你的描述整理")
                    .font(.tjScaled( 11))
                    .foregroundStyle(Tj.Palette.text3)
            } else {
                Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录"))
                    .font(.tjScaled( 11, weight: .medium))
                    .foregroundStyle(Tj.Palette.leaf)
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 6) {
                        ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in
                            Text(chip)
                                .font(.tjScaled( 11))
                                .foregroundStyle(Tj.Palette.text2)
                                .lineLimit(1)
                                .padding(.horizontal, 8)
                                .padding(.vertical, 4)
                                .background(Capsule().fill(Tj.Palette.sand2))
                                .overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1))
                        }
                    }
                    .padding(.vertical, 1)
                }
            }
        }
        .transition(.opacity.combined(with: .move(edge: .top)))
    }
}

// MARK: - 简易 Markdown 渲染(行级)

/// 极简 Markdown 渲染器,够给医生看的报告就行。
/// 支持: `# 一级`、`## 二级`、`-` 列表、`**粗体**`(走 AttributedString 的 inline 解析)。
/// 不支持表格、代码块、链接 —— 报告生成 prompt 也不会让 LLM 输出这些。
struct MarkdownView: View {
    let text: String

    var body: some View {
        let blocks = Self.parse(text)
        VStack(alignment: .leading, spacing: 10) {
            ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
                renderBlock(block)
            }
        }
    }

    @ViewBuilder
    private func renderBlock(_ block: Block) -> some View {
        switch block {
        case .h1(let s):
            VStack(alignment: .leading, spacing: 8) {
                Text(inline(s))
                    .font(.tjScaled( 22, weight: .bold))
                    .foregroundStyle(Tj.Palette.text)
                    .fixedSize(horizontal: false, vertical: true)
                Rectangle()
                    .fill(Tj.Palette.ink)
                    .frame(height: 1)
                    .frame(maxWidth: .infinity)
            }
            .padding(.top, 2)
            .padding(.bottom, 4)

        case .h2(let s):
            HStack(alignment: .center, spacing: 8) {
                RoundedRectangle(cornerRadius: 1.5, style: .continuous)
                    .fill(Tj.Palette.brick)
                    .frame(width: 3, height: 16)
                Text(inline(s))
                    .font(.tjScaled( 16, weight: .semibold))
                    .foregroundStyle(Tj.Palette.text)
            }
            .padding(.top, 10)
            .padding(.bottom, 2)

        case .bullet(let s):
            if let abnormalText = Self.extractAbnormal(s) {
                HStack(alignment: .firstTextBaseline, spacing: 8) {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .font(.tjScaled( 11))
                        .foregroundStyle(Tj.Palette.brick)
                    Text(inline(abnormalText))
                        .font(.tjScaled( 14, weight: .medium))
                        .foregroundStyle(Tj.Palette.text)
                        .fixedSize(horizontal: false, vertical: true)
                    Spacer(minLength: 0)
                }
                .padding(.horizontal, 10)
                .padding(.vertical, 7)
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(
                    RoundedRectangle(cornerRadius: 6, style: .continuous)
                        .fill(Tj.Palette.brickSoft.opacity(0.55))
                )
                .overlay(alignment: .leading) {
                    RoundedRectangle(cornerRadius: 1.5, style: .continuous)
                        .fill(Tj.Palette.brick)
                        .frame(width: 3)
                }
            } else {
                HStack(alignment: .firstTextBaseline, spacing: 10) {
                    Circle()
                        .fill(Tj.Palette.text3)
                        .frame(width: 4, height: 4)
                        .padding(.top, 6)
                    Text(inline(s))
                        .font(.tjScaled( 14))
                        .foregroundStyle(Tj.Palette.text)
                        .fixedSize(horizontal: false, vertical: true)
                }
                .padding(.leading, 2)
            }

        case .body(let s):
            Text(inline(s))
                .font(.tjScaled( 14))
                .lineSpacing(3)
                .foregroundStyle(Tj.Palette.text)
                .fixedSize(horizontal: false, vertical: true)

        case .gap:
            Spacer().frame(height: 4)
        }
    }

    /// 如果 bullet 文本以 ⚠️ 或常见异常关键词开头,返回 strip 掉前缀后的纯文本。
    /// 否则返回 nil(表示不是异常项)。
    private static func extractAbnormal(_ s: String) -> String? {
        let trimmed = s.trimmingCharacters(in: .whitespaces)
        if trimmed.hasPrefix("⚠️") {
            return trimmed.replacingOccurrences(of: "⚠️", with: "")
                          .trimmingCharacters(in: .whitespaces)
        }
        // 关键词兜底高亮,但排除否定语境(「无异常」「未见偏高」「没有偏低」等),
        // 否则正常结论会被误标红。判断:信号词前最近 4 字内出现否定词即视为否定。
        let negations = ["无", "未", "没"]
        let abnormalSignals = ["偏高", "偏低", "异常", "过高", "过低"]
        for sig in abnormalSignals {
            guard let r = trimmed.range(of: sig) else { continue }
            let window = String(trimmed[..<r.lowerBound].suffix(4))
            if negations.contains(where: { window.contains($0) }) { continue }
            return trimmed
        }
        return nil
    }

    /// 行内样式缓存:流式生成时整段会被反复重渲染,缓存命中可避免对同一行重复跑昂贵的
    /// markdown 解析。键为行文本,稳定的历史行命中缓存,只有正在增长的尾行才真正解析。
    private final class AttrBox { let value: AttributedString; init(_ v: AttributedString) { value = v } }
    private static let inlineCache: NSCache<NSString, AttrBox> = {
        let c = NSCache<NSString, AttrBox>()
        c.countLimit = 256
        return c
    }()

    private func inline(_ s: String) -> AttributedString {
        // 快路径:整段报告里绝大多数行(标题、普通条目、纯正文)都没有内联标记,
        // 直接走纯文本,跳过 AttributedString(markdown:) —— 这是流式期间最大的一笔开销。
        if !s.contains(where: { $0 == "*" || $0 == "_" || $0 == "[" || $0 == "`" }) {
            return AttributedString(s)
        }
        let key = s as NSString
        if let hit = Self.inlineCache.object(forKey: key) { return hit.value }
        // **bold** / *italic* / [text](url) 走 AttributedString markdown 解析
        let attr = (try? AttributedString(
            markdown: s,
            options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
        )) ?? AttributedString(s)
        Self.inlineCache.setObject(AttrBox(attr), forKey: key)
        return attr
    }

    // MARK: - 行级解析

    enum Block {
        case h1(String)
        case h2(String)
        case bullet(String)
        case body(String)
        case gap
    }

    static func parse(_ raw: String) -> [Block] {
        var out: [Block] = []
        let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
        for line in lines {
            let t = line.trimmingCharacters(in: .whitespaces)
            if t.isEmpty {
                // 连续空行折叠成一个 gap
                if case .gap = out.last { continue }
                out.append(.gap)
                continue
            }
            if t.hasPrefix("# ") {
                out.append(.h1(String(t.dropFirst(2))))
            } else if t.hasPrefix("## ") {
                out.append(.h2(String(t.dropFirst(3))))
            } else if t.hasPrefix("### ") {
                out.append(.h2(String(t.dropFirst(4))))
            } else if t.hasPrefix("- ") || t.hasPrefix("* ") {
                out.append(.bullet(String(t.dropFirst(2))))
            } else {
                out.append(.body(t))
            }
        }
        return out
    }
}

#Preview("HealthExportSheet · 空状态") {
    HealthExportSheet()
        .modelContainer(for: [
            Indicator.self, Report.self, DiaryEntry.self, Asset.self,
            ChatTurn.self, Symptom.self, UserProfile.self,
            MetricReminder.self, CustomMonitorMetric.self, HealthExport.self
        ], inMemory: true)
}

#Preview("MarkdownView · 演示") {
    ScrollView {
        MarkdownView(text: """
        # 就诊摘要 — 感冒就诊

        ## 主诉
        本人男,38 岁,感冒 3 天未愈,主诉鼻塞、咳嗽、低烧。

        ## 本人背景
        - 高血压 2 年
        - 在服药:**缬沙坦 80mg qd**
        - 过敏:青霉素

        ## 近期症状
        - 2026-05-24 感冒(进行中,severity 2):鼻塞、低烧
        - 2026-05-20 头痛(已结束)

        ## 关键指标
        - ⚠️ 收缩压 142 mmHg (参考 <140) — 2026-05-26
        - 体温 37.2 ℃ (参考 36-37) — 2026-05-25
        """)
        .padding()
    }
    .background(Tj.Palette.sand)
}
