//
// WriteBacklog.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//

import Foundation

/// One queued write: the latest pending version of a single document (or its
/// deletion), plus retry bookkeeping. `timestamp` is the document's `updatedAt`
/// (a delete's deletion stamp) — slot replacement and the import/reconcile
/// supersede checks key on it, newer wins.
struct PendingWrite: Codable, Sendable {
    enum Payload: Codable, Sendable {
        case routine(RoutineDocument)
        case workout(WorkoutDocument)
        case schedule(ScheduleDocument)
        case delete(id: String, kind: String, livePath: String)

        // PINNED KEY: the case was renamed `.split` → `.routine`, but its persisted
        // JSON key stays `"split"` so a `PendingWrites.json` sidecar written by a
        // previous run still decodes. `workout` / `schedule` / `delete` keep their
        // default keys.
        enum CodingKeys: String, CodingKey {
            case routine = "split"
            case workout
            case schedule
            case delete
        }
    }

    var payload: Payload
    var timestamp: Date
    /// Paths this document previously lived at (month re-bucketing, replaced
    /// pending versions, a delete that superseded a queued save) — best-effort
    /// removed after the write lands so the id never survives at two paths.
    var stalePaths: Set<String> = []
    /// When the slot was first occupied — replacement keeps it, so the TTL
    /// measures how long the user's intent has been stuck, not the last edit.
    var enqueuedAt: Date
    var attempts: Int = 0
    var lastAttemptAt: Date?
    /// Set when an attempt failed with an error retrying can't fix (disk full).
    /// The drain keeps retrying regardless — conditions can change — but the
    /// banner escalates immediately.
    var faultMessage: String?

    var documentID: String {
        switch payload {
        case .routine(let doc): doc.id
        case .workout(let doc): doc.id
        case .schedule(let doc): doc.id
        case .delete(let id, _, _): id
        }
    }

    /// Where a successful write lands; nil for deletes (they only remove).
    var targetPath: String? {
        switch payload {
        case .routine(let doc): doc.relativePath
        case .workout(let doc): doc.relativePath
        case .schedule(let doc): doc.relativePath
        case .delete: nil
        }
    }

    var nextAttemptAt: Date {
        guard let lastAttemptAt else { return enqueuedAt }
        return lastAttemptAt.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: attempts))
    }
}

/// Depth-1 per-document write queue: at most one pending write per document id,
/// newest `updatedAt` wins the slot. Pure state + policy — no I/O, no clock of
/// its own — so the whole replacement/retry/TTL table is unit-testable.
/// `SyncEngine` owns the drain loop and persistence.
struct WriteBacklog: Codable, Sendable {
    private(set) var slots: [String: PendingWrite] = [:]

    var isEmpty: Bool { slots.isEmpty }
    var count: Int { slots.count }

    /// Highest attempt count across pending writes — drives the banner tiers.
    var maxAttempts: Int { slots.values.map(\.attempts).max() ?? 0 }

    /// The oldest entry's fault, if any entry has hit an unrecoverable error.
    var firstFaultMessage: String? {
        entries.compactMap(\.faultMessage).first
    }

    /// All pending writes, oldest slot first.
    var entries: [PendingWrite] {
        slots.values.sorted { $0.enqueuedAt < $1.enqueuedAt }
    }

    func pendingWrite(for id: String) -> PendingWrite? { slots[id] }

    /// Newer-wins slot replacement. An incoming write at least as new as the
    /// occupant takes the slot (equal = the latest local re-save of the same
    /// state; content-identical, so last-in is right). It inherits the
    /// occupant's retry bookkeeping — the backlog is failing for environmental
    /// reasons, and resetting attempts on every edit would flap the banner —
    /// plus its stale paths and its target path when the two differ (the
    /// occupant was never written, but an earlier attempt may have landed).
    /// An incoming write strictly older than the occupant is dropped.
    mutating func enqueue(_ write: PendingWrite) {
        let id = write.documentID
        guard let existing = slots[id] else {
            slots[id] = write
            return
        }
        guard write.timestamp >= existing.timestamp else { return }
        var merged = write
        merged.stalePaths.formUnion(existing.stalePaths)
        if let oldPath = existing.targetPath, oldPath != merged.targetPath {
            merged.stalePaths.insert(oldPath)
        }
        merged.enqueuedAt = existing.enqueuedAt
        merged.attempts = existing.attempts
        merged.lastAttemptAt = existing.lastAttemptAt
        merged.faultMessage = existing.faultMessage
        slots[id] = merged
    }

