import SwiftUI
import SwiftData

/// 「健康记录」录入 sheet。
/// 主体仍是 DiaryEntry @Model;UI/文案改为面向健康记录,并加 AI 辅助区:
/// 让 Qwen3 从医生问诊角度提 3-4 个追问,用户可一键将「补充模板」追加到输入框。
/// 支持多轮——每轮把已问过的 q 传给 LLM 要求别重复;已采纳的 row 灰色 + ✓ 标记。
struct DiaryQuickSheet: View {
    @Environment(\.modelContext) private var ctx
    @Environment(\.dismiss) private var dismiss

    @State private var content: String = ""
    @State private var createdAt: Date = .now

    /// AI 辅助状态
    enum AssistPhase {
        case idle               // 从未生成
        case loading            // 正在 LLM 调用
        case ready              // 有结果显示,等待下一轮 / 采纳 / 重试
        case failed(Error)      // 最近一次失败
    }
    @State private var phase: AssistPhase = .idle
    @State private var questions: [DiaryAssistService.Question] = []
    @State private var lastRate: Double = 0
    @State private var currentRound: Int = 0
    /// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
    @State private var coveredDims: Set<String> = []
    @State private var suggestTask: Task<Void, Never>?
    /// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。
    @State private var fillingId: UUID?
    /// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。
    @State private var fillValues: [String] = []
    /// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。
    @State private var exhaustedNote = false
    /// sheet detent。默认 large,确保建议面板有足够展示空间。
    /// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
    @State private var detent: PresentationDetent = .large
    @FocusState private var contentFocused: Bool

    // MARK: 语音输入状态(spec 2026-06-10-voice-diary)

    enum VoicePhase: Equatable { case idle, recording, organizing }
    @State private var voicePhase: VoicePhase = .idle
    @State private var liveTranscript = ""
    @State private var recordingSeconds = 0
    /// 最近一次最终转写稿,「改用原话」回退用;再次录音时覆盖。
    @State private var rawTranscript: String?
    /// 刚追加进正文的整理稿,用于「改用原话」时在正文中定位替换。
    /// 用户手动编辑掉该段(正文中找不到了)时 pill 自然消失。
    @State private var organizedAppended: String?
    /// 一次性提示条文案(整理失败已填原话 / 没听清等),开始新录音时清掉。
    @State private var voiceNote: String?
    @State private var voiceDeniedAlert = false
    @State private var voiceFlowTask: Task<Void, Never>?
    @State private var recordingWatchdog: Task<Void, Never>?
    private let dictation = SpeechDictationService()

    private var hasContent: Bool {
        !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }
    private var hasQuestions: Bool { !questions.isEmpty }
    private var isLoading: Bool {
        if case .loading = phase { return true }
        return false
    }
    private var canRequestSuggest: Bool { hasContent && !isLoading && voicePhase == .idle }
    private var canSubmit: Bool { hasContent }

