import Foundation
import Testing
@testable import Workouts_Watch_App

/// Pins the pure session-lifecycle decisions (`SessionEndPlanner`) — the rules that start and
/// end the watch's `HKWorkoutSession` from the authoritative workout set instead of a view
/// observer or the phone's one-shot launch handoff. The decisive properties: a running session
/// ends only on a genuine non-empty → empty transition of the active set; a survivor that's
/// `.completed` is a finish, otherwise a discard; the launch race (session running before the
/// run doc has synced) resolves to `.none` so a real run is never discarded before we've heard
/// about it; a too-young `.finish` demotes to `.discard` so a stale-data race can't save a junk
/// workout to Health; and a session should start whenever an active run has none.
struct SessionEndPlannerTests {
    private static let ts = Date(timeIntervalSince1970: 1_700_000_000)

    /// Comfortably past `minimumFinishAge` — the "normal running session" age.
    private static let matureAge: TimeInterval = 3600

    private func workout(_ id: String, _ status: WorkoutStatus, start: Date = ts) -> WorkoutDocument {
        WorkoutDocument(
            schemaVersion: WorkoutDocument.currentSchemaVersion,
            id: id, routineID: nil, routineName: nil, start: start, end: nil,
            status: status.rawValue, createdAt: start, updatedAt: start,
            logs: [], metrics: nil)
    }

    // MARK: - decide

    @Test func launchRace_runningButNothingHeardYet_isNone() {
        // Session running before any run document has synced: previous active set empty →
        // never discard a run we simply don't know about yet.
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: 1, previouslyActiveIDs: [], workouts: [])
        #expect(d == .none)
    }

    @Test func completedSurvivor_isFinish() {
        let done = workout("A", .completed)
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: Self.matureAge,
            previouslyActiveIDs: ["A"], workouts: [done])
        #expect(d == .finish(done))
    }

    @Test func vanishedRun_isDiscard() {
        // "A" was active, now absent entirely (discarded/deleted on the phone) → discard.
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: Self.matureAge,
            previouslyActiveIDs: ["A"], workouts: [])
        #expect(d == .discard)
    }

    @Test func stillPartlyActive_isNone() {
        // One of two previously-active runs finished, the other is still in progress → the
        // active set isn't empty, so the session keeps running.
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: Self.matureAge,
            previouslyActiveIDs: ["A", "B"],
            workouts: [workout("A", .completed), workout("B", .inProgress)])
        #expect(d == .none)
    }

    @Test func notRunning_isNone() {
        // A cold-relaunched watch with no session must never try to end one.
        let d = SessionEndPlanner.decide(
            isRunning: false, sessionAge: nil,
            previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)])
        #expect(d == .none)
    }

    @Test func alreadyEnded_isNone() {
        // Baseline already empty (we ended on a prior reconcile) — a redelivered completed
        // run must not re-trigger the end.
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: Self.matureAge,
            previouslyActiveIDs: [], workouts: [workout("A", .completed)])
        #expect(d == .none)
    }

    @Test func skippedOnlySurvivor_isDiscard() {
        // A run that resolved entirely to `.skipped` (ended early, nothing completed) is not a
        // finish — there's no completed run to save, so the session data is discarded.
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: Self.matureAge,
            previouslyActiveIDs: ["A"], workouts: [workout("A", .skipped)])
        #expect(d == .discard)
    }

    // MARK: - decide, session-age demotion

    @Test func youngFinish_demotesToDiscard() {
        // "End Current & Start New": the new run's session just started when the transient
        // "old run completed, new run not yet pushed" context applies. A finish here would
        // save a seconds-long junk workout to Health attributed to the OLD run — demote to
        // discard; `shouldStart` restarts the session when the new run's push lands.
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: 2,
            previouslyActiveIDs: ["A"], workouts: [workout("A", .completed)])
        #expect(d == .discard)
    }

    @Test func matureFinish_staysFinish() {
        let done = workout("A", .completed)
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: SessionEndPlanner.minimumFinishAge,
            previouslyActiveIDs: ["A"], workouts: [done])
        #expect(d == .finish(done))
    }

    @Test func unknownAge_staysFinish() {
        // A recovered session has no in-process start date — it predates this process, so
        // it's never "young" and finishes normally.
        let done = workout("A", .completed)
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: nil,
            previouslyActiveIDs: ["A"], workouts: [done])
        #expect(d == .finish(done))
    }

    @Test func parallelCompletions_pickMostRecentlyStarted() {
        // Two parallel runs completing together: the finish deterministically attaches the
        // session's metrics to the most recently started one, not Set-iteration luck.
        let older = workout("A", .completed, start: Self.ts)
        let newer = workout("B", .completed, start: Self.ts.addingTimeInterval(600))
        let d = SessionEndPlanner.decide(
            isRunning: true, sessionAge: Self.matureAge,
            previouslyActiveIDs: ["A", "B"], workouts: [older, newer])
        #expect(d == .finish(newer))
    }

    // MARK: - shouldStart / runToStart

    @Test func activeRunWithoutSession_shouldStart() {
        // The self-start rule: an active run with no session (dropped launch handoff, manual
        // engagement, post-crash) gets one at the next reconcile.
        #expect(SessionEndPlanner.shouldStart(
            isRunning: false, workouts: [workout("A", .inProgress)]))
    }

    @Test func sessionAlreadyRunning_shouldNotStart() {
        #expect(!SessionEndPlanner.shouldStart(
            isRunning: true, workouts: [workout("A", .inProgress)]))
    }

    @Test func nothingActive_shouldNotStart() {
        // Completed/skipped runs (or an empty cache) must never spin up a session.
        #expect(!SessionEndPlanner.shouldStart(
            isRunning: false,
            workouts: [workout("A", .completed), workout("B", .skipped)]))
        #expect(!SessionEndPlanner.shouldStart(isRunning: false, workouts: []))
    }

    @Test func runToStart_isMostRecentlyStartedActive() {
        let older = workout("A", .inProgress, start: Self.ts)
        let newer = workout("B", .notStarted, start: Self.ts.addingTimeInterval(600))
        let done = workout("C", .completed, start: Self.ts.addingTimeInterval(1200))
        #expect(SessionEndPlanner.runToStart(in: [older, newer, done]) == newer)
    }

    // MARK: - activeIDs

    @Test func activeIDs_countsOnlyUnfinished() {
        let workouts = [
            workout("A", .notStarted),
            workout("B", .inProgress),
            workout("C", .completed),
            workout("D", .skipped),
        ]
        #expect(SessionEndPlanner.activeIDs(in: workouts) == ["A", "B"])
    }
}
