// ClaudeHUD — Liquid-Glass heads-up display for the Claude voice assistant.
//
// A menu-bar (accessory) app that OWNS the Python voice engine: it spawns
// main.py with EMIT_JSON=1, tails its stdout for `@@EVT {json}` lines, and
// renders the assistant's live state as floating glass panels over the
// desktop — status orb + transcript (bottom-right), spinning-agent chips
// (top-right), and draggable image popups when Claude shows something.
//
// The HUD talks back to the engine through the same control/ files the
// assistant uses (control/mute, etc.), so nothing new couples them.

import AppKit
import SwiftUI
import Combine
import AVFoundation
import UniformTypeIdentifiers
import WebKit

// MARK: - Paths

enum Paths {
    static let root: URL = {
        if let env = ProcessInfo.processInfo.environment["CLAUDE_VOICE_DIR"] {
            return URL(fileURLWithPath: env)
        }
        // <root>/hud/ClaudeHUD.app/Contents/MacOS/ClaudeHUD  -> strip 5
        var u = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
        for _ in 0..<5 { u.deleteLastPathComponent() }
        if FileManager.default.fileExists(atPath: u.appendingPathComponent("main.py").path) {
            return u
        }
        return FileManager.default.homeDirectoryForCurrentUser
            .appendingPathComponent("devFolder/Ultron/claude-voice")
    }()
    static var python: URL { root.appendingPathComponent(".venv/bin/python") }
    static var engine: URL { root.appendingPathComponent("main.py") }
    static var control: URL { root.appendingPathComponent("control") }
}

// MARK: - Sticky corner

/// The four screen corners the floating/bubble HUD can home to. The user picks
/// one by dragging the window; it snaps to (and remembers) the nearest corner
/// of whatever screen it landed on, per-screen, across relaunches.
enum HUDCorner: String {
    case topLeft, topRight, bottomLeft, bottomRight
    var isLeft: Bool { self == .topLeft || self == .bottomLeft }
    var isBottom: Bool { self == .bottomLeft || self == .bottomRight }
}

// MARK: - Model

struct Line: Identifiable {
    let id = UUID()
    let who: String   // "you" | "claude" | "task"
    let text: String
}

struct AgentChip: Identifiable {
    let id = UUID()
    let agent: String
    let desc: String
    var taskId: String = ""   // pool worker id — clears THIS chip when it ends
    var done = false
    // Live supervision (the agent pool reads every worker's stream): `note` is
    // what it is doing RIGHT NOW, `stalled` means it has gone quiet for minutes.
    var note: String = ""
    var stalled = false
    var startedAt = Date()
}

@MainActor
final class AppModel: ObservableObject {
    @Published var state = "off"          // off|listening|thinking|speaking
    @Published var running = false
    @Published var muted = false
    @Published var wakeWord = false        // true = only responds after "Jarvis"
    @Published var enrolling: (Int, Int)? = nil
    @Published var transcript: [Line] = []
    @Published var agents: [AgentChip] = []
    @Published var draft = ""              // text being typed in the chat box
    @Published var attachments: [URL] = [] // files staged to send with the message
    @Published var textVoice = false       // typed replies also spoken aloud
    @Published var recording = false        // meeting mode: recording + noting
    @Published var bootStage = ""            // what it's loading during startup
    @Published var collapsed = false       // minimized to a small corner bubble
    @Published var docked = false          // full-height strip pinned to an edge
    @Published var dockSide = "left"        // "left" or "right"
    @Published var dockWidth: CGFloat = 340 // adjustable dock width (260...760)
    @Published var longPressCue = false     // brief scale blip when a long-press toggles bubble⇄window

    func push(_ who: String, _ text: String) {
        // Cap CHARACTERS, not just lines: a single huge paste is one line but can
        // be hundreds of KB, and rendered with fixedSize it lays out to a view
        // tens of thousands of points tall (→ lag + memory blow-up). The full
        // text still went to the engine; the transcript only needs a preview.
        let capped = text.count > 4000 ? String(text.prefix(4000)) + "…" : text
        transcript.append(Line(who: who, text: capped))
        if transcript.count > 12 { transcript.removeFirst(transcript.count - 12) }
    }

    func addAgent(_ agent: String, _ desc: String, _ taskId: String = "") {
        // If this exact agent id is somehow already shown, don't double-add.
        if !taskId.isEmpty && agents.contains(where: { $0.taskId == taskId }) { return }
        agents.append(AgentChip(agent: agent, desc: desc, taskId: taskId))
        // No timer. A chip lives exactly as long as its agent does — cleared
        // by finishAgent when the SDK reports that agent's real end
        // (completed/failed/killed/stopped), be that 10 seconds or hours.
    }

    // Live "what is it doing right now" from the pool's supervisor.
    func agentProgress(_ taskId: String, _ note: String) {
        guard let i = agents.firstIndex(where: { $0.taskId == taskId }) else { return }
        agents[i].note = note
        agents[i].stalled = false
    }

    // The worker has gone quiet for minutes — flag it instead of spinning
    // forever with Ahmed none the wiser.
    func agentStalled(_ taskId: String) {
        guard let i = agents.firstIndex(where: { $0.taskId == taskId }) else { return }
        agents[i].stalled = true
    }

    // Clear the chip for a SPECIFIC agent by its pool id — live, the moment
    // that agent actually ends (or is killed). Falls back to the oldest
    // running chip only when no id is supplied (legacy callers).
    func finishAgent(_ taskId: String) {
        let idx: Int?
        if !taskId.isEmpty {
            idx = agents.firstIndex { $0.taskId == taskId && !$0.done }
        } else {
            idx = agents.firstIndex { !$0.done }
        }
        guard let i = idx else { return }
        agents[i].done = true
        let chipId = agents[i].id
        Task { @MainActor in
            try? await Task.sleep(nanoseconds: 1_500_000_000)  // brief ✅ then gone
            self.agents.removeAll { $0.id == chipId }
        }
    }

    // legacy shim
    func finishOneAgent() { finishAgent("") }
}

// MARK: - Glass helper (Liquid Glass on macOS 26, material fallback)

extension View {
    @ViewBuilder func glassCard(_ radius: CGFloat = 22) -> some View {
        if #available(macOS 26.0, *) {
            self.glassEffect(.regular, in: .rect(cornerRadius: radius))
        } else {
            self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: radius))
        }
    }
}

// MARK: - Status orb

struct Orb: View {
    let state: String
    let muted: Bool
    var recording: Bool = false
    @State private var pulse = false

    // Recording is signalled by the orb ITSELF going red and breathing — no
    // separate REC badge. Boot shows amber so Ahmed sees it's coming up.
    var color: Color {
        if recording { return .red }
        if muted { return .red }
        switch state {
        case "booting":     return .orange
        case "listening":   return .cyan
        case "thinking":    return .orange
        case "speaking":    return .green
        case "restarting":  return .yellow
        default:            return .gray
        }
    }
    // faster, stronger breathing while recording so it's unmistakable
    var period: Double { recording ? 0.7 : 1.1 }

    // Only breathe when there's LIVE activity. Idle/off/muted → a static orb and
    // NO animation loop, so the app isn't driving a display-rate render 24/7 (the
    // orb is always on screen). This is the difference between ~0% and constant CPU.
    private var shouldPulse: Bool {
        if muted { return false }
        if recording { return true }
        // NOT "listening" — that's the steady idle state (Jarvis on, waiting), so
        // pulsing there means a perpetual display-rate animation ~24/7. A static
        // cyan orb already reads as "on & listening". Only breathe during the
        // transient active moments, so idle CPU is ~0.
        return ["booting", "thinking", "speaking", "restarting"].contains(state)
    }

    var body: some View {
        ZStack {
            Circle().fill(color.opacity(0.25))
                .frame(width: 46, height: 46)
                .scaleEffect(pulse ? (recording ? 1.4 : 1.25) : 0.85)
                .opacity(pulse ? (recording ? 0.15 : 0.2) : 0.6)
            Circle()
                .fill(RadialGradient(colors: [color, color.opacity(0.5)],
                                     center: .center, startRadius: 1, endRadius: 18))
                .frame(width: 26, height: 26)
                .shadow(color: color.opacity(recording ? 1.0 : 0.8),
                        radius: recording ? 12 : 8)
            if muted {
                Image(systemName: "mic.slash.fill")
                    .font(.system(size: 11, weight: .bold))
                    .foregroundStyle(.white)
            } else if recording {
                Circle().fill(.white).frame(width: 8, height: 8)  // REC dot core
            }
        }
        .frame(width: 46, height: 46)
        .onAppear { syncPulse() }
        .onChange(of: shouldPulse) { syncPulse() }   // activity started/stopped
        .onChange(of: recording) { syncPulse() }     // period changed
    }

    private func syncPulse() {
        // Hard-cancel any running repeatForever FIRST. Reassigning `pulse` does
        // NOT stop a repeating animation — it needs a non-animated reset, or each
        // toggle stacks another perpetual animation on top (compounding CPU).
        var t = Transaction(); t.disablesAnimations = true
        withTransaction(t) { pulse = false }
        guard shouldPulse else { return }            // idle → leave it static
        withAnimation(.easeInOut(duration: period).repeatForever(autoreverses: true)) {
            pulse = true
        }
    }
}

// MARK: - Main HUD panel content

struct HUDView: View {
    @ObservedObject var model: AppModel
    var onMute: () -> Void
    var onPower: () -> Void
    var onToggleWakeWord: (Bool) -> Void
    var onMinimize: () -> Void
    var onQuit: () -> Void
    var onSend: (String, [URL]) -> Void
    var onAttach: () -> Void
    var onPaste: () -> Void
    var onActivateInput: () -> Void
    var onMemory: () -> Void

    var stateLabel: String {
        if model.state == "booting" {
            return model.bootStage.isEmpty ? "starting up…" : model.bootStage
        }
        if !model.running { return "offline" }
        if model.state == "restarting" { return "Restarting…" }
        if model.muted { return "muted" }
        if model.recording { return "recording — taking notes" }
        if let (n, t) = model.enrolling { return "learning your voice \(n)/\(t)" }
        switch model.state {
        case "listening": return "listening…"
        case "thinking":  return "thinking…"
        case "speaking":  return "speaking…"
        default:          return "ready"
        }
    }

    var body: some View {
        Group {
            if model.collapsed {
                collapsedBubble
            } else {
                fullPanel
            }
        }
        // Subtle "press recognized" blip for the long-press bubble⇄window toggle
        // (render-only scale — doesn't change layout size, so the window/fit
        // sizing is untouched).
        .scaleEffect(model.longPressCue ? 0.96 : 1.0)
        .animation(.easeOut(duration: 0.12), value: model.longPressCue)
        // Right-click ANYWHERE on the HUD → the essentials, including Quit.
        // The menu-bar icon can hide behind the notch on the Air, and the ⋯
        // menu vanishes when collapsed to the bubble — the orb is the one
        // thing always on screen, so hang the escape hatch off it.
        .contextMenu {
            Button(model.running ? "Stop Jarvis" : "Start Jarvis", action: onPower)
            Button(model.muted ? "Unmute Mic" : "Mute Mic", action: onMute)
            Button(model.collapsed ? "Expand" : "Minimize", action: onMinimize)
            Divider()
            Button("Quit Jarvis", role: .destructive, action: onQuit)
        }
    }

    // Minimized state: a small always-visible glass bubble (orb + name).
    // Tap to expand back — can't be lost behind the notch like a menu-bar icon.
    var collapsedBubble: some View {
        Button { withAnimation(.spring(duration: 0.3)) { model.collapsed = false } } label: {
            HStack(spacing: 9) {
                Orb(state: model.state, muted: model.muted,
                    recording: model.recording)
                Text("Jarvis").font(.system(size: 13, weight: .semibold))
                Image(systemName: "chevron.up")
                    .font(.system(size: 10, weight: .bold)).foregroundStyle(.secondary)
            }
            .padding(.horizontal, 14).padding(.vertical, 10)
        }
        .buttonStyle(.plain)
        .glassCard(24)
        .help("Expand Jarvis")
    }

    var fullPanel: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Top status row: orb + label + the two toggles you use often,
            // and everything else tucked into one ⋯ menu (declutter).
            HStack(spacing: 10) {
                Orb(state: model.state, muted: model.muted,
                    recording: model.recording)
                VStack(alignment: .leading, spacing: 1) {
                    Text(model.recording ? "Jarvis — recording"
                                         : "Jarvis")
                        .font(.system(size: 15, weight: .semibold))
                        .foregroundStyle(model.recording ? .red : .primary)
                    Text(stateLabel).font(.system(size: 12))
                        .foregroundStyle(model.state == "restarting"
                                         ? .yellow : .secondary)
                }
                Spacer()
                Button { let n = !model.wakeWord; model.wakeWord = n; onToggleWakeWord(n) } label: {
                    Image(systemName: model.wakeWord ? "ear.fill" : "ear")
                        .font(.system(size: 13, weight: .medium))
                        .foregroundStyle(model.wakeWord ? .cyan : .secondary)
                }.buttonStyle(.plain)
                 .help(model.wakeWord ? "Wake word ON — say \"Jarvis\" first"
                                      : "Wake word off — responds to all speech")
                Button(action: onMute) {
                    Image(systemName: model.muted ? "mic.slash.fill" : "mic.fill")
                        .font(.system(size: 13, weight: .medium))
                        .foregroundStyle(model.muted ? .red : .primary)
                }.buttonStyle(.plain).help("Mute / unmute")
                Button(action: onMemory) {
                    Image(systemName: "brain")
                        .font(.system(size: 13, weight: .medium))
                        .foregroundStyle(.secondary)
                }.buttonStyle(.plain).help("What Jarvis remembers")
                Menu {
                    Button(model.running ? "Stop listening" : "Start listening",
                           action: onPower)
                    Button(model.docked ? "Undock (float free)"
                                        : "Dock to left edge") {
                        withAnimation(.spring(duration: 0.3)) { model.docked.toggle() }
                    }
                    Button("Minimize to a bubble") {
                        withAnimation(.spring(duration: 0.3)) { model.collapsed = true }
                    }
                    Divider()
                    Button("Quit Jarvis", role: .destructive, action: onQuit)
                } label: {
                    Image(systemName: "ellipsis.circle")
                        .font(.system(size: 15, weight: .medium))
                        .foregroundStyle(.secondary)
                }
                .menuStyle(.borderlessButton).menuIndicator(.hidden)
                .frame(width: 20).help("More")
            }

            // Agent chips row — sits between status and transcript
            if !model.agents.isEmpty {
                AgentStrip(model: model)
            }

            if !model.transcript.isEmpty {
                Divider().opacity(0.4)
                ScrollViewReader { proxy in
                    ScrollView {
                        LazyVStack(alignment: .leading, spacing: 7) {
                            ForEach(model.transcript) { line in
                                lineView(line).id(line.id)
                            }
                        }.padding(.trailing, 4)
                    }
                    // Docked = fill the tall strip; floating = capped height.
                    .frame(maxHeight: model.docked ? .infinity : 190)
                    .onChange(of: model.transcript.count) { _, _ in
                        if let last = model.transcript.last {
                            withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
                        }
                    }
                }
            } else if model.docked {
                Spacer(minLength: 0)  // push chat to the bottom of the strip
            }

            // Chat input — always visible at the bottom of the panel
            Divider().opacity(0.4)
            ChatInput(model: model, onSend: onSend, onAttach: onAttach,
                      onPaste: onPaste, onActivateInput: onActivateInput)
        }
        .padding(16)
        .frame(width: model.docked ? model.dockWidth : 360,
               alignment: .leading)
        .frame(maxHeight: model.docked ? .infinity : nil, alignment: .top)
        .glassCard(model.docked ? 0 : 22)
        // Resize handle on the INNER edge (right edge when docked-left,
        // left edge when docked-right) — drag it to change the dock width.
        .overlay(alignment: model.dockSide == "right" ? .leading : .trailing) {
            if model.docked { ResizeHandle(model: model) }
        }
    }

    @ViewBuilder func lineView(_ line: Line) -> some View {
        HStack(alignment: .top, spacing: 8) {
            switch line.who {
            case "you":
                Image(systemName: "person.fill").foregroundStyle(.cyan)
                    .font(.system(size: 11)).frame(width: 14)
            case "task":
                Image(systemName: "checkmark.seal.fill").foregroundStyle(.green)
                    .font(.system(size: 11)).frame(width: 14)
            default:
                Image(systemName: "sparkle").foregroundStyle(.orange)
                    .font(.system(size: 11)).frame(width: 14)
            }
            Text(line.text)
                .font(.system(size: 12.5))
                .foregroundStyle(line.who == "you" ? .primary : .secondary)
                .lineLimit(12)                      // bound tall lines; keeps fixedSize honest
                .truncationMode(.tail)
                .fixedSize(horizontal: false, vertical: true)
            Spacer(minLength: 0)
        }
    }
}

// MARK: - Chat input (attachments + text + send)

struct ChatInput: View {
    @ObservedObject var model: AppModel
    var onSend: (String, [URL]) -> Void
    var onAttach: () -> Void
    var onPaste: () -> Void
    var onActivateInput: () -> Void
    @FocusState private var focused: Bool

    var canSend: Bool {
        !model.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            || !model.attachments.isEmpty
    }

    private func submit() {
        let text = model.draft
        let atts = model.attachments
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty || !atts.isEmpty else { return }
        onSend(text, atts)
        // Optimistically echo into the transcript so it appears instantly.
        var shown = trimmed
        if !atts.isEmpty {
            let names = atts.map { $0.lastPathComponent }.joined(separator: ", ")
            shown = trimmed.isEmpty ? "📎 \(names)" : "\(trimmed)  📎 \(names)"
        }
        model.push("you", shown)
        model.draft = ""
        model.attachments = []
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            // Attachment chips (above the input row)
            if !model.attachments.isEmpty {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 6) {
                        ForEach(Array(model.attachments.enumerated()), id: \.offset) { _, url in
                            chip(url)
                        }
                    }.padding(.horizontal, 2)
                }
            }

            HStack(alignment: .bottom, spacing: 8) {
                Button(action: onAttach) {
                    Image(systemName: "paperclip").font(.system(size: 13))
                        .foregroundStyle(.secondary)
                }.buttonStyle(.plain).help("Attach files")

                // Multi-line growing box: Enter sends, Shift+Enter = newline.
                // Cmd+V pastes text NATIVELY (via the Edit menu); an image on
                // the clipboard is attached by the app-level paste monitor.
                TextField("Message Jarvis…", text: $model.draft, axis: .vertical)
                    .textFieldStyle(.plain)
                    .font(.system(size: 12.5))
                    .lineLimit(1...8)
                    .focused($focused)
                    .onKeyPress { press in
                        if press.key == .return {
                            if press.modifiers.contains(.shift) { return .ignored }
                            submit()
                            return .handled
                        }
                        return .ignored
                    }

                // Speak-my-typed-replies toggle (office mode: type silently,
                // hear the answer on earphones instead of reading).
                Button { model.textVoice.toggle() } label: {
                    Image(systemName: model.textVoice
                          ? "speaker.wave.2.fill" : "speaker.slash")
                        .font(.system(size: 12, weight: .medium))
                        .foregroundStyle(model.textVoice ? .cyan : .secondary)
                }.buttonStyle(.plain)
                 .help(model.textVoice
                       ? "Typed replies are also spoken aloud"
                       : "Typed replies are text only")

                Button(action: submit) {
                    Image(systemName: "arrow.up.circle.fill")
                        .font(.system(size: 18))
                        .foregroundStyle(canSend ? Color.accentColor : Color.secondary)
                }.buttonStyle(.plain).help("Send").disabled(!canSend)
            }
            .padding(.horizontal, 10).padding(.vertical, 7)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
            .overlay(RoundedRectangle(cornerRadius: 16)
                .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
            .contentShape(RoundedRectangle(cornerRadius: 16))
            // Nonactivating accessory panel: activate + make key so typing works.
            // simultaneousGesture doesn't swallow the TextField's own click.
            .simultaneousGesture(TapGesture().onEnded {
                onActivateInput()
                focused = true
            })
        }
    }

    @ViewBuilder private func chip(_ url: URL) -> some View {
        HStack(spacing: 4) {
            Image(systemName: "doc.fill").font(.system(size: 9)).foregroundStyle(.secondary)
            Text(url.lastPathComponent)
                .font(.system(size: 10, weight: .medium))
                .lineLimit(1).truncationMode(.middle)
                .frame(maxWidth: 120)
            Button {
                if let i = model.attachments.firstIndex(of: url) {
                    model.attachments.remove(at: i)
                }
            } label: {
                Image(systemName: "xmark").font(.system(size: 8, weight: .bold))
                    .foregroundStyle(.secondary)
            }.buttonStyle(.plain)
        }
        .padding(.horizontal, 8).padding(.vertical, 4)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }
}

// MARK: - Dock resize handle

struct ResizeHandle: View {
    @ObservedObject var model: AppModel
    @State private var startWidth: CGFloat?

    var body: some View {
        Rectangle()
            .fill(Color.white.opacity(0.001))   // invisible but hit-testable
            .frame(width: 10)
            .overlay(Rectangle().fill(.white.opacity(0.14)).frame(width: 1))
            .contentShape(Rectangle())
            .onHover { inside in
                if inside { NSCursor.resizeLeftRight.set() }
                else { NSCursor.arrow.set() }
            }
            // .global so the drag delta is real cursor movement in screen
            // space (stable even as the panel resizes under the cursor).
            .gesture(
                DragGesture(minimumDistance: 1, coordinateSpace: .global)
                    .onChanged { v in
                        if startWidth == nil { startWidth = model.dockWidth }
                        let base = startWidth ?? model.dockWidth
                        let delta = model.dockSide == "right"
                            ? -v.translation.width : v.translation.width
                        model.dockWidth = min(max(base + delta, 260), 760)
                    }
                    .onEnded { _ in startWidth = nil }
            )
    }
}

// MARK: - Agent chip (expandable)

struct AgentChipView: View {
    let chip: AgentChip
    @State private var expanded = false
    @State private var spinAngle: Double = 0
    // ticks once a second so the elapsed clock on an expanded chip actually runs
    @State private var now = Date()
    private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    // Pick an icon based on keywords in the description or agent type
    var icon: String {
        if chip.stalled && !chip.done { return "⚠️" }
        let combined = (chip.desc + " " + chip.agent).lowercased()
        if combined.contains("search") || combined.contains("research") || combined.contains("find") { return "🔍" }
        if combined.contains("web") || combined.contains("fetch") || combined.contains("http") { return "🌐" }
        if combined.contains("cod") || combined.contains("build") || combined.contains("compil") || combined.contains("swift") || combined.contains("python") { return "💻" }
        if combined.contains("run") || combined.contains("exec") || combined.contains("bash") || combined.contains("script") { return "⚙️" }
        return "🤖"
    }

    // Truncate desc to ~3 words for collapsed label
    var shortLabel: String {
        let words = chip.desc.split(separator: " ").prefix(3)
        return words.joined(separator: " ")
    }

    var elapsed: String {
        let s = Int(now.timeIntervalSince(chip.startedAt))
        return s >= 60 ? "\(s / 60)m \(s % 60)s" : "\(s)s"
    }

    // Kill this worker: the engine's ControlWatcher hands it to the pool, which
    // disconnects the CLI session at once (the chip clears on task_done).
    private func kill() {
        let payload: [String: Any] = ["action": "kill", "id": chip.taskId]
        guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
        try? FileManager.default.createDirectory(
            at: Paths.control, withIntermediateDirectories: true)
        try? data.write(to: Paths.control.appendingPathComponent("agent_action.json"),
                        options: .atomic)
    }

    private var spinner: some View {
        Image(systemName: "arrow.2.circlepath")
            .font(.system(size: 11, weight: .semibold))
            .foregroundStyle(chip.stalled ? .yellow : .orange)
            .rotationEffect(.degrees(spinAngle))
            .onAppear {
                withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
                    spinAngle = 360
                }
            }
    }

    var body: some View {
        Button(action: { withAnimation(.spring(duration: 0.3)) { expanded.toggle() } }) {
            if expanded {
                // Expanded — what it's doing right now, how long, and a kill button
                HStack(alignment: .top, spacing: 8) {
                    Text(icon).font(.system(size: 13))
                    VStack(alignment: .leading, spacing: 3) {
                        Text(chip.desc)
                            .font(.system(size: 12, weight: .medium))
                            .foregroundStyle(.primary)
                            .fixedSize(horizontal: false, vertical: true)
                        // LIVE: the tool it's running this second (Ahmed's
                        // "no clue what the agent is doing" — this is the clue)
                        if !chip.note.isEmpty && !chip.done {
                            Text(chip.note)
                                .font(.system(size: 10, weight: .medium))
                                .foregroundStyle(chip.stalled ? .yellow : .orange)
                                .lineLimit(2)
                                .fixedSize(horizontal: false, vertical: true)
                        }
                        Text(chip.stalled && !chip.done
                             ? "\(chip.agent) · \(elapsed) · gone quiet — may be stuck"
                             : "\(chip.agent) · \(elapsed)")
                            .font(.system(size: 10))
                            .foregroundStyle(.secondary)
                    }
                    Spacer(minLength: 4)
                    if chip.done {
                        Text("✅").font(.system(size: 13))
                    } else {
                        spinner
                        Button(action: kill) {
                            Image(systemName: "xmark.circle.fill")
                                .font(.system(size: 13))
                                .foregroundStyle(.secondary)
                        }
                        .buttonStyle(.plain)
                        .help("Kill this agent")
                    }
                }
                .padding(.horizontal, 10).padding(.vertical, 8)
            } else {
                // Collapsed state — icon + short label
                HStack(spacing: 5) {
                    Text(icon).font(.system(size: 11))
                    if !chip.done {
                        Image(systemName: "arrow.2.circlepath")
                            .font(.system(size: 9, weight: .semibold))
                            .foregroundStyle(chip.stalled ? .yellow : .orange)
                            .rotationEffect(.degrees(spinAngle))
                            .onAppear {
                                withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
                                    spinAngle = 360
                                }
                            }
                    } else {
                        Text("✅").font(.system(size: 9))
                    }
                    Text(shortLabel)
                        .font(.system(size: 10, weight: .medium))
                        .lineLimit(1)
                }
                .padding(.horizontal, 8).padding(.vertical, 5)
            }
        }
        .buttonStyle(.plain)
        .onReceive(clock) { t in if expanded { now = t } }
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(
            chip.stalled && !chip.done ? .yellow.opacity(0.45) : .white.opacity(0.12),
            lineWidth: 0.5))
        .contentShape(Capsule())
    }
}

// MARK: - Agent activity strip (horizontal row, embedded in HUD)

struct AgentStrip: View {
    @ObservedObject var model: AppModel
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 6) {
                ForEach(model.agents) { chip in
                    AgentChipView(chip: chip)
                        .transition(.scale(scale: 0.7).combined(with: .opacity))
                }
            }
            .padding(.horizontal, 2)
        }
        .animation(.spring(duration: 0.35), value: model.agents.map(\.id))
    }
}

// MARK: - Floating panels

final class GlassPanel: NSPanel {
    init(size: NSSize) {
        super.init(contentRect: NSRect(origin: .zero, size: size),
                   styleMask: [.borderless, .nonactivatingPanel],
                   backing: .buffered, defer: false)
        isFloatingPanel = true
        level = .floating
        backgroundColor = .clear
        isOpaque = false
        // A non-opaque window with hasShadow=true forces macOS to recompute the
        // drop shadow FROM THE ALPHA CHANNEL every time the content changes — a
        // main-thread + WindowServer round-trip per frame while the orb animates.
        // The glassEffect card draws its own edge treatment, so the OS shadow is
        // near-invisible anyway. Drop it.
        hasShadow = false
        isMovableByWindowBackground = true
        collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
        hidesOnDeactivate = false
    }
    override var canBecomeKey: Bool { true }   // buttons + text field need clicks
    override var canBecomeMain: Bool { true }  // text field needs first-responder/typing

    // ── Long-press-on-background gesture (wired only on the main HUD panel) ──
    // Press-and-HOLD ~0.5s on EMPTY, drag-eligible space (never on a button /
    // text field / control) fires `onLongPress` — the HUD uses it to toggle
    // bubble⇄window. A DRAG (pointer moved past `lpThreshold`) cancels it, so the
    // existing move-by-background-drag behavior is untouched; a quick release
    // cancels it, so a short click is unchanged. `canLongPress` gates by app
    // state (e.g. disabled in the docked side-strip). All three closures are nil
    // on the popup/tasks/memory panels, so this is a complete no-op there.
    //
    // Empty-vs-control is decided with `mouseDownCanMoveWindow` — the SAME signal
    // AppKit's isMovableByWindowBackground uses — so the toggle fires in exactly
    // the regions where a background drag would move the window, and never under
    // an interactive SwiftUI control (which reports false there).
    var onLongPress: (() -> Void)?          // fired when the hold completes
    var canLongPress: (() -> Bool)?         // may the current state toggle?
    var onLongPressBegin: (() -> Void)?     // optional press-recognized feedback

    private var lpTimer: Timer?
    private var lpDownScreen: NSPoint?
    private let lpDelay: TimeInterval = 0.5
    private let lpThreshold: CGFloat = 6

