import Foundation
import Observation
import os
import SwiftData
import WatchConnectivity

/// Watch side of the iPhone↔Watch bridge. The watch never touches iCloud — it
/// keeps a local SwiftData cache fed only by application-context pushes from the
/// phone, updates it optimistically on local edits, and forwards changed workouts
/// to the phone (which is the sole writer of iCloud Drive).
@Observable
@MainActor
final class WatchConnectivityBridge: NSObject {
    private nonisolated static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "watch-bridge")

    private let container: ModelContainer
    private var session: WCSession?

    /// Last time state was received from the phone (for a sync indicator).
    private(set) var lastSyncDate: Date?

    /// True while the phone's pushes fail to decode — the two apps are on different
    /// document schemas (the phone updated first; the watch app hasn't yet). The cache is
    /// deliberately left untouched in that state, so surface it (`ActiveWorkoutGateView`)
    /// instead of silently showing stale workouts until the watch app updates.
    private(set) var schemaMismatch = false

    /// Fired after every authoritative cache mutation (a phone push applied, or the watch's
    /// own optimistic `update(workout:)`), once the write is committed. The
    /// `WorkoutSessionCoordinator` hangs off this to end the `HKWorkoutSession` from the
    /// authoritative data rather than a view observer — see `WorkoutSessionCoordinator`.
    var onWorkoutsChanged: (() -> Void)?

    /// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching
    /// run (popping out of its progress view) and blocks re-entry, so the phone owns the
    /// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID`
    /// matches a run by its workout id; `editingRoutineID` matches any run by its `routineID`.
    private(set) var editingWorkoutID: String?
    private(set) var editingRoutineID: String?

    /// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any
    /// frame we *receive*, so the two devices share one increasing per-run sequence and either
    /// side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
    /// Persisted as a high-water mark: an app relaunch resetting it to 0 would stamp frames
    /// the peer has already outranked — and with no human transition on the peer to catch
    /// the counter back up, every subsequent frame would be dropped as stale.
    private var liveVersion = UserDefaults.standard.integer(forKey: liveVersionDefaultsKey) {
        didSet { UserDefaults.standard.set(liveVersion, forKey: liveVersionDefaultsKey) }
    }

    /// The latest live-run message we haven't confirmed reached the phone (depth 1, latest-wins).
    /// Staged regardless of reachability and re-sent by `flushLive()` when reachability/activation
    /// returns, so a brief WatchConnectivity drop doesn't desync the mirror. A newer frame — or the
    /// terminal `.ended` — replaces it, so we never deliver stale state. Frames carry an absolute
    /// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
    private var pendingLive: PendingLive?

    /// In-flight backoff retry for a staged live message whose send *failed* (as opposed to
    /// never being attempted because the peer was unreachable — that case is re-flushed by the
    /// reachability/activation delegates). See `scheduleLiveRetry`.
    private var liveRetryTask: Task<Void, Never>?
    private var liveRetryAttempt = 0

    /// The latest live-run frame the *phone* sent, for the run we currently have open to apply
    /// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
    /// this to follow a phone-driven transition; it's never persisted.
    private(set) var liveIncoming: LiveProgress?

    /// The run currently open in the watch's navigated driver. When the incoming frame is for
    /// it, the watch follows inline there and suppresses the follower cover (so it never stacks
    /// on top of a run the user already has open).
    var navigatedRunID: String?

    /// A run the user dismissed the follower cover for; suppressed until that run ends.
    private var mutedLogID: String?

    /// The frame to present as a follower cover when the phone drives a run the watch isn't
    /// already showing: the latest, unless the user dismissed it or has that run open inline.
    var presentable: LiveProgress? {
        guard let f = liveIncoming, f.logID != mutedLogID, f.logID != navigatedRunID else { return nil }
        return f
    }

    /// The user dismissed the follower cover; don't re-present this run until it ends.
    func muteLive() { mutedLogID = liveIncoming?.logID }

    private var context: ModelContext { container.mainContext }

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

    func activate() {
        guard WCSession.isSupported() else { return }
        let session = WCSession.default
        session.delegate = self
        session.activate()
        self.session = session
        // Apply whatever the phone last pushed, then ask for a fresh push. On real
        // hardware activation completes asynchronously and this eager read returns an
        // empty context — `activationDidCompleteWith` re-applies it once it's valid;
        // this read is just the simulator/warm-launch fast path.
        applyReceivedContext()
        requestSync()
    }

    /// Apply the last application context the system holds for us (settings, edit
    /// locks, and the authoritative routines + workouts sets). Idempotent, so it's safe
    /// to call both eagerly at launch and again when activation completes.
    private func applyReceivedContext() {
        guard let session else { return }
        let ctx = session.receivedApplicationContext
        Self.log.info("applyReceivedContext: activation=\(session.activationState.rawValue) keys=\(ctx.keys.sorted().joined(separator: ","), privacy: .public)")
        applyState(WCPayload.decodeRoutines(ctx), workouts: WCPayload.decodeWorkouts(ctx))
        applySettings(ctx)
        editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
        editingRoutineID = WCPayload.decodeEditingRoutineID(ctx)
    }

    /// Apply a decoded state push. `nil` (decode failure — the phone runs a build with
    /// a different document schema) is logged and skipped so we neither prune the cache
    /// against a bogus empty set nor silently show stale data forever. The upsert/prune
    /// itself is delegated to the pure, session-free `WatchCacheApplier` seam so the
    /// apply/prune contract is unit-testable.
    private func applyState(_ routines: [RoutineDocument]?, workouts: [WorkoutDocument]?) {
        guard WatchCacheApplier.apply(routines: routines, workouts: workouts, into: context) else {
            Self.log.error("applyState: payload failed to decode (routines=\(routines == nil ? "failed" : "ok", privacy: .public), workouts=\(workouts == nil ? "failed" : "ok", privacy: .public)) — phone/watch build mismatch?")
            schemaMismatch = true
            return
        }
        schemaMismatch = false
        Self.log.info("applyState: applied \(routines?.count ?? 0) routines, \(workouts?.count ?? 0) workouts")
        lastSyncDate = Date()
        onWorkoutsChanged?()
    }

    func requestSync() {
        guard let session, session.activationState == .activated, session.isReachable else {
            Self.log.info("requestSync skipped: activation=\(self.session?.activationState.rawValue ?? -1) reachable=\(self.session?.isReachable ?? false)")
            return
        }
        session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: { @Sendable error in
            // No retry — the activation/reachability edges re-pull, and the durable
            // context slot delivers regardless — but a dropped pull must be visible.
            Self.log.warning("requestSync send failed: \(error, privacy: .public)")
        })
    }

    /// Optimistically applies a workout edit to the local cache and forwards it to
    /// the phone for durable persistence in iCloud Drive.
    func update(workout doc: WorkoutDocument) {
        CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
        try? context.save()
        onWorkoutsChanged?()
        sendToPhone(doc)
    }

    // MARK: - Live run mirror (ephemeral; coalesced redelivery)

    /// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Staged as
    /// the latest pending frame and sent when the phone is reachable; if it's unreachable the frame
    /// is held and re-sent on reconnect (`flushLive`). Because frames are full state snapshots with
    /// a wall-clock anchor, holding only the newest one (depth 1) and self-correcting its timers on
    /// arrival means a re-send is never stale.
    func sendLiveProgress(_ frame: LiveProgress) {
        guard let session, session.activationState == .activated else { return }
        liveVersion += 1
        var stamped = frame
        stamped.version = liveVersion
        pendingLive = .progress(stamped)
        liveRetryAttempt = 0
        flushLive()
    }

    /// Tell the phone to stop mirroring this run (the user left the progress flow). Staged like a
    /// frame so a drop at the moment the run ends doesn't strand the phone's follower cover — the
    /// terminal marker supersedes any pending progress and is re-sent on reconnect.
    func sendLiveEnded(workoutID: String, logID: String) {
        guard let session, session.activationState == .activated else { return }
        pendingLive = .ended(workoutID: workoutID, logID: logID)
        liveRetryAttempt = 0
        flushLive()
    }

    /// (Re)send the staged live-run message if the phone is reachable. Called on each new frame and
    /// whenever reachability/activation is restored. Leaves it staged on failure; a newer frame or
    /// `.ended` supersedes it, so we never deliver stale state. The error handler runs on
    /// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an
    /// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
    private func flushLive() {
        liveRetryTask?.cancel()
        liveRetryTask = nil
        guard let session, let pending = pendingLive,
              session.activationState == .activated, session.isReachable else { return }
        let payload: [String: Any]
        switch pending {
        case .progress(let frame):
            payload = WCPayload.encodeLiveProgress(frame)
        case .ended(let workoutID, let logID):
            payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
        }
        session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in
            Task { @MainActor in self?.scheduleLiveRetry() }
        })
    }

    /// A send failed while the phone was nominally reachable. Without this the staged message
    /// would sit until the next reachability/activation edge — which may never come mid-workout —
    /// so back off briefly and re-flush, a handful of times per staged message (the edges still
    /// re-flush after the retries are spent). Whatever is staged *when the retry fires* is sent,
    /// so a newer frame staged meanwhile supersedes the failed one here too.
    private func scheduleLiveRetry() {
        guard pendingLive != nil, liveRetryTask == nil, liveRetryAttempt < 5 else { return }
        liveRetryAttempt += 1
        let delay = min(8.0, 0.5 * pow(2.0, Double(liveRetryAttempt - 1)))
        liveRetryTask = Task { [weak self] in
            try? await Task.sleep(for: .seconds(delay))
            guard let self, !Task.isCancelled else { return }
            self.liveRetryTask = nil
            self.flushLive()
        }
    }

    // MARK: - Live heart rate (ephemeral; best-effort)

    /// Rounded bpm last sent and when, to throttle the forward: HR samples land every few
    /// seconds, but an unchanged reading only needs an occasional keep-alive so the phone's
    /// staleness window doesn't blank the readout mid-run. A zone flip breaks the throttle
    /// so the panel's zone can't lag a boundary crossing by the keep-alive interval.
    private var lastHRSentBpm: Int?
    private var lastHRSentZone: Int?
    private var lastHRSentAt = Date.distantPast

    /// Forward a live sample (heart rate + running calories + zone) to a mirroring phone.
    /// Unlike the run frames this is pure best-effort — reachable-only, no staging, no
    /// retry: a gauge, not a record. The durable summary still lands in `WorkoutMetrics`
    /// when the session finishes.
    func sendLiveSample(_ sample: WorkoutSessionManager.LiveSample) {
        guard let session, session.activationState == .activated, session.isReachable else { return }
        let rounded = Int(sample.bpm.rounded())
        let now = Date()
        if rounded == lastHRSentBpm, sample.zone == lastHRSentZone,
           now.timeIntervalSince(lastHRSentAt) < 10 { return }
        lastHRSentBpm = rounded
        lastHRSentZone = sample.zone
        lastHRSentAt = now
        session.sendMessage(
            WCPayload.encodeLiveHeartRate(bpm: sample.bpm, at: now, kcal: sample.kcal, zone: sample.zone),
            replyHandler: nil, errorHandler: nil)
    }

    /// Apply a live-run frame the phone sent. Catches our send counter up first (shared per-run
    /// sequence), then arbitrates against our own staged outbound frame for the same run: if the
    /// incoming frame outranks it, drop the staged one (so a later reconnect can't re-send stale
    /// state and yank the run backwards); if the *staged* frame outranks the delivery — a late or
    /// version-collided arrival predating our own latest action — ignore the incoming frame.
    /// Finally drops anything not newer than what we're already showing (redeliveries included).
    private func applyIncomingLive(_ frame: LiveProgress) {
        liveVersion = max(liveVersion, frame.version)
        if case .progress(let staged) = pendingLive, staged.logID == frame.logID {
            guard !staged.isNewer(than: frame) else { return }
            pendingLive = nil
            liveRetryTask?.cancel()
            liveRetryTask = nil
        }
        if let current = liveIncoming, current.logID == frame.logID, !frame.isNewer(than: current) { return }
        liveIncoming = frame
    }

    /// The phone left the run — stop following it (and clear any dismiss for it).
    private func endIncomingLive(logID: String) {
        if liveIncoming?.logID == logID { liveIncoming = nil }
        if mutedLogID == logID { mutedLogID = nil }
    }

    // MARK: - Internal

    private func sendToPhone(_ doc: WorkoutDocument) {
        guard let session, session.activationState == .activated else { return }
        let payload = WCPayload.encodeWorkoutUpdate(doc)
        if session.isReachable {
            // The error handler runs on WatchConnectivity's background queue, so it must be
            // nonisolated (@Sendable) or it would trap (swift_task_checkIsolated). Hop back to the
            // MainActor to fall back to guaranteed delivery; `doc` is Sendable, the payload isn't.
            session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in
                Task { @MainActor in
                    _ = self.session?.transferUserInfo(WCPayload.encodeWorkoutUpdate(doc))
                }
            })
        } else {
            session.transferUserInfo(payload)
        }
    }

    // MARK: - Settings edits (watch → phone)

    /// Debounces the stepper's tick-per-second bursts into one settings message.
    private var settingsSendTask: Task<Void, Never>?

    /// Forward a rest-time change to the phone, which owns the durable value and echoes
    /// it back through the application context. The local default was already written by
    /// the setting's `@AppStorage`; debounced so holding the stepper sends once, and the
    /// send falls back to `transferUserInfo` so the change survives the phone being
    /// unreachable right now.
    func sendRestSeconds(_ seconds: Int) {
        settingsSendTask?.cancel()
        settingsSendTask = Task { [weak self] in
            try? await Task.sleep(for: .seconds(0.6))
            guard let self, !Task.isCancelled else { return }
            self.settingsSendTask = nil
            self.pushSettingsUpdate(restSeconds: seconds)
        }
    }

    private func pushSettingsUpdate(restSeconds: Int) {
        guard let session, session.activationState == .activated else { return }
        let payload = WCPayload.encodeSettingsUpdate(restSeconds: restSeconds)
        if session.isReachable {
            // Re-encode in the fallback rather than capturing the non-Sendable payload
            // (the error handler runs on WatchConnectivity's background queue).
            session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable [weak self] _ in
                Task { @MainActor in
                    _ = self?.session?.transferUserInfo(WCPayload.encodeSettingsUpdate(restSeconds: restSeconds))
                }
            })
        } else {
            session.transferUserInfo(payload)
        }
    }

    private func applySettings(_ dict: [String: Any]) {
        // Skip the context's rest value while a local edit is still waiting to be sent —
        // a concurrent push (triggered by anything) would carry the pre-edit value and
        // yank the stepper back mid-edit. The phone echoes our value right after it lands.
        if let rest = WCPayload.decodeRestSeconds(dict), settingsSendTask == nil {
            UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey)
        }
        if let done = WCPayload.decodeDoneCountdownSeconds(dict) {
            UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey)
        }
        if let unit = WCPayload.decodeWeightUnit(dict) {
            UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey)
        }
    }

}

