//
//  UpdateCheckService.swift
//  Confab
//
//  Checks for new releases on GitLab and surfaces an update badge in the footer
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.


import Foundation
#if os(macOS)
import AppKit
#endif
import Observation

/// A single Gitea release's display name + markdown body, for in-app viewing (Help menu, and the
/// "update available" alert) instead of opening the web releases page.
struct ReleaseNotes: Sendable, Equatable {
    let versionTag: String
    let title: String
    let body: String
}

enum ReleaseNotesError: Error, Sendable {
    /// No published release exists for this tag — e.g. a development build ahead of the last
    /// published version.
    case notFound
    case networkError
}

@Observable
final class UpdateCheckService {
    static let shared = UpdateCheckService()

    var updateAvailable: Bool = false
    var latestVersion: String? = nil
    var downloadURL: URL? = nil

    // Manual check state — drives the update alert in ContentView
    var isCheckingManually: Bool = false
    var manualCheckMessage: String? = nil

    private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
    private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
    private let releasesByTagBaseURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/tags/"

    private init() {}

    /// Kick off a background update check. Silently does nothing on failure.
    func checkForUpdates() {
        Task.detached(priority: .background) {
            await self.performCheck()
        }
    }

    /// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage.
    func checkForUpdatesManually() {
        guard !isCheckingManually else { return }
        isCheckingManually = true
        Task.detached(priority: .background) {
            await self.performCheck()
            let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
            await MainActor.run {
                if self.updateAvailable, let v = self.latestVersion {
                    self.manualCheckMessage = String(localized: "Version \(v) is available.")
                } else {
                    self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
                }
                self.isCheckingManually = false
            }
        }
    }

    private func performCheck() async {
        guard let url = URL(string: apiURL) else { return }

        var request = URLRequest(url: url)
        request.timeoutInterval = 10

        guard let (data, _) = try? await URLSession.shared.data(for: request) else {
            Log.ui.warning("UpdateCheck: network request failed")
            return
        }

        guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let tagName = release["tag_name"] as? String else {
            Log.ui.warning("UpdateCheck: unexpected API response — \(String(data: data, encoding: .utf8) ?? "<binary>")")
            return
        }

        // Strip leading "v" from tag (e.g. "v2.3.1" → "2.3.1")
        let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
        let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"

        // Extract direct DMG download URL from release assets
        let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
            .first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
            .flatMap { $0["browser_download_url"] as? String }
            .flatMap { URL(string: $0) }

        if isNewer(latestVer, than: currentVer) {
            await MainActor.run {
                self.latestVersion = latestVer
                self.downloadURL = dmgURL
                self.updateAvailable = true
            }
        }
    }

    /// Semantic version comparison — returns true if `version` is newer than `current`.
    private func isNewer(_ version: String, than current: String) -> Bool {
        let lhs = version.split(separator: ".").compactMap { Int($0) }
        let rhs = current.split(separator: ".").compactMap { Int($0) }
        let count = max(lhs.count, rhs.count)
        for i in 0..<count {
            let l = i < lhs.count ? lhs[i] : 0
            let r = i < rhs.count ? rhs[i] : 0
            if l > r { return true }
            if l < r { return false }
        }
        return false
    }

    /// Open the GitLab releases page in the default browser.
    func openReleasesPage() {
        #if os(macOS)
        NSWorkspace.shared.open(releasesURL)
        #endif
    }

    // MARK: - Release Notes

    private static let releaseNotesCacheKeyPrefix = "releaseNotesCache_"

    /// Fetches a specific release's title + markdown body by git tag (e.g. "v2.5.0"), for showing
    /// in-app. Checked against a small on-disk cache first — a published release's notes don't
    /// change after the fact, so there's no need to hit the network every time the same version's
    /// notes are viewed again.
    func fetchReleaseNotes(forTag tag: String) async -> Result<ReleaseNotes, ReleaseNotesError> {
        if let cached = Self.cachedReleaseNotes(forTag: tag) {
            return .success(cached)
        }

        guard let url = URL(string: releasesByTagBaseURL + tag) else {
            return .failure(.networkError)
        }
        var request = URLRequest(url: url)
        request.timeoutInterval = 10

        guard let (data, response) = try? await URLSession.shared.data(for: request) else {
            return .failure(.networkError)
        }
        if let http = response as? HTTPURLResponse, http.statusCode == 404 {
            return .failure(.notFound)
        }
        guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let tagName = release["tag_name"] as? String,
              let body = release["body"] as? String else {
            return .failure(.notFound)
        }

        let notes = ReleaseNotes(versionTag: tagName, title: release["name"] as? String ?? tagName, body: body)
        Self.cacheReleaseNotes(notes)
        return .success(notes)
    }

    private static func cachedReleaseNotes(forTag tag: String) -> ReleaseNotes? {
        guard let json = DatabaseService.shared.getSetting(key: releaseNotesCacheKeyPrefix + tag),
              let data = json.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String],
              let versionTag = obj["versionTag"], let title = obj["title"], let body = obj["body"]
        else { return nil }
        return ReleaseNotes(versionTag: versionTag, title: title, body: body)
    }

    private static func cacheReleaseNotes(_ notes: ReleaseNotes) {
        guard let data = try? JSONSerialization.data(withJSONObject: [
            "versionTag": notes.versionTag, "title": notes.title, "body": notes.body,
        ]), let json = String(data: data, encoding: .utf8) else { return }
        DatabaseService.shared.setSetting(key: releaseNotesCacheKeyPrefix + notes.versionTag, value: json)
    }
}