    override func sendEvent(_ event: NSEvent) {
        if onLongPress != nil {
            switch event.type {
            case .leftMouseDown:    lpBegin(event)
            case .leftMouseDragged: lpMaybeCancel()   // best-effort early cancel
            case .leftMouseUp:      lpEnd()
            default: break
            }
        }
        super.sendEvent(event)   // never consume — normal dispatch + bg-drag intact
    }

    private func lpBegin(_ event: NSEvent) {
        lpEnd()                                  // clear any stale press
        guard canLongPress?() ?? true else { return }
        // Only on EMPTY, drag-eligible space — never over a control.
        let hit = contentView?.hitTest(event.locationInWindow)
        guard hit == nil || hit!.mouseDownCanMoveWindow else { return }
        lpDownScreen = NSEvent.mouseLocation
        // Common run-loop modes so it still fires if AppKit pulls us into an
        // event-tracking loop for the background drag.
        let t = Timer(timeInterval: lpDelay, repeats: false) { [weak self] _ in
            Task { @MainActor in self?.lpFire() }
        }
        RunLoop.current.add(t, forMode: .common)
        lpTimer = t
    }

    private func lpMaybeCancel() {
        guard let down = lpDownScreen else { return }
        if dist2(NSEvent.mouseLocation, down) > lpThreshold * lpThreshold { lpEnd() }
    }

    private func lpEnd() {
        lpTimer?.invalidate(); lpTimer = nil
        lpDownScreen = nil
    }

    private func lpFire() {
        guard let down = lpDownScreen else { return }   // already cancelled
        lpEnd()
        // Authoritative drag check: if the pointer wandered past the threshold by
        // now it was a drag (we may have missed the moves inside a tracking loop).
        guard dist2(NSEvent.mouseLocation, down) <= lpThreshold * lpThreshold else { return }
        guard canLongPress?() ?? true else { return }
        onLongPressBegin?()
        onLongPress?()
    }

    private func dist2(_ a: NSPoint, _ b: NSPoint) -> CGFloat {
        let dx = a.x - b.x, dy = a.y - b.y
        return dx * dx + dy * dy
    }
}

// MARK: - Minimize-to-bubble (shared by the Compose / Tasks / Memory panels)

/// Minimized-state for a floating panel — the aux-panel twin of the main
/// HUD's `collapsed` + `longPressCue` pair. Each panel owns its own, so every
/// panel remembers its bubble state independently.
@MainActor
final class BubbleState: ObservableObject {
    @Published var minimized = false   // showing as a small circle bubble
    @Published var pressCue = false    // brief scale blip on long-press
}

/// Swaps a panel's full content for a small circular glass bubble (an SF
/// Symbol on glass) while minimized — the aux-panel version of the main HUD's
/// collapsedBubble. Expanding back is a press-and-HOLD on the bubble (wired
/// at the window level by BubbleController, the SAME GlassPanel long-press
/// engine the main HUD uses), so the bubble stays freely draggable; same
/// spring animation + press-cue blip as the HUD's bubble⇄window toggle.
struct MinimizableCard<Content: View>: View {
    @ObservedObject var state: BubbleState
    let icon: () -> String     // closure: the compose bubble's icon follows kind
    let tint: Color
    let help: String
    @ViewBuilder let content: () -> Content

    var body: some View {
        Group {
            if state.minimized { bubble } else { content() }
        }
        // Same render-only "press recognized" blip as the main HUD.
        .scaleEffect(state.pressCue ? 0.96 : 1.0)
        .animation(.easeOut(duration: 0.12), value: state.pressCue)
    }

    private var bubble: some View {
        ZStack {
            Circle().fill(tint.opacity(0.22)).frame(width: 44, height: 44)
            Image(systemName: icon())
                .font(.system(size: 17, weight: .semibold))
                .foregroundStyle(tint)
                .shadow(color: tint.opacity(0.7), radius: 7)
        }
        .frame(width: 58, height: 58)
        .glassCard(29)
        .help(help)
    }
}

/// Wires a GlassPanel to a BubbleState: press-and-hold on empty card space
/// minimizes to the bubble; press-and-hold on the bubble expands it back —
/// exactly the main HUD's bubble⇄window long-press (same gesture recognizer,
/// same spring, same cue). `onToggled` lets each panel resize its window to
/// match (auto-sizing panels re-fit; the fixed-size memory panel sets frames).
@MainActor
final class BubbleController {
    let state = BubbleState()
    var canMinimize: () -> Bool = { true }   // gate while EXPANDED (a bubble can always expand)
    var onToggled: (Bool) -> Void = { _ in }

    func attach(_ p: GlassPanel) {
        p.canLongPress = { [weak self] in
            guard let self else { return false }
            return self.state.minimized || self.canMinimize()
        }
        p.onLongPress = { [weak self] in self?.toggle() }
        p.onLongPressBegin = { [weak self] in self?.cue() }
    }

    func toggle() {
        withAnimation(.spring(duration: 0.3)) { state.minimized.toggle() }
        let mini = state.minimized
        // Next runloop tick: SwiftUI has applied the swap, so the panel can
        // resize around the new content (mirrors the HUD's reanchor-on-change).
        DispatchQueue.main.async { [weak self] in self?.onToggled(mini) }
    }

    /// Plain un-minimize for "a NEW draft arrived / the panel was explicitly
    /// opened" paths — the caller re-frames the window itself right after.
    func reset() {
        state.minimized = false
        state.pressCue = false
    }

    private func cue() {   // same blip timing as Delegate.longPressCue()
        state.pressCue = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak self] in
            self?.state.pressCue = false
        }
    }

    /// Re-fit an auto-sizing panel to its SwiftUI content, keeping the window
    /// CENTER where it was (clamped on-screen) — the card shrinks into a
    /// bubble in place, and the bubble grows back into the card in place.
    static func refitKeepingCenter(_ p: NSPanel) {
        guard let v = p.contentView else { return }
        let center = NSPoint(x: p.frame.midX, y: p.frame.midY)
        v.layoutSubtreeIfNeeded()
        let s = v.fittingSize
        guard s.width > 1, s.height > 1 else { return }
        var o = NSPoint(x: center.x - s.width / 2, y: center.y - s.height / 2)
        if let vf = (p.screen ?? NSScreen.main)?.visibleFrame {
            o.x = min(max(o.x, vf.minX + 8), vf.maxX - s.width - 8)
            o.y = min(max(o.y, vf.minY + 8), vf.maxY - s.height - 8)
        }
        p.setFrame(NSRect(origin: o, size: s), display: true, animate: false)
    }
}

// MARK: - Image popup manager

@MainActor
final class Popups {
    private var windows: [NSPanel] = []
    private var offset: CGFloat = 0

    func show(path: String, title: String) {
        let ext = (path as NSString).pathExtension.lowercased()
        let imageExts: Set<String> = ["png", "jpg", "jpeg", "gif", "webp", "heic", "bmp", "tiff"]
        guard imageExts.contains(ext), let img = NSImage(contentsOfFile: path) else {
            NSWorkspace.shared.open(URL(fileURLWithPath: path))  // non-image: default app
            return
        }
        var s = img.size
        let maxDim: CGFloat = 520
        if s.width > maxDim || s.height > maxDim {
            let k = maxDim / max(s.width, s.height)
            s = NSSize(width: s.width * k, height: s.height * k)
        }
        let panel = GlassPanel(size: NSSize(width: s.width + 24, height: s.height + 52))
        let host = NSHostingView(rootView: PopupView(
            image: img, title: title.isEmpty ? (path as NSString).lastPathComponent : title,
            size: s, onClose: { [weak self, weak panel] in
                guard let panel else { return }
                panel.close()
                self?.windows.removeAll { $0 === panel }
            }))
        panel.contentView = host
        if let vf = NSScreen.main?.visibleFrame {
            panel.setFrameOrigin(NSPoint(x: vf.midX - s.width/2 + offset,
                                         y: vf.midY - s.height/2 - offset))
        }
        offset = offset >= 120 ? 0 : offset + 30
        panel.orderFrontRegardless()
        windows.append(panel)
    }

    func closeAll() { windows.forEach { $0.close() }; windows.removeAll() }
}

struct PopupView: View {
    let image: NSImage
    let title: String
    let size: NSSize
    var onClose: () -> Void
    var body: some View {
        VStack(spacing: 0) {
            HStack {
                Text(title).font(.system(size: 12, weight: .semibold)).lineLimit(1)
                Spacer()
                Button { onClose() } label: {
                    Image(systemName: "xmark.circle.fill")
                        .font(.system(size: 16))
                        .foregroundStyle(.secondary)
                }.buttonStyle(.plain).help("Close")
            }.padding(.horizontal, 12).padding(.vertical, 8)
            Image(nsImage: image).resizable().scaledToFit()
                .frame(width: size.width, height: size.height)
                .clipShape(RoundedRectangle(cornerRadius: 10))
        }
        .padding(6)
        .frame(width: size.width + 24, height: size.height + 52)
        .glassCard(18)
    }
}

// MARK: - Compose / confirm cards (email + calendar drafts)

@MainActor
final class ComposeModel: ObservableObject {
    @Published var kind = "email"          // "email" | "event"
    @Published var draftId = ""
    // email fields
    @Published var to = ""
    @Published var cc = ""
    @Published var subject = ""
    @Published var body = ""
    @Published var attachments: [URL] = []  // files staged to ride with the email
    @Published var dropTargeted = false     // a drag hovers the card (highlight)
    // event fields
    @Published var title = ""
    @Published var start = ""
    @Published var end = ""
    @Published var attendees = ""
    @Published var meet = false
    @Published var context: [String] = []  // his other events that day (read-only)
}

/// A floating glass REVIEW card for an email / calendar draft (voice/compose.py).
/// Jarvis pops it (`compose` / `event_card` events) BEFORE anything is sent;
/// Ahmed can eyeball and hand-edit every field, then Send / Cancel / ✕. Actions
/// go back to the engine as control/card_action.json in exactly the shape
/// ControlWatcher._card_action applies: {kind, draft_id, action, fields} with
/// action send|cancel|close|edit — fields are folded into the draft BEFORE the
/// action runs, so his edits are always honored. For email drafts, fields
/// also carries `attachments: [String]` (file paths) — files Jarvis staged
/// plus whatever Ahmed added via Finder / drag-drop or removed as chips.
/// Typing is also synced silently (debounced "edit") so a spoken "just send
/// it" sends HIS corrected version. The card can minimize to an envelope
/// bubble (hold the bubble to reopen, like the main HUD's bubble).
/// One card at a time — a new draft replaces the view; `card_close` dismisses.
@MainActor
final class ComposePanel {
    private var panel: GlassPanel?
    let model = ComposeModel()
    private var editTimer: Timer?
    // Minimize-to-bubble (shared machinery — envelope/calendar bubble).
    private let bubbleCtl = BubbleController()

    // engine → card (a `compose` event: new draft, or Jarvis edited a field)
    func showEmail(draftId: String, to: String, cc: String,
                   subject: String, body: String, attachments: [String]) {
        editTimer?.invalidate()
        if draftId != model.draftId { bubbleCtl.reset() }  // NEW draft → expanded
        model.kind = "email"
        model.draftId = draftId
        model.to = to; model.cc = cc; model.subject = subject; model.body = body
        model.attachments = attachments.map { URL(fileURLWithPath: $0) }
        show()
    }

    // engine → card (an `event_card` event)
    func showEvent(draftId: String, title: String, start: String, end: String,
                   attendees: String, meet: Bool, context: [String]) {
        editTimer?.invalidate()
        if draftId != model.draftId { bubbleCtl.reset() }  // NEW draft → expanded
        model.kind = "event"
        model.draftId = draftId
        model.title = title; model.start = start; model.end = end
        model.attendees = attendees; model.meet = meet; model.context = context
        show()
    }

    // engine → card (`card_close`: the draft was sent/cancelled elsewhere)
    func close(_ draftId: String) {
        guard draftId.isEmpty || draftId == model.draftId else { return }
        hideCard()
    }

    // MARK: user actions (the card's buttons)

    func send() {   // Send / Book — edited fields ride along so they're honored
        writeAction("send", includeFields: true)
        hideCard()   // optimistic; the engine's card_close confirms
    }
    func cancel() {
        writeAction("cancel", includeFields: false)
        hideCard()
    }
    func dismiss() {   // ✕ — close without sending
        writeAction("close", includeFields: false)
        hideCard()
    }

    /// Called on every field keystroke. Debounced: shortly after typing stops,
    /// sync the edited fields silently (action "edit") so a spoken "send it"
    /// (compose_send) uses Ahmed's corrected values, not the original draft.
    func noteEdited() {
        editTimer?.invalidate()
        editTimer = Timer.scheduledTimer(withTimeInterval: 0.8, repeats: false) {
            [weak self] _ in
            Task { @MainActor in self?.writeAction("edit", includeFields: true) }
        }
    }

    // MARK: attachments (Finder picker + drag-drop, mirrors the chat input)

    /// Attachments row / "Add files" → the same NSOpenPanel flow as the chat
    /// input's paperclip (Delegate.pickFiles): activate first so the accessory
    /// app can present a modal panel and take keyboard focus.
    func pickAttachments() {
        NSApp.activate(ignoringOtherApps: true)
        let op = NSOpenPanel()
        op.allowsMultipleSelection = true
        op.canChooseFiles = true
        op.canChooseDirectories = false
        if op.runModal() == .OK { addAttachments(op.urls) }
    }

    /// Stage files on the draft (picker or drag-drop), deduped, then sync the
    /// draft silently (debounced "edit") — so a spoken "send it" includes them.
    func addAttachments(_ urls: [URL]) {
        var added = false
        for u in urls where u.isFileURL && !model.attachments.contains(u) {
            model.attachments.append(u)
            added = true
        }
        if added { noteEdited() }
    }

    private func hideCard() {
        editTimer?.invalidate(); editTimer = nil
        model.dropTargeted = false
        bubbleCtl.reset()          // next card always opens expanded
        panel?.orderOut(nil)
    }

    private func fields() -> [String: Any] {
        if model.kind == "email" {
            return ["to": model.to, "cc": model.cc,
                    "subject": model.subject, "body": model.body,
                    "attachments": model.attachments.map { $0.path }]
        }
        return ["title": model.title, "start": model.start, "end": model.end,
                "attendees": model.attendees, "meet": model.meet]
    }

    // Same atomic control-file write the other panels use (→ ControlWatcher).
    private func writeAction(_ action: String, includeFields: Bool) {
        guard !model.draftId.isEmpty else { return }
        if action != "edit" { editTimer?.invalidate(); editTimer = nil }
        var payload: [String: Any] = ["kind": model.kind,
                                      "draft_id": model.draftId,
                                      "action": action]
        if includeFields { payload["fields"] = fields() }
        try? FileManager.default.createDirectory(
            at: Paths.control, withIntermediateDirectories: true)
        if let data = try? JSONSerialization.data(withJSONObject: payload) {
            try? data.write(to: Paths.control.appendingPathComponent("card_action.json"),
                            options: .atomic)
        }
    }

    private func show() {
        let p: GlassPanel
        if let existing = panel { p = existing } else {
            let m = model
            let card = ComposeCardView(
                model: model,
                onSend: { [weak self] in self?.send() },
                onCancel: { [weak self] in self?.cancel() },
                onDismiss: { [weak self] in self?.dismiss() },
                onEdited: { [weak self] in self?.noteEdited() },
                onMinimize: { [weak self] in self?.bubbleCtl.toggle() },
                onAttach: { [weak self] in self?.pickAttachments() })
            let root = MinimizableCard(
                state: bubbleCtl.state,
                icon: { m.kind == "email" ? "envelope.fill"
                                          : "calendar.badge.plus" },
                tint: .cyan,
                help: "Draft minimized — hold to expand") { card }
                // Drop a file anywhere on the card (or its bubble) to attach.
                .onDrop(of: [.fileURL],
                        isTargeted: Binding(get: { m.dropTargeted },
                                            set: { m.dropTargeted = $0 })) {
                    [weak self] providers in
                    guard let self, self.model.kind == "email" else { return false }
                    var accepted = false
                    for pr in providers where pr.canLoadObject(ofClass: URL.self) {
                        accepted = true
                        _ = pr.loadObject(ofClass: URL.self) { url, _ in
                            guard let url, url.isFileURL else { return }
                            Task { @MainActor in self.addAttachments([url]) }
                        }
                    }
                    return accepted
                }
            let host = NSHostingView(rootView: root)
            // content drives the size (body field grows as Ahmed types)
            host.sizingOptions = [.minSize, .intrinsicContentSize, .maxSize]
            p = GlassPanel(size: NSSize(width: 460, height: 380))
            p.contentView = host
            bubbleCtl.onToggled = { [weak p] _ in
                if let p { BubbleController.refitKeepingCenter(p) }
            }
            bubbleCtl.attach(p)   // long-press: card ⇄ envelope bubble
            panel = p
        }
        // Minimized (Jarvis re-emitted an edit on the SAME draft): the model
        // is already refreshed — keep the bubble, don't yank focus or recenter.
        if bubbleCtl.state.minimized {
            BubbleController.refitKeepingCenter(p)
            p.orderFrontRegardless()
            return
        }
        // size to content, then drop it front-and-centre for review
        if let v = p.contentView {
            v.layoutSubtreeIfNeeded()
            let s = v.fittingSize
            if s.width > 1, s.height > 1 { p.setContentSize(s) }
        }
        if let vf = NSScreen.main?.visibleFrame {
            p.setFrameOrigin(NSPoint(x: vf.midX - p.frame.width / 2,
                                     y: vf.midY - p.frame.height / 2 + 40))
        }
        NSApp.activate(ignoringOtherApps: true)
        p.makeKeyAndOrderFront(nil)   // the fields need typing focus right away
        p.orderFrontRegardless()
    }
}

struct ComposeCardView: View {
    @ObservedObject var model: ComposeModel
    var onSend: () -> Void
    var onCancel: () -> Void
    var onDismiss: () -> Void
    var onEdited: () -> Void
    var onMinimize: () -> Void
    var onAttach: () -> Void

