import Cocoa
import Network
import UserNotifications

@main
class AppDelegate: NSObject, NSApplicationDelegate {
    static func main() {
        let app = NSApplication.shared
        let delegate = AppDelegate()
        app.delegate = delegate
        app.setActivationPolicy(.accessory)
        app.run()
    }

    // MARK: - Varaibles

    var statusItem: NSStatusItem!
    var statusMenuItem: NSMenuItem!
    var outdatedItem: NSMenuItem!
    var upgradeAllItem: NSMenuItem!
    var outdatedSubmenu: NSMenu!
    var versionItem: NSMenuItem!

    var logWindow: LogWindowController?
    var settingsWindow: SettingsWindowController?
    var logBuffer: String = ""

    var brewPath: String?

    var refreshTimer: Timer?
    /// var refreshInterval: TimeInterval = 3600
    var refreshInterval: TimeInterval {
        get {
            let saved = UserDefaults.standard.double(forKey: "refreshInterval")
            return saved > 0 ? saved : 3600 // fallback 1h si jamais défini
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "refreshInterval")
            UserDefaults.standard.synchronize()
        }
    }

    /// last known outdated list, shown in the Upgrade All confirmation
    var cachedOutdated: OutdatedPackages = .none
    var lastNotifiedOutdated: Set<String> = []

    let pathMonitor = NWPathMonitor()
    /// false until the monitor's first update, so the launch refresh waits
    /// until the network state is actually known. Main-queue only.
    var isOnline = false

    // MARK: - Menu

    func applicationDidFinishLaunching(_: Notification) {
        Settings.registerDefaults()
        UNUserNotificationCenter.current().delegate = self

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(windowWillClose(_:)),
            name: NSWindow.willCloseNotification,
            object: nil
        )

        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        statusItem.button?.imagePosition = .imageLeft // icon stays visible next to the count
        setMenuBarIcon("brewbar-uptodate")

        let menu = NSMenu()

        statusMenuItem = NSMenuItem(title: "Checking...", action: nil, keyEquivalent: "")
        menu.addItem(statusMenuItem)

        menu.addItem(NSMenuItem.separator())

        menu.addItem(NSMenuItem(title: "🔄 Refresh", action: #selector(refreshAction), keyEquivalent: "r"))
        upgradeAllItem = NSMenuItem(title: "📦 Upgrade All", action: #selector(upgradeAll), keyEquivalent: "u")
        upgradeAllItem.isHidden = true // shown once a fetch finds outdated packages
        menu.addItem(upgradeAllItem)
        outdatedItem = NSMenuItem(title: "📋 Outdated", action: nil, keyEquivalent: "")
        outdatedSubmenu = NSMenu()
        outdatedItem.submenu = outdatedSubmenu
        outdatedItem.isHidden = true // shown once a fetch finds outdated packages
        menu.addItem(outdatedItem)
        menu.addItem(NSMenuItem(title: "🧹 Cleanup", action: #selector(cleanupAction), keyEquivalent: ""))
        // isAlternate swaps this in for the item right above while ⌥ is held
        let purgeItem = NSMenuItem(title: "🧹 Cleanup (purge cache)", action: #selector(cleanupPurgeAction), keyEquivalent: "")
        purgeItem.keyEquivalentModifierMask = .option
        purgeItem.isAlternate = true
        menu.addItem(purgeItem)
        menu.addItem(NSMenuItem(title: "📜 Logs", action: #selector(showLogs), keyEquivalent: "l"))

        menu.addItem(NSMenuItem.separator())

        menu.addItem(NSMenuItem(title: "⚙️ Settings…", action: #selector(showSettings), keyEquivalent: ","))

        versionItem = NSMenuItem(title: "🏷 Brew: ...", action: nil, keyEquivalent: "")
        menu.addItem(versionItem)

        menu.addItem(NSMenuItem.separator())

        menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))

        statusItem.menu = menu

        // no refreshAll() here: the monitor fires once immediately after
        // start(), and the first online update triggers the launch refresh
        pathMonitor.pathUpdateHandler = { path in
            // updates arrive on the monitor's queue; hop to main for AppKit
            DispatchQueue.main.async {
                self.networkPathChanged(online: path.status == .satisfied)
            }
        }
        pathMonitor.start(queue: DispatchQueue(label: "fr.socheleau.BrewBar.network"))

        refreshBrewVersion()

        refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
            self.refreshAll()
        } // pour arrêter : refreshTimer?.invalidate()
    }

    // MARK: - Env func

    func setMenuBarIcon(_ name: String) {
        DispatchQueue.main.async {
            if let button = self.statusItem.button {
                if let icon = NSImage(named: name) {
                    icon.size = NSSize(width: 22, height: 22)
                    icon.isTemplate = true
                    button.image = icon
                }
            }
        }
    }

    func restartTimer() {
        refreshTimer?.invalidate() // stop running timer
        refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
            self.refreshAll()
        }
    }

    @objc func showSettings() {
        if settingsWindow == nil {
            settingsWindow = SettingsWindowController(appDelegate: self)
        }
        settingsWindow?.syncUI()
        presentWindow(of: settingsWindow)
    }

    // MARK: - Window presentation

    /// Accessory apps never appear in Cmd-Tab and their windows can open
    /// behind other apps. While one of our windows is open we temporarily
    /// become a regular app (Dock icon + Cmd-Tab entry); windowWillClose
    /// drops back to accessory once the last window is gone.
    func presentWindow(of controller: NSWindowController?) {
        NSApp.setActivationPolicy(.regular)
        controller?.showWindow(nil)
        NSApp.activate(ignoringOtherApps: true)
        controller?.window?.makeKeyAndOrderFront(nil)
    }

    @objc func windowWillClose(_: Notification) {
        // async so the closing window's isVisible has flipped to false
        DispatchQueue.main.async {
            let ourWindows = [self.logWindow?.window, self.settingsWindow?.window]
            let stillOpen = ourWindows.compactMap { $0 }.contains { $0.isVisible }
            if !stillOpen {
                NSApp.setActivationPolicy(.accessory)
            }
        }
    }

    func resolveBrewPath() -> String {
        if let cached = brewPath {
            return cached
        }

        let paths = [
            "/opt/homebrew/bin/brew",
            "/usr/local/bin/brew",
        ]

        for path in paths {
            if FileManager.default.isExecutableFile(atPath: path) {
                brewPath = path
                return path
            }
        }

        // fallback
        brewPath = "brew"
        return "brew"
    }

    /// Rewritten before each brew command so the sudo password dialog can say
    /// which command needs it. Sudo runs this script to show a GUI password
    /// prompt whenever brew needs admin rights (no terminal is attached to
    /// Process). If two brew commands overlap, the later one's text wins —
    /// harmless, since prompts realistically only appear during upgrades.
    static func writeAskpassScript(message: String) -> URL {
        let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("BrewBar", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)

        // the text lands inside shell single quotes AND an AppleScript string;
        // whitelist characters that cannot break out of either
        let safeMessage = message.filter { $0.isLetter || $0.isNumber || " ._@+=:/-".contains($0) }

        let url = dir.appendingPathComponent("askpass.sh")
        let script = """
        #!/bin/zsh
        osascript -e 'display dialog "BrewBar needs your administrator password to \(safeMessage)." default answer "" with hidden answer with title "BrewBar" with icon caution buttons {"Cancel", "OK"} default button "OK"' -e 'text returned of result'
        """
        try? script.write(to: url, atomically: true, encoding: .utf8)
        try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
        return url
    }

    /// Serial: brew commands run strictly one after another. Concurrent brew
    /// processes could each summon their own sudo password dialog (and their
    /// log output interleaved); queueing them prevents both.
    static let brewQueue = DispatchQueue(label: "fr.socheleau.BrewBar.brew")

    /// askpassMessage customizes the sudo dialog ("...password to <message>.");
    /// defaults to naming the brew command.
    func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (_ output: String, _ success: Bool) -> Void = { _, _ in }) {
        Self.brewQueue.async {
            let brew = self.resolveBrewPath()
            let cleaned = command.replacingOccurrences(of: "brew ", with: "")
            let arguments = [brew] + cleaned.split(separator: " ").map(String.init)

            self.log("→ \(arguments.joined(separator: " "))\n")

            // env resolves brew via PATH (covers the bare "brew" fallback) and
            // execs it directly — no shell, so no metacharacter interpretation
            let process = Process()
            process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
            process.arguments = arguments

            var environment = ProcessInfo.processInfo.environment
            environment["SUDO_ASKPASS"] = Self.writeAskpassScript(message: askpassMessage ?? "run: brew \(cleaned)").path
            process.environment = environment

            let pipe = Pipe()
            process.standardOutput = pipe
            process.standardError = pipe

            do {
                try process.run()
            } catch {
                self.log("ERROR: \(error)\n")
                DispatchQueue.main.async { completion("", false) }
                return
            }

            let data = pipe.fileHandleForReading.readDataToEndOfFile()
            process.waitUntilExit()

            let output = String(data: data, encoding: .utf8) ?? ""

            self.log(output)
            let success = process.terminationStatus == 0
            if !success {
                self.log("✗ exited with status \(process.terminationStatus)\n")
            }
            // completion always on main: callers update AppKit UI, which is main-thread only
            DispatchQueue.main.async { completion(output, success) }
        }
    }

    // MARK: - Actions

    @objc func refreshAction() {
        refreshAll()
    }

    @objc func cleanupAction() {
        runCleanup(purgeCache: false)
    }

    @objc func cleanupPurgeAction() {
        runCleanup(purgeCache: true)
    }

    /// --prune=all also deletes cached downloads brew would normally keep
    /// (current versions); the only cost is re-downloading at next install
    func runCleanup(purgeCache: Bool) {
        statusMenuItem.title = "Cleaning up..."
        runBrew(purgeCache ? "cleanup --prune=all" : "cleanup") { _, _ in
            self.updateStatus(count: self.cachedOutdated.count)
        }
    }

    /// Interposed between the end of an upgrade and its closing refetch;
    /// passes straight through unless the user opted in.
    func cleanupAfterUpgradeIfEnabled(completion: @escaping () -> Void) {
        guard Settings.cleanupAfterUpgrade else {
            completion()
            return
        }
        statusMenuItem.title = "Cleaning up..."
        runBrew("cleanup") { _, _ in completion() }
    }

    @objc func upgradeAll() {
        if Settings.confirmBeforeUpgradeAll {
            guard confirmUpgrade() else { return }
        }

        statusMenuItem.title = "Upgrading..."

        runBrew("update") { _, _ in
            // packages that turn outdated only after this brew update are not
            // in cachedOutdated yet; the final refetch will surface them
            let caskNames = self.cachedOutdated.casks.map(\.name)
            let upgradeCasksThenRefresh = {
                self.upgradeCasksSequentially(caskNames) {
                    self.cleanupAfterUpgradeIfEnabled {
                        // everything was just upgraded; don't chain an auto-upgrade
                        self.fetchOutdated(allowAutoUpgrade: false)
                    }
                }
            }

            if self.cachedOutdated.formulae.isEmpty {
                upgradeCasksThenRefresh()
            } else {
                self.runBrew("upgrade --formula") { _, _ in
                    upgradeCasksThenRefresh()
                }
            }
        }
    }

    /// Upgrades casks one at a time so each sudo prompt can name the app it
    /// is for — a batched "upgrade --cask" gives no per-package hook.
    func upgradeCasksSequentially(_ names: [String], completion: @escaping () -> Void) {
        guard let next = names.first else {
            completion()
            return
        }

        statusMenuItem.title = "Upgrading \(next)..."
        // an explicitly named cask upgrades even when self-updating, no --greedy needed
        runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _, _ in
            self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
        }
    }

    func confirmUpgrade() -> Bool {
        let alert = NSAlert()
        alert.alertStyle = .warning

        if cachedOutdated.isEmpty {
            alert.messageText = "Upgrade all outdated packages?"
        } else {
            alert.messageText = "Upgrade \(cachedOutdated.count) package\(cachedOutdated.count == 1 ? "" : "s")?"
        }

        var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first."
        if !cachedOutdated.isEmpty {
            info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
        }
        alert.informativeText = info

        alert.addButton(withTitle: "Upgrade")
        alert.addButton(withTitle: "Cancel")

        // accessory app: bring the alert to the front, otherwise it can appear behind other windows
        NSApp.activate(ignoringOtherApps: true)
        return alert.runModal() == .alertFirstButtonReturn
    }

    /// allowAutoUpgrade guards against an upgrade loop: packages can stay
    /// outdated after an upgrade (pinned formulae, greedy casks brew declines),
    /// so the fetch that follows an auto-upgrade must not trigger another one.
    func fetchOutdated(allowAutoUpgrade: Bool = true) {
        setMenuBarIcon("brewbar-updating")

        // --greedy also lists casks that self-update (brew skips them by default)
        let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"

        runBrew(command) { output, success in
            // parsing a failure's output would yield .none and masquerade as
            // "all up to date"; keep the cached state and its icon instead
            guard success else {
                self.statusMenuItem.title = "⚠️ Check failed — see Logs"
                self.setMenuBarIcon(self.cachedOutdated.isEmpty ? "brewbar-uptodate" : "brewbar-outdated")
                return
            }

            let outdated = BrewParser.parseOutdatedJSON(output)

            self.cachedOutdated = outdated

            // reflect what was found before any auto-upgrade starts, so the
            // menu shows the real list while the upgrade is running
            self.updateOutdatedMenu(with: outdated)
            self.updateStatus(count: outdated.count)
            self.updateMenuBarCount()

            // only trigger for the kinds that are both enabled AND outdated —
            // e.g. an outdated cask must not start a pointless formulae upgrade
            let upgradeFormulae = Settings.autoUpgradeFormulae && !outdated.formulae.isEmpty
            let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty
            if allowAutoUpgrade, upgradeFormulae || upgradeCasks {
                self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks)
                return
            }

            // notify only about what auto-upgrade didn't (or couldn't) handle
            self.notifyIfNeeded(outdated: outdated.all.map(\.name))
            if outdated.isEmpty {
                self.setMenuBarIcon("brewbar-uptodate")
            } else {
                self.setMenuBarIcon("brewbar-outdated")
            }
        }
    }

    /// Notifies only when packages appear that weren't in the last notified
    /// set — an unchanged list stays silent instead of pinging every refresh.
    func notifyIfNeeded(outdated: [String]) {
        let current = Set(outdated)
        defer { lastNotifiedOutdated = current }

        guard Settings.notifyOnNewUpdates,
              !current.subtracting(lastNotifiedOutdated).isEmpty else { return }

        let content = UNMutableNotificationContent()
        content.title = "Homebrew updates available"
        let shown = outdated.prefix(4).joined(separator: ", ")
        content.body = outdated.count > 4 ? "\(shown) and \(outdated.count - 4) more" : shown

        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
        UNUserNotificationCenter.current().add(request)
    }

    func runAutoUpgrade(formulae: Bool, casks: Bool) {
        statusMenuItem.title = "Auto-upgrading..."

        let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
        let upgradeCasksThenRefresh = {
            self.upgradeCasksSequentially(caskNames) {
                self.cleanupAfterUpgradeIfEnabled {
                    self.fetchOutdated(allowAutoUpgrade: false)
                }
            }
        }

        if formulae {
            runBrew("upgrade --formula") { _, _ in
                upgradeCasksThenRefresh()
            }
        } else {
            upgradeCasksThenRefresh()
        }
    }

    func updateOutdatedMenu(with outdated: OutdatedPackages) {
        outdatedSubmenu.removeAllItems()
        outdatedItem.isHidden = outdated.isEmpty
        upgradeAllItem.isHidden = outdated.isEmpty

        for package in outdated.all {
            let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
            item.representedObject = package.name // the title has versions; brew needs the bare name
            outdatedSubmenu.addItem(item)
        }
    }

    func updateMenuBarCount() {
        if Settings.showCountInMenuBar, !cachedOutdated.isEmpty {
            statusItem.button?.title = " \(cachedOutdated.count)"
        } else {
            statusItem.button?.title = ""
        }
    }

    func updateStatus(count: Int) {
        if count == 0 {
            statusMenuItem.title = "✅ All up to date"
        } else {
            statusMenuItem.title = "⚠️ \(count) outdated"
        }
    }

    @objc func upgradeSingle(_ sender: NSMenuItem) {
        guard let formula = sender.representedObject as? String else { return }

        runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _, _ in
            self.cleanupAfterUpgradeIfEnabled {
                self.refreshAll()
            }
        }
    }

    /// Refreshing offline would silently succeed on stale data: brew update
    /// fails but its exit code was never checked, and brew outdated compares
    /// against the local taps without touching the network — so the app
    /// claimed "all up to date" no matter what. Every offline→online
    /// transition refreshes, which also covers the refreshes skipped below.
    func networkPathChanged(online: Bool) {
        let wasOnline = isOnline
        isOnline = online

        guard online else {
            statusMenuItem.title = "📡 Offline"
            return
        }
        if !wasOnline {
            refreshAll()
        }
    }

    func refreshAll() {
        guard isOnline else {
            statusMenuItem.title = "📡 Offline"
            return
        }

        statusMenuItem.title = "Updating..."

        // NWPathMonitor can't see a network that is up but broken (captive
        // portal, dead DNS, git host down); brew update's exit code can.
        // Proceeding anyway would read stale local data and report a false OK.
        runBrew("update") { _, success in
            guard success else {
                self.statusMenuItem.title = "⚠️ Check failed — see Logs"
                return
            }
            self.fetchOutdated() // ✅ ONLY place calling outdated
        }
    }

    /// ✅ Brew version (correct)
    func refreshBrewVersion() {
        runBrew("--version") { output, _ in
            let firstLine = BrewParser.parseVersion(output)
            self.versionItem.title = "🏷 \(firstLine)"
        }
    }

    // MARK: - Logs

    /// oldest content is dropped past this size, or the buffer grows forever
    /// and every log() re-renders an ever-larger string into the text view
    let maxLogLength = 100_000

    func log(_ text: String) {
        DispatchQueue.main.async {
            self.logBuffer += text
            if self.logBuffer.count > self.maxLogLength {
                self.logBuffer = String(self.logBuffer.suffix(self.maxLogLength))
            }
            self.logWindow?.update(text: self.logBuffer)
        }
    }

    @objc func showLogs() {
        if logWindow == nil {
            logWindow = LogWindowController()
        }
        logWindow?.update(text: logBuffer)
        presentWindow(of: logWindow)
    }
}

extension AppDelegate: UNUserNotificationCenterDelegate {
    /// Without this, macOS suppresses banners while BrewBar is the active
    /// app (e.g. right after toggling the setting with the window open).
    func userNotificationCenter(_: UNUserNotificationCenter,
                                willPresent _: UNNotification,
                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
    {
        completionHandler([.banner])
    }
}