    mutating func markFailed(id: String, at now: Date, fault: String? = nil) {
        guard var entry = slots[id] else { return }
        entry.attempts += 1
        entry.lastAttemptAt = now
        if let fault { entry.faultMessage = fault }
        slots[id] = entry
    }

    /// Remove after a successful write — replacement-safe: a newer write that
    /// slotted in while this one was in flight stays queued.
    mutating func resolve(id: String, ifTimestampAtMost timestamp: Date) {
        guard let entry = slots[id], entry.timestamp <= timestamp else { return }
        slots[id] = nil
    }

    mutating func removeAll() { slots.removeAll() }

    /// Drop entries stuck longer than `ttl` — applied when a persisted backlog
    /// is loaded, so a slot from a long-dead run can't drain stale state into a
    /// container that has long since moved on.
    mutating func pruneExpired(now: Date, ttl: TimeInterval = 24 * 60 * 60) {
        slots = slots.filter { now.timeIntervalSince($0.value.enqueuedAt) < ttl }
    }

    /// The next entry whose backoff has elapsed, oldest slot first.
    func nextDue(at now: Date) -> PendingWrite? {
        entries.first { $0.nextAttemptAt <= now }
    }

    func earliestNextAttempt() -> Date? {
        slots.values.map(\.nextAttemptAt).min()
    }

    /// Backoff schedule: three quick silent retries (tier 0, ~14 s total), then
    /// calm long retries capped at 5 minutes.
    static func retryDelay(afterAttempts attempts: Int) -> TimeInterval {
        switch attempts {
        case ..<1: 0
        case 1: 2
        case 2: 4
        case 3: 8
        case 4: 30
        case 5: 60
        case 6: 120
        default: 300
        }
    }

    /// Attempts after which the calm "changes pending" banner shows (~14 s of
    /// silent retries have failed).
    static let tier1AttemptThreshold = 3
    /// Attempts after which the backlog is treated as a fault even without an
    /// unrecoverable error (~10 minutes of failures).
    static let tier2AttemptThreshold = 8
}

/// UI-facing rollup of the backlog — the banner tiers.
enum WriteQueueState: Equatable {
    /// Nothing queued.
    case idle
    /// Writes queued inside the silent tier-0 window — no UI.
    case pending
    /// Tier 1: writes keep failing; calm "changes pending" banner.
    case retrying
    /// Tier 2: unrecoverable error or backoff exhaustion; prominent banner
    /// pointing at Diagnostics.
    case fault(String)
}

/// Load/persist the backlog sidecar file. Failures are non-fatal by design: an
/// unreadable file loads as an empty backlog (worst case, queued-but-unwritten
/// edits from a previous run are lost — the same loss the queue exists to
/// narrow, never corruption), and a failed persist leaves the previous file in
/// place for the in-memory backlog to overwrite on the next change.
enum WriteBacklogFile {
    static func load(from url: URL) -> WriteBacklog {
        guard let data = try? Data(contentsOf: url),
              let backlog = try? JSONDecoder().decode(WriteBacklog.self, from: data) else {
            return WriteBacklog()
        }
        return backlog
    }

    static func save(_ backlog: WriteBacklog, to url: URL) {
        if backlog.isEmpty {
            try? FileManager.default.removeItem(at: url)
            return
        }
        guard let data = try? JSONEncoder().encode(backlog) else { return }
        try? data.write(to: url, options: .atomic)
    }
}