    private var isEmail: Bool { model.kind == "email" }

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            header
            if isEmail { emailFields } else { eventFields }
            actions
        }
        .padding(16)
        .frame(width: 460)
        .glassCard(22)
    }

    private var header: some View {
        HStack(spacing: 8) {
            Image(systemName: isEmail ? "envelope.fill" : "calendar.badge.plus")
                .font(.system(size: 13, weight: .semibold))
                .foregroundStyle(.cyan)
            Text(isEmail ? "Email — review before it sends"
                         : "Event — confirm before it books")
                .font(.system(size: 13, weight: .semibold))
            Spacer()
            Button { onMinimize() } label: {
                Image(systemName: "minus.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain)
             .help("Minimize to a bubble — hold the bubble to reopen")
            Button { onDismiss() } label: {
                Image(systemName: "xmark.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Close without sending")
        }
    }

    @ViewBuilder private var emailFields: some View {
        field("To", text: $model.to)
        field("Cc", text: $model.cc)
        field("Subject", text: $model.subject)
        bodyField
        attachmentsSection
    }

    // Body: grows with the text up to a cap, then SCROLLS — a long email no
    // longer pushes the card off the screen. Same glass input styling.
    private var bodyField: some View {
        VStack(alignment: .leading, spacing: 3) {
            Text("Body").font(.system(size: 10, weight: .semibold))
                .foregroundStyle(.secondary)
            ScrollView {
                TextField("", text: $model.body, axis: .vertical)
                    .textFieldStyle(.plain)
                    .font(.system(size: 12.5))
                    .lineLimit(4...)
                    .frame(maxWidth: .infinity, alignment: .topLeading)
                    .padding(.horizontal, 10).padding(.vertical, 7)
            }
            .frame(maxHeight: 170)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
            .overlay(RoundedRectangle(cornerRadius: 10)
                .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
        }
        .onChange(of: model.body) { _, _ in onEdited() }
    }

    // Attachments: what will ride along with the email. Chips mirror the chat
    // input's attachment chips (doc icon + name + ✕); click adds via Finder;
    // dropping a file anywhere on the card lands here too (panel-level onDrop).
    private var attachmentsSection: some View {
        VStack(alignment: .leading, spacing: 3) {
            Text("Attachments").font(.system(size: 10, weight: .semibold))
                .foregroundStyle(.secondary)
            VStack(alignment: .leading, spacing: 6) {
                if !model.attachments.isEmpty {
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 6) {
                            ForEach(Array(model.attachments.enumerated()),
                                    id: \.offset) { _, url in
                                attachmentChip(url)
                            }
                        }.padding(.horizontal, 2)
                    }
                }
                Button(action: onAttach) {
                    HStack(spacing: 5) {
                        Image(systemName: "paperclip")
                            .font(.system(size: 10, weight: .medium))
                        Text(model.attachments.isEmpty
                             ? "Add files — or drop them on the card"
                             : "Add more…")
                            .font(.system(size: 10.5, weight: .medium))
                    }
                    .foregroundStyle(.secondary)
                }.buttonStyle(.plain).help("Pick files to attach")
            }
            .padding(.horizontal, 10).padding(.vertical, 8)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
            .overlay(RoundedRectangle(cornerRadius: 10)
                .strokeBorder(model.dropTargeted ? Color.cyan.opacity(0.8)
                                                 : Color.white.opacity(0.12),
                              lineWidth: model.dropTargeted ? 1.5 : 0.5))
            .contentShape(RoundedRectangle(cornerRadius: 10))
            .onTapGesture { onAttach() }   // whole section is clickable
        }
    }

    @ViewBuilder private func attachmentChip(_ url: URL) -> some View {
        HStack(spacing: 4) {
            Image(systemName: "doc.fill").font(.system(size: 9))
                .foregroundStyle(.secondary)
            Text(url.lastPathComponent)
                .font(.system(size: 10, weight: .medium))
                .lineLimit(1).truncationMode(.middle)
                .frame(maxWidth: 120)
            Button {
                if let i = model.attachments.firstIndex(of: url) {
                    model.attachments.remove(at: i)
                    onEdited()   // sync the removal silently, like typing
                }
            } label: {
                Image(systemName: "xmark").font(.system(size: 8, weight: .bold))
                    .foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Remove")
        }
        .padding(.horizontal, 8).padding(.vertical, 4)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }

    @ViewBuilder private var eventFields: some View {
        field("Title", text: $model.title)
        HStack(alignment: .top, spacing: 8) {
            field("Starts", text: $model.start)
            field("Ends", text: $model.end)
        }
        field("Attendees", text: $model.attendees)
        Toggle(isOn: $model.meet) {
            Text("Attach a Google Meet link").font(.system(size: 12))
        }
        .toggleStyle(.switch).controlSize(.mini)
        .onChange(of: model.meet) { _, _ in onEdited() }
        if !model.context.isEmpty { contextStrip }
    }

    // Editable field: caption + glass input, matching the chat box's styling.
    // Multi-line fields grow (Return inserts a newline, as in the chat box).
    @ViewBuilder private func field(_ label: String, text: Binding<String>,
                                    lines: ClosedRange<Int> = 1...1) -> some View {
        VStack(alignment: .leading, spacing: 3) {
            Text(label).font(.system(size: 10, weight: .semibold))
                .foregroundStyle(.secondary)
            TextField("", text: text,
                      axis: lines.upperBound > 1 ? .vertical : .horizontal)
                .textFieldStyle(.plain)
                .font(.system(size: 12.5))
                .lineLimit(lines)
                .padding(.horizontal, 10).padding(.vertical, 7)
                .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
                .overlay(RoundedRectangle(cornerRadius: 10)
                    .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
        }
        .onChange(of: text.wrappedValue) { _, _ in onEdited() }
    }

    // Read-only strip: what's already on his calendar that day (event card).
    private var contextStrip: some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(spacing: 5) {
                Image(systemName: "clock").font(.system(size: 9))
                    .foregroundStyle(.secondary)
                Text("Also that day").font(.system(size: 10, weight: .semibold))
                    .foregroundStyle(.secondary)
            }
            ForEach(Array(model.context.enumerated()), id: \.offset) { _, line in
                Text(line).font(.system(size: 11))
                    .foregroundStyle(.secondary).lineLimit(1)
            }
        }
        .padding(.horizontal, 10).padding(.vertical, 8)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
        .overlay(RoundedRectangle(cornerRadius: 10)
            .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }

    private var actions: some View {
        HStack(spacing: 10) {
            Spacer()
            Button { onCancel() } label: {
                Text("Cancel").font(.system(size: 12, weight: .medium))
                    .padding(.horizontal, 14).padding(.vertical, 6)
            }
            .buttonStyle(.plain)
            .background(.ultraThinMaterial, in: Capsule())
            .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
            .help(isEmail ? "Don't send this" : "Don't book this")

            Button { onSend() } label: {
                HStack(spacing: 5) {
                    Image(systemName: isEmail ? "paperplane.fill" : "checkmark")
                        .font(.system(size: 11, weight: .semibold))
                    Text(isEmail ? "Send" : "Book")
                        .font(.system(size: 12, weight: .semibold))
                }
                .foregroundStyle(.white)
                .padding(.horizontal, 16).padding(.vertical, 6)
            }
            .buttonStyle(.plain)
            .background(Color.accentColor, in: Capsule())
            .help(isEmail ? "Send it — with your edits" : "Book it — with your edits")
        }
    }
}

// MARK: - Email reader card (full thread + attachments + reply)

/// One attachment on a thread message. `path` is "" until the engine downloads
/// it on demand (`email_attachment` event) — the chip is a download button
/// until then, an open button after.
struct EmailAttachment: Identifiable, Equatable {
    var id: String { attId }
    let attId: String
    let filename: String
    let mime: String
    let size: Int          // bytes
    var path: String       // local path once downloaded, else ""
}

struct EmailMessage: Identifiable, Equatable {
    var id: String { msgId }
    let msgId: String
    let fromName: String
    let fromAddr: String
    let to: String
    let cc: String
    let dateHuman: String   // pre-formatted by the engine — printed verbatim
    let outgoing: Bool      // sent BY Ahmed → "You" styling
    let bodyHtml: String    // sanitized by the engine; "" = render bodyText
    let bodyText: String
    let imagesBlocked: Int  // remote imgs the engine swallowed (trackers). 0 = nothing to load
    var attachments: [EmailAttachment]
}

@MainActor
final class EmailModel: ObservableObject {
    @Published var threadId = ""
    @Published var account = ""             // "work" | "business"
    @Published var subject = ""
    @Published var messages: [EmailMessage] = []
    @Published var expanded: Set<String> = []          // msg_ids showing the full body
    @Published var downloading: Set<String> = []       // att_ids awaiting email_attachment
    @Published var imagesRequested: Set<String> = []   // msg_ids awaiting the images re-emit
    @Published var replyText = ""
    @Published var sending = false          // optimistic "Sending…" until email_reply_sent
    @Published var sentFlash = false        // brief "Sent" tick after a confirmed send
    @Published var errorText = ""           // last reply/attachment failure (reply bar)
}

/// The email READER card ("show me that email"). One floating glass panel
/// renders the whole thread the engine emits (`email_thread`): older messages
/// collapsed to header rows, the newest expanded, HTML bodies in a locked-down
/// WKWebView, attachment chips that download on demand, and a reply box pinned
/// at the bottom. Everything the card does goes back through
/// control/email_action.json (attachment / open_attachment / reply / reply_all
/// / images / close); the engine answers with email_attachment /
/// email_reply_sent / a re-emitted email_thread. One thread at a time — a new
/// thread replaces the view; `card_close` (thread_id riding as draft_id)
/// dismisses. Can minimize to an envelope bubble like the other panels.
@MainActor
final class EmailPanel {
    private var panel: GlassPanel?
    let model = EmailModel()
    // Minimize-to-bubble (shared machinery — open-envelope bubble).
    private let bubbleCtl = BubbleController()
    private var sentTimer: Timer?

    // Wide, tall reading window — long threads need room (fixed-size, like Storage).
    private let panelW: CGFloat = 620
    private func panelH(_ vf: NSRect?) -> CGFloat { min(780, (vf?.height ?? 800) - 60) }

    // engine → card (an `email_thread` event: a new thread, or the SAME thread
    // re-emitted with remote images unblocked after an "images" action)
    func show(threadId: String, account: String, subject: String,
              messages: [EmailMessage]) {
        ensurePanel()
        let sameThread = !threadId.isEmpty && threadId == model.threadId
        model.threadId = threadId
        model.account = account
        model.subject = subject
        model.messages = messages
        if sameThread {
            model.imagesRequested = []   // the re-emit IS the images answer
            if !bubbleCtl.state.minimized { panel?.orderFrontRegardless() }
            return                       // keep expansion / reply / scroll state
        }
        // New thread: newest message open, earlier ones collapsed, fresh reply box.
        model.expanded = messages.last.map { [$0.msgId] } ?? []
        model.downloading = []
        model.imagesRequested = []
        model.replyText = ""
        model.sending = false
        model.sentFlash = false
        model.errorText = ""
        bubbleCtl.reset()
        applyFullFrame(center: true)   // restores full size even from the bubble
        NSApp.activate(ignoringOtherApps: true)   // reply box needs typing focus
        panel?.makeKeyAndOrderFront(nil)
        panel?.orderFrontRegardless()
    }

    // engine → card (`email_attachment`: a requested download landed)
    func attachmentReady(threadId: String, msgId: String, attId: String,
                         path: String, ok: Bool, error: String) {
        guard threadId == model.threadId else { return }
        let requested = model.downloading.contains(attId)
        model.downloading.remove(attId)
        guard ok, !path.isEmpty else {
            let name = model.messages.flatMap { $0.attachments }
                .first { $0.attId == attId }?.filename ?? "the attachment"
            model.errorText = error.isEmpty ? "Couldn't download \(name)." : error
            return
        }
        for mi in model.messages.indices {
            for ai in model.messages[mi].attachments.indices
            where model.messages[mi].attachments[ai].attId == attId {
                model.messages[mi].attachments[ai].path = path
            }
        }
        // He clicked to SEE it — open the moment it lands (engine runs `open`).
        if requested { writeAction("open_attachment", ["path": path]) }
    }

    // engine → card (`email_reply_sent`)
    func replySent(threadId: String, ok: Bool, error: String) {
        guard threadId.isEmpty || threadId == model.threadId else { return }
        model.sending = false
        if ok {
            model.replyText = ""
            model.errorText = ""
            model.sentFlash = true
            sentTimer?.invalidate()
            sentTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) {
                [weak self] _ in
                Task { @MainActor in self?.model.sentFlash = false }
            }
        } else {
            model.errorText = error.isEmpty ? "The reply didn't send." : error
        }
    }

    // engine → card (`card_close` with the thread_id riding as draft_id)
    func close(_ threadId: String) {
        guard threadId.isEmpty || threadId == model.threadId else { return }
        hideCard()
    }

    // MARK: user actions (the card's controls)

    func toggleExpand(_ msgId: String) {
        if model.expanded.contains(msgId) { model.expanded.remove(msgId) }
        else { model.expanded.insert(msgId) }
    }

    func tapAttachment(_ msg: EmailMessage, _ att: EmailAttachment) {
        if att.path.isEmpty {
            guard !model.downloading.contains(att.attId) else { return }
            model.downloading.insert(att.attId)
            writeAction("attachment", ["msg_id": msg.msgId, "att_id": att.attId,
                                       "filename": att.filename])
        } else {
            writeAction("open_attachment", ["path": att.path])
        }
    }

    func loadImages(_ msgId: String) {
        guard !model.imagesRequested.contains(msgId) else { return }
        model.imagesRequested.insert(msgId)
        writeAction("images", ["msg_id": msgId])
    }

    func sendReply(all: Bool) {
        let body = model.replyText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !body.isEmpty, !model.sending,
              let last = model.messages.last else { return }
        model.sending = true
        model.errorText = ""
        model.sentFlash = false
        writeAction(all ? "reply_all" : "reply",
                    ["msg_id": last.msgId, "body": body])
    }

    func dismiss() {   // ✕ — drop the card and the engine-side thread state
        writeAction("close", [:])
        hideCard()
    }

    // Nonactivating accessory panel: clicks in the reply box need key + activate.
    func activateInput() {
        NSApp.activate(ignoringOtherApps: true)
        panel?.makeKeyAndOrderFront(nil)
    }

    private func hideCard() {
        sentTimer?.invalidate(); sentTimer = nil
        bubbleCtl.reset()          // next thread always opens expanded
        panel?.orderOut(nil)
    }

    // Same atomic control-file write the other panels use (→ ControlWatcher).
    private func writeAction(_ action: String, _ extra: [String: Any]) {
        guard !model.threadId.isEmpty else { return }
        var payload: [String: Any] = ["thread_id": model.threadId,
                                      "account": model.account,
                                      "action": action]
        payload.merge(extra) { _, new in new }
        try? FileManager.default.createDirectory(
            at: Paths.control, withIntermediateDirectories: true)
        if let data = try? JSONSerialization.data(withJSONObject: payload) {
            try? data.write(to: Paths.control.appendingPathComponent("email_action.json"),
                            options: .atomic)
        }
    }

    // Create the single panel + hosting view ONCE (storage-panel pattern): the
    // hosting view observes `model`, so thread re-emits and attachment / reply
    // events re-render the card in place without rebuilding the view tree.
    private func ensurePanel() {
        guard panel == nil else { return }
        let vf = NSScreen.main?.visibleFrame
        let view = EmailThreadView(
            model: model,
            onToggle: { [weak self] id in self?.toggleExpand(id) },
            onAttachment: { [weak self] msg, att in self?.tapAttachment(msg, att) },
            onImages: { [weak self] id in self?.loadImages(id) },
            onReply: { [weak self] all in self?.sendReply(all: all) },
            onActivateInput: { [weak self] in self?.activateInput() },
            onMinimize: { [weak self] in self?.bubbleCtl.toggle() },
            onClose: { [weak self] in self?.dismiss() })
        let host = NSHostingView(rootView: MinimizableCard(
            state: bubbleCtl.state, icon: { "envelope.open.fill" }, tint: .cyan,
            help: "Email — hold to expand") { view })
        host.sizingOptions = []   // fill the panel; don't shrink to content
        let p = GlassPanel(size: NSSize(width: panelW, height: panelH(vf)))
        p.contentView = host
        // Fixed-size panel: set frames explicitly on bubble⇄card, like Storage.
        bubbleCtl.onToggled = { [weak self] mini in
            guard let self, let p = self.panel else { return }
            if mini {
                let f = p.frame
                p.setFrame(NSRect(x: f.minX, y: f.maxY - 58, width: 58, height: 58),
                           display: true, animate: true)
            } else {
                self.applyFullFrame(center: false)
            }
        }
        bubbleCtl.attach(p)   // long-press: reader ⇄ envelope bubble
        panel = p
    }

    /// Full-size frame for the fixed-size panel: centered for a NEW thread,
    /// otherwise grown back in place (top edge kept), clamped on-screen.
    private func applyFullFrame(center: Bool) {
        guard let p = panel else { return }
        let vf = (p.screen ?? NSScreen.main)?.visibleFrame
            ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
        let w = panelW, h = panelH(vf)
        var x = center ? vf.midX - w / 2 : p.frame.origin.x
        var y = center ? vf.midY - h / 2 : p.frame.maxY - h
        x = min(max(x, vf.minX + 8), vf.maxX - w - 8)
        y = min(max(y, vf.minY + 8), vf.maxY - h - 8)
        p.setFrame(NSRect(x: x, y: y, width: w, height: h),
                   display: true, animate: false)
    }
}

struct EmailThreadView: View {
    @ObservedObject var model: EmailModel
    var onToggle: (String) -> Void
    var onAttachment: (EmailMessage, EmailAttachment) -> Void
    var onImages: (String) -> Void
    var onReply: (Bool) -> Void
    var onActivateInput: () -> Void
    var onMinimize: () -> Void
    var onClose: () -> Void
    @FocusState private var replyFocused: Bool

    var body: some View {
        VStack(spacing: 0) {
            header
            Divider().opacity(0.08)
            thread
            Divider().opacity(0.08)
            replyBar
        }
        .frame(width: 620)
        .frame(maxHeight: .infinity, alignment: .top)
        .glassCard(18)
    }

    private var header: some View {
        HStack(alignment: .top, spacing: 8) {
            Image(systemName: "envelope.open.fill")
                .font(.system(size: 13, weight: .semibold)).foregroundStyle(.cyan)
                .padding(.top, 1)
            VStack(alignment: .leading, spacing: 1) {
                Text(model.subject.isEmpty ? "(no subject)" : model.subject)
                    .font(.system(size: 13.5, weight: .semibold))
                    .lineLimit(2)
                    .textSelection(.enabled)
                Text("\(model.messages.count) message\(model.messages.count == 1 ? "" : "s")"
                     + (model.account.isEmpty ? "" : "  ·  \(model.account)"))
                    .font(.system(size: 10.5)).foregroundStyle(.secondary)
            }
            Spacer()
            Button { onMinimize() } label: {
                Image(systemName: "minus.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain)
             .help("Minimize to a bubble — hold the bubble to reopen")
            Button { onClose() } label: {
                Image(systemName: "xmark.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Close")
        }.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10)
    }

    private var thread: some View {
        ScrollViewReader { proxy in
            ScrollView {
                VStack(alignment: .leading, spacing: 8) {
                    ForEach(model.messages) { msg in
                        EmailMessageView(
                            msg: msg,
                            expanded: model.expanded.contains(msg.msgId),
                            downloading: model.downloading,
                            imagesPending: model.imagesRequested.contains(msg.msgId),
                            onToggle: { onToggle(msg.msgId) },
                            onAttachment: { onAttachment(msg, $0) },
                            onImages: { onImages(msg.msgId) })
                            .id(msg.msgId)
                    }
                }
                .padding(.horizontal, 14).padding(.vertical, 12)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .onAppear { scrollToNewest(proxy, animate: false) }
            .onChange(of: model.threadId) { _, _ in scrollToNewest(proxy, animate: true) }
        }
    }

    private func scrollToNewest(_ proxy: ScrollViewProxy, animate: Bool) {
        guard let last = model.messages.last else { return }
        // Next tick, so the freshly-swapped message views exist to scroll to.
        DispatchQueue.main.async {
            if animate { withAnimation { proxy.scrollTo(last.msgId, anchor: .top) } }
            else { proxy.scrollTo(last.msgId, anchor: .top) }
        }
    }

    private var canSend: Bool {
        !model.replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            && !model.sending
    }

    private var replyBar: some View {
        VStack(alignment: .leading, spacing: 6) {
            if !model.errorText.isEmpty {
                HStack(spacing: 5) {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .font(.system(size: 10)).foregroundStyle(.orange)
                    Text(model.errorText).font(.system(size: 11))
                        .foregroundStyle(.orange).lineLimit(2)
                }
            }
            ZStack(alignment: .topLeading) {
                TextEditor(text: $model.replyText)
                    .font(.system(size: 12.5))
                    .scrollContentBackground(.hidden)
                    .frame(height: 72)
                    .padding(.horizontal, 6).padding(.vertical, 4)
                    .focused($replyFocused)
                    .onKeyPress { press in   // ⌘↩ sends, Enter stays a newline
                        if press.key == .return && press.modifiers.contains(.command) {
                            onReply(false)
                            return .handled
                        }
                        return .ignored
                    }
                if model.replyText.isEmpty {
                    Text("Write a reply…").font(.system(size: 12.5))
                        .foregroundStyle(.tertiary)
                        .padding(.horizontal, 11).padding(.vertical, 8)
                        .allowsHitTesting(false)
                }
            }
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
            .overlay(RoundedRectangle(cornerRadius: 12)
                .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
            // Nonactivating accessory panel: activate + make key so typing works.
            .simultaneousGesture(TapGesture().onEnded {
                onActivateInput()
                replyFocused = true
            })

            HStack(spacing: 10) {
                if model.sending {
                    ProgressView().controlSize(.small).scaleEffect(0.72)
                        .frame(width: 14, height: 14)
                    Text("Sending…").font(.system(size: 11.5))
                        .foregroundStyle(.secondary)
                } else if model.sentFlash {
                    Image(systemName: "checkmark.seal.fill")
                        .font(.system(size: 12)).foregroundStyle(.green)
                    Text("Sent").font(.system(size: 11.5)).foregroundStyle(.green)
                }
                Spacer()
                Button { onReply(true) } label: {
                    Text("Reply all").font(.system(size: 12, weight: .medium))
                        .padding(.horizontal, 14).padding(.vertical, 6)
                }
                .buttonStyle(.plain)
                .background(.ultraThinMaterial, in: Capsule())
                .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
                .disabled(!canSend)
                .help("Reply to everyone on the thread")

                Button { onReply(false) } label: {
                    HStack(spacing: 5) {
                        Image(systemName: "paperplane.fill")
                            .font(.system(size: 11, weight: .semibold))
                        Text("Send").font(.system(size: 12, weight: .semibold))
                    }
                    .foregroundStyle(.white)
                    .padding(.horizontal, 16).padding(.vertical, 6)
                }
                .buttonStyle(.plain)
                .background(canSend ? Color.accentColor : Color.secondary.opacity(0.4),
                            in: Capsule())
                .disabled(!canSend)
                .help("Reply to the sender — ⌘↩ works too")
            }
        }
        .padding(.horizontal, 14).padding(.vertical, 10)
    }
}

/// One message: a collapsed header row (sender · one-line preview · 📎 count ·
/// date) or the full expanded message (addresses, body, attachments). The
/// header row is always the click target for expand/collapse.
struct EmailMessageView: View {
    let msg: EmailMessage
    let expanded: Bool
    let downloading: Set<String>
    let imagesPending: Bool
    var onToggle: () -> Void
    var onAttachment: (EmailAttachment) -> Void
    var onImages: () -> Void

    private var senderLabel: String {
        msg.outgoing ? "You" : (msg.fromName.isEmpty ? msg.fromAddr : msg.fromName)
    }
    // "Load images" only makes sense when the engine actually swallowed remote
    // ones. Sniffing for "<img" would offer it on mail whose only images are
    // safe inline cid: parts (already rendered) — nothing to load, dead button.
    private var hasImages: Bool { msg.imagesBlocked > 0 }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            headerRow
            if expanded { expandedBody }
        }
        .background(RoundedRectangle(cornerRadius: 12)
            .fill(msg.outgoing ? Color.cyan.opacity(0.07)
                               : Color.primary.opacity(0.04)))
        .overlay(RoundedRectangle(cornerRadius: 12)
            .strokeBorder(.white.opacity(0.10), lineWidth: 0.5))
    }

    private var headerRow: some View {
        Button(action: onToggle) {
            HStack(spacing: 8) {
                Image(systemName: expanded ? "chevron.down" : "chevron.right")
                    .font(.system(size: 9, weight: .bold))
                    .foregroundStyle(.secondary)
                Text(senderLabel)
                    .font(.system(size: 12.5, weight: .semibold))
                    .foregroundStyle(msg.outgoing ? Color.cyan : Color.primary)
                    .lineLimit(1)
                if !expanded {
                    Text(preview).font(.system(size: 11))
                        .foregroundStyle(.tertiary).lineLimit(1)
                }
                Spacer(minLength: 8)
                if !msg.attachments.isEmpty {
                    HStack(spacing: 2) {
                        Image(systemName: "paperclip").font(.system(size: 9))
                        Text("\(msg.attachments.count)").font(.system(size: 10))
                    }.foregroundStyle(.secondary)
                }
                Text(msg.dateHuman).font(.system(size: 10.5))
                    .foregroundStyle(.secondary)
            }
            .padding(.horizontal, 12).padding(.vertical, 9)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .help(expanded ? "Collapse this message" : "Expand this message")
    }

    private var preview: String {
        msg.bodyText.replacingOccurrences(of: "\n", with: " ")
            .trimmingCharacters(in: .whitespacesAndNewlines)
    }

    @ViewBuilder private var expandedBody: some View {
        VStack(alignment: .leading, spacing: 8) {
            addresses
            if msg.bodyHtml.isEmpty {
                Text(msg.bodyText)
                    .font(.system(size: 13))
                    .textSelection(.enabled)
                    .fixedSize(horizontal: false, vertical: true)
                    .frame(maxWidth: .infinity, alignment: .leading)
            } else {
                HTMLBodyView(html: msg.bodyHtml)
            }
            if hasImages { imagesButton }
            if !msg.attachments.isEmpty { attachmentsRow }
        }
        .padding(.horizontal, 12).padding(.bottom, 11)
    }

    private var addresses: some View {
        VStack(alignment: .leading, spacing: 1) {
            if !msg.outgoing && !msg.fromAddr.isEmpty {
                Text(msg.fromAddr).lineLimit(1).truncationMode(.middle)
            }
            if !msg.to.isEmpty {
                Text("to \(msg.to)").lineLimit(1).truncationMode(.middle)
            }
            if !msg.cc.isEmpty {
                Text("cc \(msg.cc)").lineLimit(1).truncationMode(.middle)
            }
        }
        .font(.system(size: 10.5)).foregroundStyle(.secondary)
        .textSelection(.enabled)
    }

    @ViewBuilder private var imagesButton: some View {
        if imagesPending {
            HStack(spacing: 5) {
                ProgressView().controlSize(.small).scaleEffect(0.6)
                    .frame(width: 12, height: 12)
                Text("Loading images…").font(.system(size: 10.5))
                    .foregroundStyle(.secondary)
            }
        } else {
            Button(action: onImages) {
                HStack(spacing: 4) {
                    Image(systemName: "photo").font(.system(size: 10))
                    Text("Load images").font(.system(size: 10.5, weight: .medium))
                }.foregroundStyle(.cyan)
            }.buttonStyle(.plain)
             .help("Remote images are blocked (tracking pixels) — load them for this message")
        }
    }

    private var attachmentsRow: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 6) {
                ForEach(msg.attachments) { att in attachmentChip(att) }
            }.padding(.horizontal, 2)
        }
    }

    @ViewBuilder private func attachmentChip(_ att: EmailAttachment) -> some View {
        Button { onAttachment(att) } label: {
            HStack(spacing: 5) {
                if downloading.contains(att.attId) {
                    ProgressView().controlSize(.small).scaleEffect(0.55)
                        .frame(width: 11, height: 11)
                } else {
                    Image(systemName: storageIcon((att.filename as NSString).pathExtension))
                        .font(.system(size: 10))
                        .foregroundStyle(att.path.isEmpty ? Color.secondary : Color.cyan)
                }
                Text(att.filename)
                    .font(.system(size: 10.5, weight: .medium))
                    .lineLimit(1).truncationMode(.middle)
                    .frame(maxWidth: 150)
                if !storageSize(att.size).isEmpty {
                    Text(storageSize(att.size)).font(.system(size: 9.5))
                        .foregroundStyle(.secondary)
                }
            }
            .padding(.horizontal, 9).padding(.vertical, 5)
            .contentShape(Capsule())
        }
        .buttonStyle(.plain)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
        .help(att.path.isEmpty ? "Download \(att.filename)" : "Open \(att.filename)")
    }
}

// MARK: - Entity Dossier card

/// Everything Jarvis knows about one entity (person / company / …), assembled by
/// the reflection layer and pushed as an `entity_profile` event. The card is a
/// single floating glass panel (like the email reader): a summary + "your read",
/// tappable quick contacts, relations grouped by type (tapping one drills into
/// that entity), a scrollable memory timeline, durable facts in disclosure
/// groups, and open tasks. It talks back through control/entity_action.json —
/// {"cmd":"show_entity",…} (drill-down), {"cmd":"entity_note",…} (add a note),
/// {"cmd":"entity_summary",…} (rewrite the summary). One dossier at a time — a
/// new profile REPLACES the view (the drill-down navigation depends on it).

// --- entity palette (mirrors the memory-graph kind/etype colours) ---

func entityTypeColor(_ etype: String) -> Color {
    switch etype.lowercased().replacingOccurrences(of: "entity:", with: "") {
    case "person":  return .green
    case "company": return .orange
    case "amount":  return .yellow
    case "product": return .mint
    case "place":   return .teal
    case "thing":   return .gray
    default:        return .blue
    }
}

func entityTypeIcon(_ etype: String) -> String {
    switch etype.lowercased().replacingOccurrences(of: "entity:", with: "") {
    case "person":  return "person.fill"
    case "company": return "building.2.fill"
    case "amount":  return "dollarsign.circle.fill"
    case "product": return "shippingbox.fill"
    case "place":   return "mappin.circle.fill"
    case "thing":   return "cube.fill"
    default:        return "circle.fill"
    }
}

func entityTypeLabel(_ etype: String) -> String {
    let k = etype.lowercased().replacingOccurrences(of: "entity:", with: "")
    switch k {
    case "person":  return "Person"
    case "company": return "Company"
    case "amount":  return "Amount"
    case "product": return "Product"
    case "place":   return "Place"
    case "thing":   return "Thing"
    default:
        guard let f = k.first else { return "Entity" }
        return f.uppercased() + k.dropFirst()
    }
}

/// Colour for a memory KIND (timeline dots + fact groups) — same palette the
/// graph uses so a "fact" reads cyan here and there.
func entityKindColor(_ kind: String) -> Color {
    switch kind.lowercased() {
    case "fact":                      return .cyan
    case "event":                     return .pink
    case "preference", "pref":        return .purple
    case "task", "todo", "reminder":  return .indigo
    case "plan":                      return .blue
    case "pattern":                   return .orange
    case "synthesis", "reflection":   return .mint
    case "observation":               return .teal
    default:                          return .gray
    }
}

/// Plural section title for a facts_by_kind bucket ("preference" → "Preferences").
func entityFactSectionLabel(_ kind: String) -> String {
    switch kind.lowercased() {
    case "preference", "pref": return "Preferences"
    case "plan":               return "Plans"
    case "pattern":            return "Patterns"
    case "synthesis":          return "Syntheses"
    case "fact":               return "Facts"
    case "event":              return "Events"
    case "observation":        return "Observations"
    default:
        let base = kind.lowercased()
        guard let f = base.first else { return "Notes" }
        return f.uppercased() + base.dropFirst() + "s"
    }
}

private enum EntTimeFmt {
    static let ymd: DateFormatter = {
        let f = DateFormatter()
        f.calendar = Calendar(identifier: .gregorian)
        f.locale = Locale(identifier: "en_US_POSIX")
        f.timeZone = .current
        f.dateFormat = "yyyy-MM-dd"
        return f
    }()
}

/// A stored date (ISO-8601 or "yyyy-MM-dd") as a short relative string —
/// "today", "5d ago", "3w ago", "2mo ago", "1y ago"; future/unparseable dates
/// fall back to the absolute yyyy-MM-dd (or the raw string).
func entityRelativeDate(_ s: String) -> String {
    guard !s.isEmpty else { return "" }
    let d = MemTimeFmt.withFrac.date(from: s)
        ?? MemTimeFmt.plain.date(from: s)
        ?? EntTimeFmt.ymd.date(from: String(s.prefix(10)))
    guard let d else { return s }
    let secs = Date().timeIntervalSince(d)
    if secs < 0 { return EntTimeFmt.ymd.string(from: d) }   // future → absolute
    let day = 86_400.0
    if secs < day { return "today" }
    let days = Int(secs / day)
    if days < 7   { return "\(days)d ago" }
    if days < 30  { return "\(days / 7)w ago" }
    if days < 365 { return "\(days / 30)mo ago" }
    return "\(days / 365)y ago"
}

// --- data models (mirror the entity_profile JSON) ---

struct EntityRef: Equatable {
    let key: String
    let name: String
    let etype: String
    let aliases: [String]
}

struct EntityQuick: Equatable {
    let phones: [String]
    let emails: [String]
    let urls: [String]
    let amounts: [String]
    var isEmpty: Bool { phones.isEmpty && emails.isEmpty && urls.isEmpty && amounts.isEmpty }
    /// Flattened, tappable order: phones, emails, urls, then amounts.
    var chips: [(kind: String, value: String)] {
        phones.map { ("phone", $0) } + emails.map { ("email", $0) }
             + urls.map { ("url", $0) } + amounts.map { ("amount", $0) }
    }
}

struct EntityRelation: Identifiable, Equatable {
    let other: String
    let otype: String
    let rtype: String
    let direction: String
    let otherSummary: String
    var id: String { rtype + "|" + direction + "|" + other }
}

struct EntityTimelineItem: Identifiable, Equatable {
    let id: String
    let date: String
    let kind: String
    let fact: String
    let tone: String
}

struct EntityFact: Identifiable, Equatable {
    let id: String
    let fact: String
    let date: String
}

struct EntityFactGroup: Identifiable, Equatable {
    let kind: String
    let facts: [EntityFact]
    var id: String { kind }
}

struct EntityTaskItem: Identifiable, Equatable {
    let id: String
    let text: String
    let due: String
}

struct EntityCounts: Equatable {
    let facts: Int
    let relations: Int
    let lastTouch: String
}

/// The whole payload, parsed once from the `entity_profile` event's `profile`.
struct EntityProfile: Equatable {
    var entity = EntityRef(key: "", name: "", etype: "", aliases: [])
    var summary = ""
    var read = ""
    var quick = EntityQuick(phones: [], emails: [], urls: [], amounts: [])
    var relations: [EntityRelation] = []
    var timeline: [EntityTimelineItem] = []
    var factGroups: [EntityFactGroup] = []
    var tasks: [EntityTaskItem] = []
    var counts = EntityCounts(facts: 0, relations: 0, lastTouch: "")
    var alsoMatched: [String] = []

    static func from(_ p: [String: Any]) -> EntityProfile {
        func str(_ v: Any?) -> String { (v as? String) ?? "" }
        func strs(_ v: Any?) -> [String] { (v as? [Any])?.compactMap { $0 as? String } ?? [] }
        func int(_ v: Any?) -> Int { (v as? Int) ?? Int((v as? Double) ?? 0) }

        var out = EntityProfile()
        if let e = p["entity"] as? [String: Any] {
            out.entity = EntityRef(key: str(e["key"]), name: str(e["name"]),
                                   etype: str(e["etype"]), aliases: strs(e["aliases"]))
        }
        out.summary = str(p["summary"])
        out.read = str(p["read"])
        if let q = p["quick"] as? [String: Any] {
            out.quick = EntityQuick(phones: strs(q["phones"]), emails: strs(q["emails"]),
                                    urls: strs(q["urls"]), amounts: strs(q["amounts"]))
        }
        out.relations = (p["relations"] as? [[String: Any]] ?? []).map {
            EntityRelation(other: str($0["other"]), otype: str($0["otype"]),
                           rtype: str($0["rtype"]), direction: str($0["direction"]),
                           otherSummary: str($0["other_summary"]))
        }
        out.timeline = (p["timeline"] as? [[String: Any]] ?? []).enumerated().map { i, t in
            let id = str(t["id"])
            return EntityTimelineItem(id: id.isEmpty ? "tl-\(i)" : id,
                                      date: str(t["date"]), kind: str(t["kind"]),
                                      fact: str(t["fact"]), tone: str(t["tone"]))
        }
        if let fbk = p["facts_by_kind"] as? [String: Any] {
            // Preferred display order, then any remaining kinds alphabetically.
            let order = ["preference", "pref", "plan", "pattern", "synthesis",
                         "fact", "event", "observation"]
            let keys = fbk.keys.sorted {
                let a = order.firstIndex(of: $0.lowercased()) ?? Int.max
                let b = order.firstIndex(of: $1.lowercased()) ?? Int.max
                return a == b ? $0 < $1 : a < b
            }
            out.factGroups = keys.compactMap { k in
                guard let arr = fbk[k] as? [[String: Any]], !arr.isEmpty else { return nil }
                let facts = arr.enumerated().map { i, f -> EntityFact in
                    let id = str(f["id"])
                    return EntityFact(id: id.isEmpty ? "\(k)-\(i)" : id,
                                      fact: str(f["fact"]), date: str(f["date"]))
                }
                return EntityFactGroup(kind: k, facts: facts)
            }
        }
        out.tasks = (p["tasks"] as? [[String: Any]] ?? []).enumerated().map { i, t in
            let id = str(t["id"])
            return EntityTaskItem(id: id.isEmpty ? "task-\(i)" : id,
                                  text: str(t["text"]), due: str(t["due"]))
        }
        if let c = p["counts"] as? [String: Any] {
            out.counts = EntityCounts(facts: int(c["facts"]), relations: int(c["relations"]),
                                      lastTouch: str(c["last_touch"]))
        }
        out.alsoMatched = strs(p["also_matched"])
        return out
    }
}

// --- composed layout (the server-side DeepSeek-organized view) ---
//
// The server now COMPOSES an adaptive, ordered section list next to the raw
// `profile` (the `composed` object). A client vs an employee vs a concept get
// DIFFERENT sections; the HUD just renders a small closed widget vocabulary
// (text · kv · chips · list) beautifully. `composed` may be null (LLM failed or
// still working) — then the card falls back to the raw rendering.

struct ComposedHeader: Equatable {
    let roleLine: String
    let badges: [String]
}

struct ComposedKV: Identifiable, Equatable {
    let id: String
    let k: String
    let v: String
}

struct ComposedChip: Identifiable, Equatable {
    let id: String
    let kind: String     // phone | email | link | map | whatsapp
    let label: String
    let value: String
}

struct ComposedListRow: Identifiable, Equatable {
    let id: String
    let icon: String     // person | company | event | task | money | note
    let title: String
    let sub: String
    let date: String
    let entity: String   // non-empty → chevron + drill-down (show_entity)
}

enum ComposedSection: Identifiable, Equatable {
    case text(id: String, title: String, body: String)
    case kv(id: String, title: String, rows: [ComposedKV])
    case chips(id: String, title: String, chips: [ComposedChip])
    case list(id: String, title: String, rows: [ComposedListRow])
    var id: String {
        switch self {
        case let .text(id, _, _):  return id
        case let .kv(id, _, _):    return id
        case let .chips(id, _, _): return id
        case let .list(id, _, _):  return id
        }
    }
}

/// The composed layout parsed once from the `entity_profile` event's `composed`
/// object. Int/Double/missing tolerant — kv/list values may arrive as numbers.
struct ComposedDossier: Equatable {
    var header = ComposedHeader(roleLine: "", badges: [])
    var sections: [ComposedSection] = []

    /// True when the server already emitted a chips section — so the card can
    /// suppress its own quick-contacts chips row (they'd be redundant).
    var hasChipsSection: Bool {
        sections.contains { if case .chips = $0 { return true } else { return false } }
    }

    static func from(_ c: [String: Any]) -> ComposedDossier {
        func str(_ v: Any?) -> String {
            switch v {
            case let s as String: return s
            case let i as Int:    return String(i)
            case let d as Double: return d == d.rounded() ? String(Int(d)) : String(d)
            default:              return ""
            }
        }
        func strs(_ v: Any?) -> [String] { (v as? [Any])?.compactMap { $0 as? String } ?? [] }

        var out = ComposedDossier()
        if let h = c["header"] as? [String: Any] {
            out.header = ComposedHeader(roleLine: str(h["role_line"]), badges: strs(h["badges"]))
        }
        let secs = c["sections"] as? [[String: Any]] ?? []
        out.sections = secs.enumerated().compactMap { i, s -> ComposedSection? in
            let type = str(s["type"])
            let title = str(s["title"])
            let sid = "sec-\(i)-\(type)"
            switch type {
            case "text":
                let body = str(s["body"])
                return body.isEmpty ? nil : .text(id: sid, title: title, body: body)
            case "kv":
                let rows = (s["rows"] as? [[String: Any]] ?? []).enumerated().map { j, r in
                    ComposedKV(id: "\(sid)-\(j)", k: str(r["k"]), v: str(r["v"]))
                }.filter { !$0.k.isEmpty || !$0.v.isEmpty }
                return rows.isEmpty ? nil : .kv(id: sid, title: title, rows: rows)
            case "chips":
                let chips = (s["chips"] as? [[String: Any]] ?? []).enumerated().compactMap { j, r -> ComposedChip? in
                    let value = str(r["value"])
                    guard !value.isEmpty else { return nil }
                    return ComposedChip(id: "\(sid)-\(j)", kind: str(r["kind"]),
                                        label: str(r["label"]), value: value)
                }
                return chips.isEmpty ? nil : .chips(id: sid, title: title, chips: chips)
            case "list":
                let rows = (s["rows"] as? [[String: Any]] ?? []).enumerated().compactMap { j, r -> ComposedListRow? in
                    let t = str(r["title"])
                    guard !t.isEmpty else { return nil }
                    return ComposedListRow(id: "\(sid)-\(j)", icon: str(r["icon"]), title: t,
                                           sub: str(r["sub"]), date: str(r["date"]),
                                           entity: str(r["entity"]))
                }
                return rows.isEmpty ? nil : .list(id: sid, title: title, rows: rows)
            default:
                return nil
            }
        }
        return out
    }
}

/// SF Symbol for a composed chip kind (phone/email/link/map/whatsapp).
func composedChipIcon(_ kind: String) -> String {
    switch kind {
    case "phone":    return "phone.fill"
    case "email":    return "envelope.fill"
    case "link":     return "link"
    case "map":      return "map.fill"
    case "whatsapp": return "message.fill"
    default:         return "circle.fill"
    }
}

/// SF Symbol for a composed list-row icon.
func composedListIcon(_ icon: String) -> String {
    switch icon {
    case "person":  return "person.crop.circle"
    case "company": return "building.2"
    case "event":   return "calendar"
    case "task":    return "checklist"
    case "money":   return "banknote"
    case "note":    return "note.text"
    default:        return "circle"
    }
}

/// Tint for a composed list-row icon (matches the entity/kind palette).
func composedListColor(_ icon: String) -> Color {
    switch icon {
    case "person":  return .green
    case "company": return .orange
    case "event":   return .pink
    case "task":    return .indigo
    case "money":   return .yellow
    case "note":    return .gray
    default:        return .secondary
    }
}

/// Minimal wrapping layout for chip / badge rows (flows onto new lines).
struct WrapLayout: Layout {
    var spacing: CGFloat = 6
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxW = proposal.width ?? .infinity
        var x: CGFloat = 0, y: CGFloat = 0, rowH: CGFloat = 0, widest: CGFloat = 0
        for v in subviews {
            let s = v.sizeThatFits(.unspecified)
            if x + s.width > maxW && x > 0 { x = 0; y += rowH + spacing; rowH = 0 }
            x += s.width + spacing
            rowH = max(rowH, s.height)
            widest = max(widest, x - spacing)
        }
        return CGSize(width: maxW.isFinite ? maxW : widest, height: y + rowH)
    }
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let maxW = bounds.width
        var x: CGFloat = 0, y: CGFloat = 0, rowH: CGFloat = 0
        for v in subviews {
            let s = v.sizeThatFits(.unspecified)
            if x + s.width > maxW && x > 0 { x = 0; y += rowH + spacing; rowH = 0 }
            v.place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + y),
                    proposal: ProposedViewSize(s))
            x += s.width + spacing
            rowH = max(rowH, s.height)
        }
    }
}

