import Foundation
import SwiftData
import Observation
import os

enum ICloudStatus: Equatable {
    case checking
    case available
    case unavailable
}

/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
/// the sole source of truth: every save/delete writes files only; the metadata
/// observer (and the connect-time reconcile) is the sole mutator of the cache.
@Observable
@MainActor
final class SyncEngine {
    nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts"

    private(set) var iCloudStatus: ICloudStatus = .checking
    private(set) var isSyncing = false

    /// Called after the cache changes (local or remote). The watch bridge uses
    /// this to push fresh state to the watch.
    var onCacheChanged: (() -> Void)?

    private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
    private let modelContainer: ModelContainer
    private var fileManager: ICloudFileManager?
    private var monitor: ICloudFileMonitor?
    private var monitorTask: Task<Void, Never>?
    private var connectAttempt = 0

    private var context: ModelContext { modelContainer.mainContext }

    init(container: ModelContainer) {
        self.modelContainer = container
    }

    // MARK: - Connection (deferred, time-boxed)

    func connect() async {
        guard iCloudStatus != .available else { return }
        connectAttempt += 1
        let attempt = connectAttempt
        iCloudStatus = .checking
        log.info("connect[\(attempt)]: resolving container \(Self.containerIdentifier, privacy: .public)")

        let url = await Task.detached {
            FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
        }.value
        guard let containerURL = url else {
            log.error("connect[\(attempt)]: ubiquity container URL is nil → unavailable (iCloud Drive off, or container not provisioned)")
            if attempt == connectAttempt { iCloudStatus = .unavailable }
            return
        }
        log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")

        let fm = ICloudFileManager(containerURL: containerURL)

        let timeout = Task { [weak self] in
            try? await Task.sleep(for: .seconds(20))
            guard let self, !Task.isCancelled else { return }
            if self.iCloudStatus == .checking, attempt == self.connectAttempt {
                self.log.error("connect[\(attempt)]: timed out after 20s — first container file op blocked (dataless container?)")
                self.iCloudStatus = .unavailable
            }
        }
        log.info("connect[\(attempt)]: preparing directories…")
        await fm.prepareDirectories()
        timeout.cancel()
        guard attempt == connectAttempt else { return }

        self.fileManager = fm
        iCloudStatus = .available
        log.info("connect[\(attempt)]: directories ready → available")
        WorkoutsModelContainer.persistCurrentIdentityToken()

        await reconcile()
        startMonitoring(documentsURL: fm.documentsURL)
        cleanupOldStubs()
    }

    // MARK: - Monitoring

    private func startMonitoring(documentsURL: URL) {
        monitorTask?.cancel()
        let monitor = ICloudFileMonitor(documentsURL: documentsURL)
        self.monitor = monitor
        monitor.start()
        monitorTask = Task { [weak self] in
            for await event in monitor.events() {
                await self?.handle(event)
            }
        }
    }

    private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async {
        switch event {
        case .added(let path), .modified(let path):
            if path.hasPrefix("Stubs/") {
                deleteCachedEntity(id: idFromStubPath(path))
            } else {
                await importFile(relativePath: path)
            }
        case .removed(let path):
            if !path.hasPrefix("Stubs/") {
                deleteCachedEntity(jsonRelativePath: path)
            }
        }
        try? context.save()
        onCacheChanged?()
    }

    /// Apply a workout received from the watch. iCloud Drive stays the source of
    /// truth (we write the file), but we also upsert the cache directly here.
    ///
    /// The phone's own edits drive a local view copy, so they don't need this — but a
    /// watch-originated change has nothing else refreshing the phone UI, and a
    /// same-process file overwrite doesn't reliably wake the `NSMetadataQuery`
    /// observer. Upserting the doc we just wrote keeps cache and file consistent (the
    /// observer re-applies idempotently if it does fire) and lets open phone screens
    /// reflect watch progress live.
    func ingestFromWatch(_ doc: WorkoutDocument) async {
        await save(workout: doc)
        CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
        try? context.save()
        onCacheChanged?()
    }

    // MARK: - Public CRUD (write path: files only)

    func save(split doc: SplitDocument) async {
        guard let fm = fileManager else { return }
        do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
        catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
        // Cache updates reactively via the monitor.
    }

    func save(workout doc: WorkoutDocument) async {
        guard let fm = fileManager else { return }
        do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) }
        catch { print("[Sync] write failed for \(doc.relativePath): \(error)") }
    }

    func delete(split: Split) async {
        await softDelete(id: split.id, kind: .split, livePath: split.jsonRelativePath)
    }

    func delete(workout: Workout) async {
        await softDelete(id: workout.id, kind: .workout, livePath: workout.jsonRelativePath)
    }

    private func softDelete(id: String, kind: Tombstone.Kind, livePath: String) async {
        guard let fm = fileManager else { return }
        let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
        do {
            try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
        } catch {
            print("[Sync] delete failed for \(id): \(error)")
        }
    }

    // MARK: - Import / reconcile

    private func importFile(relativePath: String) async {
        guard let fm = fileManager else { return }
        guard let data = try? await fm.read(relativePath: relativePath) else { return }

        if relativePath.hasPrefix("Splits/") {
            guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
            if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
            CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
        } else if relativePath.hasPrefix("Workouts/") {
            guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
            if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return }
            CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
        }
    }

    /// Full sync against the current file set — imports new/changed files and
    /// prunes entities whose file is gone or tombstoned. Runs on connect so
    /// changes accumulated while the app was closed are picked up.
    private func reconcile() async {
        guard let fm = fileManager else { return }
        isSyncing = true
        defer { isSyncing = false }

        let tombstoned = Set(await fm.listTombstones().map(\.id))
        let dataFiles = await fm.listDataFiles()

        var liveSplitIDs = Set<String>()
        var liveWorkoutIDs = Set<String>()

        for path in dataFiles {
            guard let data = try? await fm.read(relativePath: path) else { continue }
            if path.hasPrefix("Splits/") {
                guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
                if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
                CacheMapper.upsertSplit(doc, relativePath: path, into: context)
                liveSplitIDs.insert(doc.id)
            } else if path.hasPrefix("Workouts/") {
                guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
                if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue }
                CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
                liveWorkoutIDs.insert(doc.id)
            }
        }

        // Prune cache entities no longer backed by a live file.
        if let splits = try? context.fetch(FetchDescriptor<Split>()) {
            for s in splits where !liveSplitIDs.contains(s.id) { context.delete(s) }
        }
        if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
            for w in workouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
        }
        try? context.save()
        onCacheChanged?()
    }

    // MARK: - Cache deletes

    private func deleteCachedEntity(id: String) {
        if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
        if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
    }

    private func deleteCachedEntity(jsonRelativePath path: String) {
        if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
            splits.forEach(context.delete)
        }
        if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
            workouts.forEach(context.delete)
        }
    }

    private func idFromStubPath(_ path: String) -> String {
        (path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "")
    }

    // MARK: - Maintenance

    private func cleanupOldStubs() {
        guard let fm = fileManager else { return }
        Task.detached(priority: .utility) {
            let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod)
            for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff {
                try? await fm.remove(relativePath: tombstone.relativePath)
            }
        }
    }
}