    var body: some View {
        VStack(spacing: 0) {
            Capsule()
                .fill(Tj.Palette.line)
                .frame(width: 40, height: 4)
                .padding(.top, 10)
                .padding(.bottom, 14)

            HStack {
                VStack(alignment: .leading, spacing: 2) {
                    Text("健康记录")
                        .font(.tjH2())
                        .foregroundStyle(Tj.Palette.text)
                    Text("记录身体状态 · 可让 AI 多轮辅助查漏补缺")
                        .font(.tjScaled( 11))
                        .foregroundStyle(Tj.Palette.text3)
                }
                Spacer()
                Text("本机保存")
                    .font(.tjScaled( 12))
                    .foregroundStyle(Tj.Palette.text3)
            }
            .padding(.horizontal, 20)
            .padding(.bottom, 14)

            ScrollViewReader { proxy in
                ScrollView(showsIndicators: false) {
                    VStack(alignment: .leading, spacing: 16) {
                        VStack(alignment: .leading, spacing: 8) {
                            HStack {
                                sectionLabel(String(appLoc: "内容"))
                                Spacer()
                                if SpeechDictationService.isAvailable, voicePhase == .idle {
                                    Button(action: startVoice) {
                                        HStack(spacing: 4) {
                                            Image(systemName: "mic.fill")
                                                .font(.tjScaled(11, weight: .semibold))
                                            Text("说一段")
                                                .font(.tjScaled(12, weight: .semibold))
                                        }
                                        .foregroundStyle(isLoading ? Tj.Palette.text3 : Tj.Palette.brick)
                                        .padding(.horizontal, 10)
                                        .padding(.vertical, 5)
                                        .background(Capsule().strokeBorder(
                                            isLoading ? Tj.Palette.line : Tj.Palette.brick.opacity(0.5),
                                            lineWidth: 1))
                                        .contentShape(Capsule())
                                    }
                                    .buttonStyle(.plain)
                                    .disabled(isLoading)   // AI 追问生成中不抢 AIRuntime 队列
                                }
                            }
                            TextField("今天身体怎么样?吃了什么药、有什么感觉?",
                                      text: $content, axis: .vertical)
                                .lineLimit(3...8)
                                .focused($contentFocused)
                                .onChange(of: content) { _, _ in exhaustedNote = false }
                                .padding(.horizontal, 14)
                                .padding(.vertical, 12)
                                .background(
                                    RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                                        .fill(Tj.Palette.paper)
                                )
                                .overlay(
                                    RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                                        .strokeBorder(Tj.Palette.line, lineWidth: 1)
                                )

                            if voicePhase != .idle {
                                DiaryVoicePanel(
                                    mode: voicePhase == .organizing
                                        ? .organizing
                                        : .recording(elapsedSeconds: recordingSeconds),
                                    transcript: liveTranscript,
                                    onStop: stopVoiceAndOrganize,
                                    onCancelOrganize: cancelOrganize
                                )
                            }

                            if let note = voiceNote {
                                HStack(spacing: 6) {
                                    Image(systemName: "info.circle")
                                        .font(.tjScaled(11))
                                        .foregroundStyle(Tj.Palette.text3)
                                    Text(note)
                                        .font(.tjScaled(11))
                                        .foregroundStyle(Tj.Palette.text3)
                                    Spacer(minLength: 0)
                                }
                            }

                            if let organized = organizedAppended,
                               rawTranscript != nil,
                               content.range(of: organized) != nil {
                                Button(action: revertToRawTranscript) {
                                    HStack(spacing: 4) {
                                        Image(systemName: "arrow.uturn.backward")
                                            .font(.tjScaled(10, weight: .semibold))
                                        Text("改用原话")
                                            .font(.tjScaled(11, weight: .semibold))
                                    }
                                    .foregroundStyle(Tj.Palette.ink)
                                    .padding(.horizontal, 10)
                                    .padding(.vertical, 5)
                                    .background(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
                                    .contentShape(Capsule())
                                }
                                .buttonStyle(.plain)
                            }
                        }

                        assistSection

                        VStack(alignment: .leading, spacing: 8) {
                            sectionLabel(String(appLoc: "时间"))
                            DatePicker("", selection: $createdAt, in: ...Date.now)
                                .datePickerStyle(.compact)
                                .labelsHidden()
                        }
                        // 底部锚点,新一轮 question 进来后自动滚到这里
                        Color.clear.frame(height: 1).id("assist-bottom")
                    }
                    .padding(.horizontal, 20)
                    .padding(.bottom, 6)
                }
                .scrollDismissesKeyboard(.interactively)
                .onChange(of: questions.count) { old, new in
                    guard new > old else { return }
                    // 滚到新一轮的 round divider(让用户先看到「第 N 轮」的标签,
                    // 再依次看到这一轮的 questions)
                    let roundId = "round-\(questions[old].round)"
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                        withAnimation(.easeOut(duration: 0.25)) {
                            proxy.scrollTo(roundId, anchor: .top)
                        }
                    }
                }
            }

            HStack(spacing: 12) {
                Button("取消") { dismiss() }
                    .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18))
                Button("保存") { submit() }
                    .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18))
                    .disabled(!canSubmit)
                    .opacity(canSubmit ? 1 : 0.4)
            }
            .padding(.horizontal, 20)
            .padding(.vertical, 14)
        }
        .background(
            Tj.Palette.sand
                .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
                .ignoresSafeArea(edges: .bottom)
        )
        .presentationDetents([.medium, .large], selection: $detent)
        .presentationDragIndicator(.hidden)
        .presentationBackground(Tj.Palette.sand)
        .presentationCornerRadius(Tj.Radius.xl)
        .onDisappear {
            suggestTask?.cancel()
            voiceFlowTask?.cancel()
            recordingWatchdog?.cancel()
            dictation.abort()
        }
        .alert(String(appLoc: "需要麦克风与语音识别权限"), isPresented: $voiceDeniedAlert) {
            Button(String(appLoc: "前往设置")) {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    UIApplication.shared.open(url)
                }
            }
            Button(String(appLoc: "取消"), role: .cancel) {}
        } message: {
            Text("语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。")
        }
    }

    // MARK: - AI 辅助区

    @ViewBuilder
    private var assistSection: some View {
        VStack(alignment: .leading, spacing: 10) {
            // section header
            HStack(spacing: 6) {
                Image(systemName: "sparkles")
                    .font(.tjScaled( 11, weight: .semibold))
                    .foregroundStyle(Tj.Palette.brick)
                sectionLabel(String(appLoc: "AI 辅助 · 医生角度查漏补缺"))
                Spacer()
                if hasQuestions {
                    Text("\(questions.count) 个建议")
                        .font(.tjScaled( 10, design: .monospaced))
                        .foregroundStyle(Tj.Palette.text3)
                }
                if lastRate > 0 {
                    Text(String(format: "%.1f tok/s", lastRate))
                        .font(.tjScaled( 10, design: .monospaced))
                        .foregroundStyle(Tj.Palette.leaf)
                }
            }

            // 累积的 questions 列表(多轮,带轮次分隔)
            if hasQuestions {
                VStack(spacing: 8) {
                    ForEach(Array(questions.enumerated()), id: \.element.id) { idx, q in
                        if idx == 0 || questions[idx - 1].round != q.round {
                            roundDivider(round: q.round,
                                         count: questions.filter { $0.round == q.round }.count)
                                .id("round-\(q.round)")
                        }
                        questionRow(index: roundLocalIndex(at: idx), question: q)
                    }
                }
                AIDisclaimerFooter()
            }

            if exhaustedNote {
                HStack(spacing: 6) {
                    Image(systemName: "checkmark.seal.fill")
                        .font(.tjScaled( 11))
                        .foregroundStyle(Tj.Palette.leaf)
                    Text("已覆盖主要问诊维度;补充原文后可再追问")
                        .font(.tjScaled( 11))
                        .foregroundStyle(Tj.Palette.text3)
                    Spacer(minLength: 0)
                }
                .padding(.vertical, 2)
            }

            // 底部主操作按钮(状态机驱动)
            phaseFooter
        }
    }

    @ViewBuilder
    private var phaseFooter: some View {
        switch phase {
        case .idle:
            assistPrimaryButton(
                icon: "sparkles",
                label: canRequestSuggest
                    ? String(appLoc: "让 AI 帮我想想还能记什么")
                    : String(appLoc: "先写几个字,AI 来帮忙补充"),
                enabled: canRequestSuggest,
                prominent: true,
                action: requestSuggestions
            )

        case .loading:
            assistLoadingIndicator

        case .ready:
            assistPrimaryButton(
                icon: "arrow.clockwise",
                label: canRequestSuggest
                    ? String(appLoc: "再问一轮 · 让 AI 从新角度追问")
                    : String(appLoc: "更新一下原文,再让 AI 继续追问"),
                enabled: canRequestSuggest,
                action: requestSuggestions
            )

        case .failed(let err):
            VStack(alignment: .leading, spacing: 8) {
                HStack(spacing: 6) {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundStyle(Tj.Palette.brick)
                    Text(err.localizedDescription)
                        .font(.tjScaled( 12))
                        .foregroundStyle(Tj.Palette.text)
                    Spacer()
                }
                Button { requestSuggestions() } label: {
                    Text("重试")
                        .font(.tjScaled( 12, weight: .semibold))
                        .foregroundStyle(Tj.Palette.ink)
                }
                .buttonStyle(.plain)
            }
            .padding(10)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(
                RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                    .fill(Tj.Palette.brickSoft.opacity(0.5))
            )
        }
    }

    /// 辅助主按钮。`prominent` 为真走实心强调样式(填充 brick + 白字 + 轻投影,一眼可点),
    /// 否则走低调描边样式(用于 .ready 的「再问一轮」)。
    private func assistPrimaryButton(icon: String,
                                     label: String,
                                     enabled: Bool,
                                     prominent: Bool = false,
                                     action: @escaping () -> Void) -> some View {
        Button(action: action) {
            HStack(spacing: 8) {
                Image(systemName: icon)
                Text(label)
            }
            .font(.tjScaled( prominent ? 14 : 13, weight: .semibold))
            .foregroundStyle(prominent
                ? (enabled ? Tj.Palette.paper : Tj.Palette.text3)
                : (enabled ? Tj.Palette.ink : Tj.Palette.text3))
            .frame(maxWidth: .infinity)
            .padding(.vertical, prominent ? 14 : 11)
            .background(assistButtonBackground(enabled: enabled, prominent: prominent))
            // 纯描边背景、内部透明:补 contentShape 让整框可点(否则只有图标+文字本体能点)。
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .disabled(!enabled)
    }

    @ViewBuilder
    private func assistButtonBackground(enabled: Bool, prominent: Bool) -> some View {
        let shape = RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
        if prominent {
            shape
                .fill(enabled ? Tj.Palette.brick : Tj.Palette.brickSoft)
                .shadow(color: enabled ? Tj.Palette.brick.opacity(0.30) : .clear,
                        radius: 8, x: 0, y: 3)
        } else {
            shape
                .strokeBorder(
                    enabled ? Tj.Palette.ink : Tj.Palette.line,
                    style: StrokeStyle(lineWidth: 1, dash: enabled ? [] : [3, 3])
                )
        }
    }

    /// .loading 等待态:安静的 paper 卡片,底部一条细窄的不确定进度条来回滑动(Linear/Vercel 式极简)。
    /// 不用高亮扫光、不填强调色,避免刺眼;只靠细线 + sparkles 轻脉冲传达「在算」。
    private var assistLoadingIndicator: some View {
        HStack(spacing: 10) {
            Image(systemName: "sparkles")
                .font(.tjScaled( 12, weight: .semibold))
                .foregroundStyle(Tj.Palette.brick)
                .symbolEffect(.pulse, options: .repeating)
            Text(lastRate > 0
                 ? String(format: String(appLoc: "AI 生成中 · %.1f tok/s"), lastRate)
                 : String(appLoc: "AI 生成中 · 本地推理"))
                .font(.tjScaled( 13, weight: .medium))
                .foregroundStyle(Tj.Palette.text2)
            Spacer(minLength: 0)
            Button("取消") { cancelSuggestions() }
                .font(.tjScaled( 12, weight: .semibold))
                .foregroundStyle(Tj.Palette.text3)
        }
        .padding(.vertical, 11)
        .padding(.horizontal, 12)
        .frame(maxWidth: .infinity)
        .background(
            RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                .fill(Tj.Palette.paper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
        )
        .overlay(alignment: .bottom) {
            AIFlowBar().padding(.horizontal, 1)
        }
        .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous))
    }

    /// 给定整张 questions list 里 idx 位置的 question,返回它在自己 round 内的序号(1-based)。
    private func roundLocalIndex(at idx: Int) -> Int {
        let target = questions[idx].round
        var count = 0
        for i in 0...idx where questions[i].round == target {
            count += 1
        }
        return count
    }

    /// 第 N 轮的分隔条 —— 让用户清楚下一轮 LLM 看到的是更新过的最新文本。
    private func roundDivider(round: Int, count: Int) -> some View {
        HStack(spacing: 8) {
            HStack(spacing: 6) {
                Image(systemName: round == 1 ? "1.circle.fill" : "arrow.triangle.2.circlepath")
                    .font(.tjScaled( 11, weight: .semibold))
                    .foregroundStyle(Tj.Palette.brick)
                Text(round == 1
                     ? String(appLoc: "第 1 轮 · \(count) 条")
                     : String(appLoc: "第 \(round) 轮 · 基于你刚才更新的文本 · \(count) 条"))
                    .font(.tjScaled( 11, weight: .semibold))
                    .tracking(0.3)
                    .foregroundStyle(Tj.Palette.text2)
            }
            Rectangle()
                .fill(Tj.Palette.line)
                .frame(height: 1)
                .mask(
                    HStack(spacing: 3) {
                        ForEach(0..<60, id: \.self) { _ in
                            Rectangle().frame(width: 3, height: 1)
                        }
                    }
                )
        }
        .padding(.top, round == 1 ? 0 : 6)
    }

    private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
        let adopted = question.adopted
        let filling = fillingId == question.id
        return VStack(alignment: .leading, spacing: 6) {
            HStack(alignment: .top, spacing: 8) {
                Text("\(index).")
                    .font(.tjScaled( 13, weight: .semibold, design: .monospaced))
                    .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.brick)
                Text(question.q)
                    .font(.tjScaled( 13, weight: .medium))
                    .foregroundStyle(adopted ? Tj.Palette.text3 : Tj.Palette.text)
                    .strikethrough(adopted, color: Tj.Palette.text3)
                    .fixedSize(horizontal: false, vertical: true)
                Spacer(minLength: 4)

                if adopted {
                    HStack(spacing: 4) {
                        Image(systemName: "checkmark")
                            .font(.tjScaled( 10, weight: .bold))
                        Text("已采纳")
                            .font(.tjScaled( 11, weight: .semibold))
                    }
                    .foregroundStyle(Tj.Palette.leaf)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 5)
                    .background(Capsule().fill(Tj.Palette.leafSoft))
                } else if !filling {
                    Button { adopt(question) } label: {
                        HStack(spacing: 4) {
                            Image(systemName: "plus.circle.fill")
                                .font(.tjScaled( 12))
                            Text("采纳")
                                .font(.tjScaled( 12, weight: .semibold))
                        }
                        .foregroundStyle(Tj.Palette.paper)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .background(Capsule().fill(Tj.Palette.ink))
                    }
                    .buttonStyle(.plain)
                }
            }
            if filling {
                QuestionFillPanel(
                    template: question.fill,
                    values: $fillValues,
                    onCommit: { assembled in commitAdoption(question, text: assembled) },
                    onCancel: { closeFill() }
                )
            } else if !question.fill.isEmpty && !adopted {
                HStack(alignment: .top, spacing: 4) {
                    Text("将追加:")
                        .font(.tjScaled( 11))
                        .foregroundStyle(Tj.Palette.text3)
                    Text(question.fill)
                        .font(.tjScaled( 11))
                        .foregroundStyle(Tj.Palette.text2)
                        .fixedSize(horizontal: false, vertical: true)
                }
                .padding(.leading, 22)
            }
        }
        .padding(10)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                .fill(adopted ? Tj.Palette.sand2 : Tj.Palette.paper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
                .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
        )
    }

    // MARK: - Actions

    private func sectionLabel(_ text: String) -> some View {
        Text(text)
            .font(.tjScaled( 12, weight: .semibold))
            .tracking(0.3)
            .foregroundStyle(Tj.Palette.text2)
    }

    // MARK: 语音输入流程

    private func startVoice() {
        contentFocused = false
        voiceNote = nil
        voiceFlowTask = Task { @MainActor in
            guard await dictation.requestAuthorization() else {
                voiceDeniedAlert = true
                return
            }
            do {
                liveTranscript = ""
                recordingSeconds = 0
                try dictation.start { partial in liveTranscript = partial }
                withAnimation(.snappy(duration: 0.2)) { voicePhase = .recording }
                // 计时 + 3 分钟看门狗(到点自动停,行为与点「停止」一致)
                recordingWatchdog = Task { @MainActor in
                    while !Task.isCancelled {
                        try? await Task.sleep(nanoseconds: 1_000_000_000)
                        guard !Task.isCancelled, voicePhase == .recording else { return }
                        recordingSeconds += 1
                        if recordingSeconds >= DiaryVoicePanel.maxRecordingSeconds {
                            stopVoiceAndOrganize()
                            return
                        }
                    }
                }
            } catch {
                voiceNote = error.localizedDescription
                voicePhase = .idle
            }
        }
    }

    private func stopVoiceAndOrganize() {
        guard voicePhase == .recording else { return }
        recordingWatchdog?.cancel()
        voiceFlowTask = Task { @MainActor in
            let transcript = (await dictation.stop())
                .trimmingCharacters(in: .whitespacesAndNewlines)
            liveTranscript = transcript
            guard !transcript.isEmpty else {
                withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
                voiceNote = String(appLoc: "没听清,再试一次")
                return
            }
            rawTranscript = transcript
            withAnimation(.snappy(duration: 0.2)) { voicePhase = .organizing }
            do {
                let result = try await DiaryAssistService.shared.organize(transcript: transcript)
                guard !Task.isCancelled else { return }
                appendToContent(result.text)
                organizedAppended = result.text
                lastRate = result.decodeRate
            } catch is CancellationError {
                // cancelOrganize 已处理回退,这里只收尾
            } catch {
                guard !Task.isCancelled else { return }
                appendToContent(transcript)   // 红线 #5:整理失败回退原话,不卡死
                organizedAppended = nil
                voiceNote = String(appLoc: "AI 整理失败,已填入原话")
            }
            withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
        }
    }

    /// 取消整理:中断 LLM,直接填原话(与失败回退同路径)。
    private func cancelOrganize() {
        guard voicePhase == .organizing else { return }
        voiceFlowTask?.cancel()
        if let raw = rawTranscript {
            appendToContent(raw)
            organizedAppended = nil
            voiceNote = String(appLoc: "已取消整理,填入原话")
        }
        withAnimation(.snappy(duration: 0.2)) { voicePhase = .idle }
    }

    /// 「改用原话」:把刚追加的整理稿替换为原始转写稿(spec §2:LLM 改数兜底)。
    private func revertToRawTranscript() {
        guard let raw = rawTranscript,
              let organized = organizedAppended,
              let range = content.range(of: organized, options: .backwards) else { return }
        withAnimation(.snappy(duration: 0.18)) {
            content = content.replacingCharacters(in: range, with: raw)
            organizedAppended = nil
        }
    }

    /// 触发一轮 AI 辅助。把已覆盖的问诊维度(coveredDims)传给 LLM,
    /// 要求本轮避开这些维度,从结构上压住跨轮换皮重复。
    private func requestSuggestions() {
        suggestTask?.cancel()
        let snapshotContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
        let covered = Array(coveredDims)
        // 1. 主动收起键盘 —— 否则建议面板被键盘吃掉一半
        contentFocused = false
        // 2. 确保 sheet 在 large(用户可能下拉到 medium 又触发 AI)
        if detent != .large {
            withAnimation(.snappy(duration: 0.25)) {
                detent = .large
            }
        }
        exhaustedNote = false
        phase = .loading
        suggestTask = Task { @MainActor in
            do {
                let result = try await DiaryAssistService.shared.suggest(
                    content: snapshotContent,
                    coveredDimensions: covered
                )
                if Task.isCancelled { return }
                // 客户端硬去重(不依赖 1.7B 听话):
                //   ① 维度已在往轮覆盖 → 丢;② 本轮内维度重复 → 丢;③ 文本与已有近似 → 丢。
                let coveredSnapshot = coveredDims
                var acceptedNorms = questions.map { Self.normalize($0.q) }
                var batchDims = Set<String>()
                let nextRound = currentRound + 1
                let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
                    let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
                    let norm = Self.normalize(q.q)
                    if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
                    if !dim.isEmpty, batchDims.contains(dim) { return nil }
                    if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
                    if !dim.isEmpty { batchDims.insert(dim) }
                    acceptedNorms.append(norm)
                    var stamped = q
                    stamped.round = nextRound
                    return stamped
                }
                withAnimation(.snappy(duration: 0.2)) {
                    if fresh.isEmpty {
                        exhaustedNote = true        // 这轮没问出任何新维度
                    } else {
                        questions.append(contentsOf: fresh)
                        for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
                        currentRound = nextRound
                        exhaustedNote = false
                    }
                    lastRate = result.decodeRate
                    phase = .ready
                }
            } catch is CancellationError {
                if !Task.isCancelled {
                    phase = hasQuestions ? .ready : .idle
                }
            } catch {
                if !Task.isCancelled {
                    phase = .failed(error)
                }
            }
        }
    }

    /// 简单归一化:去空白 + 折叠成统一形式,用于客户端去重比对。
    private static func normalize(_ s: String) -> String {
        s.trimmingCharacters(in: .whitespacesAndNewlines)
         .replacingOccurrences(of: " ", with: "")
         .replacingOccurrences(of: "?", with: "?")
    }

    /// 近似判重:归一化后相等,或字符集 Jaccard ≥ 0.8(抓「会/下」这类换一两字的重复)。
    private static func isSimilar(_ a: String, _ b: String) -> Bool {
        if a == b { return true }
        let sa = Set(a), sb = Set(b)
        guard !sa.isEmpty, !sb.isEmpty else { return false }
        let inter = sa.intersection(sb).count
        let union = sa.union(sb).count
        return union > 0 && Double(inter) / Double(union) >= 0.8
    }

    private func cancelSuggestions() {
        suggestTask?.cancel()
        phase = hasQuestions ? .ready : .idle
    }

    /// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
    /// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
    private func adopt(_ question: DiaryAssistService.Question) {
        guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
            // 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。
            commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
            return
        }
        withAnimation(.snappy(duration: 0.18)) {
            fillingId = question.id
            fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
        }
    }

    /// 关闭填空面板(取消)。
    private func closeFill() {
        withAnimation(.snappy(duration: 0.18)) {
            fillingId = nil
            fillValues = []
        }
    }

    /// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。
    private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
        if let idx = questions.firstIndex(where: { $0.id == question.id }) {
            withAnimation(.snappy(duration: 0.18)) {
                questions[idx].adopted = true
            }
        }
        appendToContent(text)
        fillingId = nil
        fillValues = []
    }

    /// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
    private func appendToContent(_ text: String) {
        let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !toAppend.isEmpty else { return }
        let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty {
            content = toAppend
        } else if content.hasSuffix("\n") {
            content += toAppend
        } else {
            content += "\n" + toAppend
        }
    }

    private func submit() {
        guard canSubmit else { return }
        let entry = DiaryEntry(
            content: content.trimmingCharacters(in: .whitespacesAndNewlines),
            createdAt: createdAt
        )
        ctx.insert(entry)
        try? ctx.save()
        dismiss()
    }
}

#Preview {
    DiaryQuickSheet()
}