/// A subtle left-to-right shimmer over an "organizing…" label, shown under the
/// header while the server composes the layout (composing:true, composed:nil).
struct OrganizingShimmer: View {
    @State private var x: CGFloat = -0.85
    private var label: some View {
        HStack(spacing: 5) {
            Image(systemName: "sparkles").font(.system(size: 9.5))
            Text("organizing…").font(.system(size: 10, weight: .medium))
        }
    }
    var body: some View {
        label
            .foregroundStyle(.tertiary)
            .overlay {
                GeometryReader { geo in
                    LinearGradient(colors: [.clear, .white.opacity(0.7), .clear],
                                   startPoint: .leading, endPoint: .trailing)
                        .frame(width: geo.size.width * 0.5)
                        .offset(x: x * geo.size.width)
                        .blendMode(.plusLighter)
                }
                .allowsHitTesting(false)
            }
            .mask { label }
            .onAppear {
                withAnimation(.easeInOut(duration: 1.15).repeatForever(autoreverses: false)) {
                    x = 0.85
                }
            }
    }
}

@MainActor
final class EntityModel: ObservableObject {
    @Published var profile = EntityProfile()
    @Published var composed: ComposedDossier? = nil   // server-organized layout (nil → raw fallback)
    @Published var composing = false                  // true → "organizing…" shimmer under header
    @Published var noteText = ""            // the footer "add a note" field
    @Published var editingSummary = false   // pencil → TextEditor
    @Published var summaryDraft = ""
    @Published var loading = false          // waiting for a drill-down profile

    var name: String { profile.entity.name }
    var key: String { profile.entity.key }
}

/// The single reusable dossier panel (email-reader machinery): one GlassPanel, a
/// hosting view that observes `model`, minimize-to-bubble, and every user action
/// written atomically to control/entity_action.json for the engine to pick up.
@MainActor
final class EntityPanel {
    private var panel: GlassPanel?
    let model = EntityModel()
    private let bubbleCtl = BubbleController()

    private let panelW: CGFloat = 560
    private func panelH(_ vf: NSRect?) -> CGFloat { min(760, (vf?.height ?? 800) - 60) }

    // engine → card (an `entity_profile` event: a new entity, or a drill-down target)
    func show(_ p: EntityProfile, composed: ComposedDossier? = nil, composing: Bool = false) {
        ensurePanel()
        // Progressive compose: the FIRST event lands composed:nil composing:true,
        // a SECOND event for the SAME entity key lands with composed set. Detect
        // that same-key follow-up and swap CONTENT in place — no reframe, no
        // reset of edit state, no re-activate — so the window doesn't flicker.
        let wasVisible = panel?.isVisible ?? false
        let prevKey = model.key
        let sameKey = !p.entity.key.isEmpty && p.entity.key == prevKey
        let inPlace = wasVisible && sameKey

        model.profile = p
        // NEVER DOWNGRADE: a same-key follow-up that arrives WITHOUT a composed
        // layout (a stage-1 re-show, composing:true composed:nil, or a raw-only
        // refresh) must NOT wipe the organized card already on screen. Keep the
        // existing composed and just refresh the raw profile fields; drop the
        // shimmer so there's no downgrade flash. A same-key event that DOES carry
        // composed is fresher and always replaces. Different key → full replace.
        if sameKey && composed == nil && model.composed != nil {
            model.composing = false
        } else {
            model.composed = composed
            model.composing = composing
        }
        if inPlace { return }

        // Fresh entity → reset all per-entity edit state.
        model.noteText = ""
        model.editingSummary = false
        model.summaryDraft = ""
        model.loading = false
        bubbleCtl.reset()
        applyFullFrame(center: true)
        NSApp.activate(ignoringOtherApps: true)   // note field needs typing focus
        panel?.makeKeyAndOrderFront(nil)
        panel?.orderFrontRegardless()
    }

    // engine → card (`card_close` with the entity key riding as draft_id)
    func close(_ key: String) {
        guard key.isEmpty || key == model.key else { return }
        hideCard()
    }

    // MARK: user actions

    /// Tap a relation row → ask the engine for THAT entity's dossier (it replaces
    /// this one when the fresh entity_profile lands).
    func drillDown(_ name: String) {
        guard !name.isEmpty else { return }
        model.loading = true
        writeCmd(["cmd": "show_entity", "name": name])
    }

    /// Open a tappable quick-contact chip (phone → tel:, email → mailto:, url → browser).
    func openQuick(_ kind: String, _ value: String) {
        switch kind {
        case "phone":
            let digits = value.filter { $0.isNumber || $0 == "+" }
            openURL("tel:\(digits)")
        case "email":
            openURL("mailto:\(value)")
        case "url":
            openURL(value.lowercased().hasPrefix("http") ? value : "https://\(value)")
        default:
            break   // amounts aren't links
        }
    }

    private func openURL(_ s: String) {
        guard let u = URL(string: s.addingPercentEncoding(
            withAllowedCharacters: .urlFragmentAllowed) ?? s) else { return }
        NSWorkspace.shared.open(u)
    }

    /// Open a composed chip: phone → tel:, email → mailto:, whatsapp/map/link →
    /// the value opened as a URL (bare domains get an https:// prefix).
    func openComposedChip(_ kind: String, _ value: String) {
        switch kind {
        case "phone":
            let digits = value.filter { $0.isNumber || $0 == "+" }
            openURL("tel:\(digits)")
        case "email":
            openURL(value.lowercased().hasPrefix("mailto:") ? value : "mailto:\(value)")
        default:   // whatsapp, map, link — value is (or becomes) a URL
            let hasScheme = value.contains("://") || value.lowercased().hasPrefix("mailto:")
            openURL(hasScheme ? value : "https://\(value)")
        }
    }

    func submitNote() {
        let text = model.noteText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty, !model.key.isEmpty else { return }
        writeCmd(["cmd": "entity_note", "key": model.key, "text": text])
        model.noteText = ""
    }

    func beginEditSummary() {
        model.summaryDraft = model.profile.summary
        model.editingSummary = true
    }
    func cancelEditSummary() { model.editingSummary = false }
    func saveSummary() {
        let text = model.summaryDraft.trimmingCharacters(in: .whitespacesAndNewlines)
        model.editingSummary = false
        guard !model.key.isEmpty else { return }
        model.profile.summary = text                       // optimistic
        writeCmd(["cmd": "entity_summary", "key": model.key, "text": text])
    }

    /// Human-in-the-loop edge curation: mark a related entity as NOT related.
    /// The machine never cuts edges autonomously — this is the one-tap manual
    /// removal. Writes the unrelate command for the engine, then optimistically
    /// drops the row locally (raw relations AND composed list rows) so it
    /// vanishes instantly.
    func unrelate(_ other: String) {
        guard !other.isEmpty, !model.key.isEmpty else { return }
        writeCmd(["cmd": "entity_unrelate", "key": model.key, "other": other])
        model.profile.relations.removeAll { $0.other == other }
        if var c = model.composed {
            c.sections = c.sections.compactMap { section in
                guard case let .list(id, title, rows) = section else { return section }
                let kept = rows.filter { $0.entity != other }
                return kept.isEmpty ? nil : .list(id: id, title: title, rows: kept)
            }
            model.composed = c
        }
    }

    func dismiss() { hideCard() }

    // Nonactivating accessory panel: clicks in the note field need key + activate.
    func activateInput() {
        NSApp.activate(ignoringOtherApps: true)
        panel?.makeKeyAndOrderFront(nil)
    }

    private func hideCard() {
        bubbleCtl.reset()
        panel?.orderOut(nil)
    }

    // Same atomic control-file write the other panels use (→ ControlWatcher).
    private func writeCmd(_ payload: [String: Any]) {
        try? FileManager.default.createDirectory(
            at: Paths.control, withIntermediateDirectories: true)
        if let data = try? JSONSerialization.data(withJSONObject: payload) {
            try? data.write(to: Paths.control.appendingPathComponent("entity_action.json"),
                            options: .atomic)
        }
    }

    private func ensurePanel() {
        guard panel == nil else { return }
        let vf = NSScreen.main?.visibleFrame
        let view = EntityDossierView(
            model: model,
            onRelation: { [weak self] name in self?.drillDown(name) },
            onUnrelate: { [weak self] name in self?.unrelate(name) },
            onQuick: { [weak self] kind, value in self?.openQuick(kind, value) },
            onChip: { [weak self] kind, value in self?.openComposedChip(kind, value) },
            onNote: { [weak self] in self?.submitNote() },
            onEditSummary: { [weak self] in self?.beginEditSummary() },
            onSaveSummary: { [weak self] in self?.saveSummary() },
            onCancelSummary: { [weak self] in self?.cancelEditSummary() },
            onActivateInput: { [weak self] in self?.activateInput() },
            onMinimize: { [weak self] in self?.bubbleCtl.toggle() },
            onClose: { [weak self] in self?.dismiss() })
        let host = NSHostingView(rootView: MinimizableCard(
            state: bubbleCtl.state,
            icon: { [weak model] in entityTypeIcon(model?.profile.entity.etype ?? "") },
            tint: .green, help: "Dossier — hold to expand") { view })
        host.sizingOptions = []
        let p = GlassPanel(size: NSSize(width: panelW, height: panelH(vf)))
        p.contentView = host
        bubbleCtl.onToggled = { [weak self] mini in
            guard let self, let p = self.panel else { return }
            if mini {
                let f = p.frame
                p.setFrame(NSRect(x: f.minX, y: f.maxY - 58, width: 58, height: 58),
                           display: true, animate: true)
            } else {
                self.applyFullFrame(center: false)
            }
        }
        bubbleCtl.attach(p)
        panel = p
    }

    private func applyFullFrame(center: Bool) {
        guard let p = panel else { return }
        let vf = (p.screen ?? NSScreen.main)?.visibleFrame
            ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
        let w = panelW, h = panelH(vf)
        var x = center ? vf.midX - w / 2 : p.frame.origin.x
        var y = center ? vf.midY - h / 2 : p.frame.maxY - h
        x = min(max(x, vf.minX + 8), vf.maxX - w - 8)
        y = min(max(y, vf.minY + 8), vf.maxY - h - 8)
        p.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true, animate: false)
    }
}

struct EntityDossierView: View {
    @ObservedObject var model: EntityModel
    var onRelation: (String) -> Void
    var onUnrelate: (String) -> Void
    var onQuick: (String, String) -> Void
    var onChip: (String, String) -> Void
    var onNote: () -> Void
    var onEditSummary: () -> Void
    var onSaveSummary: () -> Void
    var onCancelSummary: () -> Void
    var onActivateInput: () -> Void
    var onMinimize: () -> Void
    var onClose: () -> Void
    @FocusState private var noteFocused: Bool
    @State private var showMoreQuick = false
    @State private var rawExpanded = false

    private static let timelineCap = 60   // render sensibly; long histories scroll

    private var p: EntityProfile { model.profile }

    var body: some View {
        VStack(spacing: 0) {
            header
            Divider().opacity(0.08)
            ScrollView {
                VStack(alignment: .leading, spacing: 14) {
                    if let c = model.composed {
                        composedBody(c)
                    } else {
                        fallbackBody
                    }
                }
                .padding(.horizontal, 16).padding(.vertical, 14)
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            Divider().opacity(0.08)
            noteBar
        }
        .frame(width: 560)
        .frame(maxHeight: .infinity, alignment: .top)
        .glassCard(18)
    }

    // MARK: fallback (raw) body — the original rendering, verbatim

    @ViewBuilder private var fallbackBody: some View {
        summarySection
        if !p.quick.isEmpty { quickSection }
        if !p.relations.isEmpty { relationsSection }
        if !p.timeline.isEmpty { timelineSection }
        if !p.factGroups.isEmpty { factsSection }
        if !p.tasks.isEmpty { tasksSection }
    }

    // MARK: composed body — the server-organized layout

    @ViewBuilder private func composedBody(_ c: ComposedDossier) -> some View {
        // Kept: the editable summary + "your read" (pencil), above the sections.
        if model.editingSummary || !p.summary.isEmpty || !p.read.isEmpty {
            summarySection
            sectionDivider
        }
        // Kept: quick-contacts chips — but only when the server didn't already
        // emit a chips section that would cover them.
        if !p.quick.isEmpty && !c.hasChipsSection {
            quickSection
            sectionDivider
        }
        // The server's adaptive sections, IN ORDER, separated by hairline rules.
        ForEach(c.sections) { section in
            composedSectionView(section)
            sectionDivider
        }
        // Raw data lives behind ONE collapsed disclosure at the bottom.
        rawMemoryDisclosure
    }

    private var sectionDivider: some View {
        Rectangle().fill(.quaternary).frame(height: 0.5)
    }

    @ViewBuilder private func composedSectionView(_ section: ComposedSection) -> some View {
        switch section {
        case let .text(_, title, body):
            VStack(alignment: .leading, spacing: 5) {
                if !title.isEmpty { sectionLabel(title) }
                Text(body).font(.system(size: 13)).foregroundStyle(.primary)
                    .textSelection(.enabled)
                    .fixedSize(horizontal: false, vertical: true)
            }
        case let .kv(_, title, rows):
            VStack(alignment: .leading, spacing: 6) {
                if !title.isEmpty { sectionLabel(title) }
                Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 5) {
                    ForEach(rows) { row in
                        GridRow {
                            Text(row.k).font(.system(size: 11.5, weight: .medium))
                                .foregroundStyle(.secondary)
                                .gridColumnAlignment(.leading)
                            Text(row.v).font(.system(size: 12.5)).foregroundStyle(.primary)
                                .textSelection(.enabled)
                                .fixedSize(horizontal: false, vertical: true)
                                .frame(maxWidth: .infinity, alignment: .leading)
                        }
                    }
                }
            }
        case let .chips(_, title, chips):
            VStack(alignment: .leading, spacing: 6) {
                if !title.isEmpty { sectionLabel(title) }
                WrapLayout(spacing: 6) {
                    ForEach(chips) { chip in composedChip(chip) }
                }
            }
        case let .list(_, title, rows):
            VStack(alignment: .leading, spacing: 6) {
                if !title.isEmpty { sectionLabel(title) }
                VStack(alignment: .leading, spacing: 6) {
                    ForEach(rows) { row in composedListRow(row) }
                }
            }
        }
    }

    private func composedChip(_ chip: ComposedChip) -> some View {
        Button { onChip(chip.kind, chip.value) } label: {
            HStack(spacing: 5) {
                Image(systemName: composedChipIcon(chip.kind)).font(.system(size: 10))
                    .foregroundStyle(Color.cyan)
                Text(chip.label.isEmpty ? chip.value : chip.label)
                    .font(.system(size: 11, weight: .medium))
                    .lineLimit(1).truncationMode(.middle).frame(maxWidth: 220)
            }
            .padding(.horizontal, 9).padding(.vertical, 5)
            .contentShape(Capsule())
        }
        .buttonStyle(.plain)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
        .help("Open \(chip.value)")
    }

