import Foundation
import SwiftData
import Testing
import IndieSync
@testable import Workouts

/// Mirrors `SeedLibraryTests.mapperRoundTripPreservesPristineness` for the *workout*
/// aggregate: a `WorkoutDocument` pushed through `CacheMapper.upsertWorkout` (the
/// document → cache direction the metadata observer / watch bridge use) and rebuilt
/// via `WorkoutDocument(from: entity)` (the cache → document write path) must
/// preserve every field in both directions — the aggregate's own columns, the full
/// embedded log list (order, machine settings, per-log timestamps), and the health
/// metrics. A field that survives one direction but not the other silently forks or
/// drops user data on the very first cache rebuild.
struct WorkoutDocumentMapperTests {

    // Whole-second epochs kept distinct per field so a swapped assignment can't hide.
    private static let start = Date(timeIntervalSince1970: 1_700_000_000)
    private static let end = Date(timeIntervalSince1970: 1_700_003_600)
    private static let created = Date(timeIntervalSince1970: 1_699_999_000)
    private static let updated = Date(timeIntervalSince1970: 1_700_004_000)
    private static let logDate = Date(timeIntervalSince1970: 1_700_000_500)
    private static let startedAt = Date(timeIntervalSince1970: 1_700_000_600)
    private static let completedAt = Date(timeIntervalSince1970: 1_700_001_200)
    private static let recordedAt = Date(timeIntervalSince1970: 1_700_003_700)

    @MainActor
    private func makeContext() throws -> ModelContext {
        let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
        // cloudKitDatabase: .none matches WorkoutsModelContainer (see SeedLibraryTests).
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
        return ModelContext(try ModelContainer(for: schema, configurations: [config]))
    }

    private func log(
        id: String, name: String, order: Int, status: WorkoutStatus,
        weight: Double = 135, machineSettings: [MachineSetting]? = nil,
        startedAt: Date? = nil, completedAt: Date? = nil,
        setEntries: [SetEntry]? = nil, updatedAt: Date? = nil
    ) -> WorkoutLogDocument {
        WorkoutLogDocument(
            id: id, exerciseName: name, order: order, sets: 4, reps: 10, weight: weight,
            loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 2,
            status: status.rawValue, notes: "note-\(name)", date: Self.logDate,
            machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt,
            setEntries: setEntries, updatedAt: updatedAt
        )
    }

    private func fullMetrics() -> WorkoutMetrics {
        WorkoutMetrics(
            activeEnergyKcal: 321.5, avgHeartRate: 128, maxHeartRate: 165, minHeartRate: 71,
            totalVolume: 5400, hrZoneSeconds: [10, 20, 30, 40, 50],
            healthKitWorkoutUUID: "HK-UUID-123", source: .watch, recordedAt: Self.recordedAt
        )
    }

    /// A fully-populated workout (three logs exercising the nil / empty / populated
    /// machineSettings distinction, distinct per-log timestamps, full metrics) must
    /// round-trip byte-for-byte equal in both directions.
    @MainActor
    @Test func roundTripPreservesEveryField() throws {
        let context = try makeContext()
        let original = WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion,
            id: "01WORKOUTROUNDTRIP00000001", routineID: "RT-42", routineName: "Push Day",
            start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
            createdAt: Self.created, updatedAt: Self.updated,
            logs: [
                // order 0: not a machine exercise (nil), never started, no entries.
                log(id: "LOG-A", name: "Bench Press", order: 0, status: .notStarted, machineSettings: nil),
                // order 1: machine exercise, nothing recorded yet (empty ≠ nil), completed with
                // timestamps, a fractional weight, recorded per-set actuals, and a per-log updatedAt.
                log(id: "LOG-B", name: "Leg Press", order: 1, status: .completed,
                    weight: 42.5, machineSettings: [],
                    startedAt: Self.startedAt, completedAt: Self.completedAt,
                    setEntries: [
                        SetEntry(reps: 10, weight: 42.5, completedAt: Self.startedAt),
                        SetEntry(reps: 8, weight: 45, completedAt: Self.completedAt),
                    ],
                    updatedAt: Self.updated),
                // order 2: machine exercise with recorded settings, skipped.
                log(id: "LOG-C", name: "Chest Press", order: 2, status: .skipped,
                    machineSettings: [MachineSetting(name: "Seat Height", value: "4"),
                                      MachineSetting(name: "Back Rest", value: "45°")],
                    startedAt: Self.startedAt),
            ],
            metrics: fullMetrics(),
            deletedLogIDs: ["LOG-DELETED": Self.completedAt]
        )

        CacheMapper.upsertWorkout(original, relativePath: original.relativePath, into: context)
        let entity = try #require(CacheMapper.fetchWorkout(id: original.id, in: context))
        let rebuilt = WorkoutDocument(from: entity)

        #expect(rebuilt == original)

