import SwiftUI
import Combine
import Sparkle

/// Sparkle auto-update glue for the directly-distributed (non-App-Store) macOS builds, driven by a
/// fully custom, *headless* user driver so updates never open one of Sparkle's stock windows.
///
/// There are two surfaces, and they share one updater:
///   • **Background updates → sidebar banner.** A scheduled check that finds an update parks the
///     decision (we hold Sparkle's reply) and publishes `sidebar`, which lights an unobtrusive
///     strip at the top of the sidebar (`UpdateSidebarBanner`). One click downloads and relaunches
///     into the new version — no window at any step.
///   • **Manual "Check for Updates…" → Settings ▸ General.** A user-initiated check publishes
///     `manual` (checking / up-to-date / found / downloading / error) for an inline row in the
///     General tab. A manual check never touches the sidebar.
///
/// The updater only runs when the build embeds an appcast feed (`SUFeedURL`, injected per channel
/// by `scripts/package-app.sh`). A bare `swift run` dev build or the ad-hoc local channel ships no
/// feed, so the updater is never started and both surfaces stay hidden — nothing tries (and fails)
/// to reach a feed during development. Updates are delivered as the signed, notarized DMGs the
/// release pipeline publishes, described by the channel's appcast (see BUILD.md "Auto-update").
@MainActor
final class AppUpdater: NSObject, ObservableObject, SPUUserDriver {
    /// The sidebar banner's state (background-discovered updates only). `.none` ⇒ no banner.
    enum SidebarState: Equatable {
        case none
        case available(version: String)
        /// Downloading/extracting. `fraction == 0` renders as an indeterminate "Updating…".
        case downloading(fraction: Double)
        case relaunching
    }

    /// The Settings ▸ General "Updates" row state (user-initiated checks only).
    enum ManualState: Equatable {
        case idle
        case checking
        case upToDate
        case found(version: String)
        case downloading(fraction: Double)
        case error(String)
    }

    /// Which surface the user accepted the in-flight install from, so progress/relaunch callbacks
    /// (which carry no `userInitiated` flag) update the right published state.
    private enum Surface { case sidebar, settings }

    /// True when this build embeds an appcast feed (a packaged beta/rc/stable). Gates starting the
    /// updater and showing either surface.
    let isConfigured: Bool

    /// Drives the sidebar banner.
    @Published private(set) var sidebar: SidebarState = .none
    /// Drives the Settings ▸ General "Updates" row.
    @Published private(set) var manual: ManualState = .idle
    /// Mirrors `SPUUpdater.canCheckForUpdates` so the Settings button disables itself while a check
    /// is already in flight or the updater isn't ready.
    @Published private(set) var canCheckForUpdates = false

    private var updater: SPUUpdater?
    private var cancellables: Set<AnyCancellable> = []

    /// Sparkle's pending decision from `showUpdateFound`, held until the user clicks to update.
    private var updateReply: ((SPUUserUpdateChoice) -> Void)?
    /// Where the active install is being shown, set when the user accepts.
    private var activeSurface: Surface?
    /// Download accounting for a determinate progress fraction.
    private var expectedLength: UInt64 = 0
    private var receivedLength: UInt64 = 0

    override init() {
        isConfigured = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil
        super.init()
        guard isConfigured else { return }
        // We are the user driver, so Sparkle routes every interaction through this object's
        // SPUUserDriver methods instead of opening its standard windows.
        let updater = SPUUpdater(
            hostBundle: .main, applicationBundle: .main, userDriver: self, delegate: nil)
        self.updater = updater
        do {
            // Schedules background checks per SUEnableAutomaticChecks; surfaces found updates
            // through `showUpdateFound...` below.
            try updater.start()
        } catch {
            // A misconfigured feed shouldn't take down the app — just leave the updater dormant.
            self.updater = nil
            return
        }
        updater.publisher(for: \.canCheckForUpdates)
            .receive(on: RunLoop.main)
            .sink { [weak self] in self?.canCheckForUpdates = $0 }
            .store(in: &cancellables)
    }

    // MARK: - User intents

    /// Backs the Settings ▸ General "Check for Updates…" button.
    func checkForUpdates() { updater?.checkForUpdates() }

