import Foundation
import Testing
import IndieSync
@testable import Workouts

/// Locks the per-set actuals capture invariant on `WorkoutLogDocument` — the
/// `transition(to:)` fill/clear rules every status flip funnels through (list
/// checkbox, run-flow completeExercise/resetExercise, endKeepingProgress on both
/// platforms), the `fillSetEntries` plan-default shapes per `LoadType`, the
/// `effectiveSetEntries` legacy fallback every read path shares, and the
/// actuals-aware `WorkoutVolume`. Also pins that a v3 workout-log file (integer
/// weight, no `setEntries`) still decodes at today's Double/v4 shape.
struct SetEntryTests {

    private static let date = Date(timeIntervalSince1970: 1_700_000_000)

    private func log(
        sets: Int = 3, reps: Int = 10, weight: Double = 100,
        loadType: LoadType = .weight, durationSeconds: Int = 0,
        status: WorkoutStatus = .notStarted, setEntries: [SetEntry]? = nil
    ) -> WorkoutLogDocument {
        WorkoutLogDocument(
            id: "LOG", exerciseName: "Ex", order: 0, sets: sets, reps: reps, weight: weight,
            loadType: loadType.rawValue, durationSeconds: durationSeconds, currentStateIndex: 0,
            status: status.rawValue, notes: nil, date: Self.date, setEntries: setEntries
        )
    }

    // MARK: - transition(to:) fill/clear rules

    /// Completing a log with nothing recorded (the list checkbox path) synthesizes
    /// a full plan-default entry set.
    @Test func completeFillsAllMissingEntries() {
        var l = log(sets: 3, reps: 10, weight: 100)
        l.transition(to: .completed)
        #expect(l.setEntries?.count == 3)
        #expect(l.setEntries?.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.seconds == nil } == true)
        // The fill stamps the completion time on the synthesized entries.
        #expect(l.setEntries?.allSatisfy { $0.completedAt == l.completedAt } == true)
    }

    /// Completing a log that already recorded (and adjusted) entries fills only the
    /// missing tail — the recorded ones are never overwritten.
    @Test func completeFillsOnlyMissingTail() {
        let recorded = SetEntry(reps: 8, weight: 90, completedAt: Self.date)
        var l = log(sets: 3, reps: 10, weight: 100, setEntries: [recorded])
        l.transition(to: .completed)
        #expect(l.setEntries?.count == 3)
        #expect(l.setEntries?.first == recorded)             // preserved, not re-filled
        #expect(l.setEntries?.last?.reps == 10)              // tail comes from the plan
        #expect(l.setEntries?.last?.weight == 100)
    }

    /// Re-completing an already-full log changes nothing (idempotent, like the
    /// completedAt stamp).
    @Test func completeIsIdempotentOnFullEntries() {
        var l = log(sets: 2)
        l.transition(to: .completed)
        let first = l.setEntries
        l.transition(to: .completed)
        #expect(l.setEntries == first)
    }

    /// Reset to notStarted (swiping back to Ready, checkbox un-complete) wipes the
    /// recorded entries with the timestamps — the run never happened.
    @Test func notStartedClearsEntries() {
        var l = log(sets: 2)
        l.transition(to: .completed)
        l.transition(to: .notStarted)
        #expect(l.setEntries == nil)
    }