    @ViewBuilder private func composedListRow(_ row: ComposedListRow) -> some View {
        let hasEntity = !row.entity.isEmpty
        let inner = HStack(alignment: .top, spacing: 8) {
            Image(systemName: composedListIcon(row.icon)).font(.system(size: 12))
                .foregroundStyle(composedListColor(row.icon))
                .frame(width: 16).padding(.top, 1)
            VStack(alignment: .leading, spacing: 1) {
                Text(row.title).font(.system(size: 12.5, weight: .medium))
                    .foregroundStyle(.primary).lineLimit(2)
                    .fixedSize(horizontal: false, vertical: true)
                if !row.sub.isEmpty {
                    Text(row.sub).font(.system(size: 10.5)).foregroundStyle(.secondary)
                        .lineLimit(2).fixedSize(horizontal: false, vertical: true)
                }
            }
            Spacer(minLength: 4)
            if !row.date.isEmpty {
                Text(entityRelativeDate(row.date).isEmpty ? row.date : entityRelativeDate(row.date))
                    .font(.system(size: 10)).foregroundStyle(.tertiary)
            }
            if hasEntity {
                Image(systemName: "chevron.right").font(.system(size: 9, weight: .bold))
                    .foregroundStyle(.tertiary)
            }
        }
        .padding(.horizontal, 10).padding(.vertical, 6)
        .contentShape(Rectangle())

        if hasEntity {
            Button { onRelation(row.entity) } label: { inner }
                .buttonStyle(.plain)
                .background(RoundedRectangle(cornerRadius: 9).fill(Color.primary.opacity(0.04)))
                .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(.white.opacity(0.08), lineWidth: 0.5))
                .help("Open \(row.entity)'s dossier")
                .contextMenu { relationContextMenu(row.entity) }
        } else {
            inner
                .background(RoundedRectangle(cornerRadius: 9).fill(Color.primary.opacity(0.03)))
        }
    }

    // Raw memory: the full original rendering, tucked behind one collapsed
    // disclosure — "raw data is important, but not for a quick look."
    private var rawMemoryCount: Int {
        p.counts.facts > 0 ? p.counts.facts
            : p.timeline.count + p.factGroups.reduce(0) { $0 + $1.facts.count }
    }

    private var rawMemoryDisclosure: some View {
        DisclosureGroup(isExpanded: $rawExpanded) {
            VStack(alignment: .leading, spacing: 14) {
                if !p.relations.isEmpty { relationsSection }
                if !p.timeline.isEmpty { timelineSection }
                if !p.factGroups.isEmpty { factsSection }
                if !p.tasks.isEmpty { tasksSection }
                if p.relations.isEmpty && p.timeline.isEmpty
                    && p.factGroups.isEmpty && p.tasks.isEmpty {
                    Text("No raw memory stored.").font(.system(size: 11))
                        .foregroundStyle(.tertiary)
                }
            }.padding(.top, 8)
        } label: {
            HStack(spacing: 6) {
                Image(systemName: "archivebox").font(.system(size: 11)).foregroundStyle(.secondary)
                Text("Raw memory").font(.system(size: 11, weight: .semibold))
                    .foregroundStyle(.secondary).textCase(.uppercase)
                Text("\(rawMemoryCount) fact\(rawMemoryCount == 1 ? "" : "s")")
                    .font(.system(size: 10)).foregroundStyle(.tertiary)
            }
        }
        .tint(.secondary)
    }

    // MARK: header

    private var header: some View {
        HStack(alignment: .top, spacing: 9) {
            Image(systemName: entityTypeIcon(p.entity.etype))
                .font(.system(size: 15, weight: .semibold))
                .foregroundStyle(entityTypeColor(p.entity.etype))
                .padding(.top, 1)
            VStack(alignment: .leading, spacing: 3) {
                HStack(spacing: 7) {
                    Text(p.entity.name.isEmpty ? "(unknown)" : p.entity.name)
                        .font(.system(size: 15, weight: .semibold))
                        .lineLimit(1).textSelection(.enabled)
                    etypeChip
                    if model.loading {
                        ProgressView().controlSize(.small).scaleEffect(0.6)
                            .frame(width: 12, height: 12)
                    }
                }
                // Composed: prominent role line + badge capsules.
                if let c = model.composed, !c.header.roleLine.isEmpty {
                    Text(c.header.roleLine)
                        .font(.system(size: 12.5, weight: .medium))
                        .foregroundStyle(.secondary)
                        .lineLimit(2).fixedSize(horizontal: false, vertical: true)
                        .padding(.top, 1)
                }
                if let c = model.composed, !c.header.badges.isEmpty {
                    WrapLayout(spacing: 5) {
                        ForEach(c.header.badges, id: \.self) { b in badgeCapsule(b) }
                    }
                    .padding(.top, 1)
                }
                if !p.entity.aliases.isEmpty {
                    Text("aka " + p.entity.aliases.joined(separator: ", "))
                        .font(.system(size: 10.5)).foregroundStyle(.secondary)
                        .lineLimit(1).truncationMode(.tail)
                }
                if let sub = countsLine {
                    Text(sub).font(.system(size: 10)).foregroundStyle(.tertiary)
                        .lineLimit(1)
                }
                // First (composing) event has no composed layout yet → shimmer.
                if model.composing && model.composed == nil {
                    OrganizingShimmer().padding(.top, 1)
                }
            }
            Spacer(minLength: 6)
            Button { onMinimize() } label: {
                Image(systemName: "minus.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain)
             .help("Minimize to a bubble — hold the bubble to reopen")
            Button { onClose() } label: {
                Image(systemName: "xmark.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Close")
        }
        .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10)
    }

    private var etypeChip: some View {
        Text(entityTypeLabel(p.entity.etype))
            .font(.system(size: 9.5, weight: .semibold))
            .foregroundStyle(entityTypeColor(p.entity.etype))
            .padding(.horizontal, 7).padding(.vertical, 2)
            .background(entityTypeColor(p.entity.etype).opacity(0.16), in: Capsule())
    }

    private func badgeCapsule(_ text: String) -> some View {
        Text(text)
            .font(.system(size: 9.5, weight: .semibold))
            .foregroundStyle(.secondary)
            .lineLimit(1)
            .padding(.horizontal, 7).padding(.vertical, 2)
            .background(.ultraThinMaterial, in: Capsule())
            .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }

    private var countsLine: String? {
        var parts: [String] = []
        if p.counts.facts > 0 { parts.append("\(p.counts.facts) fact\(p.counts.facts == 1 ? "" : "s")") }
        if p.counts.relations > 0 { parts.append("\(p.counts.relations) relation\(p.counts.relations == 1 ? "" : "s")") }
        let lt = entityRelativeDate(p.counts.lastTouch)
        if !lt.isEmpty { parts.append("last touch \(lt)") }
        return parts.isEmpty ? nil : parts.joined(separator: "  ·  ")
    }

    // MARK: summary

    @ViewBuilder private var summarySection: some View {
        if model.editingSummary {
            VStack(alignment: .leading, spacing: 6) {
                sectionLabel("Summary")
                TextEditor(text: $model.summaryDraft)
                    .font(.system(size: 12.5))
                    .scrollContentBackground(.hidden)
                    .frame(height: 84)
                    .padding(.horizontal, 6).padding(.vertical, 4)
                    .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
                    .overlay(RoundedRectangle(cornerRadius: 10)
                        .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
                    .simultaneousGesture(TapGesture().onEnded { onActivateInput() })
                HStack(spacing: 8) {
                    Spacer()
                    Button("Cancel") { onCancelSummary() }
                        .buttonStyle(.plain).font(.system(size: 11.5))
                        .foregroundStyle(.secondary)
                    Button { onSaveSummary() } label: {
                        Text("Save").font(.system(size: 11.5, weight: .semibold))
                            .padding(.horizontal, 12).padding(.vertical, 4)
                    }
                    .buttonStyle(.plain)
                    .background(Color.accentColor, in: Capsule())
                    .foregroundStyle(.white)
                }
            }
        } else if !p.summary.isEmpty || !p.read.isEmpty {
            VStack(alignment: .leading, spacing: 5) {
                HStack {
                    sectionLabel("Summary")
                    Spacer()
                    Button { onEditSummary() } label: {
                        Image(systemName: "pencil").font(.system(size: 11))
                            .foregroundStyle(.secondary)
                    }.buttonStyle(.plain).help("Rewrite this summary")
                }
                if !p.summary.isEmpty {
                    Text(p.summary).font(.system(size: 13)).foregroundStyle(.primary)
                        .textSelection(.enabled)
                        .fixedSize(horizontal: false, vertical: true)
                }
                if !p.read.isEmpty {
                    Text("your read: \(p.read)")
                        .font(.system(size: 11.5)).italic()
                        .foregroundStyle(.secondary)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
        }
    }

    // MARK: quick contacts

    private var quickSection: some View {
        let chips = p.quick.chips
        let inline = Array(chips.prefix(4))
        let overflow = Array(chips.dropFirst(4))
        return VStack(alignment: .leading, spacing: 6) {
            sectionLabel("Quick")
            HStack(spacing: 6) {
                ForEach(inline.indices, id: \.self) { i in
                    quickChip(inline[i].kind, inline[i].value)
                }
                if !overflow.isEmpty {
                    Button { showMoreQuick = true } label: {
                        Text("+\(overflow.count)")
                            .font(.system(size: 10.5, weight: .semibold))
                            .foregroundStyle(.secondary)
                            .padding(.horizontal, 9).padding(.vertical, 5)
                            .background(.ultraThinMaterial, in: Capsule())
                            .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
                    }
                    .buttonStyle(.plain)
                    .popover(isPresented: $showMoreQuick, arrowEdge: .bottom) {
                        VStack(alignment: .leading, spacing: 6) {
                            ForEach(overflow.indices, id: \.self) { i in
                                quickChip(overflow[i].kind, overflow[i].value)
                            }
                        }.padding(12)
                    }
                }
            }
        }
    }

    private func quickChip(_ kind: String, _ value: String) -> some View {
        let icon: String = {
            switch kind {
            case "phone":  return "phone.fill"
            case "email":  return "envelope.fill"
            case "url":    return "link"
            case "amount": return "dollarsign.circle.fill"
            default:       return "circle.fill"
            }
        }()
        let tappable = kind != "amount"
        return Button { if tappable { onQuick(kind, value) } } label: {
            HStack(spacing: 5) {
                Image(systemName: icon).font(.system(size: 10))
                    .foregroundStyle(tappable ? Color.cyan : Color.secondary)
                Text(value).font(.system(size: 11, weight: .medium))
                    .lineLimit(1).truncationMode(.middle).frame(maxWidth: 190)
            }
            .padding(.horizontal, 9).padding(.vertical, 5)
            .contentShape(Capsule())
        }
        .buttonStyle(.plain)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
        .disabled(!tappable)
        .help(tappable ? "Open \(value)" : value)
    }

    // MARK: relations (grouped by rtype)

    private var relationGroups: [(rtype: String, items: [EntityRelation])] {
        let grouped = Dictionary(grouping: p.relations) { $0.rtype }
        return grouped.keys.sorted().map { (rtype: $0, items: grouped[$0] ?? []) }
    }

    private var relationsSection: some View {
        VStack(alignment: .leading, spacing: 8) {
            sectionLabel("Relations")
            ForEach(relationGroups, id: \.rtype) { group in
                VStack(alignment: .leading, spacing: 4) {
                    if !group.rtype.isEmpty {
                        Text(group.rtype.replacingOccurrences(of: "_", with: " "))
                            .font(.system(size: 10, weight: .semibold))
                            .foregroundStyle(.tertiary)
                            .textCase(.uppercase)
                    }
                    ForEach(group.items) { rel in relationRow(rel) }
                }
            }
        }
    }

    private func relationRow(_ rel: EntityRelation) -> some View {
        Button { onRelation(rel.other) } label: {
            HStack(spacing: 8) {
                Image(systemName: entityTypeIcon(rel.otype))
                    .font(.system(size: 11)).foregroundStyle(entityTypeColor(rel.otype))
                    .frame(width: 16)
                VStack(alignment: .leading, spacing: 1) {
                    Text(rel.other).font(.system(size: 12.5, weight: .medium))
                        .foregroundStyle(.primary).lineLimit(1)
                    if !rel.otherSummary.isEmpty {
                        Text(rel.otherSummary).font(.system(size: 10.5))
                            .foregroundStyle(.secondary).lineLimit(1)
                    }
                }
                Spacer(minLength: 4)
                Image(systemName: "chevron.right").font(.system(size: 9, weight: .bold))
                    .foregroundStyle(.tertiary)
            }
            .padding(.horizontal, 10).padding(.vertical, 6)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .background(RoundedRectangle(cornerRadius: 9).fill(Color.primary.opacity(0.04)))
        .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(.white.opacity(0.08), lineWidth: 0.5))
        .help("Open \(rel.other)'s dossier")
        .contextMenu { relationContextMenu(rel.other) }
    }

    // One-tap edge curation — shared by raw relation rows and composed list
    // rows that carry an entity. "Open" is the existing drill-down; "Not
    // related — remove" is the human-in-the-loop cut (the machine never trims
    // edges on its own).
    @ViewBuilder private func relationContextMenu(_ name: String) -> some View {
        Button { onRelation(name) } label: {
            Label("Open \(name)", systemImage: "arrow.up.right.square")
        }
        Divider()
        Button(role: .destructive) { onUnrelate(name) } label: {
            Label("Not related — remove", systemImage: "person.badge.minus")
        }
    }

    // MARK: timeline

    private var timelineSection: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack {
                sectionLabel("Timeline")
                Spacer()
                if p.timeline.count > Self.timelineCap {
                    Text("showing \(Self.timelineCap) of \(p.timeline.count)")
                        .font(.system(size: 9.5)).foregroundStyle(.tertiary)
                }
            }
            LazyVStack(alignment: .leading, spacing: 7) {
                ForEach(p.timeline.prefix(Self.timelineCap)) { item in timelineRow(item) }
            }
        }
    }

    private func timelineRow(_ item: EntityTimelineItem) -> some View {
        HStack(alignment: .top, spacing: 8) {
            Circle().fill(entityKindColor(item.kind))
                .frame(width: 7, height: 7).padding(.top, 4)
            VStack(alignment: .leading, spacing: 1) {
                Text(item.fact).font(.system(size: 12))
                    .foregroundStyle(.primary).lineLimit(2)
                    .fixedSize(horizontal: false, vertical: true)
                let when = entityRelativeDate(item.date)
                if !when.isEmpty {
                    Text(when).font(.system(size: 9.5)).foregroundStyle(.tertiary)
                }
            }
            Spacer(minLength: 0)
        }
    }

    // MARK: durable facts (disclosure per kind)

    private var factsSection: some View {
        VStack(alignment: .leading, spacing: 6) {
            sectionLabel("Durable facts")
            ForEach(p.factGroups) { group in
                DisclosureGroup {
                    VStack(alignment: .leading, spacing: 5) {
                        ForEach(group.facts) { f in
                            HStack(alignment: .top, spacing: 7) {
                                Circle().fill(entityKindColor(group.kind))
                                    .frame(width: 5, height: 5).padding(.top, 5)
                                VStack(alignment: .leading, spacing: 1) {
                                    Text(f.fact).font(.system(size: 12))
                                        .foregroundStyle(.primary)
                                        .fixedSize(horizontal: false, vertical: true)
                                    let when = entityRelativeDate(f.date)
                                    if !when.isEmpty {
                                        Text(when).font(.system(size: 9.5))
                                            .foregroundStyle(.tertiary)
                                    }
                                }
                                Spacer(minLength: 0)
                            }
                        }
                    }.padding(.top, 4)
                } label: {
                    HStack(spacing: 6) {
                        Text(entityFactSectionLabel(group.kind))
                            .font(.system(size: 12, weight: .medium))
                            .foregroundStyle(.primary)
                        Text("\(group.facts.count)").font(.system(size: 10))
                            .foregroundStyle(.secondary)
                    }
                }
                .tint(.secondary)
            }
        }
    }

    // MARK: open tasks

    private var tasksSection: some View {
        VStack(alignment: .leading, spacing: 6) {
            sectionLabel("Open tasks")
            ForEach(p.tasks) { task in
                HStack(alignment: .top, spacing: 8) {
                    Image(systemName: "circle").font(.system(size: 12))
                        .foregroundStyle(.secondary).padding(.top, 1)
                    Text(task.text).font(.system(size: 12.5))
                        .foregroundStyle(.primary).lineLimit(2)
                        .fixedSize(horizontal: false, vertical: true)
                    Spacer(minLength: 4)
                    if !task.due.isEmpty {
                        Text(entityRelativeDate(task.due).isEmpty ? task.due
                             : entityRelativeDate(task.due))
                            .font(.system(size: 10)).foregroundStyle(.orange)
                    }
                }
            }
        }
    }

    // MARK: note footer

    private var noteBar: some View {
        HStack(spacing: 8) {
            TextField("Add a note about \(model.name.isEmpty ? "them" : model.name)…",
                      text: $model.noteText)
                .textFieldStyle(.plain)
                .font(.system(size: 12.5))
                .focused($noteFocused)
                .onSubmit { onNote() }
                .simultaneousGesture(TapGesture().onEnded {
                    onActivateInput(); noteFocused = true
                })
                .padding(.horizontal, 11).padding(.vertical, 7)
                .background(.ultraThinMaterial, in: Capsule())
                .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
            Button { onNote() } label: {
                Image(systemName: "arrow.up.circle.fill")
                    .font(.system(size: 20))
                    .foregroundStyle(model.noteText.trimmingCharacters(in: .whitespaces).isEmpty
                                     ? Color.secondary : Color.accentColor)
            }
            .buttonStyle(.plain)
            .disabled(model.noteText.trimmingCharacters(in: .whitespaces).isEmpty)
            .help("Save this note")
        }
        .padding(.horizontal, 14).padding(.vertical, 10)
    }

    private func sectionLabel(_ s: String) -> some View {
        Text(s).font(.system(size: 10.5, weight: .semibold))
            .foregroundStyle(.secondary).textCase(.uppercase)
    }
}

/// White "paper" behind one message's sanitized HTML so it reads the way the
/// sender designed it (email HTML assumes a light background), sized to the
/// measured content height — a very long message clamps and scrolls inside.
struct HTMLBodyView: View {
    let html: String
    @State private var height: CGFloat = 200

    var body: some View {
        EmailBodyWebView(html: html, height: $height)
            .frame(height: height)
            .background(Color.white, in: RoundedRectangle(cornerRadius: 10))
            .clipShape(RoundedRectangle(cornerRadius: 10))
            .overlay(RoundedRectangle(cornerRadius: 10)
                .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }
}

/// WKWebView that hands the scroll wheel BACK to the thread's outer ScrollView
/// whenever its content fully fits its frame — otherwise every wheel tick over
/// an email body would rubber-band the web view instead of scrolling the thread.
final class ThreadWebView: WKWebView {
    var contentFits = true
    override func scrollWheel(with event: NSEvent) {
        if contentFits { nextResponder?.scrollWheel(with: event) }
        else { super.scrollWheel(with: event) }
    }
}

/// The locked-down web view that renders one message's engine-sanitized HTML.
/// Security posture (the contract's hard rules): content JavaScript OFF, only
/// ever loadHTMLString (never a URLRequest), and the navigation delegate
/// CANCELS everything after that one initial load — a CLICKED http/https/mailto
/// link opens in the real browser; anything else (meta-refresh, frames) is
/// silently dropped. Content JS being off does NOT block APP-injected
/// evaluateJavaScript, so the coordinator measures scrollHeight on didFinish
/// (again shortly after, for late-loading images) and reports it up, clamped;
/// if measuring ever fails the fallback height still reads via internal scroll.
struct EmailBodyWebView: NSViewRepresentable {
    let html: String
    @Binding var height: CGFloat

    static let minH: CGFloat = 60, maxH: CGFloat = 560

    func makeCoordinator() -> Coordinator { Coordinator() }

    func makeNSView(context: Context) -> ThreadWebView {
        let cfg = WKWebViewConfiguration()
        cfg.defaultWebpagePreferences.allowsContentJavaScript = false   // JS OFF
        let web = ThreadWebView(frame: .zero, configuration: cfg)
        web.navigationDelegate = context.coordinator
        web.uiDelegate = context.coordinator   // target=_blank links → browser
        return web
    }

    func updateNSView(_ web: ThreadWebView, context: Context) {
        context.coordinator.onHeight = { h, fits in
            height = h
            web.contentFits = fits
        }
        context.coordinator.load(web, html)
    }

    final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
        var onHeight: ((CGFloat, Bool) -> Void)?
        private var lastHTML: String?
        private var allowInitial = false

        func load(_ web: WKWebView, _ html: String) {
            guard html != lastHTML else { return }
            lastHTML = html
            allowInitial = true
            web.loadHTMLString(Self.page(html), baseURL: nil)
        }

        // Readability chrome around the (already-sanitized) fragment: mail-app
        // typography, images capped to the card width, quoted text indented.
        private static func page(_ body: String) -> String {
            """
            <!doctype html><html><head><meta charset="utf-8">
            <style>
              body { font: 13px/1.45 -apple-system, 'Helvetica Neue', Arial, sans-serif;
                     color: #1c1c1e; background: #ffffff; margin: 14px;
                     overflow-wrap: break-word; }
              img { max-width: 100%; height: auto; }
              table { max-width: 100%; }
              pre { white-space: pre-wrap; }
              blockquote { border-left: 3px solid #d0d0d0; margin: 6px 0 6px 2px;
                           padding-left: 10px; color: #555; }
              a { color: #0a66c2; }
            </style></head><body>\(body)</body></html>
            """
        }

        func webView(_ webView: WKWebView,
                     decidePolicyFor navigationAction: WKNavigationAction,
                     decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
            // Exactly ONE allow: our own loadHTMLString. Only an explicit CLICK
            // may open the browser — auto-navigations are dropped outright.
            if allowInitial, navigationAction.navigationType == .other {
                allowInitial = false
                decisionHandler(.allow)
                return
            }
            if navigationAction.navigationType == .linkActivated,
               let url = navigationAction.request.url,
               ["http", "https", "mailto"].contains(url.scheme?.lowercased() ?? "") {
                NSWorkspace.shared.open(url)
            }
            decisionHandler(.cancel)
        }

        // target="_blank" links bypass decidePolicyFor and land here instead.
        func webView(_ webView: WKWebView,
                     createWebViewWith configuration: WKWebViewConfiguration,
                     for navigationAction: WKNavigationAction,
                     windowFeatures: WKWindowFeatures) -> WKWebView? {
            if navigationAction.navigationType == .linkActivated,
               let url = navigationAction.request.url,
               ["http", "https", "mailto"].contains(url.scheme?.lowercased() ?? "") {
                NSWorkspace.shared.open(url)
            }
            return nil   // never spawn an in-card web view
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            measure(webView)
            // Remote images (post-"Load images") land after didFinish — re-measure.
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self, weak webView] in
                if let webView { self?.measure(webView) }
            }
        }

        private func measure(_ webView: WKWebView) {
            webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] v, _ in
                let raw = (v as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
                guard raw > 0 else { return }   // measure failed → keep fallback
                let h = min(max(raw + 4, EmailBodyWebView.minH), EmailBodyWebView.maxH)
                DispatchQueue.main.async {
                    self?.onHeight?(h, raw + 4 <= EmailBodyWebView.maxH)
                }
            }
        }
    }
}

// MARK: - Tasks & reminders overlay

struct TaskItem: Identifiable, Equatable {
    let id: String
    let text: String
    let due: String?
}

@MainActor
final class TasksModel: ObservableObject {
    @Published var tasks: [TaskItem] = []
}

/// A floating glass checklist (top-right). Reminders are just tasks with a
/// `due`. Ticking one writes control/card_action.json so the engine completes
/// it on Railway (synced) — and optimistically removes it here immediately.
@MainActor
final class TasksPanel {
    private var panel: GlassPanel?
    let model = TasksModel()
    // Minimize-to-bubble (shared machinery — checklist bubble).
    private let bubbleCtl = BubbleController()

    func setAll(_ items: [TaskItem]) {
        model.tasks = items
        if items.isEmpty { hide() } else { show() }
    }
    func add(_ item: TaskItem) {
        model.tasks.removeAll { $0.id == item.id }
        model.tasks.insert(item, at: 0)
        show()
    }
    func remove(_ id: String) {
        model.tasks.removeAll { $0.id == id }
        if model.tasks.isEmpty { hide() }
    }

    private func complete(_ id: String) {
        let item = model.tasks.first { $0.id == id }
        model.tasks.removeAll { $0.id == id }
        if model.tasks.isEmpty { hide() }
        let payload: [String: Any] = ["kind": "task", "action": "done",
                                      "id": id, "text": item?.text ?? ""]
        if let data = try? JSONSerialization.data(withJSONObject: payload) {
            try? FileManager.default.createDirectory(
                at: Paths.control, withIntermediateDirectories: true)
            try? data.write(to: Paths.control.appendingPathComponent("card_action.json"))
        }
    }

    private func show() {
        let p: GlassPanel
        if let existing = panel { p = existing } else {
            p = GlassPanel(size: NSSize(width: 320, height: 400))
            let view = TasksView(
                model: model,
                onComplete: { [weak self] id in self?.complete(id) },
                onMinimize: { [weak self] in self?.bubbleCtl.toggle() },
                onClose: { [weak self] in self?.hide() })
            p.contentView = NSHostingView(rootView: MinimizableCard(
                state: bubbleCtl.state, icon: { "checklist" }, tint: .cyan,
                help: "Tasks & Reminders — hold to expand") { view })
            bubbleCtl.onToggled = { [weak p] _ in
                if let p { BubbleController.refitKeepingCenter(p) }
            }
            bubbleCtl.attach(p)   // long-press: list ⇄ checklist bubble
            if let vf = NSScreen.main?.visibleFrame {
                p.setFrameOrigin(NSPoint(x: vf.maxX - 340, y: vf.maxY - 420))
            }
            panel = p
        }
        p.orderFrontRegardless()
    }
    func hide() { panel?.orderOut(nil) }
}

struct TasksView: View {
    @ObservedObject var model: TasksModel
    var onComplete: (String) -> Void
    var onMinimize: () -> Void
    var onClose: () -> Void
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack(spacing: 7) {
                Image(systemName: "checklist").font(.system(size: 13, weight: .semibold))
                Text("Tasks & Reminders").font(.system(size: 13, weight: .semibold))
                Spacer()
                Button { onMinimize() } label: {
                    Image(systemName: "minus.circle.fill")
                        .font(.system(size: 15)).foregroundStyle(.secondary)
                }.buttonStyle(.plain)
                 .help("Minimize to a bubble — hold the bubble to reopen")
                Button { onClose() } label: {
                    Image(systemName: "xmark.circle.fill")
                        .font(.system(size: 15)).foregroundStyle(.secondary)
                }.buttonStyle(.plain).help("Hide")
            }.padding(.horizontal, 14).padding(.top, 12).padding(.bottom, 8)
            if model.tasks.isEmpty {
                Text("Nothing on your list.").font(.system(size: 12))
                    .foregroundStyle(.secondary)
                    .padding(.horizontal, 14).padding(.bottom, 14)
            } else {
                ScrollView {
                    VStack(alignment: .leading, spacing: 0) {
                        ForEach(model.tasks) { t in
                            HStack(alignment: .top, spacing: 10) {
                                Button { onComplete(t.id) } label: {
                                    Image(systemName: "circle")
                                        .font(.system(size: 16))
                                        .foregroundStyle(.secondary)
                                }.buttonStyle(.plain).help("Mark done")
                                VStack(alignment: .leading, spacing: 1) {
                                    Text(t.text).font(.system(size: 13))
                                        .fixedSize(horizontal: false, vertical: true)
                                    if let due = t.due, !due.isEmpty {
                                        Text(prettyDue(due)).font(.system(size: 11))
                                            .foregroundStyle(.orange)
                                    }
                                }
                                Spacer(minLength: 0)
                            }.padding(.vertical, 7).padding(.horizontal, 14)
                            Divider().opacity(0.08)
                        }
                    }
                }.frame(maxHeight: 340)
            }
        }
        .frame(width: 320)
        .glassCard(18)
    }
    private func prettyDue(_ iso: String) -> String {
        "due " + String(iso.replacingOccurrences(of: "T", with: " ").prefix(16))
    }
}

// MARK: - Memory viewer (the shared graph brain, made visible)

struct MemoryItem: Identifiable, Equatable {
    let id: String
    let text: String
    let kind: String
    let when: String       // ISO date the fact refers to (may be empty)
    let createdAt: String  // ISO timestamp the memory was saved
    let linked: Bool       // pulled in as a connected fact, not a direct hit
}

// The same shared brain, seen as a graph: memories are nodes, relationships
// are edges. Ahmed drags nodes, connects related ones, cuts wrong links.
struct GraphNode: Identifiable, Equatable {
    let id: String
    let text: String
    let kind: String
}

struct GraphEdge: Equatable {
    let from: String
    let to: String
    let type: String
}

/// Which face of the memory viewer is showing: the scrollable Notes list, or
/// the interactive Graph canvas.
enum MemoryMode { case notes, graph }

/// Format a stored ISO-8601 UTC timestamp as a short LOCAL date+time,
/// e.g. "Jul 9, 15:10". Empty in → empty out.
// Formatters are EXPENSIVE to build and were allocated 3-at-a-time on every
// call — i.e. per memory row per body evaluation (hundreds per keystroke in the
// search box). Build them once. Main-thread-only use, so shared instances are safe.
private enum MemTimeFmt {
    static let withFrac: ISO8601DateFormatter = {
        let f = ISO8601DateFormatter(); f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]; return f
    }()
    static let plain: ISO8601DateFormatter = {
        let f = ISO8601DateFormatter(); f.formatOptions = [.withInternetDateTime]; return f
    }()
    static let out: DateFormatter = {
        let f = DateFormatter()
        // Force Gregorian + English regardless of the Mac's system calendar
        // (Ahmed's is set to Islamic/Hijri, which otherwise renders the month here).
        f.calendar = Calendar(identifier: .gregorian)
        f.locale = Locale(identifier: "en_US_POSIX")
        f.timeZone = .current
        f.dateFormat = "MMM d, HH:mm"
        return f
    }()
}

func memPrettyTime(_ iso: String) -> String {
    guard !iso.isEmpty else { return "" }
    guard let d = MemTimeFmt.withFrac.date(from: iso) ?? MemTimeFmt.plain.date(from: iso) else { return "" }
    return MemTimeFmt.out.string(from: d)
}

@MainActor
final class MemoryModel: ObservableObject {
    @Published var items: [MemoryItem] = []
    @Published var query = ""
    @Published var loading = false
    // Graph mode — nodes/edges live here; POSITIONS live in the view layer
    // (GraphView) so a data refresh never resets Ahmed's arranged layout.
    @Published var mode: MemoryMode = .notes
    @Published var fullscreen = false
    @Published var graphNodes: [GraphNode] = []
    @Published var graphEdges: [GraphEdge] = []
    @Published var graphLoading = false
    // Bumped only when the graph DATA is replaced. GraphView keys its re-layout
    // off this instead of `graphNodes.map(\.id)` — that expression was recomputed
    // on every body eval (i.e. every pan frame), allocating a 174-string array +
    // comparing it, purely to detect a change that happens twice a session.
    @Published var graphVersion = 0
}

/// A floating glass panel that shows what Jarvis remembers in the shared brain
/// (FalkorDB on Railway). Ahmed can search it live, correct a fact (pencil →
/// supersede) or delete a wrong one (trash → forget). The panel never touches
/// the API itself — it writes control/memory_query.json / memory_action.json
/// and the engine (which holds the key) queries Railway and emits `memories`.
@MainActor
final class MemoryPanel {
    private var panel: GlassPanel?
    let model = MemoryModel()
    // Minimize-to-bubble (shared machinery — trophy bubble).
    private let bubbleCtl = BubbleController()

    // engine → panel (a `memories` / `memory_forgotten` event arrived)
    func setAll(_ q: String, _ items: [MemoryItem]) {
        model.items = items
        model.loading = false
    }
    func remove(_ id: String) { model.items.removeAll { $0.id == id } }

    // engine → panel (a `graph` event arrived). Only the DATA is replaced —
    // GraphView keeps its own [id: CGPoint] so the layout survives refreshes.
    func setGraph(_ nodes: [GraphNode], _ edges: [GraphEdge]) {
        model.graphNodes = nodes
        model.graphEdges = edges
        model.graphLoading = false
        model.graphVersion &+= 1
    }

    func open() {
        bubbleCtl.reset()   // explicit open (brain button / menu) → expanded
        show()
        model.loading = true
        model.mode = .notes
        model.fullscreen = false
        writeControl("memory_query.json", ["q": model.query])
        applySize()
        NSApp.activate(ignoringOtherApps: true)
        panel?.makeKeyAndOrderFront(nil)   // so the search field can take typing
    }
    func toggle() { (panel?.isVisible == true) ? hide() : open() }
    func hide() { panel?.orderOut(nil) }

    // Notes ↔ Graph. Graph mode widens the panel and (re)loads the graph;
    // Notes mode returns to the narrow list. Fullscreen is cleared leaving graph.
    func selectMode(_ mode: MemoryMode) {
        guard model.mode != mode else { return }
        model.mode = mode
        if mode == .graph {
            applySize()
            model.graphLoading = model.graphNodes.isEmpty
            writeControl("graph_query.json", [:])   // load / refresh the graph
        } else {
            model.fullscreen = false
            applySize()
        }
    }

    func toggleFullscreen() {
        model.fullscreen.toggle()
        applySize()
    }

    private func reloadGraph() {
        model.graphLoading = true
        writeControl("graph_query.json", [:])
    }

    private func linkNodes(_ a: String, _ b: String) {
        // Optimistic: draw the edge now; the engine confirms via a `graph` event.
        if !model.graphEdges.contains(where: {
            ($0.from == a && $0.to == b) || ($0.from == b && $0.to == a) }) {
            model.graphEdges.append(GraphEdge(from: a, to: b, type: "related"))
        }
        writeControl("graph_action.json", ["action": "link", "a": a, "b": b])
    }

    private func unlinkNodes(_ a: String, _ b: String) {
        model.graphEdges.removeAll {   // optimistic (either direction)
            ($0.from == a && $0.to == b) || ($0.from == b && $0.to == a) }
        writeControl("graph_action.json", ["action": "unlink", "a": a, "b": b])
    }

    // Resize the NSPanel to fit the current mode: Notes ≈340 wide, Graph much
    // wider, Fullscreen ≈ the screen's visibleFrame. Keeps the top edge fixed
    // and clamps the panel fully on-screen.
    private func applySize() {
        guard let p = panel else { return }
        // In graph/fullscreen, dragging must PAN the map — so the panel must NOT
        // move itself by its background. Notes mode keeps drag-to-reposition.
        p.isMovableByWindowBackground = (model.mode == .notes && !model.fullscreen)
        let vf = (p.screen ?? NSScreen.main)?.visibleFrame
            ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
        if model.fullscreen {
            p.setFrame(vf.insetBy(dx: 20, dy: 20), display: true, animate: true)
            return
        }
        let w: CGFloat, h: CGFloat
        if model.mode == .graph {
            w = min(820, vf.width - 80)
            h = min(720, vf.height - 60)
        } else {
            w = 340
            h = min(640, vf.height - 60)
        }
        let oldTop = p.frame.maxY
        var x = p.frame.origin.x
        var y = oldTop - h
        x = min(max(x, vf.minX + 8), vf.maxX - w - 8)
        y = min(max(y, vf.minY + 8), vf.maxY - h - 8)
        p.setFrame(NSRect(x: x, y: y, width: w, height: h),
                   display: true, animate: true)
    }

    private func sendQuery(_ q: String) {
        model.query = q
        model.loading = true
        writeControl("memory_query.json", ["q": q])
    }
    private func forgetItem(_ item: MemoryItem) {
        model.items.removeAll { $0.id == item.id }   // optimistic
        writeControl("memory_action.json",
                     ["action": "forget", "id": item.id,
                      "text": item.text, "q": model.query])
    }
    private func editItem(_ item: MemoryItem, _ text: String) {
        if let i = model.items.firstIndex(where: { $0.id == item.id }) {
            model.items[i] = MemoryItem(id: item.id, text: text,
                                        kind: item.kind, when: item.when,
                                        createdAt: item.createdAt,
                                        linked: item.linked)   // optimistic
        }
        writeControl("memory_action.json",
                     ["action": "edit", "id": item.id,
                      "text": text, "q": model.query])
    }

    private func show() {
        let p: GlassPanel
        if let existing = panel { p = existing } else {
            let vf = NSScreen.main?.visibleFrame
            // Tall by default — a memory list needs vertical room. Clamp to the
            // screen so it never runs off the top/bottom.
            let h: CGFloat = min(640, (vf?.height ?? 760) - 60)
            let view = MemoryView(
                model: model,
                onQuery: { [weak self] q in self?.sendQuery(q) },
                onForget: { [weak self] item in self?.forgetItem(item) },
                onEdit: { [weak self] item, text in self?.editItem(item, text) },
                onSelectMode: { [weak self] m in self?.selectMode(m) },
                onLink: { [weak self] a, b in self?.linkNodes(a, b) },
                onUnlink: { [weak self] a, b in self?.unlinkNodes(a, b) },
                onReloadGraph: { [weak self] in self?.reloadGraph() },
                onToggleFullscreen: { [weak self] in self?.toggleFullscreen() },
                onMinimize: { [weak self] in self?.bubbleCtl.toggle() },
                onClose: { [weak self] in self?.hide() })
            let host = NSHostingView(rootView: MinimizableCard(
                state: bubbleCtl.state, icon: { "trophy.fill" }, tint: .cyan,
                help: "What Jarvis remembers — hold to expand") { view })
            host.sizingOptions = []   // fill the panel; don't shrink to fit content
            p = GlassPanel(size: NSSize(width: 340, height: h))
            p.contentView = host
            // Long-press minimize only in the Notes list — in graph/fullscreen
            // a stationary hold could collide with canvas gestures (the header
            // minimize button still works there). The bubble always expands.
            bubbleCtl.canMinimize = { [weak self] in
                guard let self else { return false }
                return self.model.mode == .notes && !self.model.fullscreen
            }
            // This panel is fixed-size (sizingOptions = []), so set frames
            // explicitly: shrink to the bubble keeping the top-left corner;
            // applySize() restores the mode's size (and drag behavior) on expand.
            bubbleCtl.onToggled = { [weak self] mini in
                guard let self, let p = self.panel else { return }
                if mini {
                    p.isMovableByWindowBackground = true   // bubble drags freely
                    let f = p.frame
                    p.setFrame(NSRect(x: f.minX, y: f.maxY - 58,
                                      width: 58, height: 58),
                               display: true, animate: true)
                } else {
                    self.applySize()
                }
            }
            bubbleCtl.attach(p)   // long-press: viewer ⇄ trophy bubble
            if let vf {
                p.setFrameOrigin(NSPoint(x: vf.minX + 40, y: vf.maxY - h - 30))
            }
            panel = p
        }
        p.orderFrontRegardless()
    }

