//
//  BackupService.swift
//  Confab
//
//  iCloud Drive backup of non-encrypted settings (Option C, v1)
//
// 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
import os

// MARK: - BackupManifest

struct BackupManifest: Codable {
    let version: Int
    let createdAt: String
    let appVersion: String
    let credentialsIncluded: Bool
    let settings: [String: String]
    let credentials: [String: String]?
}

// MARK: - FavoritesPayload

/// Small standalone file (separate from the full settings backup) so starring a model
/// syncs near-instantly across machines instead of waiting for the next full backup.
struct FavoritesPayload: Codable {
    let updatedAt: String
    let ids: [String]
}

// MARK: - BackupService

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

    private let log = Logger(subsystem: Log.subsystem, category: "backup")

    /// Whether iCloud Drive is available on this machine
    var iCloudAvailable: Bool = false

    /// Date of the last backup file on disk (from file attributes)
    var lastBackupDate: Date?

    /// URL of the last backup file
    var lastBackupURL: URL?

    private var autoBackupTimer: Timer?

    // Keys excluded from backup — encrypted_ prefix + internal migration flags
    private static let excludedKeys: Set<String> = [
        "encrypted_openrouterAPIKey",
        "encrypted_anthropicAPIKey",
        "encrypted_openaiAPIKey",
        "encrypted_googleAPIKey",
        "encrypted_googleSearchEngineID",
        "encrypted_anytypeMcpAPIKey",
        "encrypted_paperlessAPIToken",
        "encrypted_syncUsername",
        "encrypted_syncPassword",
        "encrypted_syncAccessToken",
        "encrypted_emailUsername",
        "encrypted_emailPassword",
        "_migrated",
        "_keychain_migrated",
    ]

    private init() {
        checkForExistingBackup()
        startAutoBackupTimer()
    }

    // MARK: - iCloud Path Resolution

    private func resolveBackupDirectory() -> URL {
        let home = FileManager.default.homeDirectoryForCurrentUser
        let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
        if FileManager.default.fileExists(atPath: icloudRoot.path) {
            let icloudOAI = icloudRoot.appendingPathComponent("oAI")
            try? FileManager.default.createDirectory(at: icloudOAI, withIntermediateDirectories: true)
            return icloudOAI
        }
        // Fallback: Downloads
        return FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
            ?? FileManager.default.temporaryDirectory
    }

    func checkForExistingBackup() {
        let home = FileManager.default.homeDirectoryForCurrentUser
        let icloudRoot = home.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs")
        iCloudAvailable = FileManager.default.fileExists(atPath: icloudRoot.path)

        let dir = resolveBackupDirectory()
        let fileURL = dir.appendingPathComponent("oai_backup.json")
        if FileManager.default.fileExists(atPath: fileURL.path),
           let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
           let modified = attrs[.modificationDate] as? Date {
            lastBackupDate = modified
            lastBackupURL = fileURL
        }
    }

    // MARK: - Export

    /// Export all non-encrypted settings to iCloud Drive (or Downloads).
    /// Returns the URL where the file was written.
    @discardableResult
    func exportSettings() async throws -> URL {
        // Load raw settings from DB
        guard let allSettings = try? DatabaseService.shared.loadAllSettings() else {
            throw BackupError.databaseReadFailed
        }

        // Filter out excluded keys
        let filtered = allSettings.filter { !Self.excludedKeys.contains($0.key) }

        // Build manifest
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime]
        let manifest = BackupManifest(
            version: 1,
            createdAt: formatter.string(from: Date()),
            appVersion: appVersion(),
            credentialsIncluded: false,
            settings: filtered,
            credentials: nil
        )

        let encoder = JSONEncoder()
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        let data = try encoder.encode(manifest)

        let dir = resolveBackupDirectory()
        let fileURL = dir.appendingPathComponent("oai_backup.json")
        try data.write(to: fileURL, options: .atomic)

        log.info("Backup written to \(fileURL.path, privacy: .public) (\(filtered.count) settings)")

        await MainActor.run {
            self.lastBackupDate = Date()
            self.lastBackupURL = fileURL
        }

        return fileURL
    }

    // MARK: - Import

    /// Restore settings from a backup JSON file.
    func importSettings(from url: URL) async throws {
        let data = try Data(contentsOf: url)

        let decoder = JSONDecoder()
        let manifest: BackupManifest
        do {
            manifest = try decoder.decode(BackupManifest.self, from: data)
        } catch {
            throw BackupError.invalidFormat(error.localizedDescription)
        }

        guard manifest.version == 1 else {
            throw BackupError.unsupportedVersion(manifest.version)
        }

        // Write each setting to the database
        for (key, value) in manifest.settings {
            DatabaseService.shared.setSetting(key: key, value: value)
        }

        // Refresh in-memory cache
        SettingsService.shared.reloadFromDatabase()

        log.info("Restored \(manifest.settings.count) settings from backup (v\(manifest.version))")
    }

    // MARK: - Favorite Models Sync

    private func favoritesFileURL() -> URL {
        resolveBackupDirectory().appendingPathComponent("oai_favorites.json")
    }

    /// Write the current local favorites to iCloud Drive. Called whenever a favorite is toggled.
    func pushFavorites() async {
        let settings = SettingsService.shared
        let payload = FavoritesPayload(
            updatedAt: settings.favoriteModelsUpdatedAt,
            ids: settings.favoriteModelIds.sorted()
        )
        guard let data = try? JSONEncoder().encode(payload) else { return }
        try? data.write(to: favoritesFileURL(), options: .atomic)
        log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud")
    }

    /// Outcome of reconciling local favorites against the iCloud copy. Pure result type --
    /// see `decideFavoritesSync` for the actual decision logic.
    enum FavoritesSyncDecision: Equatable {
        case applyRemote(ids: Set<String>, updatedAt: String)
        case pushLocal
        case mergeAndPush(ids: Set<String>, updatedAt: String)
        case noop
    }

    /// Last-write-wins reconciliation, pulled out of `syncFavoritesOnLaunch` so the branching
    /// logic (5 cases: remote absent / remote newer / local newer / tied-but-different /
    /// tied-and-identical) can be tested without touching iCloud Drive or SettingsService.
    static func decideFavoritesSync(
        localIds: Set<String>,
        localUpdatedAt: String,
        remote: FavoritesPayload?,
        now: Date
    ) -> FavoritesSyncDecision {
        guard let remote else {
            return .pushLocal
        }
        if remote.updatedAt > localUpdatedAt {
            return .applyRemote(ids: Set(remote.ids), updatedAt: remote.updatedAt)
        } else if localUpdatedAt > remote.updatedAt {
            return .pushLocal
        } else if Set(remote.ids) != localIds {
            // Tied timestamps (both empty is the common case: two machines that already had
            // favorites before this sync feature existed, neither ever bumped the timestamp).
            // Union rather than silently dropping one side's favorites.
            let merged = localIds.union(remote.ids)
            return .mergeAndPush(ids: merged, updatedAt: ISO8601DateFormatter().string(from: now))
        }
        return .noop
    }

    /// Reconcile local favorites with the iCloud copy using last-write-wins (by timestamp).
    /// Call on launch (and optionally on app-become-active) to pick up changes from other machines.
    func syncFavoritesOnLaunch() async {
        let settings = SettingsService.shared
        let fileURL = favoritesFileURL()

        let remote: FavoritesPayload? = {
            guard let data = try? Data(contentsOf: fileURL) else { return nil }
            return try? JSONDecoder().decode(FavoritesPayload.self, from: data)
        }()
        let localIds = settings.favoriteModelIds

        switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) {
        case .pushLocal:
            await pushFavorites()
        case .applyRemote(let ids, let updatedAt):
            settings.favoriteModelIds = ids
            settings.favoriteModelsUpdatedAt = updatedAt
            log.info("Applied \(ids.count) favorite model(s) from iCloud (remote was newer)")
        case .mergeAndPush(let ids, let updatedAt):
            settings.favoriteModelIds = ids
            settings.favoriteModelsUpdatedAt = updatedAt
            await pushFavorites()
            log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote?.ids.count ?? 0) remote")
        case .noop:
            break
        }
    }

    // MARK: - Automatic Backup

    private static let dailyInterval: TimeInterval = 24 * 3600
    private static let weeklyInterval: TimeInterval = 7 * 24 * 3600

    /// Checked once at launch and hourly thereafter while the app is running.
    private func startAutoBackupTimer() {
        Task { await checkAndPerformAutoBackupIfDue() }
        autoBackupTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
            Task { await self?.checkAndPerformAutoBackupIfDue() }
        }
    }

    /// Whether an automatic backup should run now, given the chosen frequency and the last
    /// known backup time. Pulled out of `checkAndPerformAutoBackupIfDue` for testability --
    /// "manual" is never due, a missing last-backup-date is always due, otherwise it's due
    /// once the elapsed time reaches the frequency's interval.
    static func isBackupDue(frequency: String, lastBackupDate: Date?, now: Date) -> Bool {
        let interval: TimeInterval
        switch frequency {
        case "daily": interval = dailyInterval
        case "weekly": interval = weeklyInterval
        default: return false // "manual" — user triggers backups by hand
        }
        guard let last = lastBackupDate else { return true }
        return now.timeIntervalSince(last) >= interval
    }

    func checkAndPerformAutoBackupIfDue() async {
        let frequency = SettingsService.shared.autoBackupFrequency
        guard frequency == "daily" || frequency == "weekly" else { return }

        checkForExistingBackup()
        guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else {
            return // not due yet
        }

        log.info("Automatic backup (\(frequency, privacy: .public)) is due — backing up now")
        _ = try? await exportSettings()
    }

    // MARK: - Helpers

    private func appVersion() -> String {
        Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
    }
}

// MARK: - BackupError

enum BackupError: LocalizedError {
    case databaseReadFailed
    case invalidFormat(String)
    case unsupportedVersion(Int)

    var errorDescription: String? {
        switch self {
        case .databaseReadFailed:
            return "Could not read settings from the database."
        case .invalidFormat(let detail):
            return "The backup file is not valid: \(detail)"
        case .unsupportedVersion(let v):
            return "Backup version \(v) is not supported by this version of Confab."
        }
    }
}
