import Foundation
import Testing
@testable import Workouts

/// Pins the per-log merge (`WorkoutMergePlanner.merge`) that replaced `ingestFromWatch`'s
/// whole-document `updatedAt` gate — the durable fix for H1. The decisive properties: edits to
/// *different* logs commute (neither side is clobbered), the same log resolves by newest per-log
/// stamp, a phone-authored deletion tombstone stops a stale watch push from resurrecting a
/// removed exercise (unless a strictly-newer edit supersedes the deletion), and a stale/duplicate
/// push merges back to exactly the cached document (so ingest re-pushes rather than writes).
struct WorkoutMergePlannerTests {
    private static let t0 = Date(timeIntervalSince1970: 1_700_000_000)
    private static func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) }

    private func log(_ id: String, order: Int = 0, status: WorkoutStatus = .inProgress,
                     reps: Int = 10, updatedAt: Date? = nil,
                     startedAt: Date? = nil, completedAt: Date? = nil) -> WorkoutLogDocument {
        WorkoutLogDocument(
            id: id, exerciseName: "Ex-\(id)", order: order, sets: 3, reps: reps, weight: 100,
            loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0,
            status: status.rawValue, notes: nil, date: Self.t0, machineSettings: nil,
            startedAt: startedAt, completedAt: completedAt, setEntries: nil, updatedAt: updatedAt)
    }

    private func workout(updatedAt: Date, status: WorkoutStatus = .inProgress,
                         logs: [WorkoutLogDocument], deletedLogIDs: [String: Date]? = nil,
                         metrics: WorkoutMetrics? = nil, end: Date? = nil) -> WorkoutDocument {
        WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion, id: "W", routineID: nil,
            routineName: nil, start: Self.t0, end: end, status: status.rawValue,
            createdAt: Self.t0, updatedAt: updatedAt, logs: logs, metrics: metrics,
            deletedLogIDs: deletedLogIDs)
    }

    private func merged(_ incoming: WorkoutDocument, _ cached: WorkoutDocument) -> WorkoutDocument {
        WorkoutMergePlanner.merge(incoming: incoming, cached: cached, now: Self.at(1000))
    }

    private func reps(_ doc: WorkoutDocument, _ id: String) -> Int? {
        doc.logs.first { $0.id == id }?.reps
    }

    // MARK: - Commuting edits to different logs (the H1 case)

    @Test func concurrentEditsToDifferentLogsBothSurvive() {
        // Phone advanced A; watch advanced B — each from the shared base, unaware of the other.
        let phone = workout(updatedAt: Self.at(10),
                            logs: [log("A", order: 0, reps: 12, updatedAt: Self.at(10)),
                                   log("B", order: 1, reps: 10, updatedAt: Self.at(5))])
        let watch = workout(updatedAt: Self.at(11),
                            logs: [log("A", order: 0, reps: 10, updatedAt: Self.at(5)),
                                   log("B", order: 1, reps: 15, updatedAt: Self.at(11))])

        let result = merged(watch, phone)
        #expect(reps(result, "A") == 12)   // phone's A edit kept
        #expect(reps(result, "B") == 15)   // watch's B edit kept
        #expect(result.logs.count == 2)
    }

    @Test func mergeIsOrderIndependentForLogContent() {
        let phone = workout(updatedAt: Self.at(10),
                            logs: [log("A", order: 0, reps: 12, updatedAt: Self.at(10)),
                                   log("B", order: 1, reps: 10, updatedAt: Self.at(5))])
        let watch = workout(updatedAt: Self.at(11),
                            logs: [log("A", order: 0, reps: 10, updatedAt: Self.at(5)),
                                   log("B", order: 1, reps: 15, updatedAt: Self.at(11))])

        let a = merged(watch, phone)
        let b = merged(phone, watch)
        // The winning log versions are the same regardless of which document is "incoming".
        #expect(reps(a, "A") == reps(b, "A"))
        #expect(reps(a, "B") == reps(b, "B"))
    }

    // MARK: - Same log: newest per-log stamp wins

    @Test func sameLogNewerStampWins() {
        let cached = workout(updatedAt: Self.at(10), logs: [log("A", reps: 12, updatedAt: Self.at(10))])
        let incoming = workout(updatedAt: Self.at(20), logs: [log("A", reps: 20, updatedAt: Self.at(20))])
        #expect(reps(merged(incoming, cached), "A") == 20)
    }

    @Test func sameLogOlderStampLoses() {
        let cached = workout(updatedAt: Self.at(20), logs: [log("A", reps: 20, updatedAt: Self.at(20))])
        let incoming = workout(updatedAt: Self.at(10), logs: [log("A", reps: 12, updatedAt: Self.at(10))])
        #expect(reps(merged(incoming, cached), "A") == 20)
    }

    @Test func legacyNilStampIsOldest() {
        // A legacy log with no per-log stamp must lose to any stamped edit.
        let cached = workout(updatedAt: Self.at(1), logs: [log("A", reps: 99, updatedAt: nil)])
        let incoming = workout(updatedAt: Self.at(5), logs: [log("A", reps: 20, updatedAt: Self.at(5))])
        #expect(reps(merged(incoming, cached), "A") == 20)
    }

    // MARK: - Additions

    @Test func watchAddedLogIsKept() {
        let cached = workout(updatedAt: Self.at(10), logs: [log("A", order: 0, updatedAt: Self.at(10))])
        let incoming = workout(updatedAt: Self.at(11),
                               logs: [log("A", order: 0, updatedAt: Self.at(10)),
                                      log("C", order: 1, updatedAt: Self.at(11))])
        let result = merged(incoming, cached)
        #expect(Set(result.logs.map(\.id)) == ["A", "C"])
    }

    // MARK: - Deletion tombstones

    @Test func tombstonedLogDoesNotResurrect() {
        // Phone deleted B (tombstone at t=10); the watch, unaware, still pushes B (edited earlier).
        let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))],
                             deletedLogIDs: ["B": Self.at(10)])
        let incoming = workout(updatedAt: Self.at(11),
                               logs: [log("A", updatedAt: Self.at(6)),
                                      log("B", updatedAt: Self.at(5))])
        let result = merged(incoming, cached)
        #expect(Set(result.logs.map(\.id)) == ["A"])          // B stays deleted
        #expect(result.deletedLogIDs?["B"] == Self.at(10))    // tombstone retained (within grace)
    }

    @Test func newerEditResurrectsAndClearsTombstone() {
        // The watch edited B *after* the phone's deletion — the newer action wins (LWW per log),
        // so B comes back and its now-stale tombstone is dropped.
        let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))],
                             deletedLogIDs: ["B": Self.at(10)])
        let incoming = workout(updatedAt: Self.at(21),
                               logs: [log("A", updatedAt: Self.at(6)),
                                      log("B", updatedAt: Self.at(20))])
        let result = merged(incoming, cached)
        #expect(Set(result.logs.map(\.id)) == ["A", "B"])
        #expect(result.deletedLogIDs?["B"] == nil)
    }

    @Test func tombstonesUnionAcrossSides() {
        let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))],
                             deletedLogIDs: ["B": Self.at(10)])
        let incoming = workout(updatedAt: Self.at(11), logs: [log("A", updatedAt: Self.at(6))],
                               deletedLogIDs: ["C": Self.at(11)])
        let result = merged(incoming, cached)
        #expect(result.deletedLogIDs?["B"] == Self.at(10))
        #expect(result.deletedLogIDs?["C"] == Self.at(11))
    }

    @Test func staleTombstonesArePruned() {
        // A tombstone older than the grace period (now = t0+1000; grace = 30d) is dropped.
        let old = Self.at(1000 - WorkoutMergePlanner.tombstoneGrace - 1)
        let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))],
                             deletedLogIDs: ["B": old])
        let result = merged(cached, cached)
        #expect(result.deletedLogIDs == nil)
    }

    // MARK: - Workout-level resolution

    @Test func statusRecomputedFromMergedLogs() {
        // Phone: A done, B in progress → inProgress. Watch completes B (newer) → merged is all
        // resolved, so status recomputes to completed with the injected end time.
        let cached = workout(updatedAt: Self.at(10), status: .inProgress,
                             logs: [log("A", status: .completed, updatedAt: Self.at(8)),
                                    log("B", status: .inProgress, updatedAt: Self.at(9))])
        let incoming = workout(updatedAt: Self.at(20), status: .inProgress,
                               logs: [log("A", status: .completed, updatedAt: Self.at(8)),
                                      log("B", status: .completed, updatedAt: Self.at(20))])
        let result = merged(incoming, cached)
        #expect(result.status == WorkoutStatus.completed.rawValue)
        #expect(result.end == Self.at(1000))   // recompute used the injected `now`
    }

    @Test func newerDocumentSuppliesWorkoutScalars() {
        // The just-finished watch push carries the metrics summary; the merge must keep it.
        let m = WorkoutMetrics(activeEnergyKcal: 200, avgHeartRate: 130, maxHeartRate: 160,
                               minHeartRate: 70, totalVolume: 3000, hrZoneSeconds: nil,
                               healthKitWorkoutUUID: "HK-1", source: .watch, recordedAt: Self.at(20))
        let cached = workout(updatedAt: Self.at(10), logs: [log("A", updatedAt: Self.at(10))])
        let incoming = workout(updatedAt: Self.at(20), logs: [log("A", updatedAt: Self.at(10))],
                               metrics: m)
        #expect(merged(incoming, cached).metrics == m)
    }

    // MARK: - The re-push (no-op) case ingest relies on

    @Test func stalePushMergesBackToCached() {
        // A behind/duplicate watch push adds nothing newer → merge == cached, so ingest re-pushes
        // authoritative state instead of writing.
        let cached = workout(updatedAt: Self.at(20),
                             logs: [log("A", order: 0, reps: 20, updatedAt: Self.at(20)),
                                    log("B", order: 1, reps: 15, updatedAt: Self.at(18))])
        let stale = workout(updatedAt: Self.at(9),
                            logs: [log("A", order: 0, reps: 10, updatedAt: Self.at(5)),
                                   log("B", order: 1, reps: 10, updatedAt: Self.at(5))])
        #expect(merged(stale, cached) == cached)
    }
}