    private func writeControl(_ name: String, _ obj: [String: Any]) {
        try? FileManager.default.createDirectory(
            at: Paths.control, withIntermediateDirectories: true)
        if let data = try? JSONSerialization.data(withJSONObject: obj) {
            try? data.write(to: Paths.control.appendingPathComponent(name),
                            options: .atomic)
        }
    }
}

struct MemoryView: View {
    @ObservedObject var model: MemoryModel
    var onQuery: (String) -> Void
    var onForget: (MemoryItem) -> Void
    var onEdit: (MemoryItem, String) -> Void
    var onSelectMode: (MemoryMode) -> Void
    var onLink: (String, String) -> Void
    var onUnlink: (String, String) -> Void
    var onReloadGraph: () -> Void
    var onToggleFullscreen: () -> Void
    var onMinimize: () -> Void
    var onClose: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack(spacing: 7) {
                Image(systemName: "brain").font(.system(size: 13, weight: .semibold))
                Text("What Jarvis Remembers")
                    .font(.system(size: 13, weight: .semibold))
                Spacer()
                Button { onMinimize() } label: {
                    Image(systemName: "minus.circle.fill")
                        .font(.system(size: 15)).foregroundStyle(.secondary)
                }.buttonStyle(.plain)
                 .help("Minimize to a bubble — hold the bubble to reopen")
                Button { onClose() } label: {
                    Image(systemName: "xmark.circle.fill")
                        .font(.system(size: 15)).foregroundStyle(.secondary)
                }.buttonStyle(.plain).help("Hide")
            }.padding(.horizontal, 14).padding(.top, 12).padding(.bottom, 8)

            // Notes | Graph toggle + (Notes only) live search over the brain.
            HStack(spacing: 8) {
                modeToggle
                if model.mode == .notes {
                    searchField
                } else {
                    Spacer(minLength: 0)
                    Text("\(model.graphNodes.count) memories · \(model.graphEdges.count) links")
                        .font(.system(size: 10)).foregroundStyle(.secondary)
                }
            }
            .padding(.horizontal, 12).padding(.bottom, 8)

            if model.mode == .graph {
                GraphView(model: model, onLink: onLink, onUnlink: onUnlink,
                          onReload: onReloadGraph,
                          onToggleFullscreen: onToggleFullscreen)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else {
                notesList
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .glassCard(18)
    }

    // Compact 2-way segmented control (Notes | Graph).
    private var modeToggle: some View {
        HStack(spacing: 0) {
            segButton("Notes", .notes)
            segButton("Graph", .graph)
        }
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }

    @ViewBuilder private func segButton(_ title: String, _ m: MemoryMode) -> some View {
        Button { onSelectMode(m) } label: {
            Text(title).font(.system(size: 11, weight: .medium))
                .padding(.horizontal, 11).padding(.vertical, 5)
                .background(model.mode == m ? Color.accentColor.opacity(0.35)
                                            : Color.clear, in: Capsule())
                .foregroundStyle(model.mode == m ? Color.primary : Color.secondary)
        }.buttonStyle(.plain)
    }

    private var searchField: some View {
        HStack(spacing: 6) {
            Image(systemName: "magnifyingglass").font(.system(size: 11))
                .foregroundStyle(.secondary)
            TextField("search memory…", text: $model.query)
                .textFieldStyle(.plain).font(.system(size: 12))
                .onSubmit { onQuery(model.query) }
            if !model.query.isEmpty {
                Button { model.query = ""; onQuery("") } label: {
                    Image(systemName: "xmark.circle.fill")
                        .font(.system(size: 12)).foregroundStyle(.secondary)
                }.buttonStyle(.plain).help("Clear")
            }
        }
        .padding(.horizontal, 10).padding(.vertical, 7)
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
        .overlay(RoundedRectangle(cornerRadius: 12)
            .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }

    @ViewBuilder private var notesList: some View {
        if model.loading && model.items.isEmpty {
            HStack { Spacer(); ProgressView().controlSize(.small); Spacer() }
                .padding(.vertical, 22)
        } else if model.items.isEmpty {
            Text(model.query.isEmpty ? "Nothing remembered yet."
                                     : "No memories match that.")
                .font(.system(size: 12)).foregroundStyle(.secondary)
                .padding(.horizontal, 14).padding(.bottom, 16)
            Spacer(minLength: 0)
        } else {
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 0) {
                    ForEach(model.items) { item in
                        MemoryRow(item: item,
                                  onForget: { onForget(item) },
                                  onEdit: { onEdit(item, $0) })
                        Divider().opacity(0.08)
                    }
                }
            }.frame(maxWidth: .infinity, maxHeight: .infinity)   // fill the panel
        }
    }
}

struct MemoryRow: View {
    let item: MemoryItem
    var onForget: () -> Void
    var onEdit: (String) -> Void
    @State private var editing = false
    @State private var draft = ""

    var body: some View {
        VStack(alignment: .leading, spacing: 3) {
            if editing {
                HStack(spacing: 6) {
                    TextField("", text: $draft, axis: .vertical)
                        .textFieldStyle(.plain).font(.system(size: 12.5))
                        .lineLimit(1...4).onSubmit { commit() }
                    Button { commit() } label: {
                        Image(systemName: "checkmark.circle.fill")
                            .font(.system(size: 15)).foregroundStyle(.green)
                    }.buttonStyle(.plain).help("Save correction")
                    Button { editing = false } label: {
                        Image(systemName: "xmark.circle.fill")
                            .font(.system(size: 15)).foregroundStyle(.secondary)
                    }.buttonStyle(.plain).help("Cancel")
                }
            } else {
                HStack(alignment: .top, spacing: 8) {
                    if item.linked {
                        Image(systemName: "link").font(.system(size: 9))
                            .foregroundStyle(.blue).padding(.top, 3)
                            .help("connected fact")
                    }
                    Text(item.text).font(.system(size: 12.5))
                        .fixedSize(horizontal: false, vertical: true)
                    Spacer(minLength: 4)
                    Button { draft = item.text; editing = true } label: {
                        Image(systemName: "pencil").font(.system(size: 11))
                            .foregroundStyle(.secondary)
                    }.buttonStyle(.plain).help("Correct this memory")
                    Button { onForget() } label: {
                        Image(systemName: "trash").font(.system(size: 11))
                            .foregroundStyle(.red.opacity(0.85))
                    }.buttonStyle(.plain).help("Forget this — delete permanently")
                }
                if !footer.isEmpty {
                    HStack(spacing: 4) {
                        Image(systemName: "clock").font(.system(size: 8))
                            .foregroundStyle(.secondary)
                        Text(footer).font(.system(size: 10))
                            .foregroundStyle(.secondary)
                    }
                }
            }
        }
        .padding(.vertical, 7).padding(.horizontal, 14)
    }

    // When it was remembered (local), plus the event date it refers to if any.
    private var footer: String {
        let t = memPrettyTime(item.createdAt)
        if !item.when.isEmpty {
            return t.isEmpty ? "re: \(item.when)" : "\(t)  ·  re: \(item.when)"
        }
        return t
    }

    private func commit() {
        let t = draft.trimmingCharacters(in: .whitespacesAndNewlines)
        editing = false
        if !t.isEmpty && t != item.text { onEdit(t) }
    }
}

// MARK: - Memory GRAPH canvas

/// Pan/zoom for the graph, held OUTSIDE `@State` so an AppKit event monitor can
/// mutate it. SwiftUI's `MagnifyGesture` proved unreliable on the trackpad, so a
/// local `NSEvent` monitor drives zoom from pinch AND two-finger scroll instead.
final class GraphNav: ObservableObject {
    @Published var scale: CGFloat = 1
    @Published var offset: CGSize = .zero
    weak var window: NSWindow?          // the graph's NSWindow (event filtering)
    private var monitor: Any?

    // Zoom on pinch (`.magnify`) or two-finger scroll (`.scrollWheel`) — but only
    // when the cursor is actually over our window — anchored on the screen centre.
    func startMonitor() {
        guard monitor == nil else { return }
        monitor = NSEvent.addLocalMonitorForEvents(matching: [.magnify, .scrollWheel]) {
            [weak self] event in
            guard let self, let win = self.window,
                  NSMouseInRect(NSEvent.mouseLocation, win.frame, false) else { return event }
            let s0 = self.scale
            var s1 = s0
            switch event.type {
            case .magnify:     s1 = s0 * (1 + event.magnification)
            case .scrollWheel: s1 = s0 * (1 + event.scrollingDeltaY * 0.01)
            default:           return event
            }
            s1 = min(max(s1, 0.2), 4)
            if abs(s1 - s0) > 0.0001 {
                self.offset = CGSize(width: self.offset.width * s1 / s0,
                                     height: self.offset.height * s1 / s0)
                self.scale = s1
            }
            return nil   // consume so it doesn't also scroll something else
        }
    }
    func stopMonitor() {
        if let m = monitor { NSEvent.removeMonitor(m); monitor = nil }
    }

    // onDisappear doesn't reliably fire for panels hidden via orderOut, so the
    // scroll/pinch monitor could outlive the view and pile up across Notes⇄Graph
    // toggles (each consuming .scrollWheel with a stale window). Belt-and-braces.
    deinit { if let m = monitor { NSEvent.removeMonitor(m) } }
}

/// Zero-size helper that hands back the NSWindow hosting this SwiftUI tree, so
/// the GraphNav monitor can tell events over the graph panel from other windows.
struct WindowAccessor: NSViewRepresentable {
    var onResolve: (NSWindow?) -> Void
    func makeNSView(context: Context) -> NSView {
        let v = NSView()
        DispatchQueue.main.async { self.onResolve(v.window) }
        return v
    }
    func updateNSView(_ nsView: NSView, context: Context) {
        DispatchQueue.main.async { self.onResolve(nsView.window) }
    }
}

/// An interactive force-directed graph of the shared brain. Memories are
/// coloured circles (by `kind`), relationships are lines. Ahmed can pan
/// (drag empty space), zoom (pinch / +− buttons), move a node (drag it),
/// select a node (tap → full text), connect two nodes (Connect toggle → tap
/// A then B), and cut a link (tap the edge → Cut). Node POSITIONS live here
/// as @State, so a `graph` refresh keeps the layout — only NEW ids are placed.
struct GraphView: View {
    @ObservedObject var model: MemoryModel
    var onLink: (String, String) -> Void
    var onUnlink: (String, String) -> Void
    var onReload: () -> Void
    var onToggleFullscreen: () -> Void

    @State private var positions: [String: CGPoint] = [:]   // graph space
    // Node degree, CACHED. Was a computed property rebuilt on every Canvas frame
    // AND every drag tick (nodeAt) — O(edges) each. Recomputed only when the data
    // changes (layout + edge add/remove).
    @State private var degree: [String: Int] = [:]
    // Pan (view px) + zoom live in a shared ObservableObject so the AppKit
    // event monitor (pinch / two-finger scroll) can drive them from outside
    // SwiftUI's gesture system.
    @StateObject private var nav = GraphNav()
    @State private var selected: String? = nil
    @State private var selectedEdge: GraphEdge? = nil
    @State private var connectMode = false
    @State private var pendingConnect: String? = nil
    // Navigation tool: hand = drag anywhere to PAN (never grabs a node); pointer
    // = move nodes / connect / cut. Hand is the default so the map is walkable.
    @State private var handTool = true
    @State private var canvasSize: CGSize = .zero
    // First layout runs exactly once BOTH node data and a real canvas size are
    // available; reset when the node id-set changes so fresh data re-lays-out.
    @State private var laidOut = false
    // drag bookkeeping (one gesture handles both move-node and pan-canvas)
    @State private var dragNode: String? = nil
    @State private var dragStart: CGPoint? = nil
    @State private var panStart: CGSize? = nil

    var body: some View {
        GeometryReader { geo in
            Canvas { ctx, size in drawGraph(&ctx, size) }
                .contentShape(Rectangle())
                .gesture(dragGesture(geo.size))
                // Double-click zooms IN at the cursor; ⌥-double-click zooms out.
                // Simultaneous so it coexists with the pan/select drag gesture.
                .simultaneousGesture(
                    SpatialTapGesture(count: 2).onEnded { v in
                        let out = NSEvent.modifierFlags.contains(.option)
                        zoom(at: v.location, factor: out ? 1 / 1.6 : 1.6, geo.size)
                    })
                // Hand our NSWindow to GraphNav so its scroll/pinch monitor knows
                // when the cursor is over us.
                .background(WindowAccessor { nav.window = $0 })
                .overlay(alignment: .topLeading) { toolbar.padding(10) }
                .overlay(alignment: .bottomLeading) {
                    if !model.graphNodes.isEmpty { legend.padding(10) }
                }
                .overlay(alignment: .bottom) {
                    if selected != nil || selectedEdge != nil {
                        selectionCard.padding(10)
                    }
                }
                .overlay {
                    if model.graphLoading && model.graphNodes.isEmpty {
                        ProgressView().controlSize(.small)
                    } else if model.graphNodes.isEmpty {
                        Text("No memories to graph yet.")
                            .font(.system(size: 12)).foregroundStyle(.secondary)
                    }
                }
                .onAppear {
                    canvasSize = geo.size
                    layoutIfReady(geo.size)
                    nav.startMonitor()
                }
                .onDisappear { nav.stopMonitor() }
                .onChange(of: geo.size) { _, ns in
                    canvasSize = ns
                    layoutIfReady(ns)
                }
                .onChange(of: model.graphVersion) { _, _ in
                    laidOut = false          // fresh graph data → re-lay-out
                    layoutIfReady(geo.size)
                }
                // Link/unlink mutate edges without a full reload — keep the cached
                // degree map (node sizing) in sync without touching layout.
                .onChange(of: model.graphEdges.count) { _, _ in
                    degree = computeDegrees()
                }
        }
        .clipShape(RoundedRectangle(cornerRadius: 14))
        .background(Color.black.opacity(0.06), in: RoundedRectangle(cornerRadius: 14))
        .padding(.horizontal, 12).padding(.bottom, 12)
    }

    // MARK: toolbar + selection overlay

    private var toolbar: some View {
        HStack(spacing: 10) {
            // Navigation tool toggle — Hand (pan/zoom) vs Pointer (act on nodes)
            Button { handTool = true; connectMode = false; pendingConnect = nil } label: {
                Image(systemName: "hand.raised.fill")
                    .foregroundStyle(handTool ? .cyan : .secondary)
            }.buttonStyle(.plain).help("Hand — drag to move around the map, pinch to zoom")
            Button { handTool = false } label: {
                Image(systemName: "cursorarrow")
                    .foregroundStyle(handTool ? Color.secondary : Color.cyan)
            }.buttonStyle(.plain).help("Pointer — move nodes, connect, cut")
            Divider().frame(height: 15).opacity(0.4)
            if !handTool {   // node actions only make sense with the pointer
                Button { connectMode.toggle(); pendingConnect = nil } label: {
                    Image(systemName: connectMode ? "link.circle.fill" : "link")
                        .foregroundStyle(connectMode ? .cyan : .secondary)
                }.buttonStyle(.plain).help("Connect: tap two memories to link them")
            }
            Button { withAnimation { fit(canvasSize) } } label: {
                Image(systemName: "viewfinder").foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Fit all")
            Button { nav.scale = min(nav.scale * 1.25, 4) } label: {
                Image(systemName: "plus.magnifyingglass").foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Zoom in")
            Button { nav.scale = max(nav.scale / 1.25, 0.2) } label: {
                Image(systemName: "minus.magnifyingglass").foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Zoom out")
            Button { onReload() } label: {
                Image(systemName: "arrow.clockwise").foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Reload graph")
            Button { onToggleFullscreen() } label: {
                Image(systemName: model.fullscreen
                      ? "arrow.down.right.and.arrow.up.left"
                      : "arrow.up.left.and.arrow.down.right")
                    .foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Fullscreen")
            if connectMode {
                Text(pendingConnect == nil ? "tap a memory" : "tap another")
                    .font(.system(size: 10, weight: .medium)).foregroundStyle(.cyan)
            }
        }
        .font(.system(size: 13))
        .padding(.horizontal, 12).padding(.vertical, 7)
        .background(.ultraThinMaterial, in: Capsule())
        .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }

    @ViewBuilder private var selectionCard: some View {
        if let e = selectedEdge {
            HStack(spacing: 8) {
                Image(systemName: "scissors").font(.system(size: 12))
                    .foregroundStyle(.orange)
                Text("Connection between two memories")
                    .font(.system(size: 11)).lineLimit(1)
                Button("Cut") {
                    onUnlink(e.from, e.to); selectedEdge = nil
                }.font(.system(size: 11, weight: .semibold)).foregroundStyle(.red)
                Button { selectedEdge = nil } label: {
                    Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
                }.buttonStyle(.plain)
            }
            .cardChrome()
        } else if let sid = selected,
                  let node = model.graphNodes.first(where: { $0.id == sid }) {
            HStack(alignment: .top, spacing: 8) {
                Circle().fill(kindColor(node.kind))
                    .frame(width: 9, height: 9).padding(.top, 3)
                VStack(alignment: .leading, spacing: 2) {
                    Text(node.text).font(.system(size: 12))
                        .fixedSize(horizontal: false, vertical: true)
                    if !node.kind.isEmpty {
                        Text(friendlyKind(node.kind)).font(.system(size: 10))
                            .foregroundStyle(.secondary)
                    }
                }
                Spacer(minLength: 4)
                Button { selected = nil } label: {
                    Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
                }.buttonStyle(.plain)
            }
            .cardChrome()
        }
    }

    // MARK: gestures

    private func dragGesture(_ size: CGSize) -> some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { v in
                if dragNode == nil && panStart == nil {   // decide on first touch
                    // Hand tool always pans; pointer moves a node if one is hit.
                    if !handTool, let hit = nodeAt(v.startLocation, size) {
                        dragNode = hit
                        dragStart = positions[hit]
                    } else {
                        panStart = nav.offset
                    }
                }
                if let nid = dragNode, let sp = dragStart {
                    positions[nid] = CGPoint(x: sp.x + v.translation.width / nav.scale,
                                             y: sp.y + v.translation.height / nav.scale)
                } else if let ps = panStart {
                    nav.offset = CGSize(width: ps.width + v.translation.width,
                                        height: ps.height + v.translation.height)
                }
            }
            .onEnded { v in
                let dist = hypot(v.translation.width, v.translation.height)
                if dist < 4 { handleTap(v.startLocation, size) }   // it was a tap
                dragNode = nil; dragStart = nil; panStart = nil
            }
    }

    // Zoom toward a point (double-click), keeping the point under the cursor
    // fixed. Same anchor math the ± buttons/scroll monitor share via `nav`.
    private func zoom(at point: CGPoint, factor: CGFloat, _ size: CGSize) {
        let s0 = nav.scale
        let s1 = min(max(s0 * factor, 0.2), 4)
        guard s1 != s0 else { return }
        nav.offset = CGSize(
            width: (point.x - size.width / 2) * (1 - s1 / s0) + nav.offset.width * s1 / s0,
            height: (point.y - size.height / 2) * (1 - s1 / s0) + nav.offset.height * s1 / s0)
        nav.scale = s1
    }

    private func handleTap(_ point: CGPoint, _ size: CGSize) {
        if handTool { return }   // Hand tool = pure navigation, never acts on taps
        if let nid = nodeAt(point, size) {
            if connectMode {
                if let first = pendingConnect {
                    if first != nid { onLink(first, nid) }
                    pendingConnect = nil
                } else {
                    pendingConnect = nid
                }
                selected = nid
            } else {
                selected = (selected == nid) ? nil : nid
                selectedEdge = nil
            }
            return
        }
        if !handTool, !connectMode, let e = edgeAt(point, size) {
            selectedEdge = e; selected = nil; return   // cut is a pointer action
        }
        selected = nil; selectedEdge = nil
        if !connectMode { pendingConnect = nil }
    }

    // MARK: layout

    /// Deterministic one-shot layout: as soon as BOTH the node data and a real
    /// canvas size exist, seed positions, settle the force sim, and fit — once.
    /// Reset `laidOut` (on an id-set change) to re-lay-out fresh data.
    private func layoutIfReady(_ size: CGSize) {
        guard !laidOut, !model.graphNodes.isEmpty, size.width > 1 else { return }
        laidOut = true                 // claim FIRST — this method is invoked from
                                       // inside a GeometryReader's onChange; mutating
                                       // nav (@Published) mid-layout could re-enter.
        ensurePositions()              // seed new nodes (cheap, main)
        degree = computeDegrees()      // cache the degree map once

        let ids = model.graphNodes.map { $0.id }
        guard ids.count > 1 else { fit(size); return }
        // Snapshot as Int-indexed value types and settle OFF the main thread. The
        // old path ran 320 × N² iterations of String-dict math synchronously on
        // main = a 1-3s beachball at ~170 nodes (quadratic → far worse as it grows).
        let index = Dictionary(uniqueKeysWithValues: ids.enumerated().map { ($1, $0) })
        let seed = ids.map { positions[$0] ?? .zero }
        let edges: [(Int, Int)] = model.graphEdges.compactMap { e in
            guard let a = index[e.from], let b = index[e.to] else { return nil }
            return (a, b)
        }
        Task.detached(priority: .userInitiated) {
            let out = Self.settle(seed: seed, edges: edges, iterations: 320)
            await MainActor.run {
                var p = positions
                for (i, id) in ids.enumerated() where i < out.count { p[id] = out[i] }
                positions = p
                fit(size)
            }
        }
    }

    private func computeDegrees() -> [String: Int] {
        var d: [String: Int] = [:]
        for e in model.graphEdges { d[e.from, default: 0] += 1; d[e.to, default: 0] += 1 }
        return d
    }

    /// Seed a position for every NEW node (deterministic golden-angle spread by
    /// index — never Date()/random), drop positions for removed nodes, and keep
    /// every existing position untouched so refreshes don't disturb the layout.
    private func ensurePositions() {
        let ids = model.graphNodes.map { $0.id }
        let live = Set(ids)
        for key in Array(positions.keys) where !live.contains(key) {
            positions.removeValue(forKey: key)
        }
        let firstFill = positions.isEmpty
        var n = 0
        for id in ids where positions[id] == nil {
            let angle = CGFloat(n) * 2.399963229728653   // golden angle (rad)
            let radius: CGFloat = firstFill ? 8 * sqrt(CGFloat(n + 1))
                                            : 40 + CGFloat(n % 6) * 14
            positions[id] = CGPoint(x: cos(angle) * radius, y: sin(angle) * radius)
            n += 1
        }
    }

    /// A short, finite Fruchterman-Reingold-style settle: all-pairs repulsion +
    /// spring attraction along edges + mild centering, clamped & damped so it
    /// converges and STOPS (no perpetual jitter). PURE + `nonisolated` so it can
    /// run off the main thread; Int-indexed parallel arrays (no per-pair String
    /// hashing) with the disp buffers allocated ONCE instead of per iteration.
    /// Same physics as before, ~20-50× cheaper constant factor.
    nonisolated private static func settle(seed: [CGPoint], edges: [(Int, Int)],
                                           iterations: Int) -> [CGPoint] {
        var pos = seed
        let n = pos.count
        guard n > 1 else { return pos }
        let k: CGFloat = 90            // ideal edge length
        let repulsion: CGFloat = 9000
        var dispx = [CGFloat](repeating: 0, count: n)
        var dispy = [CGFloat](repeating: 0, count: n)
        for _ in 0..<iterations {
            for i in 0..<n { dispx[i] = 0; dispy[i] = 0 }
            for i in 0..<n {
                for j in (i + 1)..<n {
                    var dx = pos[i].x - pos[j].x, dy = pos[i].y - pos[j].y
                    var dist = (dx * dx + dy * dy).squareRoot()
                    if dist < 0.01 {
                        dx = 0.1 * CGFloat(i + 1); dy = 0.1 * CGFloat(j + 1)
                        dist = (dx * dx + dy * dy).squareRoot()
                    }
                    let f = repulsion / (dist * dist)
                    let ux = dx / dist * f, uy = dy / dist * f
                    dispx[i] += ux; dispy[i] += uy
                    dispx[j] -= ux; dispy[j] -= uy
                }
            }
            for (a, b) in edges {
                let dx = pos[a].x - pos[b].x, dy = pos[a].y - pos[b].y
                let dist = max((dx * dx + dy * dy).squareRoot(), 0.01)
                let f = (dist - k) * 0.05
                let ux = dx / dist * f, uy = dy / dist * f
                dispx[a] -= ux; dispy[a] -= uy
                dispx[b] += ux; dispy[b] += uy
            }
            for i in 0..<n {
                var vx = dispx[i] * 0.85, vy = dispy[i] * 0.85
                let step = (vx * vx + vy * vy).squareRoot()
                if step > 30 { vx = vx / step * 30; vy = vy / step * 30 }
                pos[i].x = (pos[i].x + vx) * 0.995   // mild centering
                pos[i].y = (pos[i].y + vy) * 0.995
            }
        }
        return pos
    }

    /// Reset zoom/pan so every node fits the canvas.
    private func fit(_ size: CGSize) {
        guard size.width > 1, !positions.isEmpty else { return }
        let xs = positions.values.map { $0.x }, ys = positions.values.map { $0.y }
        let minX = xs.min()!, maxX = xs.max()!, minY = ys.min()!, maxY = ys.max()!
        let w = max(maxX - minX, 1), h = max(maxY - minY, 1)
        let pad: CGFloat = 90
        let s = min(max(min((size.width - pad) / w, (size.height - pad) / h), 0.2), 4)
        nav.scale = s
        nav.offset = CGSize(width: -(minX + maxX) / 2 * s, height: -(minY + maxY) / 2 * s)
    }

    // MARK: drawing + geometry

    private func drawGraph(_ ctx: inout GraphicsContext, _ size: CGSize) {
        let deg = degree                       // cached; no per-frame rebuild
        // Cull to the visible canvas (+40px margin) so big graphs don't pay to
        // draw — or resolve labels for — nodes/edges scrolled off-screen.
        let vis = CGRect(origin: .zero, size: size).insetBy(dx: -40, dy: -40)
        for e in model.graphEdges {
            guard let a = positions[e.from], let b = positions[e.to] else { continue }
            let va = toView(a, size), vb = toView(b, size)
            guard vis.contains(va) || vis.contains(vb) else { continue }
            var path = Path()
            path.move(to: va); path.addLine(to: vb)
            let sel = selectedEdge.map { $0.from == e.from && $0.to == e.to } ?? false
            ctx.stroke(path, with: .color(sel ? .orange : .gray.opacity(0.30)),
                       lineWidth: sel ? 2 : 1)
        }
        let showLabels = nav.scale > 1.25
        for node in model.graphNodes {
            guard let p = positions[node.id] else { continue }
            let v = toView(p, size)
            guard vis.contains(v) else { continue }
            let r = nodeRadius(deg[node.id] ?? 0)
            let rect = CGRect(x: v.x - r, y: v.y - r, width: r * 2, height: r * 2)
            ctx.fill(Path(ellipseIn: rect), with: .color(kindColor(node.kind)))
            if node.id == selected || node.id == pendingConnect {
                ctx.stroke(Path(ellipseIn: rect.insetBy(dx: -3, dy: -3)),
                           with: .color(.white), lineWidth: 2)
            }
            if showLabels || node.id == selected {
                var label = ctx.resolve(Text(shortLabel(node.text))
                    .font(.system(size: 9, weight: .medium)))
                label.shading = .color(.primary)
                ctx.draw(label, at: CGPoint(x: v.x, y: v.y + r + 8), anchor: .top)
            }
        }
    }

    private func toView(_ p: CGPoint, _ size: CGSize) -> CGPoint {
        CGPoint(x: size.width / 2 + p.x * nav.scale + nav.offset.width,
                y: size.height / 2 + p.y * nav.scale + nav.offset.height)
    }

    private func nodeAt(_ point: CGPoint, _ size: CGSize) -> String? {
        let deg = degree
        var best: String? = nil
        var bestD = CGFloat.greatestFiniteMagnitude
        for node in model.graphNodes {
            guard let p = positions[node.id] else { continue }
            let v = toView(p, size)
            let d = hypot(v.x - point.x, v.y - point.y)
            let r = nodeRadius(deg[node.id] ?? 0) + 6
            if d < r && d < bestD { best = node.id; bestD = d }
        }
        return best
    }

    private func edgeAt(_ point: CGPoint, _ size: CGSize) -> GraphEdge? {
        for e in model.graphEdges {
            guard let a = positions[e.from], let b = positions[e.to] else { continue }
            if distToSegment(point, toView(a, size), toView(b, size)) < 6 { return e }
        }
        return nil
    }

    private func distToSegment(_ p: CGPoint, _ a: CGPoint, _ b: CGPoint) -> CGFloat {
        let dx = b.x - a.x, dy = b.y - a.y
        let len2 = dx * dx + dy * dy
        if len2 < 0.0001 { return hypot(p.x - a.x, p.y - a.y) }
        var t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2
        t = min(max(t, 0), 1)
        return hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
    }


    private func nodeRadius(_ deg: Int) -> CGFloat { min(6 + CGFloat(deg) * 1.5, 16) }

    private func kindColor(_ kind: String) -> Color {
        switch kind.lowercased() {
        // entity nodes — each type its own colour
        case "entity:person":            return .green
        case "entity:company":           return .orange
        case "entity:amount":            return .yellow
        case "entity:product":           return .mint
        case "entity:place":             return .teal
        case "entity:thing":             return .gray
        // memory kinds
        case "fact":                     return .cyan
        case "event":                    return .pink
        case "preference", "pref":       return .purple
        case "task", "todo", "reminder": return .indigo
        default:
            // any other entity:* → gray; any other memory kind → blue
            return kind.lowercased().hasPrefix("entity:") ? .gray : .blue
        }
    }

    /// Human-readable display name for a node `kind` ("entity:person" → "Person",
    /// "fact" → "Fact"), used by the legend and the selection card.
    private func friendlyKind(_ kind: String) -> String {
        let k = kind.lowercased()
        switch k {
        case "entity:person":  return "Person"
        case "entity:company": return "Company"
        case "entity:amount":  return "Amount"
        case "entity:product": return "Product"
        case "entity:place":   return "Place"
        case "entity:thing":   return "Thing"
        case "fact":           return "Fact"
        case "event":          return "Event"
        case "preference", "pref": return "Preference"
        case "task", "todo":   return "Task"
        case "reminder":       return "Reminder"
        case "observation":    return "Observation"
        default:
            let base = k.hasPrefix("entity:") ? String(k.dropFirst("entity:".count)) : k
            guard let f = base.first else { return "Memory" }
            return f.uppercased() + base.dropFirst()
        }
    }

    // Compact translucent key pinned bottom-leading: only the kinds actually in
    // the graph (deduped, sorted) plus one navigation hint line.
    private var legend: some View {
        let kinds = Array(Set(model.graphNodes.map { $0.kind })).sorted()
        return VStack(alignment: .leading, spacing: 4) {
            ForEach(kinds, id: \.self) { k in
                HStack(spacing: 5) {
                    Circle().fill(kindColor(k)).frame(width: 7, height: 7)
                    Text(friendlyKind(k)).font(.system(size: 9))
                        .foregroundStyle(.secondary)
                }
            }
            Text("drag to pan · pinch/scroll to zoom · double-click to zoom in")
                .font(.system(size: 8)).foregroundStyle(.tertiary).padding(.top, 2)
        }
        .padding(.horizontal, 9).padding(.vertical, 6)
        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 9))
        .overlay(RoundedRectangle(cornerRadius: 9)
            .strokeBorder(.white.opacity(0.1), lineWidth: 0.5))
    }

    private func shortLabel(_ s: String) -> String {
        let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
        return t.count <= 18 ? t : String(t.prefix(18)) + "…"
    }
}

private extension View {
    // Shared chrome for the graph's floating selection/edge cards.
    func cardChrome() -> some View {
        self.padding(.horizontal, 12).padding(.vertical, 8)
            .frame(maxWidth: 380)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
            .overlay(RoundedRectangle(cornerRadius: 12)
                .strokeBorder(.white.opacity(0.12), lineWidth: 0.5))
    }
}

// MARK: - Storage HUD (Ahmed's shared files, browsed lazily)

/// One file in the storage bucket — METADATA ONLY. There is deliberately no
/// content here: the list stays fast because bytes are fetched only when Ahmed
/// opens a file (the engine downloads + caches on click).
struct StorageItem: Identifiable, Equatable {
    var id: String { name }
    let name: String
    let type: String     // extension (or mime subtype): txt / pdf / png …
    let size: Int        // bytes
    let updated: String  // ISO-8601 timestamp (may be empty)
}

/// SF Symbol for a file by its type tag (extension). Falls back to a plain doc.
func storageIcon(_ type: String) -> String {
    switch type.lowercased() {
    case "txt", "text", "md", "markdown", "rtf", "log":       return "doc.text"
    case "pdf":                                                return "doc.richtext"
    case "csv", "tsv", "xls", "xlsx", "numbers", "sheet":     return "tablecells"
    case "doc", "docx", "pages", "odt":                       return "doc.text.fill"
    case "ppt", "pptx", "key":                                return "rectangle.on.rectangle"
    case "png", "jpg", "jpeg", "gif", "webp", "heic", "bmp",
         "tiff", "svg":                                       return "photo"
    case "mp3", "wav", "m4a", "aac", "flac", "ogg":           return "waveform"
    case "mp4", "mov", "avi", "mkv", "webm", "m4v":           return "film"
    case "zip", "tar", "gz", "rar", "7z", "bz2":              return "doc.zipper"
    case "json", "js", "ts", "py", "swift", "html", "css",
         "sh", "rb", "go", "rs", "java", "c", "cpp", "yml",
         "yaml", "toml", "xml":                               return "chevron.left.forwardslash.chevron.right"
    default:                                                   return "doc"
    }
}

/// Human byte size: 0 → "", else KB/MB/GB.
func storageSize(_ bytes: Int) -> String {
    guard bytes > 0 else { return "" }
    let b = Double(bytes)
    if b < 1024 { return "\(bytes) B" }
    if b < 1024 * 1024 { return String(format: "%.0f KB", b / 1024) }
    if b < 1024 * 1024 * 1024 { return String(format: "%.1f MB", b / (1024 * 1024)) }
    return String(format: "%.1f GB", b / (1024 * 1024 * 1024))
}

@MainActor
final class StorageModel: ObservableObject {
    @Published var files: [StorageItem] = []
    @Published var loading = false
    @Published var uploading = false      // an upload is in flight (big files are slow)
    @Published var dropTargeted = false   // a file is being dragged over the panel
}

/// The Storage panel: a fast, lazy file browser. The list is metadata-only
/// (the engine emits `storage`); clicking a row downloads+opens that one file,
/// the trash icon deletes it, and dropping files (or "Add files") uploads them.
/// Like the memory panel, this never talks to Supabase itself — it writes
/// control/storage_query.json (refresh) and control/storage_action.json
/// (open/delete/upload) and the engine (which holds the key) does the work.
@MainActor
final class StoragePanel {
    private var panel: GlassPanel?
    let model = StorageModel()
    // Minimize-to-bubble (shared machinery — folder bubble).
    private let bubbleCtl = BubbleController()

    // Wide, landscape "File Explorer" window (not a tall narrow list).
    private let panelW: CGFloat = 720
    private func panelH(_ vf: NSRect?) -> CGFloat { min(460, (vf?.height ?? 760) - 60) }

    // engine → panel (a `storage` event arrived). This is the LIVE update path:
    // the engine re-emits `storage` after every upload / delete / refresh, and
    // because `model` is the SAME ObservableObject the shown StorageView observes,
    // assigning `model.files` here re-renders the panel in place. The panel host
    // is created ONCE (ensurePanel) and never rebuilt, so this observation is
    // never dropped — matching MemoryPanel.setAll.
    func setAll(_ files: [StorageItem]) {
        model.files = files
        model.loading = false
        model.uploading = false   // a fresh list means any in-flight upload landed
    }

    // engine `storage` event. It fires on the first voice/menu "pull up storage"
    // AND on every later refresh (post-upload/delete/manual). ALWAYS push the
    // fresh data to the live model (real-time update); only RAISE/show the window
    // when it isn't already on-screen — so a refresh never re-activates the app
    // (which, on this LSUIElement/accessory app, is what flashed a second Dock
    // entry) and never steals focus.
    func present(_ files: [StorageItem]) {
        let onScreen = (panel?.isVisible == true) && !bubbleCtl.state.minimized
        ensurePanel()      // create the single panel+host once; it observes `model`
        setAll(files)      // live refresh (clears loading/upload spinners)
        guard !onScreen else { return }
        bubbleCtl.reset()  // if minimized to a bubble, expand it
        raise()
    }

    func open() {
        bubbleCtl.reset()   // explicit open (voice / menu) → expanded
        ensurePanel()
        model.loading = true
        writeControl("storage_query.json", [:])   // load / refresh the list
        raise()
    }
    func toggle() { (panel?.isVisible == true) ? hide() : open() }
    func hide() { panel?.orderOut(nil) }

    // Bring the single panel forward WITHOUT NSApp.activate: a GlassPanel is a
    // floating .nonactivatingPanel with canBecomeKey=true, so its buttons /
    // drag-drop / double-click all work while key even though the accessory app
    // is never "activated" — the whole point of a non-activating panel, and why
    // showing it can't spawn a Dock icon or a second app instance. (Storage has
    // no text field, so unlike the memory search it never needs app activation.)
    private func raise() {
        panel?.orderFrontRegardless()
        panel?.makeKeyAndOrderFront(nil)
    }

    private func refresh() {
        model.loading = true
        writeControl("storage_query.json", [:])
    }

    private func openItem(_ item: StorageItem) {
        writeControl("storage_action.json", ["action": "open", "name": item.name])
    }

    private func deleteItem(_ item: StorageItem) {
        model.files.removeAll { $0.id == item.id }   // optimistic; engine re-emits
        writeControl("storage_action.json", ["action": "delete", "name": item.name])
    }

    private func uploadURLs(_ urls: [URL]) {
        let paths = urls.filter { $0.isFileURL }.map { $0.path }
        guard !paths.isEmpty else { return }
        model.uploading = true   // show the header spinner until the engine re-emits
        writeControl("storage_action.json", ["action": "upload", "paths": paths])
    }

    /// "Add files" → the same NSOpenPanel flow as the chat input's paperclip.
    private func pickFiles() {
        NSApp.activate(ignoringOtherApps: true)
        let op = NSOpenPanel()
        op.allowsMultipleSelection = true
        op.canChooseFiles = true
        op.canChooseDirectories = false
        if op.runModal() == .OK { uploadURLs(op.urls) }
    }

    // Create the single panel + hosting view ONCE (no raise). Reused forever
    // after — critically, the NSHostingView (and the StorageView inside it that
    // observes `model`) is never rebuilt, so live `setAll` updates always reach
    // the on-screen view. `raise()` handles ordering separately.
    private func ensurePanel() {
        guard panel == nil else { return }
        let vf = NSScreen.main?.visibleFrame
        let w = panelW
        let h = panelH(vf)
        let view = StorageView(
            model: model,
            onOpen: { [weak self] item in self?.openItem(item) },
            onDelete: { [weak self] item in self?.deleteItem(item) },
            onUpload: { [weak self] urls in self?.uploadURLs(urls) },
            onPick: { [weak self] in self?.pickFiles() },
            onRefresh: { [weak self] in self?.refresh() },
            onMinimize: { [weak self] in self?.bubbleCtl.toggle() },
            onClose: { [weak self] in self?.hide() })
        let host = NSHostingView(rootView: MinimizableCard(
            state: bubbleCtl.state, icon: { "folder.fill" }, tint: .cyan,
            help: "Your files — hold to expand") { view })
        host.sizingOptions = []   // fill the panel; don't shrink to content
        let p = GlassPanel(size: NSSize(width: w, height: h))
        p.contentView = host
        bubbleCtl.onToggled = { [weak self] mini in
            guard let self, let p = self.panel else { return }
            if mini {
                p.isMovableByWindowBackground = true
                let f = p.frame
                p.setFrame(NSRect(x: f.minX, y: f.maxY - 58,
                                  width: 58, height: 58),
                           display: true, animate: true)
            } else {
                let vf = (p.screen ?? NSScreen.main)?.visibleFrame
                    ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
                let ww = self.panelW
                let hh = self.panelH(vf)
                let top = p.frame.maxY
                // keep the wide panel fully on-screen when it grows back
                let x = min(max(p.frame.origin.x, vf.minX + 8), vf.maxX - ww - 8)
                p.setFrame(NSRect(x: x, y: top - hh, width: ww, height: hh),
                           display: true, animate: true)
            }
        }
        bubbleCtl.attach(p)   // long-press: browser ⇄ folder bubble
        if let vf {
            let x = min(vf.minX + 40, vf.maxX - w - 20)   // wide panel stays on-screen
            p.setFrameOrigin(NSPoint(x: x, y: vf.maxY - h - 30))
        }
        panel = p
    }

    private func writeControl(_ name: String, _ obj: [String: Any]) {
        try? FileManager.default.createDirectory(
            at: Paths.control, withIntermediateDirectories: true)
        if let data = try? JSONSerialization.data(withJSONObject: obj) {
            try? data.write(to: Paths.control.appendingPathComponent(name),
                            options: .atomic)
        }
    }
}

/// Windows-File-Explorer-style browser: a WIDE landscape window whose files
/// are shown as an icon grid (big type-icon + name) that wraps across the
/// window. Double-click a tile to open it; hover reveals a delete affordance;
/// drop files anywhere (or use "Add files") to upload. Observes `model` so
/// the grid re-renders live whenever the engine re-emits the file list.
struct StorageView: View {
    @ObservedObject var model: StorageModel
    var onOpen: (StorageItem) -> Void
    var onDelete: (StorageItem) -> Void
    var onUpload: ([URL]) -> Void
    var onPick: () -> Void
    var onRefresh: () -> Void
    var onMinimize: () -> Void
    var onClose: () -> Void

    // Tiles wrap to fill the wide window (≈5 columns at 720pt).
    private let columns = [GridItem(.adaptive(minimum: 120, maximum: 168),
                                    spacing: 14, alignment: .top)]

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
            Divider().opacity(0.08)
            content
        }
        .frame(width: 720)
        .frame(maxHeight: .infinity, alignment: .top)
        .glassCard(18)
        // Drop files anywhere on the panel to upload them.
        .overlay(dropHighlight)
        .onDrop(of: [.fileURL],
                isTargeted: Binding(get: { model.dropTargeted },
                                    set: { model.dropTargeted = $0 })) { providers in
            let group = DispatchGroup()
            let lock = NSLock()
            var urls: [URL] = []
            for pr in providers where pr.canLoadObject(ofClass: URL.self) {
                group.enter()
                _ = pr.loadObject(ofClass: URL.self) { url, _ in
                    if let url, url.isFileURL {
                        lock.lock(); urls.append(url); lock.unlock()
                    }
                    group.leave()
                }
            }
            group.notify(queue: .main) { if !urls.isEmpty { onUpload(urls) } }
            return true
        }
    }