    /// Skipping keeps partial entries — "End Workout, keeping progress" must not
    /// discard the sets that were done.
    @Test func skippedPreservesPartialEntries() {
        let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)]
        var l = log(sets: 3, status: .inProgress, setEntries: partial)
        l.transition(to: .skipped)
        #expect(l.setEntries == partial)
    }

    /// Moving to inProgress leaves entries alone (the run flow appends explicitly).
    @Test func inProgressLeavesEntriesAlone() {
        let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)]
        var l = log(sets: 3, status: .inProgress, setEntries: partial)
        l.transition(to: .inProgress)
        #expect(l.setEntries == partial)
    }

    // MARK: - fillSetEntries per LoadType

    @Test func fillShapesWeightedEntries() {
        var l = log(reps: 12, weight: 42.5, loadType: .weight)
        l.fillSetEntries(upTo: 1, at: Self.date)
        #expect(l.setEntries == [SetEntry(reps: 12, weight: 42.5, completedAt: Self.date)])
    }

    @Test func fillShapesBodyweightEntries() {
        var l = log(reps: 15, loadType: .none)
        l.fillSetEntries(upTo: 1, at: Self.date)
        #expect(l.setEntries == [SetEntry(reps: 15, completedAt: Self.date)])
    }

    @Test func fillShapesDurationEntries() {
        var l = log(reps: 0, loadType: .duration, durationSeconds: 45)
        l.fillSetEntries(upTo: 1, at: Self.date)
        #expect(l.setEntries == [SetEntry(seconds: 45, completedAt: Self.date)])
    }

    /// A fill to zero (or below the recorded count) never turns nil into [] and
    /// never truncates — entries are append-only.
    @Test func fillNeverShrinksOrMaterializesEmpty() {
        var untouched = log()
        untouched.fillSetEntries(upTo: 0, at: Self.date)
        #expect(untouched.setEntries == nil)

        let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date),
                        SetEntry(reps: 6, weight: 90, completedAt: Self.date)]
        var full = log(setEntries: recorded)
        full.fillSetEntries(upTo: 1, at: Self.date)
        #expect(full.setEntries == recorded)
    }

    // MARK: - effectiveSetEntries fallback

    /// Recorded entries win verbatim.
    @Test func effectiveEntriesPreferRecorded() {
        let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date)]
        let l = log(status: .completed, setEntries: recorded)
        #expect(l.effectiveSetEntries == recorded)
    }

    /// A completed legacy log (no entries on disk) synthesizes plan-default entries.
    @Test func effectiveEntriesSynthesizeForCompletedLegacy() {
        var l = log(sets: 3, reps: 10, weight: 100, status: .completed)
        l.completedAt = Self.date
        let entries = l.effectiveSetEntries
        #expect(entries.count == 3)
        #expect(entries.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.completedAt == Self.date })
    }

    /// An unfinished log with nothing recorded performed nothing.
    @Test func effectiveEntriesEmptyForUnfinishedLegacy() {
        #expect(log(status: .notStarted).effectiveSetEntries.isEmpty)
        #expect(log(status: .inProgress).effectiveSetEntries.isEmpty)
        #expect(log(status: .skipped).effectiveSetEntries.isEmpty)
    }

    // MARK: - WorkoutVolume from actuals

    /// Recorded actuals drive the sum: reps×weight per entry, not the plan.
    @Test func volumeSumsActualEntries() {
        let l = log(sets: 3, reps: 10, weight: 100, status: .completed, setEntries: [
            SetEntry(reps: 10, weight: 100, completedAt: Self.date),   // 1000
            SetEntry(reps: 8, weight: 102.5, completedAt: Self.date),  // 820
        ])
        #expect(WorkoutVolume.total([l]) == 1820)
    }

    /// A completed legacy log (no entries) matches the old sets×reps×weight formula,
    /// and mixed actual/legacy logs sum both correctly.
    @Test func volumeLegacyEqualsOldFormulaAndMixes() {
        let legacyCompleted = log(sets: 4, reps: 10, weight: 135, status: .completed)  // 5400
        #expect(WorkoutVolume.total([legacyCompleted]) == 5400)

        // Legacy logs that never completed also keep the old full-plan behavior.
        let legacySkipped = log(sets: 3, reps: 8, weight: 100, status: .skipped)       // 2400
        #expect(WorkoutVolume.total([legacySkipped]) == 2400)

        let actual = log(sets: 2, reps: 10, weight: 50, status: .completed, setEntries: [
            SetEntry(reps: 10, weight: 50, completedAt: Self.date),                    // 500
        ])
        #expect(WorkoutVolume.total([legacyCompleted, legacySkipped, actual]) == 8300)
    }

    /// Non-weight logs contribute nothing, with or without entries.
    @Test func volumeIgnoresUnweightedLogs() {
        var timed = log(loadType: .duration, durationSeconds: 45, status: .completed)
        timed.transition(to: .completed)
        let bodyweight = log(reps: 12, loadType: .none, status: .completed)
        #expect(WorkoutVolume.total([timed, bodyweight]) == 0)
    }

    // MARK: - Legacy v3 decode

    /// A workout-log shape from a v3 file — integer `weight`, no `setEntries`, no
    /// per-log `updatedAt` — decodes at today's shape: the Int lands in the Double
    /// natively and both new optionals stay nil.
    @Test func decodesLegacyV3WorkoutLog() throws {
        let json = """
        {
            "id": "01HZZZZZZZZZZZZZZZZZZZZZZW",
            "exerciseName": "Bench Press",
            "order": 0,
            "sets": 4,
            "reps": 10,
            "weight": 135,
            "loadType": 1,
            "durationSeconds": 0,
            "currentStateIndex": 4,
            "status": "completed",
            "date": "2023-11-14T22:13:20Z"
        }
        """
        let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: Data(json.utf8))
        #expect(decoded.weight == 135)
        #expect(decoded.setEntries == nil)
        #expect(decoded.updatedAt == nil)
        // And a fractional weight round-trips through the coder.
        var fractional = decoded
        fractional.weight = 42.5
        let re = try DocumentCoder.decode(WorkoutLogDocument.self, from: DocumentCoder.encode(fractional))
        #expect(re.weight == 42.5)
    }
}

