import ActivityKit
import Foundation
import NucleicProtocol

/// Owns the one aggregate session Live Activity (UX_IOS §5.3): started when work exists,
/// updated as sessions change, ended when everything is idle or the device unpairs. State
/// flows in from `RemoteStore` on every session-list change; the widget extension renders it
/// (`SessionLiveActivity`).
@MainActor
final class LiveActivityManager {
    static let shared = LiveActivityManager()
    private init() {}

    /// How many sessions the detail rows show. The glance stays a glance — the counts and churn
    /// still summarize everything, this just bounds the per-session list.
    private static let maxLines = 3

    /// Whether the glance is currently holding the terminal "Done" summary. When work finishes while
    /// the app is away, the Activity is kept on the lock screen showing the finished sessions and
    /// *held there* — not dismissed on a timer — until the user opens the app and sees them, so a
    /// completed run isn't dropped after a few seconds unseen (UX_IOS §5.3). Set when the Done glance
    /// goes up; cleared when work resumes, or when the app foregrounds and the glance ends.
    private var showingDoneGlance = false

    private var activity: Activity<NucleicSessionAttributes>?

    /// Whether an aggregate glance is currently on screen — `RemoteStore` checks this to decide
    /// whether to fall back to a banner (no glance ⇒ the banner is the only surface).
    var hasLiveActivity: Bool { activity != nil }

    /// Whether the app is foreground, mirrored from `RemoteStore` (its `isForeground`). Drives where
    /// a "needs you" arrival is announced: **foreground** the app UI / a banner does it, so the glance
    /// updates silently; **backgrounded** there's no banner (we prefer the glance), so the update
    /// itself carries an `AlertConfiguration` (sound/haptic). Starts `true` — `onAppear` runs
    /// foreground; the background adopt path flips it. Mirrors the host's connected-vs-away gate for
    /// the pushed glance, but for the still-connected phone whose own socket is alive.
    var foreground = true {
        didSet {
            // The app just came forward and the user can now see the in-app session list — so dismiss
            // the "Done" glance we were holding on the lock screen for exactly this moment. The
            // counterpart to `finishWithDoneGlance` keeping it up while the app was away.
            if foreground, !oldValue, showingDoneGlance { end() }
        }
    }

    /// The last content we pushed. Updates that don't change it are skipped so we don't spend
    /// ActivityKit's update budget on no-ops — `sync` fires on every host message (dashboard,
    /// connectivity, pong, diff ticks…), most of which leave the aggregate identical. Burning the
    /// budget on those is exactly what makes a *real* change land late and the glance read stale.
    private var lastState: NucleicSessionAttributes.ContentState?
    /// When the last update was applied — the clock for the mid-turn churn refresh. A status change
    /// (attention-signature shift) updates immediately regardless; a churn-only change (diff totals
    /// ticking as the agent edits) only re-applies once `churnRefreshInterval` has elapsed, so a busy
    /// turn refreshes the numbers slowly instead of on every transcript delta. A passive timestamp
    /// check on the normal path — no timer sits in front of a status update.
    private var lastPushAt: Date?
    /// How slowly the mid-turn diff totals refresh while nothing else about a session changes —
    /// mirrors the host's `SyncHost.churnRefreshInterval` so the local and pushed glances agree.
    private static let churnRefreshInterval: TimeInterval = 45
    /// The newest state waiting to be applied, and the single task draining it. Coalescing to the
    /// latest through one serial task means the newest data always wins — firing an unstructured
    /// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance.
    private var pendingState: NucleicSessionAttributes.ContentState?
    /// The alert the next applied update should carry (sound/haptic when a backgrounded session newly
    /// needs the user). Latched alongside `pendingState` so an update coalesced away by a fresher one
    /// can't drop the alert; consumed (and cleared) when an update is applied.
    private var pendingAlert: AlertConfiguration?
    private var updateTask: Task<Void, Never>?