// MARK: - Cache apply/prune seam

/// The pure, session-free core of the phone→watch state apply: it upserts the authoritative
/// routines/workouts into the watch's SwiftData cache and prunes anything the phone no longer
/// sends. Split out of `WatchConnectivityBridge` (which wraps `WCSession`) so the apply/prune
/// contract — including the authoritative-empty prune and the nil-decode skip — is unit-testable
/// against an in-memory `ModelContext` without a live WatchConnectivity session.
enum WatchCacheApplier {
    /// Entry point mirroring the wire decode: `nil` for *either* set means the phone's payload
    /// failed to decode (a build/schema mismatch). We skip entirely — no upsert, no prune — so a
    /// bogus empty set can never wipe real rows. Returns `true` when an authoritative push was
    /// applied, `false` when skipped, so the caller can log / stamp `lastSyncDate` accordingly.
    @MainActor
    @discardableResult
    static func apply(routines: [RoutineDocument]?, workouts: [WorkoutDocument]?, into context: ModelContext) -> Bool {
        guard let routines, let workouts else { return false }
        apply(routines: routines, workouts: workouts, into: context)
        return true
    }

    /// Upsert every routine/workout the phone sent, then prune anything it *didn't*. Both sets are
    /// authoritative, so an authoritative empty push clears rows the phone no longer has (a deleted
    /// routine; a run discarded/deleted on the phone, completed-and-aged-out, or otherwise dropped
    /// from the ~24h window). On first launch the cache is empty, so the prune is a harmless no-op.
    /// The watch never originates a routine/workout, so pruning can't lose local-only data.
    @MainActor
    static func apply(routines: [RoutineDocument], workouts: [WorkoutDocument], into context: ModelContext) {
        var liveRoutineIDs = Set<String>()
        for s in routines {
            CacheMapper.upsertRoutine(s, relativePath: s.relativePath, into: context)
            liveRoutineIDs.insert(s.id)
        }
        var liveWorkoutIDs = Set<String>()
        for w in workouts {
            CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
            liveWorkoutIDs.insert(w.id)
        }
        if let allRoutines = try? context.fetch(FetchDescriptor<Routine>()) {
            for s in allRoutines where !liveRoutineIDs.contains(s.id) { context.delete(s) }
        }
        if let allWorkouts = try? context.fetch(FetchDescriptor<Workout>()) {
            for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
        }
        try? context.save()
    }
}

