import Foundation
import Testing
import IndieSync
@testable import Workouts

/// Round-trips every `WCPayload` wire shape — the iPhone↔Watch bridge's only
/// serialization boundary. Routines/workouts ride as JSON `Data` blobs (via
/// `DocumentCoder`, ISO-8601), settings and edit locks as native plist scalars, and
/// the ephemeral live-run frames as native values (so their timers keep sub-second
/// precision). A field dropped or a `nil`-vs-empty confusion here silently desyncs
/// the two devices, so each direction is pinned end to end, including the
/// decode-failure contract (`nil` means "failed to decode", never "empty list").
struct WCPayloadRoundTripTests {

    private static let ts = Date(timeIntervalSince1970: 1_700_000_000)   // whole second: ISO-8601 safe

    private func routine(id: String, name: String) -> RoutineDocument {
        RoutineDocument(
            schemaVersion: RoutineDocument.currentSchemaVersion, id: id, name: name, color: "blue",
            systemImage: "dumbbell.fill", order: 0, createdAt: Self.ts, updatedAt: Self.ts,
            exercises: [ExerciseDocument(id: "EX-\(id)", name: "Bench Press", order: 0, sets: 4,
                                         reps: 10, weight: 135, loadType: 1, durationSeconds: 0,
                                         machineSettings: [MachineSetting(name: "Seat", value: "4")])],
            activityType: nil
        )
    }