    /// Set by `RemoteStore` to ship the activity's APNS push token to the paired Macs (and to tell
    /// them it ended). The Macs use the token to keep this glance fresh over APNs while the phone
    /// is backgrounded and its sync socket is suspended (UX_IOS §5.3).
    var onPushToken: ((_ token: String, _ activityID: String) -> Void)?
    var onActivityEnded: ((_ activityID: String) -> Void)?
    /// Set by `RemoteStore` to ship this device's **push-to-start** token to the paired Macs. Unlike
    /// `onPushToken` (a per-activity update token that exists only once an Activity does), this token
    /// is device-scoped and exists before any Activity — it's what lets a Mac *create* the glance
    /// over APNs when work starts while the app is closed, so it appears without the user opening the
    /// app first (iOS 17.2+, UX_IOS §5.3).
    var onPushToStartToken: ((_ token: String) -> Void)?
    /// Streams the activity's per-activity APNS update token (it can rotate); cancelled on end.
    private var tokenObservation: Task<Void, Never>?
    /// Streams this device's push-to-start token (it can rotate). Device-scoped, so — unlike
    /// `tokenObservation` — it lives for the whole process and is never cancelled on `end()`.
    private var pushToStartObservation: Task<Void, Never>?
    /// Observes Activities that appear without us creating them — i.e. ones a Mac push-started while
    /// the app was closed — so we adopt them and forward their update token. Also process-lived.
    private var activityAdoptionObservation: Task<Void, Never>?

    /// Reconcile the Activity with the current session set.
    func sync(hostName: String, sessions: [WireSessionSummary]) {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
        let live = sessions.filter { !$0.archived }
        let running = live.filter { $0.status == .running || $0.status == .provisioning }
        let needsYou = live.filter { $0.status.needsYou($0.disposition) }

        guard !running.isEmpty || !needsYou.isEmpty else {
            finishWithDoneGlance(hostName: hostName, live: live)
            return
        }
        // Work is active again — drop any held "Done" glance so the fresh active state renders
        // instead of the finished summary (a run just resumed, or a new one started).
        showingDoneGlance = false

        // Everything in flight or waiting on the user, attention-first (approvals, then waiting
        // input, then running), freshest within a rank. This is both the detail-row source and
        // the set the aggregate churn/approval totals sum over.
        let active = live
            .filter {
                $0.status == .running || $0.status == .provisioning
                    || $0.status.needsYou($0.disposition)
            }
            .sorted(by: StatusStyle.attentionThenRecency)

        let approvals = active.reduce(0) { $0 + $1.pendingApprovalCount }
        let files = active.reduce(0) { $0 + ($1.diffStat?.filesChanged ?? 0) }
        let added = active.reduce(0) { $0 + ($1.diffStat?.added ?? 0) }
        let removed = active.reduce(0) { $0 + ($1.diffStat?.removed ?? 0) }
        let lines = active.prefix(Self.maxLines).map(Self.line(for:))

        let state = NucleicSessionAttributes.ContentState(
            runningCount: running.count,
            needsYouCount: needsYou.count,
            approvalCount: approvals,
            filesChanged: files,
            linesAdded: added,
            linesRemoved: removed,
            lines: Array(lines))

        push(state, hostName: hostName)
    }