// MARK: - WCSessionDelegate

extension WatchConnectivityBridge: WCSessionDelegate {
    nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error { Self.log.error("activation failed: \(error, privacy: .public)") }
        Task { @MainActor in
            // Activation is async on real hardware, so the eager context read in
            // `activate()` saw an empty dictionary — re-apply now that it's valid.
            self.applyReceivedContext()
            self.requestSync()
            self.flushLive()   // deliver any frame staged before the session was ready
        }
    }

    nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
        if session.isReachable {
            Task { @MainActor in self.flushLive() }   // catch the phone up on the latest run state after a reconnect
        }
    }

    /// Live-run frames arrive as messages (reachable-only), distinct from the latest-wins
    /// application context that carries durable state.
    nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
        switch message[WCPayload.typeKey] as? String {
        case WCPayload.liveProgressType:
            if let frame = WCPayload.decodeLiveProgress(message) {
                Task { @MainActor in self.applyIncomingLive(frame) }
            }
        case WCPayload.liveEndedType:
            if let logID = message[WCPayload.lpLogIDKey] as? String {
                Task { @MainActor in self.endIncomingLive(logID: logID) }
            }
        default:
            break
        }
    }

    nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
        let routines = WCPayload.decodeRoutines(applicationContext)
        let workouts = WCPayload.decodeWorkouts(applicationContext)
        let rest = WCPayload.decodeRestSeconds(applicationContext)
        let done = WCPayload.decodeDoneCountdownSeconds(applicationContext)
        let unit = WCPayload.decodeWeightUnit(applicationContext)
        let editingWorkoutID = WCPayload.decodeEditingWorkoutID(applicationContext)
        let editingRoutineID = WCPayload.decodeEditingRoutineID(applicationContext)
        Task { @MainActor in
            self.applyState(routines, workouts: workouts)
            if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
            if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) }
            if let unit { UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey) }
            // Absent keys mean "not editing" — set unconditionally so the lock clears.
            self.editingWorkoutID = editingWorkoutID
            self.editingRoutineID = editingRoutineID
        }
    }
}

/// The single staged live-run message awaiting (re)delivery to the phone — see `pendingLive`.
/// One slot, latest-wins: a newer progress frame or the terminal `.ended` replaces whatever's held.
private enum PendingLive {
    case progress(LiveProgress)
    case ended(workoutID: String, logID: String)
}

/// Defaults key for the persisted live-frame version high-water mark (see `liveVersion`).
private let liveVersionDefaultsKey = "liveMirrorVersionHighWater"
