import Foundation
import Testing
import IndieSync
@testable import Workouts

/// Locks the workout status state machine on `WorkoutDocument` — the single
/// source of the status-from-logs rule every screen funnels through
/// (`recomputeStatusFromLogs`, `endKeepingProgress`) plus the per-log
/// `WorkoutLogDocument.transition(to:)` timestamp bookkeeping. Includes the
/// regression for the "end re-stamped on every edit" bug: recomputing an
/// already-completed workout must keep its original `end`, not jump it to now.
struct WorkoutStatusMachineTests {

    private static let start = Date(timeIntervalSince1970: 1_700_000_000)
    private static let originalEnd = Date(timeIntervalSince1970: 1_700_003_600)

    private func log(id: String, order: Int, status: WorkoutStatus) -> WorkoutLogDocument {
        WorkoutLogDocument(
            id: id, exerciseName: "Ex-\(id)", order: order, sets: 3, reps: 10, weight: 100,
            loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0,
            status: status.rawValue, notes: nil, date: Self.start
        )
    }

    private func workout(
        status: WorkoutStatus, end: Date?, logs: [WorkoutLogDocument]
    ) -> WorkoutDocument {
        WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", routineID: "S",
            routineName: "S", start: Self.start, end: end, status: status.rawValue,
            createdAt: Self.start, updatedAt: Self.start, logs: logs, metrics: nil
        )
    }

    // MARK: - recomputeStatusFromLogs

    /// All logs resolved (a mix of completed and skipped) → the workout is completed and
    /// gets an `end` stamped on the transition.
    @Test func allResolvedCompletesAndStampsEnd() {
        var w = workout(status: .inProgress, end: nil, logs: [
            log(id: "a", order: 0, status: .completed),
            log(id: "b", order: 1, status: .skipped),
            log(id: "c", order: 2, status: .completed),
        ])
        w.recomputeStatusFromLogs()
        #expect(w.status == WorkoutStatus.completed.rawValue)
        #expect(w.end != nil)   // stamped on the transition into completed
    }

    /// Any log started but not all resolved → in progress, `end` cleared.
    @Test func anyStartedIsInProgressWithNoEnd() {
        var w = workout(status: .completed, end: Self.originalEnd, logs: [
            log(id: "a", order: 0, status: .completed),
            log(id: "b", order: 1, status: .inProgress),
            log(id: "c", order: 2, status: .notStarted),
        ])
        w.recomputeStatusFromLogs()
        #expect(w.status == WorkoutStatus.inProgress.rawValue)
        #expect(w.end == nil)   // reopened — a stale end must clear
    }

    /// No log started → not started, `end` cleared.
    @Test func noneStartedIsNotStarted() {
        var w = workout(status: .inProgress, end: Self.originalEnd, logs: [
            log(id: "a", order: 0, status: .notStarted),
            log(id: "b", order: 1, status: .notStarted),
        ])
        w.recomputeStatusFromLogs()
        #expect(w.status == WorkoutStatus.notStarted.rawValue)
        #expect(w.end == nil)
    }

    /// A workout with no logs is not "all resolved" (the guard excludes the empty set) →
    /// not started.
    @Test func emptyLogsIsNotStarted() {
        var w = workout(status: .inProgress, end: Self.originalEnd, logs: [])
        w.recomputeStatusFromLogs()
        #expect(w.status == WorkoutStatus.notStarted.rawValue)
        #expect(w.end == nil)
    }

    /// REGRESSION (FIX-3): an already-completed workout that still has all logs resolved
    /// keeps its ORIGINAL `end` when recompute runs again (e.g. the user re-opens the
    /// finished workout to fix a typo). The old code re-stamped `end = Date()` every
    /// time, jerking a months-old workout's end forward to now.
    @Test func alreadyCompletedKeepsOriginalEnd() {
        var w = workout(status: .completed, end: Self.originalEnd, logs: [
            log(id: "a", order: 0, status: .completed),
            log(id: "b", order: 1, status: .skipped),
        ])
        w.recomputeStatusFromLogs()
        #expect(w.status == WorkoutStatus.completed.rawValue)
        #expect(w.end == Self.originalEnd)   // preserved, not re-stamped to now
    }

    /// A workout marked completed but somehow missing an `end` (defensive: a legacy or
    /// partially-written file) gets one stamped — the preservation only applies when a
    /// real end already exists.
    @Test func completedWithNilEndGetsStamped() {
        var w = workout(status: .completed, end: nil, logs: [
            log(id: "a", order: 0, status: .completed),
        ])
        w.recomputeStatusFromLogs()
        #expect(w.status == WorkoutStatus.completed.rawValue)
        #expect(w.end != nil)
    }

    // MARK: - endKeepingProgress

    /// Ending a run in progress skips every not-completed log, keeps completed ones, and
    /// resolves the workout to completed with an `end` and a bumped `updatedAt`.
    @Test func endKeepingProgressSkipsRemainderAndCompletes() {
        var w = workout(status: .inProgress, end: nil, logs: [
            log(id: "a", order: 0, status: .completed),
            log(id: "b", order: 1, status: .inProgress),
            log(id: "c", order: 2, status: .notStarted),
        ])
        w.endKeepingProgress()

        #expect(w.status == WorkoutStatus.completed.rawValue)
        #expect(w.end != nil)
        #expect(w.logs[0].status == WorkoutStatus.completed.rawValue)   // kept
        #expect(w.logs[1].status == WorkoutStatus.skipped.rawValue)     // was in progress
        #expect(w.logs[2].status == WorkoutStatus.skipped.rawValue)     // was not started
        #expect(w.updatedAt > Self.start)
    }

    // MARK: - WorkoutLogDocument.transition

    @Test func transitionToInProgressStampsStartOnce() {
        var l = log(id: "a", order: 0, status: .notStarted)
        l.transition(to: .inProgress)
        let firstStart = l.startedAt
        #expect(firstStart != nil)
        #expect(l.completedAt == nil)

        // Re-entering inProgress must not overwrite the original startedAt.
        l.transition(to: .inProgress)
        #expect(l.startedAt == firstStart)
    }

    @Test func transitionToCompletedStampsCompletionOnceAndKeepsStart() {
        var l = log(id: "a", order: 0, status: .notStarted)
        l.transition(to: .inProgress)
        let started = l.startedAt
        l.transition(to: .completed)
        let firstCompleted = l.completedAt
        #expect(l.status == WorkoutStatus.completed.rawValue)
        #expect(firstCompleted != nil)
        #expect(l.startedAt == started)   // start preserved through completion

        // A second completed transition must not move completedAt.
        l.transition(to: .completed)
        #expect(l.completedAt == firstCompleted)
    }

    @Test func transitionToNotStartedClearsBothTimestamps() {
        var l = log(id: "a", order: 0, status: .completed)
        l.transition(to: .inProgress)
        l.transition(to: .completed)
        l.transition(to: .notStarted)
        #expect(l.status == WorkoutStatus.notStarted.rawValue)
        #expect(l.startedAt == nil)
        #expect(l.completedAt == nil)
    }

    /// Skipping clears any completion stamp but leaves `startedAt` (the exercise may
    /// have been partway through before the user skipped the rest).
    @Test func transitionToSkippedClearsCompletionKeepsStart() {
        var l = log(id: "a", order: 0, status: .notStarted)
        l.transition(to: .inProgress)
        l.transition(to: .completed)
        let started = l.startedAt
        l.transition(to: .skipped)
        #expect(l.status == WorkoutStatus.skipped.rawValue)
        #expect(l.completedAt == nil)
        #expect(l.startedAt == started)
    }
}