    private var header: some View {
        HStack(spacing: 10) {
            Image(systemName: "folder.fill")
                .font(.system(size: 13, weight: .semibold)).foregroundStyle(.cyan)
            Text("My Files").font(.system(size: 13, weight: .semibold))
            if model.uploading {
                ProgressView().controlSize(.small).scaleEffect(0.72)
                    .frame(width: 16, height: 16)
                Text("Uploading…").font(.system(size: 11)).foregroundStyle(.secondary)
            } else if !model.files.isEmpty {
                Text("\(model.files.count) item\(model.files.count == 1 ? "" : "s")")
                    .font(.system(size: 11)).foregroundStyle(.secondary)
            }
            Spacer()
            Button(action: onPick) {
                HStack(spacing: 4) {
                    Image(systemName: "plus")
                    Text("Add files")
                }.font(.system(size: 11.5, weight: .medium)).foregroundStyle(.cyan)
            }.buttonStyle(.plain).help("Upload files to storage")
            Button { onRefresh() } label: {
                Image(systemName: "arrow.clockwise")
                    .font(.system(size: 12, weight: .semibold))
                    .foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Refresh the list")
            Button { onMinimize() } label: {
                Image(systemName: "minus.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain)
             .help("Minimize to a bubble — hold the bubble to reopen")
            Button { onClose() } label: {
                Image(systemName: "xmark.circle.fill")
                    .font(.system(size: 15)).foregroundStyle(.secondary)
            }.buttonStyle(.plain).help("Hide")
        }.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10)
    }

    @ViewBuilder private var content: some View {
        if model.loading && model.files.isEmpty {
            VStack { Spacer(); ProgressView().controlSize(.small); Spacer() }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        } else if model.files.isEmpty {
            VStack(spacing: 8) {
                Spacer()
                Image(systemName: "tray").font(.system(size: 30))
                    .foregroundStyle(.secondary)
                Text("No files yet.").font(.system(size: 13))
                    .foregroundStyle(.secondary)
                Text("Drop files here, or use “Add files”.")
                    .font(.system(size: 11)).foregroundStyle(.secondary.opacity(0.7))
                Spacer()
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        } else {
            ScrollView {
                LazyVGrid(columns: columns, alignment: .leading, spacing: 14) {
                    ForEach(model.files) { item in
                        StorageTile(item: item,
                                    onOpen: { onOpen(item) },
                                    onDelete: { onDelete(item) })
                    }
                }
                .padding(.horizontal, 16).padding(.vertical, 16)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }

    private var dropHighlight: some View {
        RoundedRectangle(cornerRadius: 18)
            .strokeBorder(Color.cyan.opacity(model.dropTargeted ? 0.9 : 0),
                          lineWidth: 2)
            .animation(.easeOut(duration: 0.15), value: model.dropTargeted)
            .allowsHitTesting(false)
    }
}

/// One file as a Finder/Explorer-style icon tile: big type icon + wrapped name
/// + size. Double-click opens; hover reveals a delete badge in the corner.
struct StorageTile: View {
    let item: StorageItem
    var onOpen: () -> Void
    var onDelete: () -> Void
    @State private var hovering = false

    var body: some View {
        VStack(spacing: 6) {
            Image(systemName: storageIcon(item.type))
                .font(.system(size: 34, weight: .regular))
                .foregroundStyle(.cyan)
                .frame(height: 44)
            Text(item.name)
                .font(.system(size: 11.5))
                .lineLimit(2)
                .truncationMode(.middle)
                .multilineTextAlignment(.center)
                .frame(height: 30, alignment: .top)
            if !sizeText.isEmpty {
                Text(sizeText).font(.system(size: 9.5)).foregroundStyle(.secondary)
            }
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 12).padding(.horizontal, 6)
        .background(RoundedRectangle(cornerRadius: 12)
            .fill(hovering ? Color.primary.opacity(0.08) : Color.clear))
        .overlay(alignment: .topTrailing) {
            Button { onDelete() } label: {
                Image(systemName: "xmark.circle.fill")
                    .font(.system(size: 14))
                    .foregroundStyle(.red.opacity(0.9))
                    .background(Circle().fill(Color.black.opacity(0.2)))
            }.buttonStyle(.plain).help("Delete this file")
             .padding(5)
             .opacity(hovering ? 1 : 0)
             .allowsHitTesting(hovering)   // invisible badge must not eat clicks
        }
        .contentShape(Rectangle())
        .onTapGesture(count: 2) { onOpen() }   // double-click opens (Explorer-style)
        .onHover { hovering = $0 }
        .help("Double-click to open · \(item.name)\(whenText)")
        .animation(.easeOut(duration: 0.12), value: hovering)
    }

    private var sizeText: String { storageSize(item.size) }
    private var whenText: String {
        let when = memPrettyTime(item.updated)
        return when.isEmpty ? "" : "  ·  \(when)"
    }
}

// MARK: - Engine controller

@MainActor
final class Engine {
    private let model: AppModel
    private let popups: Popups
    private let tasks: TasksPanel
    private let memory: MemoryPanel
    private let compose: ComposePanel
    private let storage: StoragePanel
    private let email: EmailPanel
    private let entity: EntityPanel
    private let windows = WindowManager()
    private var proc: Process?
    private var outHandle: FileHandle?   // engine stdout reader; nil its handler on stop/EOF or it spins a core
    private var buffer = Data()
    private var restartSource: DispatchSourceFileSystemObject?
    var onGestures: ((Bool) -> Void)?   // engine's "gestures" events → HUD (Delegate wires it)

    init(model: AppModel, popups: Popups, tasks: TasksPanel, memory: MemoryPanel,
         compose: ComposePanel, storage: StoragePanel, email: EmailPanel,
         entity: EntityPanel) {
        self.model = model
        self.popups = popups
        self.tasks = tasks
        self.memory = memory
        self.compose = compose
        self.storage = storage
        self.email = email
        self.entity = entity
        watchHudRestart()
    }

    // Jarvis's window command → the AX tiler. When Jarvis is docked to the
    // left edge, reserve its width so tiled windows don't slide under it.
    func handleWindowCommand(_ cmd: [String: Any]?) {
        guard var c = cmd else { return }
        if !WindowManager.ensureAccessibilityPermission() {
            model.push("claude", "I need Accessibility permission to arrange "
                + "your windows, sir. Enable Jarvis under System Settings, "
                + "Privacy & Security, Accessibility, then relaunch me.")
            return
        }
        if model.docked {
            let inset = Double(model.dockWidth)
            func isMain(_ s: Any?) -> Bool {
                s == nil || (s as? String)?.lowercased() == "main"
            }
            if isMain(c["screen"]), c["left_inset"] == nil { c["left_inset"] = inset }
            if var layout = c["layout"] as? [[String: Any]] {
                for i in layout.indices where isMain(layout[i]["screen"])
                    && layout[i]["left_inset"] == nil {
                    layout[i]["left_inset"] = inset
                }
                c["layout"] = layout
            }
        }
        windows.apply(c)
    }

    // Watch control/hud_restart — Python signals this file when it wants a
    // full engine restart that the HUD owns.  We kill the old subprocess and
    // launch a fresh one so the HUD's stdout pipe re-attaches cleanly.
    private func watchHudRestart() {
        let dir = Paths.control
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        // kqueue watches the *directory* for writes so we don't need the file
        // to exist up front.
        let fd = open(dir.path, O_EVTONLY)
        guard fd >= 0 else { return }
        let src = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fd, eventMask: .write, queue: .main)
        src.setEventHandler { [weak self] in
            guard let self else { return }
            Task { @MainActor in self.checkHudRestart() }
        }
        src.setCancelHandler { close(fd) }
        src.resume()
        restartSource = src
    }

    private func checkHudRestart() {
        let flag = Paths.control.appendingPathComponent("hud_restart")
        guard FileManager.default.fileExists(atPath: flag.path) else { return }
        try? FileManager.default.removeItem(at: flag)   // consume first
        print("ClaudeHUD: hud_restart detected — cycling engine")
        stop()
        // Brief pause so the old process has time to clean up, then relaunch.
        Task { @MainActor in
            try? await Task.sleep(nanoseconds: 1_200_000_000)   // 1.2 s
            self.start()
        }
    }

    var isRunning: Bool { proc?.isRunning ?? false }

    func start() {
        guard !isRunning else { return }
        let p = Process()
        p.executableURL = Paths.python
        p.arguments = ["-u", Paths.engine.path]
        p.currentDirectoryURL = Paths.root
        var env = ProcessInfo.processInfo.environment
        env["EMIT_JSON"] = "1"
        env["PYTHONUNBUFFERED"] = "1"
        p.environment = env
        let out = Pipe()
        p.standardOutput = out
        p.standardError = FileHandle.standardError
        let rh = out.fileHandleForReading
        outHandle = rh
        rh.readabilityHandler = { [weak self] h in
            let d = h.availableData
            guard !d.isEmpty else {
                // Empty data == EOF. A pipe at EOF stays permanently "readable", so
                // leaving the source armed refires it instantly forever = one pegged
                // core. Tear the handler down; the process is done producing output.
                h.readabilityHandler = nil
                return
            }
            Task { @MainActor in self?.ingest(d) }
        }
        p.terminationHandler = { [weak self] _ in
            Task { @MainActor in
                self?.outHandle?.readabilityHandler = nil
                self?.outHandle = nil
                self?.model.running = false
                self?.model.state = "off"
                self?.model.agents.removeAll()
                self?.onGestures?(false)   // tracker died with the engine
            }
        }
        do { try p.run() } catch {
            model.push("claude", "Couldn't start the engine: \(error.localizedDescription)")
            return
        }
        proc = p
        model.running = true
    }

    func stop() {
        outHandle?.readabilityHandler = nil   // stop the stdout reader before it hits EOF-spin
        outHandle = nil
        proc?.terminate()   // SIGTERM: python cleans up Chrome/aecmic children
        proc = nil
        model.running = false
        model.state = "off"
    }

    func toggleMute() { touch("mute") }

    // Wake-word mode toggle → control/config.json {"wake_word": bool}.
    // Optimistically flip the model so the button responds instantly; the
    // engine echoes a `wake_word` event to keep us in sync if it changes it.
    func setWakeWord(_ on: Bool) {
        writeControlJSON("config.json", ["wake_word": on])
        model.wakeWord = on
    }

    // Hand-gesture tracker on/off → control/gestures.json {"on": bool}.
    // The engine watches the file and starts/stops the camera tracker; it
    // echoes a "gestures" event so the menu checkmark tracks reality.
    func setGestures(_ on: Bool) {
        writeControlJSON("gestures.json", ["on": on])
    }

    // Typed message → control/text_input.json
    // want_voice mirrors the "speak my typed replies" toggle (office mode).
    func sendText(text: String, attachments: [URL]) {
        writeControlJSON("text_input.json", [
            "text": text,
            "attachments": attachments.map { $0.path },
            "want_voice": model.textVoice,
        ])
    }

    private func touch(_ name: String) {
        try? FileManager.default.createDirectory(at: Paths.control,
                                                 withIntermediateDirectories: true)
        FileManager.default.createFile(atPath: Paths.control.appendingPathComponent(name).path,
                                       contents: Data())
    }

    private func writeControlJSON(_ name: String, _ obj: [String: Any]) {
        try? FileManager.default.createDirectory(at: Paths.control,
                                                 withIntermediateDirectories: true)
        guard let data = try? JSONSerialization.data(withJSONObject: obj) else { return }
        try? data.write(to: Paths.control.appendingPathComponent(name), options: .atomic)
    }

    private func ingest(_ d: Data) {
        buffer.append(d)
        while let nl = buffer.firstIndex(of: 0x0a) {
            let lineData = buffer.subdata(in: buffer.startIndex..<nl)
            buffer.removeSubrange(buffer.startIndex...nl)
            guard let line = String(data: lineData, encoding: .utf8) else { continue }
            if let r = line.range(of: "@@EVT ") {
                handle(String(line[r.upperBound...]))
            } else if !line.trimmingCharacters(in: .whitespaces).isEmpty {
                FileHandle.standardError.write(Data((line + "\n").utf8))  // engine log
            }
        }
    }

    private func handle(_ json: String) {
        guard let data = json.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let type = obj["type"] as? String else { return }
        switch type {
        case "booting":
            model.running = true; model.state = "booting"
            model.bootStage = (obj["stage"] as? String) ?? "starting up…"
        case "ready":
            model.running = true; model.state = "listening"
            model.bootStage = ""
        case "status":
            model.state = (obj["state"] as? String) ?? model.state
        case "you":
            model.enrolling = nil
            model.push("you", (obj["text"] as? String) ?? "")
        case "claude":
            model.push("claude", (obj["text"] as? String) ?? "")
        case "task_started":
            model.addAgent((obj["agent"] as? String) ?? "worker",
                           (obj["desc"] as? String) ?? "task",
                           (obj["id"] as? String) ?? "")
        case "task_progress":
            model.agentProgress((obj["id"] as? String) ?? "",
                                (obj["note"] as? String) ?? "")
        case "task_stalled":
            model.agentStalled((obj["id"] as? String) ?? "")
        case "task_done":
            model.finishAgent((obj["id"] as? String) ?? "")
            let report = (obj["report"] as? String) ?? ""
            if !report.isEmpty {
                model.push("task", report)
            }
        case "notify":
            model.push("task", (obj["text"] as? String) ?? "")
        case "enroll":
            let n = (obj["n"] as? Int) ?? 0, t = (obj["total"] as? Int) ?? 5
            model.enrolling = (n, t)
        case "locked":
            model.enrolling = nil
        case "muted":
            model.muted = (obj["on"] as? Bool) ?? !model.muted
        case "text_voice":
            model.textVoice = (obj["on"] as? Bool) ?? model.textVoice
        case "collapsed":
            withAnimation { model.collapsed = (obj["on"] as? Bool) ?? model.collapsed }
        case "dock":
            withAnimation {
                model.docked = (obj["on"] as? Bool) ?? model.docked
                if let w = obj["width"] as? Double { model.dockWidth = CGFloat(w) }
                if let s = obj["side"] as? String { model.dockSide = s }
            }
        case "window":
            handleWindowCommand(obj["command"] as? [String: Any])
        case "wake_word":
            model.wakeWord = (obj["on"] as? Bool) ?? model.wakeWord
        case "gestures":
            onGestures?((obj["on"] as? Bool) ?? false)
        case "show":
            popups.show(path: (obj["path"] as? String) ?? "",
                        title: (obj["title"] as? String) ?? "")
        case "tasks":
            if let arr = obj["tasks"] as? [[String: Any]] {
                tasks.setAll(arr.compactMap { d in
                    guard let id = d["id"] as? String,
                          let text = d["text"] as? String else { return nil }
                    return TaskItem(id: id, text: text, due: d["due"] as? String)
                })
            }
        case "task_added":
            if let id = obj["id"] as? String, let text = obj["text"] as? String {
                tasks.add(TaskItem(id: id, text: text,
                                   due: obj["due"] as? String))
            }
        case "task_done_evt":
            if let id = obj["id"] as? String { tasks.remove(id) }
        case "memories":
            let q = (obj["q"] as? String) ?? ""
            let items = (obj["items"] as? [[String: Any]] ?? []).compactMap {
                d -> MemoryItem? in
                guard let id = d["id"] as? String,
                      let text = d["text"] as? String else { return nil }
                return MemoryItem(id: id, text: text,
                                  kind: (d["kind"] as? String) ?? "",
                                  when: (d["when"] as? String) ?? "",
                                  createdAt: (d["created_at"] as? String) ?? "",
                                  linked: (d["linked"] as? Bool) ?? false)
            }
            memory.setAll(q, items)
        case "graph":
            let nodes = (obj["nodes"] as? [[String: Any]] ?? []).compactMap {
                d -> GraphNode? in
                guard let id = d["id"] as? String,
                      let text = d["text"] as? String else { return nil }
                return GraphNode(id: id, text: text,
                                 kind: (d["kind"] as? String) ?? "")
            }
            let edges = (obj["edges"] as? [[String: Any]] ?? []).compactMap {
                d -> GraphEdge? in
                guard let from = d["from"] as? String,
                      let to = d["to"] as? String else { return nil }
                return GraphEdge(from: from, to: to,
                                 type: (d["type"] as? String) ?? "related")
            }
            memory.setGraph(nodes, edges)
        case "storage":   // file panel — metadata-only list (never file bodies)
            let files = (obj["files"] as? [[String: Any]] ?? []).compactMap {
                d -> StorageItem? in
                guard let name = d["name"] as? String else { return nil }
                let size = (d["size"] as? Int) ?? Int((d["size"] as? Double) ?? 0)
                return StorageItem(name: name,
                                   type: (d["type"] as? String) ?? "",
                                   size: size,
                                   updated: (d["updated"] as? String) ?? "")
            }
            storage.present(files)   // show the panel, not just refresh its data
        case "memory_forgotten":
            if let id = obj["id"] as? String { memory.remove(id) }
        case "compose":   // email draft card — review/edit before it sends
            compose.showEmail(
                draftId: (obj["draft_id"] as? String) ?? "",
                to: (obj["to"] as? String) ?? "",
                cc: (obj["cc"] as? String) ?? "",
                subject: (obj["subject"] as? String) ?? "",
                body: (obj["body"] as? String) ?? "",
                attachments: (obj["attachments"] as? [String]) ?? [])
        case "event_card":   // calendar draft card — confirm before it books
            compose.showEvent(
                draftId: (obj["draft_id"] as? String) ?? "",
                title: (obj["title"] as? String) ?? "",
                start: (obj["start"] as? String) ?? "",
                end: (obj["end"] as? String) ?? "",
                attendees: (obj["attendees"] as? String) ?? "",
                meet: (obj["meet"] as? Bool) ?? false,
                context: (obj["context"] as? [String]) ?? [])
        case "card_close":   // draft sent/cancelled elsewhere — drop the card
            compose.close((obj["draft_id"] as? String) ?? "")
            email.close((obj["draft_id"] as? String) ?? "")   // thread_id rides as draft_id
            entity.close((obj["draft_id"] as? String) ?? "")  // entity key rides as draft_id
        case "email_thread":   // full-thread reader card — email_action.json talks back
            let msgs = (obj["messages"] as? [[String: Any]] ?? []).compactMap {
                m -> EmailMessage? in
                guard let msgId = m["msg_id"] as? String else { return nil }
                let atts = (m["attachments"] as? [[String: Any]] ?? []).compactMap {
                    a -> EmailAttachment? in
                    guard let attId = a["att_id"] as? String else { return nil }
                    let size = (a["size"] as? Int) ?? Int((a["size"] as? Double) ?? 0)
                    return EmailAttachment(attId: attId,
                                           filename: (a["filename"] as? String) ?? "attachment",
                                           mime: (a["mime"] as? String) ?? "",
                                           size: size,
                                           path: (a["path"] as? String) ?? "")
                }
                return EmailMessage(msgId: msgId,
                                    fromName: (m["from_name"] as? String) ?? "",
                                    fromAddr: (m["from_addr"] as? String) ?? "",
                                    to: (m["to"] as? String) ?? "",
                                    cc: (m["cc"] as? String) ?? "",
                                    dateHuman: (m["date_human"] as? String) ?? "",
                                    outgoing: (m["outgoing"] as? Bool) ?? false,
                                    bodyHtml: (m["body_html"] as? String) ?? "",
                                    bodyText: (m["body_text"] as? String) ?? "",
                                    imagesBlocked: (m["images_blocked"] as? Int)
                                        ?? Int((m["images_blocked"] as? Double) ?? 0),
                                    attachments: atts)
            }
            email.show(threadId: (obj["thread_id"] as? String) ?? "",
                       account: (obj["account"] as? String) ?? "",
                       subject: (obj["subject"] as? String) ?? "",
                       messages: msgs)
        case "email_attachment":   // a requested download landed — chip opens it
            email.attachmentReady(threadId: (obj["thread_id"] as? String) ?? "",
                                  msgId: (obj["msg_id"] as? String) ?? "",
                                  attId: (obj["att_id"] as? String) ?? "",
                                  path: (obj["path"] as? String) ?? "",
                                  ok: (obj["ok"] as? Bool) ?? false,
                                  error: (obj["error"] as? String) ?? "")
        case "email_reply_sent":
            email.replySent(threadId: (obj["thread_id"] as? String) ?? "",
                            ok: (obj["ok"] as? Bool) ?? false,
                            error: (obj["error"] as? String) ?? "")
        case "entity_profile":   // full entity dossier — entity_action.json talks back
            if let p = obj["profile"] as? [String: Any] {
                // Optional server-composed layout: absent on old engine events
                // (→ raw fallback); may arrive null-then-set across two events.
                let composed = (obj["composed"] as? [String: Any]).map { ComposedDossier.from($0) }
                let composing = (obj["composing"] as? Bool) ?? false
                entity.show(EntityProfile.from(p), composed: composed, composing: composing)
            }
        case "meeting":
            model.recording = (obj["on"] as? Bool) ?? false
        case "restarting":
            model.state = "restarting"
        case "interrupted":
            model.state = "listening"
        default: break
        }
    }
}

// MARK: - App

@MainActor
final class Delegate: NSObject, NSApplicationDelegate {
    let model = AppModel()
    let popups = Popups()
    let tasksPanel = TasksPanel()
    let memoryPanel = MemoryPanel()
    let composePanel = ComposePanel()
    let storagePanel = StoragePanel()
    let emailPanel = EmailPanel()
    let entityPanel = EntityPanel()
    lazy var engine = Engine(model: model, popups: popups, tasks: tasksPanel,
                             memory: memoryPanel, compose: composePanel,
                             storage: storagePanel, email: emailPanel,
                             entity: entityPanel)
    lazy var gestures = GestureController()
    var hud: GlassPanel!
    var hudHost: NSHostingView<HUDView>?
    var statusItem: NSStatusItem!
    var gesturesItem: NSMenuItem?
    var bag = Set<AnyCancellable>()
    private var panelsWired = false   // positionPanels() re-runs on every screen change; wire sinks once
    var signalSources: [DispatchSourceSignal] = []

    // Idle auto-dim (bubble + expanded only; side-mode is excluded) + the
    // per-screen sticky-corner memory for the floating/bubble window.
    private var idleTimer: Timer?
    private var idleDimmed = false
    private var idleOpacity: CGFloat = 0.35        // dim level; live from control/hud_config.json
    private var mouseMonitors: [Any] = []
    private var hudConfigSource: DispatchSourceFileSystemObject?
    private var dragPending = false                // a manual drag is in flight (suppress auto-reanchor)
    private var cornerByScreen: [CGDirectDisplayID: HUDCorner] = [:]
    private static let cornersKey = "jarvis.hud.cornerByScreen"

    func applicationDidFinishLaunching(_ n: Notification) {
        NSApp.setActivationPolicy(.accessory)   // menu-bar only, no dock, no focus theft
        buildPanels()
        buildMenuBar()
        // Engine's "gestures" events drive the checkmark + UDP listener + reticle,
        // so the menu state always tracks what the tracker is actually doing.
        engine.onGestures = { [weak self] on in self?.setGesturesActive(on) }
        buildEditMenu()          // makes Cmd+V/C/X/A work in the chat field
        installPasteMonitor()    // Cmd+V of an IMAGE → attach it
        engine.start()
        positionPanels()
        setupIdleDimming()
        NotificationCenter.default.addObserver(
            forName: NSApplication.didChangeScreenParametersNotification,
            object: nil, queue: .main) { [weak self] _ in
                Task { @MainActor in self?.positionPanels() } }
        installSignalHandlers()
    }

    // An accessory app has no menu bar, so the standard Cmd+V responder chain
    // isn't wired — that's why pasting text into the chat needed a button.
    // A minimal Edit menu restores Cmd+X/C/V/A for the text field.
    func buildEditMenu() {
        let main = NSMenu()
        let editItem = NSMenuItem()
        main.addItem(editItem)
        let edit = NSMenu(title: "Edit")
        edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
        edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
        edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
        edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
        editItem.submenu = edit
        NSApp.mainMenu = main
    }

    // Cmd+V with an IMAGE on the clipboard → paste it as an attachment (the
    // text-field responder only handles text). If it's not an image, we return
    // the event so the normal text paste proceeds.
    var pasteMonitor: Any?
    func installPasteMonitor() {
        pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] ev in
            guard ev.modifierFlags.contains(.command),
                  ev.charactersIgnoringModifiers == "v" else { return ev }
            let pb = NSPasteboard.general
            let hasImage = pb.canReadObject(forClasses: [NSImage.self], options: nil)
                && (pb.string(forType: .string)?.isEmpty ?? true)
            if hasImage {
                self?.pasteAttachment()
                return nil   // consumed
            }
            return ev
        }
    }

    // If the HUD is killed (pkill/SIGTERM) or the session ends, tear the
    // engine down too — otherwise the Python + Claude CLI orphan.
    func installSignalHandlers() {
        for sig in [SIGTERM, SIGINT, SIGHUP] {
            signal(sig, SIG_IGN)
            let src = DispatchSource.makeSignalSource(signal: sig, queue: .main)
            src.setEventHandler { [weak self] in self?.quit() }
            src.resume()
            signalSources.append(src)
        }
    }

    func applicationWillTerminate(_ n: Notification) { engine.stop() }

    func buildPanels() {
        let hudView = HUDView(
            model: model,
            onMute: { [weak self] in self?.engine.toggleMute() },
            onPower: { [weak self] in self?.togglePower() },
            onToggleWakeWord: { [weak self] on in self?.engine.setWakeWord(on) },
            onMinimize: { [weak self] in self?.hud.setIsVisible(false) },
            onQuit: { [weak self] in self?.quit() },
            onSend: { [weak self] text, atts in self?.engine.sendText(text: text, attachments: atts) },
            onAttach: { [weak self] in self?.pickFiles() },
            onPaste: { [weak self] in self?.pasteAttachment() },
            onActivateInput: { [weak self] in
                NSApp.activate(ignoringOtherApps: true)
                self?.hud.makeKeyAndOrderFront(nil)
            },
            onMemory: { [weak self] in self?.memoryPanel.open() })
        let host = NSHostingView(rootView: hudView)
        // let SwiftUI content drive the window size as transcript/agents grow
        host.sizingOptions = [.minSize, .intrinsicContentSize, .maxSize]
        hudHost = host
        hud = GlassPanel(size: NSSize(width: 392, height: 120))
        hud.contentView = host
        // Press-and-hold on EMPTY HUD space toggles bubble⇄window, reusing the
        // existing collapse/expand path. Allowed in the bubble and the floating
        // window; suppressed in the docked side-strip (drag/resize still work).
        hud.canLongPress = { [weak self] in
            guard let self else { return false }
            return self.model.collapsed || !self.model.docked
        }
        hud.onLongPress = { [weak self] in
            guard let self else { return }
            withAnimation(.spring(duration: 0.3)) { self.model.collapsed.toggle() }
            self.bumpActivity()   // long-press = interaction → full opacity + reset idle timer
        }
        hud.onLongPressBegin = { [weak self] in self?.longPressCue() }
        hud.orderFrontRegardless()
    }

    // Attach button → file picker. Activate first so the accessory app can
    // present a modal panel and take keyboard focus.
    func pickFiles() {
        NSApp.activate(ignoringOtherApps: true)
        let panel = NSOpenPanel()
        panel.allowsMultipleSelection = true
        panel.canChooseFiles = true
        panel.canChooseDirectories = false
        if panel.runModal() == .OK {
            model.attachments.append(contentsOf: panel.urls)
        }
    }

    // Paste button → pull file URLs from the clipboard, or if it holds an
    // image, write it to a temp PNG and stage that.
    func pasteAttachment() {
        let pb = NSPasteboard.general
        let urls = (pb.readObjects(forClasses: [NSURL.self]) as? [URL]) ?? []
        let fileURLs = urls.filter { $0.isFileURL }
        if !fileURLs.isEmpty {                      // real files on disk -> attach
            model.attachments.append(contentsOf: fileURLs)
        } else if let img = NSImage(pasteboard: pb), // pasted image -> attach png
                  let tiff = img.tiffRepresentation,
                  let rep = NSBitmapImageRep(data: tiff),
                  let png = rep.representation(using: .png, properties: [:]) {
            let url = FileManager.default.temporaryDirectory
                .appendingPathComponent("jarvis-paste-\(UUID().uuidString).png")
            try? png.write(to: url)
            model.attachments.append(url)
        } else if let s = pb.string(forType: .string), !s.isEmpty {
            model.draft += s                         // links / text -> into the box
        }
    }

    private func fit(_ panel: NSPanel) {
        guard let v = panel.contentView else { return }
        v.layoutSubtreeIfNeeded()
        let s = v.fittingSize
        // No-op when nothing actually changed: setContentSize triggers another
        // layout pass, so re-setting the same size on every model publish is
        // pure waste (and part of the per-keystroke thrash).
        guard s.width > 1, s.height > 1, s != v.frame.size else { return }
        panel.setContentSize(s)
    }

    // Which screen is Jarvis currently on? (his panel's center). Enables
    // pin-to-current-screen across a multi-monitor setup.
    private func currentScreen() -> NSScreen {
        let c = NSPoint(x: hud.frame.midX, y: hud.frame.midY)
        return NSScreen.screens.first { $0.frame.contains(c) }
            ?? NSScreen.main ?? NSScreen.screens[0]
    }

    func reanchor() {
        // Don't fight a manual drag in progress — snap happens on release.
        guard !dragPending else { return }
        if model.docked && !model.collapsed {
            // Side mode never dims — make sure it's at full opacity.
            hud.alphaValue = 1.0
            idleDimmed = false
            // Full-height strip pinned to the left OR right edge of the
            // screen Jarvis is currently on. Fill from the very bottom of
            // the screen up to just under the menu bar — so there's no gap
            // where the macOS Dock reserves space (visibleFrame excludes it).
            hudHost?.sizingOptions = []
            let sc = currentScreen()
            let vf = sc.visibleFrame          // excludes menu bar + Dock
            let full = sc.frame               // the whole display
            let w = min(max(model.dockWidth, 260), 760)
            let x = model.dockSide == "right" ? vf.maxX - w : vf.minX
            let topY = vf.maxY                // just under the menu bar
            let botY = full.minY              // absolute screen bottom
            hud.setFrame(NSRect(x: x, y: botY, width: w, height: topY - botY),
                         display: true, animate: false)
            return
        }
        // Floating / bubble: size panel to its SwiftUI content, then pin it to
        // the LAST corner the user dragged it to on THIS screen (default
        // bottom-right). Per-screen + persisted, so show/hide/state changes
        // always return it to his chosen corner, not a hardcoded default.
        hudHost?.sizingOptions = [.minSize, .intrinsicContentSize, .maxSize]
        fit(hud)
        let sc = currentScreen()
        let corner = cornerByScreen[displayID(of: sc)] ?? .bottomRight
        hud.setFrameOrigin(cornerOrigin(corner, size: hud.frame.size,
                                        in: sc.visibleFrame))
    }

    // Dragging the panel by its background lets the user throw it around. We
    // DEBOUNCE — snap only after the drag stops (~0.3s of no movement) so it
    // doesn't fight the drag. When DOCKED it re-docks to the nearest edge; when
    // FLOATING/BUBBLE it snaps to (and remembers) the nearest CORNER of whatever
    // screen it landed on. `snapping` guards our own setFrame retriggering the
    // observer; `dragPending` suppresses auto-reanchor mid-drag.
    private var snapping = false
    private var snapTimer: Timer?
    func windowMoved() {
        guard !snapping else { return }
        // Only react to a genuine USER drag (a mouse button is held) — never to
        // our own programmatic repositioning in reanchor(), which also fires
        // didMove but with no button down.
        guard NSEvent.pressedMouseButtons & 0x1 != 0 else { return }
        bumpActivity()            // dragging is interaction
        dragPending = true
        snapTimer?.invalidate()
        snapTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) {
            [weak self] _ in Task { @MainActor in self?.snapNow() }
        }
    }
    private func snapNow() {
        dragPending = false
        guard !snapping else { return }
        snapping = true
        defer { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { self.snapping = false } }
        let sc = currentScreen()
        let vf = sc.visibleFrame
        if model.docked && !model.collapsed {
            let side = hud.frame.midX < vf.midX ? "left" : "right"
            if side != model.dockSide { model.dockSide = side }
        } else {
            // Floating / bubble: the nearest corner becomes the new home corner
            // for this screen. Persist it so it survives relaunch.
            let corner = nearestCorner(ofWindow: hud.frame, in: vf)
            cornerByScreen[displayID(of: sc)] = corner
            saveCorners()
        }
        reanchor()
    }

    // MARK: Sticky-corner helpers

    private func displayID(of s: NSScreen) -> CGDirectDisplayID {
        (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
    }

    /// Cocoa-space origin that seats a `size` window into `corner` of `vf`
    /// (bottom-left origin: bottom == minY, top == maxY), with a small margin.
    private func cornerOrigin(_ corner: HUDCorner, size: NSSize,
                              in vf: CGRect, margin: CGFloat = 22) -> NSPoint {
        let x = corner.isLeft   ? vf.minX + margin : vf.maxX - size.width  - margin
        let y = corner.isBottom ? vf.minY + margin : vf.maxY - size.height - margin
        return NSPoint(x: x, y: y)
    }

    /// Which corner of `vf` is the window's center closest to.
    private func nearestCorner(ofWindow frame: CGRect, in vf: CGRect) -> HUDCorner {
        let left = frame.midX < vf.midX
        let bottom = frame.midY < vf.midY
        switch (left, bottom) {
        case (true, true):   return .bottomLeft
        case (false, true):  return .bottomRight
        case (true, false):  return .topLeft
        case (false, false): return .topRight
        }
    }

    private func loadCorners() {
        guard let raw = UserDefaults.standard.dictionary(forKey: Self.cornersKey) as? [String: String]
        else { return }
        var out: [CGDirectDisplayID: HUDCorner] = [:]
        for (k, v) in raw {
            if let id = UInt32(k), let c = HUDCorner(rawValue: v) { out[CGDirectDisplayID(id)] = c }
        }
        cornerByScreen = out
    }

    private func saveCorners() {
        var raw: [String: String] = [:]
        for (id, c) in cornerByScreen { raw[String(id)] = c.rawValue }
        UserDefaults.standard.set(raw, forKey: Self.cornersKey)
    }

    // MARK: Idle auto-dim

    /// True while Jarvis is actively doing something the user should keep seeing
    /// at full opacity even without touching anything (boot, thinking, speaking).
    private var isBusy: Bool {
        if model.recording || model.enrolling != nil { return true }
        switch model.state {
        case "booting", "thinking", "speaking", "restarting": return true
        default: return false
        }
    }

    private func setupIdleDimming() {
        loadCorners()
        idleOpacity = readIdleOpacity() ?? 0.35
        watchHudConfig()

        // Mouse activity over the HUD (hover / move / click / drag / scroll)
        // keeps it awake. GLOBAL catches events while another app is active
        // (the panel is non-activating); LOCAL catches them when we're key.
        let mask: NSEvent.EventTypeMask =
            [.mouseMoved, .leftMouseDown, .leftMouseDragged,
             .rightMouseDown, .otherMouseDown, .scrollWheel]
        // NSEvent monitors are delivered on the main thread, so (like
        // installPasteMonitor) we can touch main-actor state directly.
        if let g = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: {
            [weak self] _ in self?.mouseActivity()
        }) { mouseMonitors.append(g) }
        if let l = NSEvent.addLocalMonitorForEvents(matching: mask, handler: {
            [weak self] ev in self?.mouseActivity(); return ev
        }) { mouseMonitors.append(l) }

        // Any model change (new message, state change, speaking start, agent
        // chip, mute, etc.) is activity too. Throttled: this fires on EVERY
        // @Published mutation (incl. every keystroke in the chat box), and
        // bumpActivity reallocates the idle Timer each time — coalesce it.
        model.objectWillChange
            .throttle(for: .milliseconds(200), scheduler: RunLoop.main, latest: true)
            .sink { [weak self] in self?.bumpActivity() }
            .store(in: &bag)

        // Hand gestures (armed streaming) count as interaction.
        gestures.onActivity = { [weak self] in self?.bumpActivity() }

        scheduleIdleTimer()
    }

    private func mouseActivity() {
        guard hud.isVisible, hud.frame.contains(NSEvent.mouseLocation) else { return }
        bumpActivity()
    }

    /// Any interaction: restore full opacity instantly and reset the 10s timer.
    private func bumpActivity() {
        restoreFullOpacity()
        scheduleIdleTimer()
    }

    /// Brief scale blip when a long-press toggle is recognized (subtle feedback).
    private func longPressCue() {
        model.longPressCue = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak self] in
            self?.model.longPressCue = false
        }
    }

    private func restoreFullOpacity() {
        guard idleDimmed || hud.alphaValue < 0.999 else { return }
        idleDimmed = false
        NSAnimationContext.runAnimationGroup { ctx in
            ctx.duration = 0.18
            hud.animator().alphaValue = 1.0
        }
    }

    private func scheduleIdleTimer() {
        idleTimer?.invalidate()
        idleTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) {
            [weak self] _ in Task { @MainActor in self?.dimIfIdle() }
        }
    }

    /// After 10s idle, fade to the configured idle level — but NEVER in side
    /// mode, while busy (speaking/thinking/…), or while hidden. If any of those
    /// hold, just check again later.
    private func dimIfIdle() {
        guard hud.isVisible, !model.docked, !isBusy else {
            scheduleIdleTimer()
            return
        }
        idleDimmed = true
        NSAnimationContext.runAnimationGroup { ctx in
            ctx.duration = 0.25
            hud.animator().alphaValue = clampedIdleOpacity()
        }
    }

    private func clampedIdleOpacity() -> CGFloat { max(0.1, min(1.0, idleOpacity)) }

    // MARK: Idle-opacity config (control/hud_config.json → "idle_opacity")

    /// Read control/hud_config.json → "idle_opacity". Accepts a 0.0–1.0 fraction
    /// OR an integer/number percent (e.g. 30 → 0.30). Clamped to [0.1, 1.0].
    private func readIdleOpacity() -> CGFloat? {
        let f = Paths.control.appendingPathComponent("hud_config.json")
        guard let data = try? Data(contentsOf: f),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else { return nil }
        let raw = obj["idle_opacity"]
        var frac: Double
        if let d = raw as? Double { frac = d }
        else if let i = raw as? Int { frac = Double(i) }
        else if let n = raw as? NSNumber { frac = n.doubleValue }
        else if let s = raw as? String, let d = Double(s) { frac = d }
        else { return nil }
        if frac > 1.0 { frac /= 100.0 }        // percent form (e.g. 30 → 0.30)
        return CGFloat(max(0.1, min(1.0, frac)))
    }

    /// Watch the control/ directory; re-read idle_opacity on any write and apply
    /// it live. (Kqueue on the dir so the file needn't exist up front.)
    private func watchHudConfig() {
        let dir = Paths.control
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let fd = open(dir.path, O_EVTONLY)
        guard fd >= 0 else { return }
        let src = DispatchSource.makeFileSystemObjectSource(
            fileDescriptor: fd, eventMask: .write, queue: .main)
        src.setEventHandler { [weak self] in
            Task { @MainActor in self?.reloadIdleOpacity() }
        }
        src.setCancelHandler { close(fd) }
        src.resume()
        hudConfigSource = src
    }

    private func reloadIdleOpacity() {
        guard let v = readIdleOpacity(), abs(v - idleOpacity) > 0.001 else { return }
        idleOpacity = v
        // If we're currently dimmed (and dimming applies), retint live.
        if idleDimmed && !model.docked {
            NSAnimationContext.runAnimationGroup { ctx in
                ctx.duration = 0.25
                hud.animator().alphaValue = clampedIdleOpacity()
            }
        }
    }

    func positionPanels() {
        reanchor()
        // This runs again on every screen-parameter change (sleep/wake, monitor
        // plug, resolution). The subscriptions below must be wired ONCE — without
        // this guard each call added another reanchor sink + didMove observer to
        // `bag`, so after N screen changes one keystroke ran reanchor N+1 times
        // (a full layout each). That's the session-long degradation → hang.
        guard !panelsWired else { return }
        panelsWired = true
        // Reanchor whenever the HUD's content size can actually change. Throttled,
        // and driven off objectWillChange (coalesced) rather than firing a full
        // window re-measure synchronously on every keystroke.
        model.objectWillChange
            .throttle(for: .milliseconds(80), scheduler: RunLoop.main, latest: true)
            .sink { [weak self] in self?.reanchor() }
            .store(in: &bag)
        // Dragged to another screen/side while docked → snap on release.
        NotificationCenter.default.addObserver(
            forName: NSWindow.didMoveNotification, object: hud, queue: .main) {
                [weak self] _ in Task { @MainActor in self?.windowMoved() } }
    }

    func buildMenuBar() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusItem.button?.image = NSImage(systemSymbolName: "waveform.circle.fill",
                                           accessibilityDescription: "Claude")
        let menu = NSMenu()
        menu.addItem(withTitle: "Show / Hide HUD", action: #selector(toggleHUD), keyEquivalent: "h")
        menu.addItem(withTitle: "Start / Stop Assistant", action: #selector(togglePower), keyEquivalent: "s")
        menu.addItem(withTitle: "Mute / Unmute Mic", action: #selector(mute), keyEquivalent: "m")
        menu.addItem(withTitle: "What Jarvis Remembers", action: #selector(toggleMemory), keyEquivalent: "r")
        menu.addItem(withTitle: "My Files", action: #selector(toggleStorage), keyEquivalent: "f")
        gesturesItem = menu.addItem(withTitle: "Hand Gestures",
                                    action: #selector(toggleGestures), keyEquivalent: "g")
        menu.addItem(.separator())
        menu.addItem(withTitle: "Quit", action: #selector(quit), keyEquivalent: "q")
        menu.items.forEach { $0.target = self }
        statusItem.menu = menu
    }

    @objc func toggleHUD() {
        let show = !hud.isVisible
        hud.setIsVisible(show)
        if show { bumpActivity() }   // re-showing = full opacity + fresh idle timer
    }
    @objc func togglePower() { engine.isRunning ? engine.stop() : engine.start() }
    @objc func mute() { engine.toggleMute() }
    @objc func toggleMemory() { memoryPanel.toggle() }
    @objc func toggleStorage() { storagePanel.toggle() }
    @objc func toggleGestures() {
        let on = !gestures.enabled
        engine.setGestures(on)     // write control/gestures.json (python tracker)
        setGesturesActive(on)      // camera + listener; engine's event re-confirms
    }

    /// Request camera access, invoking `done` on the main actor. Returns true
    /// immediately if already authorized; prompts on first use; false if denied.
    private func ensureCamera(_ done: @escaping (Bool) -> Void) {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            done(true)
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { granted in
                DispatchQueue.main.async { done(granted) }
            }
        default:   // .denied / .restricted
            done(false)
        }
    }

    private func showCameraDeniedAlert() {
        let a = NSAlert()
        a.messageText = "Camera access needed for hand gestures"
        a.informativeText = "Enable Jarvis under System Settings → Privacy & "
            + "Security → Camera, then turn Hand Gestures on again."
        a.addButton(withTitle: "Open Settings")
        a.addButton(withTitle: "Cancel")
        if a.runModal() == .alertFirstButtonReturn {
            NSWorkspace.shared.open(URL(string:
                "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera")!)
        }
    }
    func setGesturesActive(_ on: Bool) {
        // Camera sits behind a GUI-app permission: the tracker is a grandchild
        // process, so macOS only surfaces the prompt when WE (the app) request
        // it — the child then inherits Jarvis's grant. This is the common sink
        // for BOTH the menu item and Jarvis's own voice command (which writes
        // control/gestures.json → comes back as the engine's "gestures" event),
        // so requesting here covers both. The tracker retries the camera ~20s,
        // so it picks up the grant the moment Ahmed allows.
        if on {
            ensureCamera { [weak self] granted in
                if !granted { self?.showCameraDeniedAlert() }
            }
        }
        gestures.setEnabled(on)
        gesturesItem?.state = on ? .on : .off
    }
    @objc func quit() { engine.stop(); NSApp.terminate(nil) }
}

@main
enum Main {
    @MainActor static func main() {
        let app = NSApplication.shared
        let delegate = Delegate()
        app.delegate = delegate
        app.run()
    }
}