    /// Nothing is running or waiting. If the glance was showing active work, replace it with a "Done"
    /// summary of the just-completed sessions and hold it on the lock screen until the user opens the
    /// app and sees them (UX_IOS §5.3 — a finished run should read as *done* and stay put, not vanish
    /// after a few seconds unseen). Foreground, the user is already on the in-app session list, so
    /// there's nothing to hold — dismiss. If nothing was on screen, there's nothing to close.
    private func finishWithDoneGlance(hostName: String, live: [WireSessionSummary]) {
        // Already holding the "Done" summary — leave it up until the app foregrounds, don't rebuild it.
        guard !showingDoneGlance else { return }
        // Nothing tracked on screen: only reach into ActivityKit if an untracked orphan is lingering.
        guard activity != nil else {
            if !Activity<NucleicSessionAttributes>.activities.isEmpty { end() }
            return
        }
        // Foreground: the user is already looking at the in-app session list (the glance isn't even
        // visible over the app), so there's nothing to hold for later — just dismiss.
        guard !foreground else { end(); return }
        // The sessions that just finished — completed conversational turns and finished runs. These
        // are exactly the ones the active-work filter above drops, surfaced now as `.done` rows.
        let done = live
            .filter {
                $0.status == .finished
                    || ($0.status == .awaitingInput && $0.disposition == .completed)
            }
            .sorted(by: StatusStyle.attentionThenRecency)
            .prefix(Self.maxLines)
            .map(Self.line(for:))
        // Nothing to show (e.g. the work was discarded/deleted) → just dismiss.
        guard !done.isEmpty else { end(); return }

        let state = NucleicSessionAttributes.ContentState(
            runningCount: 0, needsYouCount: 0, approvalCount: 0,
            filesChanged: 0, linesAdded: 0, linesRemoved: 0, lines: Array(done))
        push(state, hostName: hostName)
        // Hold it: the Activity stays alive showing "Done". No scheduled dismissal — `foreground`
        // flipping true (the app coming forward) is what ends it.
        showingDoneGlance = true
    }

