import SwiftUI
import Foundation
import NucleicCore
import NucleicProtocol

/// 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
    @AppStorage(StreakBadge.showKey) private var showStreak = true
    /// 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

    /// 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 {
        @Bindable var store = store
        // 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)

                    // Mesh session sync: fold connected peer Macs' counts into the union, so every
                    // Mac in the mesh shows the same totals (`meshDashboard` == local when solo).
                    let stats = store.meshDashboard
                    HStack(spacing: 16) {
                        StatCard(value: stats.projects, label: "Projects", icon: "folder")
                        StatCard(value: stats.chats, label: "Chats", icon: "bubble.left.and.bubble.right")
                        StatCard(value: stats.activeChats, label: "Active", icon: "bolt")
                        StatCard(value: stats.messages, label: "Messages", icon: "paperplane")
                        StatCard(
                            values: TokenCount.adaptiveCandidates(stats.tokens),
                            label: "Tokens", icon: "text.word.spacing",
                            help: "\(stats.tokens.formatted()) tokens used across all chats")
                    }

                    TodoSection()
                }
                .padding(32)
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .scrollContentBackground(.hidden)
            Divider()
            // The dashboard's pinned "start a new chat" bar — the same composer the ⌘⇧N
            // floating panel presents, bound here to the persisted home draft.
            NewChatComposer(draft: $store.homeDraft)
        }
        .task { await store.loadDashboard() }
        .task { await store.loadTodos() }
    }

    /// 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)
            usageColumn(stacked: stacked)
                .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 }
        })
    }

    /// The usage column beside the activity chart: the Claude quota card, plus the Codex card
    /// stacked beneath once a Codex chat has reported usage (hidden for Claude-only users so the
    /// dashboard stays uncluttered). The height-match to the activity chart applies only to a
    /// lone Claude card shown side-by-side; when the Codex card joins, both size to their content.
    @ViewBuilder
    private func usageColumn(stacked: Bool) -> some View {
        let hasCodex = store.codexUsage != nil
        VStack(spacing: 16) {
            QuotaCard(matchHeight: (stacked || hasCodex) ? 0 : activityHeight)
            if hasCodex {
                CodexUsageCard()
            }
        }
    }

    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 var subtitle: String {
        let stats = store.meshDashboard
        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.
    /// Surfaced only in the tooltip (`helpText`), not as an on-card counter.
    var freezes: Int = 0

    /// 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 brightness and glow by the time it's fully charged. The pulse is purely
        // a brightness/glow throb — no scale or rotation — so the bolt never shifts position
        // and stays horizontally fixed rather than swinging side to side.
        let pulsing = active && charged
        // 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)
                .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 }
                // A fixed leading slot pins the bolt in place: the glyph grows with the streak
                // but its footprint doesn't, so it stays locked to the left of the count and
                // never drifts into or overlaps the "day streak" label.
                .frame(width: 32, alignment: .center)
            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)
            }
        }
        .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> = []
    /// Brightness scales for the whole grid, computed once per data change (at init). This
    /// used to be built inside the render pass, which re-derived both percentile scales on
    /// every body evaluation — including each hover enter/leave over the grid's cells.
    private let shading: ActivityShading

    init(activityByDay: [Date: Int], tokensByDay: [Date: Int] = [:], frozenDays: Set<Date> = []) {
        self.activityByDay = activityByDay
        self.tokensByDay = tokensByDay
        self.frozenDays = frozenDays
        self.shading = ActivityShading(tokensByDay: tokensByDay, messagesByDay: activityByDay)
    }

    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. `shading` is computed once at init.
        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)"
    }
}