/// The Completed screen's one-line actuals summary (`SetEntryFormat.actualsLine`):
/// a uniform per-set list collapses to "N × v" for brevity, anything varying stays
/// a "· "-separated list.
struct SetEntryFormatTests {

    private static let date = Date(timeIntervalSince1970: 1_700_000_000)
    private func entry(_ reps: Int? = nil, w: Double? = nil, s: Int? = nil) -> SetEntry {
        if let s { return SetEntry(seconds: s, completedAt: Self.date) }
        if let w { return SetEntry(reps: reps, weight: w, completedAt: Self.date) }
        return SetEntry(reps: reps, completedAt: Self.date)
    }
    private func line(_ entries: [SetEntry]) -> String {
        SetEntryFormat.actualsLine(entries, weightUnit: .lb)
    }

    /// Four identical weighted sets read as "4 × 10", not "10 · 10 · 10 · 10".
    @Test func uniformWeightedCollapses() {
        let e = entry(10, w: 130)
        #expect(line([e, e, e, e]) == "4 × 10 reps @ 130 lb")
    }

    /// Varying reps at one weight stay a list.
    @Test func varyingRepsStayAList() {
        #expect(line([entry(10, w: 100), entry(8, w: 100), entry(6, w: 100)]) == "10 · 8 · 6 reps @ 100 lb")
    }

    /// Varying weight falls back to the per-set "reps × weight" list.
    @Test func varyingWeightIsPerSet() {
        #expect(line([entry(10, w: 100), entry(8, w: 90)]) == "10 × 100 lb · 8 × 90 lb")
    }

    /// A single set is just its value — never "1 × 10".
    @Test func singleSetIsNotCollapsed() {
        #expect(line([entry(10, w: 130)]) == "10 reps @ 130 lb")
    }

    /// Bodyweight (no weight) and duration lists collapse the same way.
    @Test func bodyweightAndDurationCollapse() {
        #expect(line([entry(12), entry(12), entry(12)]) == "3 × 12 reps")
        #expect(line([entry(s: 45), entry(s: 45), entry(s: 45)]) == "3 × 45 sec")
        #expect(line([entry(s: 45), entry(s: 45), entry(s: 30)]) == "45 · 45 · 30 sec")
    }
}