    /// Backs the sidebar banner: accept the parked background update and start installing.
    func installSidebarUpdate() {
        guard case .available = sidebar, let reply = updateReply else { return }
        updateReply = nil
        activeSurface = .sidebar
        sidebar = .downloading(fraction: 0)
        reply(.install)
    }

    /// Backs the Settings "Update Now" button when a manual check found an update.
    func installManualUpdate() {
        guard case .found = manual, let reply = updateReply else { return }
        updateReply = nil
        activeSurface = .settings
        manual = .downloading(fraction: 0)
        reply(.install)
    }

    // MARK: - Progress routing

    private func setProgress(_ fraction: Double) {
        switch activeSurface {
        case .sidebar: sidebar = .downloading(fraction: fraction)
        case .settings: manual = .downloading(fraction: fraction)
        case nil: break
        }
    }

    private func resetActive() {
        activeSurface = nil
        updateReply = nil
        expectedLength = 0
        receivedLength = 0
    }

    // MARK: - SPUUserDriver

    func show(
        _ request: SPUUpdatePermissionRequest,
        reply: @escaping (SUUpdatePermissionResponse) -> Void
    ) {
        // SUEnableAutomaticChecks is set in Info.plist, so Sparkle won't normally ask — but answer
        // sensibly if it ever does: keep automatic checks on, send no system profile.
        reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
    }

    func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
        // Only ever a manual check (Settings button) reaches here.
        manual = .checking
    }

    func showUpdateFound(
        with appcastItem: SUAppcastItem,
        state: SPUUserUpdateState,
        reply: @escaping (SPUUserUpdateChoice) -> Void
    ) {
        // Information-only updates can't be installed; just dismiss (rare, used as a kill switch).
        guard !appcastItem.isInformationOnlyUpdate else {
            reply(.dismiss)
            if state.userInitiated { manual = .idle }
            return
        }
        // Park the decision — don't reply until the user clicks. Route by who asked.
        updateReply = reply
        let version = appcastItem.displayVersionString
        if state.userInitiated {
            manual = .found(version: version)
        } else {
            sidebar = .available(version: version)
        }
    }

    func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {}

    func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {}

    func showUpdateNotFoundWithError(_ error: Error) async {
        // Only a manual check surfaces "no update"; tell the user they're current.
        manual = .upToDate
    }

    func showUpdaterError(_ error: Error) async {
        if activeSurface == .sidebar {
            sidebar = .none
        } else {
            manual = .error("Update failed: \(error.localizedDescription)")
        }
        resetActive()
    }

    func showDownloadInitiated(cancellation: @escaping () -> Void) {
        expectedLength = 0
        receivedLength = 0
        setProgress(0)
    }

    func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
        expectedLength = expectedContentLength
        receivedLength = 0
    }

    func showDownloadDidReceiveData(ofLength length: UInt64) {
        receivedLength += length
        guard expectedLength > 0 else { return }
        setProgress(min(1, Double(receivedLength) / Double(expectedLength)))
    }

    func showDownloadDidStartExtractingUpdate() {
        // Flip to indeterminate while extracting/installing.
        setProgress(0)
    }

    func showExtractionReceivedProgress(_ progress: Double) {
        setProgress(progress)
    }

    func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
        // No second prompt — go straight to install + relaunch.
        if activeSurface == .sidebar { sidebar = .relaunching }
        reply(.install)
    }

    func showInstallingUpdate(
        withApplicationTerminated applicationTerminated: Bool,
        retryTerminatingApplication: @escaping () -> Void
    ) {
        // Headless: the app is about to quit and relaunch into the new version. Nothing to show.
        if activeSurface == .sidebar { sidebar = .relaunching }
    }

    func showUpdateInstalledAndRelaunched(_ relaunched: Bool) async {
        // Reached only if the updater process outlives the app, which it doesn't here.
    }

    func dismissUpdateInstallation() {
        // Sparkle is tearing the session down (finished or aborted). Clear both surfaces.
        if activeSurface == .sidebar { sidebar = .none }
        if activeSurface == .settings, case .downloading = manual { manual = .idle }
        resetActive()
    }
}