    /// Apply `state` to the Activity — coalesced through one serial task so the newest state always
    /// wins, and gated on the attention signature so a mid-turn churn tick (which changes the diff
    /// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The
    /// fresh churn still rides along on the next status-driven push.
    private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) {
        guard activity != nil else {
            // No Activity yet — recover one that survived an app relaunch, or start a fresh one. The
            // dedup gate below only guards *updates* to a live Activity; creation must never sit
            // behind it. ActivityKit refuses a start unless the app has a foreground/background-
            // assertion window, and committing the dedup state *before* the request meant a refused
            // start left `lastState` matching the aggregate — so the next `sync` deduped the glance
            // away and it never activated until the session's state changed. Record the pushed state
            // only once an Activity actually exists; a refused start leaves `lastState` untouched so
            // the next sync simply retries creation.
            if let existing = Activity<NucleicSessionAttributes>.activities.first {
                activity = existing
                observePushToken(existing)
                // Dismiss any duplicates so only the adopted Activity renders. iOS stacks multiple
                // Activities of one type in the Dynamic Island — an orphan (from a prior launch that
                // was killed before `end()`) then shows *its* stale state in the collapsed pill while
                // expanding reveals the fresh one, and `end()` on completion would leave it lingering
                // on the last "needs attention" glance. Keep exactly one.
                endStrays(keeping: existing.id)
                lastState = state
                lastPushAt = Date()
                enqueue(state)
            } else if let started = try? Activity.request(
                // `pushType: .token` opts the activity into APNs updates — the Macs push new
                // content-state to the token so the glance stays fresh while the phone is locked.
                attributes: NucleicSessionAttributes(hostName: hostName),
                content: ActivityContent(state: state, staleDate: nil),
                pushType: .token) {
                activity = started
                observePushToken(started)
                lastState = state
                lastPushAt = Date()
            }
            return
        }

        // A live Activity exists — spend ActivityKit's update budget only when the glance actually
        // moved: immediately when the attention signature shifts (start/finish/approval/input — the
        // alert-worthy changes), otherwise on a slow cadence for mid-turn churn (diff totals ticking
        // on every transcript delta). Elapsed-time gate, not a sleeping timer — a status change is
        // never held behind it.
        let signatureChanged = state.attentionSignature != lastState?.attentionSignature
        let churnRefreshDue = lastPushAt.map {
            Date().timeIntervalSince($0) >= Self.churnRefreshInterval
        } ?? true
        guard signatureChanged || (state != lastState && churnRefreshDue) else { return }
        // When a session newly needs the user while we're backgrounded, this update carries the alert
        // (sound/haptic) — there's no banner then, we prefer the glance. Foreground, the app UI / a
        // banner announces it, so the glance updates silently. Computed against the *previous* state
        // before `lastState` is overwritten.
        let alert = foreground ? nil : Self.alert(from: lastState, to: state)
        lastState = state
        lastPushAt = Date()
        enqueue(state, alert: alert)
    }

    /// The alert an update should carry when backgrounded: a session newly needs the user. An approval
    /// (its Allow/Deny live on the glance) outranks a needs-input for the wording; a rise in neither ⇒
    /// nil (silent update). Reuses the same localized keys as the host's pushed alert so a local and a
    /// pushed alert read identically. Approvals/inputs that merely persist (or resolve) don't re-alert.
    private static func alert(
        from previous: NucleicSessionAttributes.ContentState?,
        to next: NucleicSessionAttributes.ContentState
    ) -> AlertConfiguration? {
        if next.approvalCount > (previous?.approvalCount ?? 0) {
            return AlertConfiguration(
                title: LocalizedStringResource("approval.title"),
                body: LocalizedStringResource("approval.pending"), sound: .default)
        }
        if next.needsYouCount > (previous?.needsYouCount ?? 0) {
            return AlertConfiguration(
                title: LocalizedStringResource("input.title"),
                body: LocalizedStringResource("input.pending"), sound: .default)
        }
        return nil
    }

    /// Start the process-lived observers that make push-to-start work: the device's push-to-start
    /// token (forwarded to the Macs so they can create the glance over APNs) and adoption of any
    /// Activity a Mac push-started while the app was closed. Idempotent — safe to call on every
    /// bridge setup; the guards keep a single observer each.
    func beginPushToStartObservation() {
        guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
        // Grab an activity that already exists when we start observing — e.g. one a Mac push-started
        // while the app was closed, which won't re-emit through `activityUpdates` for a late observer.
        // This is what lets a background "adopt" wake harvest and forward its update token.
        if activity == nil, let existing = Activity<NucleicSessionAttributes>.activities.first {
            adopt(existing)
        }
        if pushToStartObservation == nil {
            pushToStartObservation = Task { [weak self] in
                for await data in Activity<NucleicSessionAttributes>.pushToStartTokenUpdates {
                    let hex = data.map { String(format: "%02x", $0) }.joined()
                    self?.onPushToStartToken?(hex)
                }
            }
        }
        if activityAdoptionObservation == nil {
            activityAdoptionObservation = Task { [weak self] in
                for await activity in Activity<NucleicSessionAttributes>.activityUpdates {
                    self?.adopt(activity)
                }
            }
        }
    }

    /// Adopt an Activity we didn't create locally — almost always one a Mac push-started while the
    /// app was closed. Taking ownership wires up its update-token stream (`observePushToken`) so the
    /// Macs can keep the glance fresh the normal way, and collapses any strays to one. If we already
    /// track an Activity, leave it: the local `Activity.request` path and `endStrays` already keep a
    /// single glance, and re-adopting would just churn the token observation.
    private func adopt(_ activity: Activity<NucleicSessionAttributes>) {
        guard self.activity == nil else { return }
        self.activity = activity
        observePushToken(activity)
        endStrays(keeping: activity.id)
    }

    /// Forward the activity's APNS update token (and its rotations) to `RemoteStore`.
    private func observePushToken(_ activity: Activity<NucleicSessionAttributes>) {
        tokenObservation?.cancel()
        tokenObservation = Task { [weak self] in
            for await tokenData in activity.pushTokenUpdates {
                let hex = tokenData.map { String(format: "%02x", $0) }.joined()
                self?.onPushToken?(hex, activity.id)
            }
        }
    }

    /// Hand the newest state to the serial drainer (starting it if idle).
    private func enqueue(
        _ state: NucleicSessionAttributes.ContentState, alert: AlertConfiguration? = nil
    ) {
        pendingState = state
        if let alert { pendingAlert = alert }  // latch — a coalesced-away update must not drop it
        guard updateTask == nil else { return }  // the running drainer will pick this up
        updateTask = Task { @MainActor [weak self] in
            guard let self else { return }
            while let next = self.pendingState {
                self.pendingState = nil
                let alert = self.pendingAlert
                self.pendingAlert = nil
                await self.activity?.update(
                    ActivityContent(state: next, staleDate: nil), alertConfiguration: alert)
            }
            self.updateTask = nil
        }
    }

    /// End the Activity (all idle, or unpaired).
    func end() {
        updateTask?.cancel()
        updateTask = nil
        tokenObservation?.cancel()
        tokenObservation = nil
        showingDoneGlance = false
        pendingState = nil
        pendingAlert = nil
        lastState = nil
        lastPushAt = nil
        let tracked = activity
        self.activity = nil
        if let tracked { onActivityEnded?(tracked.id) }  // let the Macs stop pushing to this token
        // Dismiss the tracked Activity *and any strays*: ending only the one we track would leave an
        // orphan (from a prior launch killed before `end()`) rendering a stale "needs attention"
        // glance in the Dynamic Island after the work it described has finished. Clear them all so
        // "everything idle" can't get stuck on screen.
        Task {
            for activity in Activity<NucleicSessionAttributes>.activities {
                await activity.end(
                    ActivityContent(state: activity.content.state, staleDate: nil),
                    dismissalPolicy: .immediate)
            }
        }
    }

    /// Dismiss every live Activity except `keep` — collapses accidental duplicates down to one so
    /// iOS can't render a stale orphan alongside the tracked glance.
    private func endStrays(keeping keep: String) {
        for stray in Activity<NucleicSessionAttributes>.activities where stray.id != keep {
            Task {
                await stray.end(
                    ActivityContent(state: stray.content.state, staleDate: nil),
                    dismissalPolicy: .immediate)
            }
        }
    }

    // MARK: - Projection

    /// Project a wire summary into the widget's self-contained row model.
    private static func line(for s: WireSessionSummary) -> NucleicSessionAttributes.SessionLine {
        // Carry the approval id/risk only for a session awaiting a *tool approval* — the row the
        // glance renders inline Allow/Deny on (`ApproveFromActivityIntent`). An `AskUserQuestion`
        // block (pendingQuestionCount set) is excluded: it needs an answer selection the glance can't
        // collect, so it deep-links to the app's picker card, like the notification path. Other states
        // leave it nil so the row stays a plain deep-link tap.
        let approvalID = (s.status == .awaitingApproval && s.pendingQuestionCount == nil)
            ? s.firstApprovalID?.rawValue : nil
        return NucleicSessionAttributes.SessionLine(
            id: s.sessionID.rawValue,
            title: s.title.isEmpty ? s.projectName : s.title,
            project: s.projectName,
            backend: backend(s.backend),
            kind: kind(for: s),
            detail: detail(for: s),
            approvalID: approvalID,
            approvalIsHighRisk: approvalID == nil ? nil : s.firstApprovalIsHighRisk)
    }

    private static func kind(for s: WireSessionSummary) -> NucleicSessionAttributes.Kind {
        switch s.status {
        case .awaitingApproval: .approval
        case .running: .running
        case .provisioning: .provisioning
        case .awaitingInput: s.disposition == .completed ? .done : .needsInput
        case .idle: .idle
        case .finished: .done
        case .interrupted, .error: .error
        }
    }

    private static func backend(_ id: BackendID) -> NucleicSessionAttributes.Backend {
        switch id {
        case .claudeCode: .claude
        case .codex, .codexExec: .codex
        case .grok: .grok
        }
    }

    /// The compact right-aligned status for a row: the actionable ask wins (approvals, then
    /// "waiting on you"), else the worktree churn, else what the agent is up to.
    private static func detail(for s: WireSessionSummary) -> String {
        if s.status == .awaitingApproval || s.pendingApprovalCount > 0 {
            let n = max(s.pendingApprovalCount, 1)
            return "\(n) to approve"
        }
        if s.status == .awaitingInput, s.disposition != .completed {
            return "Waiting on you"
        }
        if let d = s.diffStat, d.filesChanged > 0 {
            return "\(d.filesChanged) file\(d.filesChanged == 1 ? "" : "s")  +\(d.added) −\(d.removed)"
        }
        switch s.status {
        case .provisioning: return "Starting…"
        case .running: return "Working…"
        case .awaitingInput: return "Done"
        default: return StatusStyle.label(s.status, disposition: s.disposition)
        }
    }
}
