import SwiftUI
import Foundation
import NucleicCore

/// The home dashboard shown when no chat is selected: a greeting, rollup stats,
/// and a GitHub-style activity grid of usage over the last several weeks.
struct HomeView: View {
    @Environment(AppStore.self) private var store
    @Environment(\.appPalette) private var palette
    /// Persisted so the home chat bar reopens on the last project you picked.
    @AppStorage("nucleic.home.lastProjectID") private var lastProjectID = ""
    @AppStorage(StreakBadge.showKey) private var showStreak = true
    @AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
    /// Drives the composer's accent: the chat bar themes lavender for a Nucleic Control project,
    /// matching how that project's own session is themed (read straight from the same setting
    /// `RootView` uses to build palettes).
    @AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
    @State private var composerHeight = ChatInputField.restingHeight
    /// Files/images staged in the home composer (attach button, paste, or drag) to send with the
    /// first message of the new chat; materialized into its worktree once it's created.
    @State private var attachments: [ComposerAttachment] = []
    /// True while a file/image drag hovers the home composer, to light up the drop zone.
    @State private var composerDropTargeted = false
    @State private var showingAddProject = false
    @State private var modelOverride: String?
    @State private var effortOverride: String?
    @State private var branches: [String] = []
    @State private var selectedBranch: String?
    @State private var useWorktree = true
    /// nil until the user explicitly toggles, so the button reflects the app default.
    @State private var autoOverride: Bool?
    @State private var shipOverride: Bool?
    /// Measured height of the activity column, mirrored onto the usage card so the two
    /// sit at the same height (the card's content is shorter, so it stretches to match).
    @State private var activityHeight: CGFloat = 0
    /// Measured width of the activity/usage row, so it can reflow from side-by-side to
    /// a vertical stack once there isn't room to hold both columns.
    @State private var dashboardWidth: CGFloat = 0
    /// Below this content width the usage card drops beneath the activity card instead
    /// of being squeezed up over it.
    private let dashboardStackThreshold: CGFloat = 600

    /// Width reserved for the send button, so the bottom controls row can be
    /// inset to line up with the text field's trailing edge.
    private let sendButtonWidth: CGFloat = 28