    private func workout(id: String) -> WorkoutDocument {
        WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, routineID: "S", routineName: "Push",
            start: Self.ts, end: Self.ts, status: WorkoutStatus.completed.rawValue,
            createdAt: Self.ts, updatedAt: Self.ts,
            logs: [WorkoutLogDocument(id: "L-\(id)", exerciseName: "Bench Press", order: 0, sets: 4,
                                      reps: 10, weight: 135, loadType: 1, durationSeconds: 0,
                                      currentStateIndex: 4, status: WorkoutStatus.completed.rawValue,
                                      notes: "solid", date: Self.ts)],
            metrics: nil
        )
    }

    // MARK: - Phone → Watch state + settings

    @Test func stateRoundTripsRoutinesWorkoutsAndSettings() {
        let routines = [routine(id: "SP1", name: "Upper Body"), routine(id: "SP2", name: "Lower Body")]
        let workouts = [workout(id: "01WKA"), workout(id: "01WKB")]
        let dict = WCPayload.encodeState(
            routines: routines, workouts: workouts, restSeconds: 90, doneCountdownSeconds: 5,
            weightUnit: "kg", editingWorkoutID: "01WKA", editingRoutineID: "SP1"
        )

        #expect(WCPayload.decodeRoutines(dict) == routines)
        #expect(WCPayload.decodeWorkouts(dict) == workouts)
        #expect(WCPayload.decodeRestSeconds(dict) == 90)
        #expect(WCPayload.decodeDoneCountdownSeconds(dict) == 5)
        #expect(WCPayload.decodeWeightUnit(dict) == "kg")
        #expect(WCPayload.decodeEditingWorkoutID(dict) == "01WKA")
        #expect(WCPayload.decodeEditingRoutineID(dict) == "SP1")
    }

    /// Nil edit locks mean "not editing" → the keys are absent, so the decoders return
    /// nil (the receiver clears the lock). An empty routines/workouts list is legitimately
    /// empty — it still encodes a key and decodes back to `[]`, never nil.
    @Test func stateOmitsNilEditLocksAndKeepsEmptyListsDistinct() {
        let dict = WCPayload.encodeState(
            routines: [], workouts: [], restSeconds: 60, doneCountdownSeconds: 3,
            weightUnit: "lb", editingWorkoutID: nil, editingRoutineID: nil
        )
        #expect(WCPayload.decodeEditingWorkoutID(dict) == nil)
        #expect(WCPayload.decodeEditingRoutineID(dict) == nil)
        // Authoritative empty push: an empty (present) list decodes to [], not nil.
        #expect(WCPayload.decodeRoutines(dict) == [])
        #expect(WCPayload.decodeWorkouts(dict) == [])
    }

    /// An absent routines/workouts key decodes to `[]` (nothing to apply), NOT nil — nil is
    /// reserved for a genuine decode failure below.
    @Test func absentRoutinesWorkoutsKeysDecodeToEmpty() {
        let empty: [String: Any] = [:]
        #expect(WCPayload.decodeRoutines(empty) == [])
        #expect(WCPayload.decodeWorkouts(empty) == [])
    }

    /// A present-but-corrupt payload (a build-mismatch schema the receiver can't decode)
    /// yields `nil` — the signal the watch bridge's nil-decode guard uses to skip
    /// pruning against a bogus set. This must stay distinct from an empty list.
    @Test func corruptStateDecodesToNilNotEmpty() {
        let garbage = Data([0xFF, 0xFE, 0x00, 0x01])
        let dict: [String: Any] = [WCPayload.routinesKey: garbage, WCPayload.workoutsKey: garbage]
        #expect(WCPayload.decodeRoutines(dict) == nil)
        #expect(WCPayload.decodeWorkouts(dict) == nil)
    }

    // MARK: - Watch → Phone workout update

    @Test func workoutUpdateRoundTrips() {
        let w = workout(id: "01WKUPDATE")
        let dict = WCPayload.encodeWorkoutUpdate(w)
        #expect(dict[WCPayload.typeKey] as? String == WCPayload.workoutUpdateType)
        #expect(WCPayload.decodeWorkoutUpdate(dict) == w)
    }

    @Test func workoutUpdateWithMissingBlobDecodesToNil() {
        let dict: [String: Any] = [WCPayload.typeKey: WCPayload.workoutUpdateType]
        #expect(WCPayload.decodeWorkoutUpdate(dict) == nil)
    }

    // MARK: - Watch → Phone requestSync

    @Test func requestSyncMessageCarriesType() {
        let dict = WCPayload.requestSyncMessage()
        #expect(dict[WCPayload.typeKey] as? String == WCPayload.requestSyncType)
    }

    // MARK: - Live-run mirror (ephemeral)

    private func liveProgress(phaseEnd: Date?) -> LiveProgress {
        LiveProgress(
            workoutID: "01WKLIVE", logID: "LOG-LIVE", exerciseName: "Leg Press", phase: .rest,
            setIndex: 2, setCount: 4, detail: "30 sec",
            phaseStart: Date(timeIntervalSince1970: 1_700_000_123.75),   // sub-second: proves native (not ISO-8601) transport
            phaseEnd: phaseEnd, version: 7
        )
    }

    @Test func liveProgressRoundTripsWithCountdownEnd() {
        let frame = liveProgress(phaseEnd: Date(timeIntervalSince1970: 1_700_000_153.75))
        let dict = WCPayload.encodeLiveProgress(frame)
        #expect(dict[WCPayload.typeKey] as? String == WCPayload.liveProgressType)
        #expect(WCPayload.decodeLiveProgress(dict) == frame)
    }

    /// A rep-based work set counts up and carries no `phaseEnd`; the key must be omitted
    /// and decode back to nil, not a zero date.
    @Test func liveProgressRoundTripsWithoutEnd() {
        let frame = liveProgress(phaseEnd: nil)
        let dict = WCPayload.encodeLiveProgress(frame)
        #expect(dict[WCPayload.lpEndKey] == nil)
        let decoded = WCPayload.decodeLiveProgress(dict)
        #expect(decoded == frame)
        #expect(decoded?.phaseEnd == nil)
    }

    @Test func liveProgressMissingRequiredKeyDecodesToNil() {
        var dict = WCPayload.encodeLiveProgress(liveProgress(phaseEnd: nil))
        dict[WCPayload.lpWorkoutIDKey] = nil   // drop a required field
        #expect(WCPayload.decodeLiveProgress(dict) == nil)
    }

    @Test func liveEndedCarriesTypeAndIDs() {
        let dict = WCPayload.encodeLiveEnded(workoutID: "01WKLIVE", logID: "LOG-LIVE")
        #expect(dict[WCPayload.typeKey] as? String == WCPayload.liveEndedType)
        #expect(dict[WCPayload.lpWorkoutIDKey] as? String == "01WKLIVE")
        #expect(dict[WCPayload.lpLogIDKey] as? String == "LOG-LIVE")
    }
}