        // Spot-pin the load-bearing fields the whole-document `==` rolls up, so a future
        // regression names itself.
        #expect(rebuilt.routineID == "RT-42")
        #expect(rebuilt.routineName == "Push Day")
        #expect(rebuilt.start == Self.start)
        #expect(rebuilt.end == Self.end)
        #expect(rebuilt.createdAt == Self.created)
        #expect(rebuilt.updatedAt == Self.updated)
        #expect(rebuilt.status == WorkoutStatus.completed.rawValue)
        #expect(rebuilt.logs.count == 3)
        #expect(rebuilt.logs[0].machineSettings == nil)      // not a machine exercise
        #expect(rebuilt.logs[0].setEntries == nil)           // nothing recorded stays nil
        #expect(rebuilt.logs[0].updatedAt == nil)
        #expect(rebuilt.logs[1].machineSettings == [])       // machine, nothing recorded
        #expect(rebuilt.logs[1].startedAt == Self.startedAt)
        #expect(rebuilt.logs[1].completedAt == Self.completedAt)
        #expect(rebuilt.logs[1].weight == 42.5)              // fractional weight survives
        #expect(rebuilt.logs[1].setEntries == [
            SetEntry(reps: 10, weight: 42.5, completedAt: Self.startedAt),
            SetEntry(reps: 8, weight: 45, completedAt: Self.completedAt),
        ])
        #expect(rebuilt.logs[1].updatedAt == Self.updated)   // per-log merge stamp survives
        #expect(rebuilt.logs[2].machineSettings?.count == 2)
        #expect(rebuilt.metrics == fullMetrics())
        #expect(rebuilt.metrics?.hrZoneSeconds == [10, 20, 30, 40, 50])
        #expect(rebuilt.metrics?.healthKitWorkoutUUID == "HK-UUID-123")
        #expect(rebuilt.deletedLogIDs == ["LOG-DELETED": Self.completedAt])   // merge tombstones survive
    }

    /// A workout with no metrics yet (the common in-progress / just-started case) must
    /// round-trip to `nil` metrics, not a phantom zero-filled `WorkoutMetrics`.
    @MainActor
    @Test func roundTripPreservesNilMetrics() throws {
        let context = try makeContext()
        let original = WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion,
            id: "01WORKOUTNOMETRICS0000001", routineID: nil, routineName: nil,
            start: Self.start, end: nil, status: WorkoutStatus.inProgress.rawValue,
            createdAt: Self.created, updatedAt: Self.updated,
            logs: [log(id: "LOG-X", name: "Plank", order: 0, status: .inProgress, startedAt: Self.startedAt)],
            metrics: nil
        )

        CacheMapper.upsertWorkout(original, relativePath: original.relativePath, into: context)
        let entity = try #require(CacheMapper.fetchWorkout(id: original.id, in: context))
        let rebuilt = WorkoutDocument(from: entity)

        #expect(rebuilt == original)
        #expect(rebuilt.metrics == nil)
        #expect(rebuilt.routineID == nil)
        #expect(rebuilt.end == nil)
    }

    /// The embedded logs are reconciled by id and rebuilt via `logsArray` (sorted by
    /// `order`), so a document whose logs arrive out of order comes back canonically
    /// ordered — the round-trip normalizes, never scrambles.
    @MainActor
    @Test func roundTripNormalizesLogOrder() throws {
        let context = try makeContext()
        let scrambled = WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion,
            id: "01WORKOUTLOGORDER00000001", routineID: "S", routineName: "S",
            start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue,
            createdAt: Self.created, updatedAt: Self.updated,
            logs: [
                log(id: "LOG-2", name: "Third", order: 2, status: .completed),
                log(id: "LOG-0", name: "First", order: 0, status: .completed),
                log(id: "LOG-1", name: "Second", order: 1, status: .completed),
            ],
            metrics: nil
        )

        CacheMapper.upsertWorkout(scrambled, relativePath: scrambled.relativePath, into: context)
        let entity = try #require(CacheMapper.fetchWorkout(id: scrambled.id, in: context))
        let rebuilt = WorkoutDocument(from: entity)

        #expect(rebuilt.logs.map(\.order) == [0, 1, 2])
        #expect(rebuilt.logs.map(\.id) == ["LOG-0", "LOG-1", "LOG-2"])
        #expect(rebuilt.logs.map(\.exerciseName) == ["First", "Second", "Third"])
    }

    /// Re-upserting a changed document over an existing cache entity reconciles the log
    /// set by id: a removed log is dropped, a new one inserted, survivors updated in
    /// place — matching the observer's frequent in-workout re-imports.
    @MainActor
    @Test func reUpsertReconcilesLogsByID() throws {
        let context = try makeContext()
        let first = WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion,
            id: "01WORKOUTREUPSERT00000001", routineID: "S", routineName: "S",
            start: Self.start, end: nil, status: WorkoutStatus.inProgress.rawValue,
            createdAt: Self.created, updatedAt: Self.updated,
            logs: [
                log(id: "LOG-keep", name: "Kept", order: 0, status: .completed),
                log(id: "LOG-drop", name: "Dropped", order: 1, status: .notStarted),
            ],
            metrics: nil
        )
        CacheMapper.upsertWorkout(first, relativePath: first.relativePath, into: context)
        try context.save()

        var second = first
        second.logs = [
            log(id: "LOG-keep", name: "Kept Renamed", order: 0, status: .completed),  // updated in place
            log(id: "LOG-add", name: "Added", order: 1, status: .notStarted),         // inserted
        ]
        CacheMapper.upsertWorkout(second, relativePath: second.relativePath, into: context)
        // The observer saves after applying each delta batch; the relationship array only
        // reflects a child deletion once saved, so mirror that here.
        try context.save()

        let entity = try #require(CacheMapper.fetchWorkout(id: second.id, in: context))
        let rebuilt = WorkoutDocument(from: entity)
        #expect(rebuilt.logs.map(\.id) == ["LOG-keep", "LOG-add"])
        #expect(rebuilt.logs.first?.exerciseName == "Kept Renamed")
        #expect(!rebuilt.logs.contains { $0.id == "LOG-drop" })
    }
}