    private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }

    /// Current activity streak with streak freezes folded in (see `StreakState`): the run of
    /// consecutive days with at least one message, where a banked freeze (one earned per five
    /// days) bridges a missed day. Drives the badge and tells the grid which missed days a
    /// freeze kept alive (rendered blue). Derived from the same activity data as the grid.
    private var streakState: StreakState {
        StreakState.compute(activityByDay: store.activityByDay)
    }

    private var firstName: String {
        if let first = NSFullUserName().split(separator: " ").first, !first.isEmpty {
            return String(first)
        }
        let login = NSUserName()
        return login.isEmpty ? "there" : login
    }

    var body: some View {
        // Computed once per render and threaded down, so the badge and the grid agree on the
        // streak and which days a freeze bridged without recomputing it several times.
        let streak = streakState
        return VStack(spacing: 0) {
            ScrollView {
                VStack(alignment: .leading, spacing: 28) {
                    HStack(alignment: .center, spacing: 16) {
                        VStack(alignment: .leading, spacing: 4) {
                            Text("Hi, \(firstName)")
                                .font(.system(size: 36, weight: .bold))
                            Text(subtitle).foregroundStyle(.secondary)
                        }
                        if showStreak {
                            Spacer(minLength: 16)
                            StreakBadge(days: streak.streak, freezes: streak.availableFreezes)
                        }
                    }

                    // Offers to ride out a provider outage on a comparable model from another
                    // provider (and to switch back on recovery). Hidden unless the default
                    // model's provider is reporting an incident or a failover is in effect.
                    ModelFailoverBanner()

                    dashboardRow(frozenDays: streak.frozenDays)

                    HStack(spacing: 16) {
                        StatCard(value: store.dashboard.projects, label: "Projects", icon: "folder")
                        StatCard(value: store.dashboard.chats, label: "Chats", icon: "bubble.left.and.bubble.right")
                        StatCard(value: store.dashboard.activeChats, label: "Active", icon: "bolt")
                        StatCard(value: store.dashboard.messages, label: "Messages", icon: "paperplane")
                        StatCard(
                            values: TokenCount.adaptiveCandidates(store.dashboard.tokens),
                            label: "Tokens", icon: "text.word.spacing",
                            help: "\(store.dashboard.tokens.formatted()) tokens used across all chats")
                    }

                    TodoSection()
                }
                .padding(32)
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .scrollContentBackground(.hidden)
            Divider()
            chatBar
        }
        .task { await store.loadDashboard() }
        .task { await store.loadTodos() }
        .task(id: selectedProject?.id) { await loadBranches() }
        .sheet(isPresented: $showingAddProject) { AddProjectSheet() }
    }

    /// Activity and usage cards: side by side when there's room, but once the row
    /// narrows past `dashboardStackThreshold` the usage card drops below the activity
    /// card rather than being squeezed up over it.
    private func dashboardRow(frozenDays: Set<Date>) -> some View {
        let stacked = dashboardWidth > 0 && dashboardWidth < dashboardStackThreshold
        let layout = stacked
            ? AnyLayout(VStackLayout(alignment: .leading, spacing: 28))
            : AnyLayout(HStackLayout(alignment: .top, spacing: 28))
        return layout {
            activityColumn(frozenDays: frozenDays)
            // Only mirror the activity height side-by-side; stacked, the card sizes
            // to its own content at full width.
            QuotaCard(matchHeight: stacked ? 0 : activityHeight)
                .frame(maxWidth: .infinity, alignment: .top)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(GeometryReader { proxy in
            Color.clear
                .onAppear { dashboardWidth = proxy.size.width }
                .onChange(of: proxy.size.width) { _, width in dashboardWidth = width }
        })
    }

    private func activityColumn(frozenDays: Set<Date>) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Activity").font(.headline)
            ActivityGrid(
                activityByDay: store.activityByDay, tokensByDay: store.tokensByDay,
                frozenDays: frozenDays)
            HStack(spacing: 5) {
                Text("Less").font(.caption2).foregroundStyle(.secondary)
                ForEach(0..<4, id: \.self) { level in
                    RoundedRectangle(cornerRadius: 2)
                        .fill(palette.activity(level: level))
                        .frame(width: 11, height: 11)
                }
                Text("More").font(.caption2).foregroundStyle(.secondary)
                // A streak freeze covered a missed day, keeping the run alive — set apart from
                // the activity ramp so the blue reads as "saved", not "more active".
                Spacer().frame(width: 12)
                RoundedRectangle(cornerRadius: 2)
                    .fill(palette.frozen)
                    .frame(width: 11, height: 11)
                Text("Frozen").font(.caption2).foregroundStyle(.secondary)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(GeometryReader { proxy in
            Color.clear
                .onAppear { activityHeight = proxy.size.height }
                .onChange(of: proxy.size.height) { _, height in activityHeight = height }
        })
    }

    private func loadBranches() async {
        guard let project = selectedProject else { branches = []; return }
        branches = await store.branches(in: project)
        if let current = selectedBranch, !branches.contains(current) { selectedBranch = nil }
    }

    /// Base branch a new chat would start from: the explicit pick, else the
    /// project's default branch.
    private var effectiveBranch: String {
        selectedBranch ?? selectedProject?.defaultBranch.value ?? "main"
    }

    /// The project a new chat would start in: the last-picked project (persisted),
    /// else the store's current/first project.
    private var selectedProject: Project? {
        if !lastProjectID.isEmpty, let project = store.project(ProjectID(rawValue: lastProjectID)) {
            return project
        }
        return store.currentProject
    }

    /// A composer pinned to the bottom of the dashboard: pick a project (or add a
    /// new one) and start a chat in it directly from home.
    private var chatBar: some View {
        @Bindable var store = store
        return VStack(spacing: 8) {
            HStack(spacing: 8) {
                Menu {
                    ForEach(store.projects) { project in
                        Button(project.name) { lastProjectID = project.id.rawValue }
                    }
                    if !store.projects.isEmpty { Divider() }
                    Button("New Project…", systemImage: "folder.badge.plus") {
                        showingAddProject = true
                    }
                } label: {
                    HStack(spacing: 4) {
                        Image(systemName: "folder")
                        Text(selectedProject?.name ?? "Select a project")
                        Image(systemName: "chevron.down").font(.caption2)
                    }
                }
                .menuStyle(.button)
                // The button menu style draws its own disclosure chevron; we supply a
                // custom-styled one in the label above, so hide the built-in to avoid
                // a double chevron.
                .menuIndicator(.hidden)
                .fixedSize()
                // nvrsion projects have no per-session base branch and no per-session worktree —
                // every session runs on the one shared workspace — so neither the branch picker
                // nor the worktree toggle has anything to control; hide both.
                if selectedProject?.nvrsionActive != true {
                    branchMenu
                    Toggle(isOn: $useWorktree) {
                        Label("Worktree", systemImage: "arrow.triangle.branch")
                    }
                    .toggleStyle(.checkbox)
                    .help(useWorktree
                        ? "Run on an isolated worktree branched from \(effectiveBranch)."
                        : "Run directly in the main checkout on \(effectiveBranch) — no isolation.")
                }
                Spacer()
                // Provider service status (Claude / OpenAI / xAI) — a glanceable health pill,
                // expanding to the current incidents on click/hover. Trailing-inset so its right
                // edge lines up with the text field's trailing edge (matching the controls row).
                StatusFeedIndicator(palette: composerPalette)
            }
            .font(.callout)
            // Past the text field's trailing edge (sendButtonWidth + 8), plus an optical nudge
            // left so the pill doesn't read as hugging the right edge.
            .padding(.trailing, sendButtonWidth + 20)
            // Staged attachments (attach button / paste / drag), shown above the field.
            if !attachments.isEmpty {
                ComposerAttachmentBar(attachments: $attachments)
                    .padding(.trailing, sendButtonWidth + 8)
            }
            HStack(alignment: .bottom, spacing: 8) {
                ChatInputField(
                    text: $store.homeDraft, placeholder: "Start a new chat…",
                    submitMode: submitMode, isEnabled: selectedProject != nil,
                    height: $composerHeight, onSend: start,
                    onAttach: { attachments.append(contentsOf: $0) },
                    onDropTargetedChanged: { composerDropTargeted = $0 })
                    .frame(height: composerHeight)
                    .padding(8)
                    .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
                    // A persistent lavender ring cues a Nucleic Control project. When Orchestra
                    // is on, its own purple glow owns the ring, so don't double it up.
                    .overlay {
                        if isControlProject && !isOrchestra {
                            RoundedRectangle(cornerRadius: 8)
                                .strokeBorder(composerPalette.accent.opacity(0.55), lineWidth: 1)
                                .allowsHitTesting(false)
                        }
                    }
                    // Light up the field's border while a file/image drag hovers it.
                    .overlay {
                        if composerDropTargeted {
                            RoundedRectangle(cornerRadius: 8)
                                .strokeBorder(composerPalette.accent, lineWidth: 2)
                                .allowsHitTesting(false)
                        }
                    }
                    .orchestraGlow(active: isOrchestra)
                Button(action: start) {
                    SubmitKeyIcon(mode: submitMode)
                }
                .buttonStyle(.plain)
                .frame(width: sendButtonWidth)
                .help(submitMode.detail)
                .disabled(selectedProject == nil || !canStart)
            }
            HStack(spacing: 8) {
                autoToggle
                // Shipping implies Auto, so the Ship toggle is only meaningful when Auto
                // is on. It slides out from behind the Auto button when Auto is enabled
                // and tucks back under it when Auto is turned off. Under nvrsion the trunk is
                // promoted at the project level, so a per-chat Merge toggle is meaningless
                // and hidden entirely.
                if effectiveAuto && !nvrsionActive {
                    shipToggle
                        .transition(.move(edge: .leading).combined(with: .opacity))
                }
                // Attach files/images. Sits with the left-hand controls (not beside the field) so
                // it never indents the composer.
                ComposerAttachButton(isEnabled: selectedProject != nil) { picked in
                    attachments.append(contentsOf: picked)
                }
                Spacer()
                modelMenu
                effortMenu
            }
            .animation(.easeInOut(duration: 0.25), value: effectiveAuto)
            .font(.callout)
            .padding(.trailing, sendButtonWidth + 8)
        }
        .padding(12)
    }

    // Effective concrete values for the chat about to be started: the user's
    // override, or the app default (never shows "Default").
    private var effectiveModel: String { modelOverride ?? store.defaultModel ?? ModelCatalog.fallbackModel }
    // Clamp to what the chosen model supports, so a stored "max" can't survive onto a Codex model.
    private var effectiveEffort: String {
        let raw = ModelCatalog.clampedEffort(
            effortOverride ?? store.defaultEffort ?? ModelCatalog.fallbackEffort, for: effectiveModel)
        // Orchestra requires Nucleic Control; off-control (e.g. an override carried over when the
        // project picker changes) it falls back to its underlying level, so the menu never shows
        // it active — and a started chat never carries it — where it can't run.
        if ModelCatalog.isOrchestra(raw), !isControlProject {
            return OrchestrationMode.resolvedEffort(raw) ?? ModelCatalog.fallbackEffort
        }
        return raw
    }
    private var defaultModel: String { store.defaultModel ?? ModelCatalog.fallbackModel }
    private var defaultEffort: String { store.defaultEffort ?? ModelCatalog.fallbackEffort }

    /// Menu item: checkmark on the selected value, a "house" on the app default.
    @ViewBuilder
    private func choiceLabel(_ display: String, badge: String? = nil, isSelected: Bool, isDefault: Bool) -> some View {
        let title = Text(display)
            + (badge.map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
            + (isDefault ? Text("  (default)") : Text(""))
        if isSelected {
            Label { title } icon: { Image(systemName: "checkmark") }
        } else if isDefault {
            Label { title } icon: { Image(systemName: "house") }
        } else {
            title
        }
    }

    private var modelMenu: some View {
        Menu {
            // Group the SKUs by provider (Claude, GPT, Grok), set apart with a divider.
            ForEach(Array(ModelCatalog.modelGroups.enumerated()), id: \.offset) { index, group in
                if index > 0 { Divider() }
                ForEach(group, id: \.self) { sku in
                    Button { modelOverride = sku } label: {
                        choiceLabel(ModelCatalog.displayName(sku),
                                    badge: ModelCatalog.contextBadge(for: sku),
                                    isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
                    }
                }
            }
        } label: {
            Text("Model: \(ModelCatalog.displayName(effectiveModel))")
                + (ModelCatalog.contextBadge(for: effectiveModel).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
        }
        .menuStyle(.button)
        .fixedSize()
        .help("Model for the new chat")
    }

    /// Whether the chat about to start runs in orchestra (orchestration) mode.
    private var isOrchestra: Bool { ModelCatalog.isOrchestra(effectiveEffort) }

    /// Whether the selected project is under Nucleic Control — the gate for Orchestra and the
    /// cue for the composer's lavender accent.
    private var isControlProject: Bool { selectedProject?.isNucleicControlled == true }

    /// Whether the selected project is governed by nvrsion (shared trunk). When it is, the new
    /// chat lands on the shared trunk and merges by project-level promotion, so a per-chat Merge
    /// toggle is meaningless and hidden — mirrors the in-session composer.
    private var nvrsionActive: Bool { selectedProject?.nvrsionActive == true }

    /// The accent palette for the chat bar: lavender when the selected project is Nucleic
    /// Control (matching how that project's own session is themed), the app default (teal)
    /// otherwise. Recomputed as the project picker changes, so the accent tracks the selection.
    private var composerPalette: AppPalette {
        AppPalette.make(ColorVisionMode(rawValue: colorVisionRaw) ?? .standard, controlled: isControlProject)
    }

    private var effortMenu: some View {
        Menu {
            ForEach(ModelCatalog.efforts(for: effectiveModel), id: \.self) { level in
                Button { effortOverride = level } label: {
                    choiceLabel(ModelCatalog.effortDisplayName(level),
                                isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
                }
            }
            // Orchestra sits below the API levels, set apart: it isn't an effort level but an
            // orchestration mode (xhigh + standing consent to fan out to subagents). It's a
            // Nucleic Control capability, so it's only selectable for a Control project — shown
            // but disabled (with the reason) otherwise so the requirement is discoverable.
            Divider()
            Button { effortOverride = ModelCatalog.orchestraEffort } label: {
                orchestraMenuItem(available: isControlProject)
            }
            .disabled(!isControlProject)
            .help(isControlProject ? ModelCatalog.orchestraBlurb : ModelCatalog.orchestraRequiresControlHelp)
        } label: {
            if isOrchestra {
                OrchestraEffortLabel()
            } else {
                Text("\(ModelCatalog.effortNoun(for: effectiveModel)): \(ModelCatalog.effortDisplayName(effectiveEffort))")
            }
        }
        .menuStyle(.button)
        .fixedSize()
        .help(isOrchestra ? ModelCatalog.orchestraBlurb : "Reasoning effort for the new chat")
    }

    /// The Orchestra row in an effort menu: a sparkles glyph (a checkmark when it's the selected
    /// mode), "Orchestra", and either the "(default)" suffix, or — when unavailable because the
    /// project isn't under Nucleic Control — a grayed "Requires Nucleic Control" note.
    @ViewBuilder
    private func orchestraMenuItem(available: Bool) -> some View {
        let selected = isOrchestra
        let isDefault = ModelCatalog.isOrchestra(defaultEffort)
        Label {
            Text("Orchestra")
                + (available
                    ? (isDefault ? Text("  (default)") : Text(""))
                    : Text("  — \(ModelCatalog.orchestraRequiresControlNote)").foregroundColor(.secondary))
        } icon: {
            Image(systemName: selected ? "checkmark" : OrchestraStyle.symbol)
        }
    }

    /// Auto-approve mode for the chat about to be started; defaults to the app-wide setting
    /// until the user toggles it, but is forced on for a Nucleic Control project — those chats
    /// run autonomously and Auto is locked on there. Mirrors the in-session button.
    private var effectiveAuto: Bool { isControlProject || (autoOverride ?? store.defaultAuto) }

    private var autoToggle: some View {
        // Auto is locked on for Nucleic Control projects (nvrsion can't function without it), so
        // disable the toggle there. Off-control it stays a normal toggle.
        let locked = isControlProject
        let lockedHelp = "Auto-approve is always on for Nucleic Control chats — they run autonomously."
        return Button {
            autoOverride = !effectiveAuto
        } label: {
            Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
                .foregroundStyle(effectiveAuto ? composerPalette.accent : Color.secondary)
        }
        .buttonStyle(.bordered)
        .fixedSize()
        .disabled(locked)
        .help(locked
            ? lockedHelp
            : (effectiveAuto
                ? "Auto-approve mode on: the new chat auto-approves safe actions; destructive ones still ask."
                : "Manual approvals: every gated tool in the new chat asks first."))
        // macOS suppresses .help on disabled controls, so overlay a transparent hit area to
        // carry the explanation when Auto is locked on under Nucleic Control.
        .overlay {
            if locked {
                Color.clear
                    .contentShape(Rectangle())
                    .help(lockedHelp)
            }
        }
    }

    /// Whether autoship can be enabled for the new chat: only Nucleic Control projects may
    /// autoship (the hardened, sandboxed path). Mirrors the in-session gate.
    private var shipAvailable: Bool { selectedProject?.isNucleicControlled == true }

    /// Autoship for the chat about to be started; defaults to the app-wide setting, but is
    /// clamped off for a non-Control project so the UI never promises a merge Core won't run.
    private var effectiveShip: Bool { (shipOverride ?? store.defaultAutoShip) && shipAvailable }

    private var shipToggle: some View {
        Button {
            guard shipAvailable else { return }
            shipOverride = !effectiveShip
        } label: {
            Label("Merge", systemImage: effectiveShip ? "shippingbox.fill" : "shippingbox")
                .foregroundStyle(effectiveShip ? composerPalette.accent : Color.secondary)
        }
        .buttonStyle(.bordered)
        .fixedSize()
        .disabled(!shipAvailable)
        .help(shipAvailable
            ? (effectiveShip
                ? "Autoship on: when the new chat's agent finishes, squash-merge its branch into the destination branch via the merge queue (keeps Auto on)."
                : "Autoship off: finished work waits for a manual merge.")
            : "Autoship requires Nucleic Control — clone or move this project under Nucleic Control to enable it.")
        // macOS suppresses .help tooltips on disabled controls, so overlay an enabled,
        // transparent hit area to carry the explanation when Merge is grayed out off-control.
        .overlay {
            if !shipAvailable {
                Color.clear
                    .contentShape(Rectangle())
                    .help("Autoship/automerge is only available for Nucleic Control projects — clone or move this project under Nucleic Control to enable it.")
            }
        }
    }

    private var branchMenu: some View {
        Menu {
            ForEach(branches, id: \.self) { name in
                Button { selectedBranch = name } label: {
                    choiceLabel(name, isSelected: name == effectiveBranch,
                                isDefault: name == selectedProject?.defaultBranch.value)
                }
            }
        } label: {
            // Mirror the project selector to its left (icon + name + chevron) so the
            // two read as the same control; a plain Text label rendered as a near-
            // invisible white field against the chat bar.
            HStack(spacing: 4) {
                Image(systemName: "arrow.triangle.branch")
                Text(effectiveBranch)
                Image(systemName: "chevron.down").font(.caption2)
            }
        }
        .menuStyle(.button)
        // See the project selector above: hide the built-in disclosure chevron so
        // the custom one in the label isn't doubled up.
        .menuIndicator(.hidden)
        .fixedSize()
        .disabled(branches.isEmpty)
        .help("Base branch the new chat starts from")
    }

    /// Whether the new chat can start: a typed prompt, or staged attachments on their own (an
    /// attachment-only first message is allowed — the references become its body).
    private var canStart: Bool {
        !store.homeDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
    }

    private func start() {
        let trimmed = store.homeDraft.trimmingCharacters(in: .whitespacesAndNewlines)
        guard let project = selectedProject, !trimmed.isEmpty || !attachments.isEmpty else { return }
        // Hand the staged attachments to the start Task and clear the composer immediately.
        let staged = attachments
        store.homeDraft = ""
        attachments = []
        let text = SingularityPreparation.prepare(trimmed)
        let base = GitRef(effectiveBranch)
        let worktree = useWorktree
        let auto = effectiveAuto
        let ship = effectiveShip
        Task {
            // Read each attachment's bytes off the main thread — media can be large.
            let pending = await Task.detached { staged.compactMap { try? $0.pending() } }.value
            await store.startChat(
                in: project, message: text, model: effectiveModel, effort: effectiveEffort,
                base: base, useWorktree: worktree, auto: auto, autoShip: ship, attachments: pending)
        }
    }

    private var subtitle: String {
        let stats = store.dashboard
        if stats.chats == 0 {
            return "No chats yet — pick a project below and start one from the chat bar."
        }
        let projectWord = stats.projects == 1 ? "project" : "projects"
        return "\(stats.activeChats) active of \(stats.chats) chats across \(stats.projects) \(projectWord)."
    }
}

/// A day-streak indicator aligned to the greeting: a lightning bolt and the run length.
/// The bolt charges hotter — bigger, brighter, with a stronger electric glow — as
/// the streak lengthens. Dims to grey when the streak is broken (0 days).
struct StreakBadge: View {
    static let showKey = "nucleic.home.showStreak"
    let days: Int
    /// Streak freezes in reserve (one earned per five days), each able to cover a missed day.
    /// Shown as a small iced-blue snowflake tally beside the count when any are banked.
    var freezes: Int = 0

    @Environment(\.appPalette) private var palette
    /// Drives the repeating charge pulse; toggled on appear so the animation runs forever.
    @State private var charged = false

    /// 0 for a one-day streak, ramping to 1 around three weeks — how charged to glow.
    private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }

    /// Electric gradient from a hot white tip down to a deep electric blue base. Short
    /// streaks read as a muted steel-blue spark; long ones as a vivid cyan→blue arc.
    private var boltGradient: LinearGradient {
        func lerp(_ lo: Double, _ hi: Double) -> Double { lo + (hi - lo) * intensity }
        let tip = Color(hue: lerp(0.55, 0.50), saturation: lerp(0.40, 0.70), brightness: 1.0)
        let mid = Color(hue: lerp(0.58, 0.54), saturation: lerp(0.70, 0.95), brightness: 1.0)
        let base = Color(hue: lerp(0.62, 0.60), saturation: lerp(0.85, 1.0), brightness: lerp(0.85, 1.0))
        return LinearGradient(colors: [tip, mid, base], startPoint: .top, endPoint: .bottom)
    }

    /// One pulse cycle: a lazy ~1.5s throb for a fresh streak, accelerating to a frantic
    /// ~0.35s flicker once fully charged — the higher the streak, the more electric.
    private var pulsePeriod: Double { 1.5 - 1.15 * intensity }

    var body: some View {
        let active = days > 0
        // Pulse amplitudes scale with the streak: a barely-there flutter early, a hard
        // strobe of scale, brightness, rotation and glow by the time it's fully charged.
        let pulsing = active && charged
        let scaleAmp = 0.04 + 0.16 * intensity
        let tiltAmp = 1.5 + 6.0 * intensity      // degrees of jitter
        // The bolt never fades back to base: it rests brighter than base and pulses
        // brighter still, so the animation only ever lights it up further.
        let restBright = active ? 0.08 + 0.10 * intensity : 0
        let peakBright = active ? 0.22 + 0.45 * intensity : 0
        // Glow likewise rests boosted and crests stronger — never collapsing to base.
        let glowFactor = active ? (pulsing ? 1.4 + 1.5 * intensity : 1.2 + 0.4 * intensity) : 1
        return HStack(spacing: 8) {
            Image(systemName: "bolt.fill")
                // Grows from ~22pt to ~32pt as the charge intensifies.
                .font(.system(size: 22 + 10 * intensity))
                .foregroundStyle(active ? AnyShapeStyle(boltGradient) : AnyShapeStyle(Color.secondary))
                .brightness(pulsing ? peakBright : restBright)
                .scaleEffect(pulsing ? 1 + scaleAmp : 1)
                .rotationEffect(.degrees(pulsing ? tiltAmp : 0))
                .shadow(color: active ? Color(hue: 0.56, saturation: 0.85, brightness: 1.0).opacity((0.30 + 0.50 * intensity) * glowFactor) : .clear,
                        radius: active ? (1 + 7 * intensity) * glowFactor : 0)
                .animation(active ? .easeInOut(duration: pulsePeriod).repeatForever(autoreverses: true) : .default,
                           value: pulsing)
                .onAppear { charged = true }
            HStack(alignment: .firstTextBaseline, spacing: 6) {
                Text("\(days)")
                    .font(.system(size: 26, weight: .semibold).monospacedDigit())
                Text("day streak")
                    .font(.system(size: 26, weight: .semibold).monospacedDigit())
                    .foregroundStyle(.secondary)
            }
            // Banked freezes: a small iced-blue snowflake tally, shown only when the streak is
            // live and at least one freeze is in reserve.
            if active && freezes > 0 {
                HStack(spacing: 3) {
                    Image(systemName: "snowflake")
                    Text("\(freezes)").monospacedDigit()
                }
                .font(.system(size: 15, weight: .semibold))
                .foregroundStyle(palette.frozen)
            }
        }
        .opacity(active ? 1 : 0.55)
        .padding(.horizontal, 16)
        .padding(.vertical, 10)
        .background(AppTheme.surface, in: .rect(cornerRadius: 12))
        .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
        .help(helpText)
    }

    /// Tooltip: the streak length plus how freezes stand — how many are banked, or how soon
    /// the next one is earned — so the mechanic is discoverable from the badge alone.
    private var helpText: String {
        guard days > 0 else { return "No active streak — send a message today to start one." }
        let base = "\(days)-day activity streak — consecutive days with at least one message, "
            + "with streak freezes bridging any missed day."
        if freezes > 0 {
            let s = freezes == 1 ? "" : "s"
            return base + " \(freezes) freeze\(s) banked — each covers one missed day."
        }
        let toNext = StreakState.freezeEveryDays - (days % StreakState.freezeEveryDays)
        let dayWord = toNext == 1 ? "day" : "days"
        return base + " Earn a freeze every \(StreakState.freezeEveryDays) days — \(toNext) \(dayWord) to the next."
    }
}

private struct StatCard: View {
    /// Candidate renderings of the value, widest first: the card shows the fullest one its width
    /// allows (via `ViewThatFits`) and falls back to the narrowest. A single entry for the plain
    /// integer cards; several for the token card, so a wide card shows more digits than "1.6B".
    let values: [String]
    let label: String
    let icon: String
    /// Help text shown on hover — used to spell out an abbreviated value (e.g. the exact token
    /// count behind "12.3K"). Nil for the plain integer cards, whose value is already exact.
    var help: String?

    init(value: Int, label: String, icon: String) {
        self.init(values: [value.formatted()], label: label, icon: icon)
    }

    init(value: String, label: String, icon: String, help: String? = nil) {
        self.init(values: [value], label: label, icon: icon, help: help)
    }

    init(values: [String], label: String, icon: String, help: String? = nil) {
        self.values = values.isEmpty ? [""] : values
        self.label = label
        self.icon = icon
        self.help = help
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Image(systemName: icon).foregroundStyle(.secondary)
            // Show the fullest value the card's width fits, shrinking toward the compact form when
            // narrow; ViewThatFits renders the last candidate if none fit, so it never overflows.
            ViewThatFits(in: .horizontal) {
                ForEach(Array(values.enumerated()), id: \.offset) { _, value in
                    Text(value).font(.system(size: 26, weight: .semibold).monospacedDigit())
                        .lineLimit(1)
                }
            }
            Text(label).font(.caption).foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.horizontal, 16)
        .padding(.vertical, 10)
        .background(AppTheme.surface, in: .rect(cornerRadius: 12))
        .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
        .help(help ?? "")
    }
}

/// Squares for the last `weeks` weeks, columns = weeks (old → new), rows = Sun…Sat,
/// shaded by that day's token usage — or, for days with no recorded usage, its message count
/// (see `ActivityShading`).
struct ActivityGrid: View {
    @Environment(\.appPalette) private var palette
    let activityByDay: [Date: Int]
    /// Tokens used per day — the primary intensity signal. Days absent here fall back to their
    /// `activityByDay` message count, so older transcripts still shade sensibly.
    var tokensByDay: [Date: Int] = [:]
    /// Missed days a streak freeze kept alive — drawn in the frozen blue instead of the
    /// empty-day gray, so a bridged gap reads as "saved" rather than skipped.
    var frozenDays: Set<Date> = []
    private let cell: CGFloat = 16
    private let gap: CGFloat = 3
    private let minWeeks = 16
    private var step: CGFloat { cell + gap }
    private var gridHeight: CGFloat { cell * 7 + gap * 6 }

    /// The square the pointer is currently over — its day plus where it sits in the grid, so
    /// the floating tooltip can be parked against that exact cell. Nil when the pointer is
    /// outside the grid.
    @State private var hovered: HoveredCell?

    private struct HoveredCell: Equatable {
        let date: Date
        let count: Int
        let tokens: Int
        let isFrozen: Bool
        let column: Int
        let row: Int
    }

    var body: some View {
        GeometryReader { geo in
            let weeks = max(minWeeks, Int((geo.size.width + gap) / (cell + gap)))
            let width = geo.size.width
            grid(weeks: weeks)
                // Four anchored overlays let the bubble pick the corner that keeps it on-grid:
                // top rows drop the tip below the square, lower rows raise it above; cells past
                // the midline grow leftward, the rest rightward. Anchoring to a known edge means
                // no measured tooltip size is needed to position it.
                .overlay(alignment: .topLeading)     { tooltip(.topLeading, width: width) }
                .overlay(alignment: .topTrailing)    { tooltip(.topTrailing, width: width) }
                .overlay(alignment: .bottomLeading)  { tooltip(.bottomLeading, width: width) }
                .overlay(alignment: .bottomTrailing) { tooltip(.bottomTrailing, width: width) }
        }
        .frame(height: gridHeight)
    }

    private func grid(weeks: Int) -> some View {
        let calendar = Calendar.current
        let today = calendar.startOfDay(for: Date())
        let weekday = calendar.component(.weekday, from: today)  // 1 = Sun … 7 = Sat
        let startOfThisWeek = calendar.date(byAdding: .day, value: -(weekday - 1), to: today)!
        let gridStart = calendar.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfThisWeek)!
        // Brightness is relative to *this user's* activity, with outliers excluded so a few
        // marathon days don't crush the everyday range. Shades by tokens-per-day, falling back to
        // message count for days without recorded usage. Computed once for the whole grid.
        let shading = ActivityShading(tokensByDay: tokensByDay, messagesByDay: activityByDay)

        return HStack(alignment: .top, spacing: gap) {
            ForEach(0..<weeks, id: \.self) { column in
                VStack(spacing: gap) {
                    ForEach(0..<7, id: \.self) { row in
                        let date = calendar.date(byAdding: .day, value: column * 7 + row, to: gridStart)!
                        cellView(date: date, today: today, shading: shading, column: column, row: row)
                    }
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    private func cellView(date: Date, today: Date, shading: ActivityShading, column: Int, row: Int) -> some View {
        let isFuture = date > today
        let isFrozen = !isFuture && frozenDays.contains(date)
        let count = activityByDay[date] ?? 0
        let tokens = tokensByDay[date] ?? 0
        let fill = isFuture ? Color.clear
            : (isFrozen ? palette.frozen
                : palette.activity(intensity: shading.intensity(tokens: tokens, messages: count)))
        let isHovered = hovered?.date == date
        return RoundedRectangle(cornerRadius: 2)
            .fill(fill)
            .frame(width: cell, height: cell)
            // A faint outline marks the square the tooltip is describing.
            .overlay(RoundedRectangle(cornerRadius: 2)
                .strokeBorder(Color.primary.opacity(isHovered ? 0.35 : 0), lineWidth: 1))
            .accessibilityLabel(isFuture ? "" : Self.tooltipText(date: date, count: count, tokens: tokens, isFrozen: isFrozen))
            .onHover { inside in
                guard !isFuture else { return }
                if inside {
                    hovered = HoveredCell(date: date, count: count, tokens: tokens, isFrozen: isFrozen, column: column, row: row)
                } else if hovered?.date == date {
                    hovered = nil
                }
            }
    }

    /// The floating tooltip for the hovered square, rendered only by the anchored overlay whose
    /// corner keeps the bubble on-grid (see the overlay comment in `body`).
    @ViewBuilder
    private func tooltip(_ corner: Alignment, width: CGFloat) -> some View {
        if let h = hovered {
            let below = h.row <= 1
            let trailing = (CGFloat(h.column) * step + cell / 2) > width / 2
            let wantCorner: Alignment = below
                ? (trailing ? .topTrailing : .topLeading)
                : (trailing ? .bottomTrailing : .bottomLeading)
            if corner == wantCorner {
                // Leading corners anchor the bubble's left edge to the cell's left; trailing
                // corners anchor its right edge to the cell's right. Below corners sit just under
                // the cell; above corners (bottom-anchored) just over it.
                let x = trailing ? CGFloat(h.column) * step + cell - width
                                 : CGFloat(h.column) * step
                let y = below ? CGFloat(h.row) * step + cell + gap
                              : CGFloat(h.row) * step - gap - gridHeight
                bubble(h).offset(x: x, y: y).allowsHitTesting(false)
            }
        }
    }

    private func bubble(_ h: HoveredCell) -> some View {
        Text(Self.tooltipText(date: h.date, count: h.count, tokens: h.tokens, isFrozen: h.isFrozen))
            .font(.caption)
            .foregroundStyle(.primary)
            .padding(.horizontal, 8)
            .padding(.vertical, 5)
            .background(AppTheme.surface, in: .rect(cornerRadius: 6))
            .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(AppTheme.hairline, lineWidth: 1))
            .shadow(color: .black.opacity(0.18), radius: 6, y: 1)
            .fixedSize()
    }

    private static let formatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return formatter
    }()
    static func dayLabel(_ date: Date) -> String { formatter.string(from: date) }

    /// "Jun 12, 2026 · 12.3K tokens · 5 messages" — the day with its token usage (when recorded)
    /// and message count, or a streak-freeze note. Tokens lead since they drive the shade; the
    /// clause is dropped on days with no recorded usage so older days still read cleanly.
    static func tooltipText(date: Date, count: Int, tokens: Int, isFrozen: Bool) -> String {
        if isFrozen { return "\(dayLabel(date)) · Streak freeze" }
        let messages = "\(count) message\(count == 1 ? "" : "s")"
        guard tokens > 0 else { return "\(dayLabel(date)) · \(messages)" }
        return "\(dayLabel(date)) · \(TokenCount.abbreviated(tokens)) tokens · \(messages)"
    }
}
