import Foundation
import IndieSync

/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
/// entities so the wire format can evolve without dragging the cache schema.
///
/// One document = one aggregate root:
///   • `RoutineDocument` embeds its `[ExerciseDocument]`   → `Splits/<ULID>.json`
///   • `WorkoutDocument` embeds its `[WorkoutLogDocument]` → `Workouts/YYYY/MM/<ULID>.json`
///
/// `schemaVersion` lets us migrate old files on read without forcing a rewrite
/// at sync time, and forward-gates files written by a newer app version.
/// These documents are also the wire format for the iPhone↔Watch bridge.

// MARK: - Routine

struct RoutineDocument: Codable, Sendable, Equatable, Identifiable {
    var schemaVersion: Int
    var id: String                 // ULID
    var name: String
    var color: String
    var systemImage: String
    var order: Int
    var createdAt: Date
    var updatedAt: Date
    var exercises: [ExerciseDocument]
    /// Raw `WorkoutActivityType`; nil → traditional strength training. Optional and
    /// deliberately NOT schema-bumped: it's a minor categorization preference, so an
    /// older app dropping it on rewrite (reverting to the default) is preferable to
    /// quarantining the user's whole routine.
    var activityType: Int?
    /// Per-routine rest length (seconds) and flow-mode toggle. Both optional and
    /// deliberately NOT schema-bumped, same rationale as `activityType`: an older
    /// app dropping them on rewrite reverts to the global default rest / no-flow,
    /// which is preferable to quarantining the user's whole routine.
    var restSeconds: Int? = nil
    var autoAdvance: Bool? = nil
    /// Target heart rate (bpm) for endurance routines — drives the live too-low /
    /// too-high indicator during a run. Nil → no target. Only meaningful when the
    /// `activityType` supports it (see `WorkoutActivityType.supportsHeartRateTarget`).
    /// Optional and NOT schema-bumped, same rationale as the fields above.
    var targetHeartRate: Int? = nil

    // Bumped 1→2 when the weight-reminder fields (`weightLastUpdated`,
    // `weightReminderWeeks`) and `category` were removed and `machineSettings` was
    // added. Removing required fields means older apps can no longer decode new
    // files, so the forward-gate must quarantine them rather than let an older app
    // silently rewrite them.
    // Bumped 2→3 when `ExerciseDocument.weight` went Int → Double: an older app
    // decoding a fractional weight (e.g. 45.5) into `Int` fails, so it must
    // quarantine rather than rewrite.
    static let currentSchemaVersion = 3

    // PINNED: the iCloud directory name stays "Splits/" even though the type is now
    // `RoutineDocument`. Existing user files live under Splits/ — the directory name
    // is on-disk data, not a symbol, and must not change on the rename.
    var relativePath: String { "Splits/\(id).json" }
}

struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
    var id: String                 // ULID
    var name: String
    var order: Int
    var sets: Int
    var reps: Int
    var weight: Double             // unit-less; fractional since schema v3 (Int weights decode natively)
    var loadType: Int
    var durationSeconds: Int       // total seconds (0 when not a timed exercise)
    /// Ordered, user-defined comfort settings for machine-based exercises (seat
    /// height, back rest incline, pin position…). `nil` → not a machine exercise;
    /// empty → a machine exercise with nothing recorded yet. This optionality
    /// doubles as the machine-based flag — there is no separate Bool.
    var machineSettings: [MachineSetting]? = nil
    /// The library exercise `name` was customized from (e.g. "Treadmill" renamed to
    /// "Brisk Walk 10 min" for one segment of an interval routine); nil → `name` IS
    /// the library name. Optional and deliberately NOT schema-bumped, same rationale
    /// as `RoutineDocument.activityType`: an older app dropping it on rewrite only
    /// strands the figure/info link for the renamed exercise, which is preferable to
    /// quarantining the user's whole routine.
    var libraryName: String? = nil
}

extension ExerciseDocument {
    /// The name to resolve figure/info/cues against — `name` itself unless it was
    /// customized, in which case the library exercise it derives from.
    var libraryExerciseName: String { libraryName ?? name }
}

/// A single free-form comfort setting for a machine-based exercise. `value` is a
/// string, not a number, because machine dials aren't uniformly numeric ("3rd
/// hole", "45°"). Ordered within `ExerciseDocument.machineSettings`.
struct MachineSetting: Codable, Sendable, Equatable {
    var name: String    // "Seat Height", "Back Rest Incline"
    var value: String   // "4", "3rd hole", "45°" — free-form; machine dials aren't uniformly numeric
}

/// One performed set. Which fields are set follows the log's `LoadType`:
/// weight → reps + weight; none (bodyweight) → reps; duration → seconds.
struct SetEntry: Codable, Sendable, Equatable {
    var reps: Int? = nil
    var weight: Double? = nil
    var seconds: Int? = nil
    var completedAt: Date
}

// MARK: - Workout

struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
    var schemaVersion: Int
    var id: String                 // ULID (chronological)
    var routineID: String?
    var routineName: String?
    var start: Date
    var end: Date?
    var status: String             // WorkoutStatus raw value
    var createdAt: Date
    var updatedAt: Date
    var logs: [WorkoutLogDocument]
    /// Health metrics captured for a finished workout (watch sensors) or estimated
    /// by the phone. Nil until the workout completes and a writer fills it in. The
    /// presence of `metrics.healthKitWorkoutUUID` is the dedupe signal that keeps the
    /// watch and the phone from both writing the same workout to Health.
    var metrics: WorkoutMetrics?

    /// Per-log deletion tombstones — `logID → when deleted`. Authored only by the phone
    /// (`WorkoutLogListView.deleteLog`, the sole log-removal site). They disambiguate an
    /// absent log during the per-log merge: without them, a log the watch still carries
    /// (having missed a phone-side delete) is indistinguishable from one the watch just
    /// added. `nil`/absent in files written before the merge shipped; pruned after a grace
    /// period. See `WorkoutMergePlanner`.
    var deletedLogIDs: [String: Date]? = nil

    /// Snapshot of the routine's rest length / flow flag / target heart rate at plan
    /// time (a running workout has no live link to its routine, same reason
    /// sets/reps/weight are snapshotted per log). Optional and not schema-bumped,
    /// same rationale as `RoutineDocument.restSeconds`/`autoAdvance`.
    var restSeconds: Int? = nil
    var autoAdvance: Bool? = nil
    var targetHeartRate: Int? = nil

    // PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed
    // from `splitID`/`splitName`, but their on-disk (and iPhone↔Watch wire) JSON keys
    // stay `"splitID"`/`"splitName"` so existing files and a not-yet-updated peer still
    // decode. Every stored property must be enumerated here (the computed `relativePath`
    // and static members are not coded); all others keep their default key names.
    enum CodingKeys: String, CodingKey {
        case schemaVersion
        case id
        case routineID = "splitID"
        case routineName = "splitName"
        case start
        case end
        case status
        case createdAt
        case updatedAt
        case logs
        case metrics
        case deletedLogIDs
        case restSeconds
        case autoAdvance
        case targetHeartRate
    }

    // Bumped 1→2 when `metrics` was added: the captured HR/calorie data is
    // irreplaceable, so the forward-gate must quarantine these files on older apps
    // rather than let them strip `metrics` on rewrite.
    // Bumped 2→3 when the derived `completed` flag was removed from
    // `WorkoutLogDocument` and `machineSettings` was added to it. Dropping a
    // required field means older apps can no longer decode new files, so the
    // forward-gate must quarantine them.
    // Bumped 3→4 when `WorkoutLogDocument.weight` went Int → Double and per-set
    // actuals (`setEntries`) were added: an older app decoding a fractional weight
    // into `Int` fails, and one rewriting the file would strip `setEntries` —
    // the recorded actuals are irreplaceable (same rationale as the v2 `metrics`
    // bump), so older apps must quarantine.
    // Bumped 4→5 when per-log `updatedAt` began being written and `deletedLogIDs`
    // was added for the per-log merge: an older app rewriting the file would strip
    // the deletion tombstones and resurrect a deleted exercise (irreplaceable
    // intent, same rationale as v2/v4), so older apps must quarantine.
    static let currentSchemaVersion = 5

    var relativePath: String { Self.relativePath(id: id, start: start) }

    static func relativePath(id: String, start: Date) -> String {
        let cal = Calendar(identifier: .gregorian)
        let comps = cal.dateComponents([.year, .month], from: start)
        let year = comps.year ?? 1970
        let month = comps.month ?? 1
        return String(format: "Workouts/%04d/%02d/%@.json", year, month, id)
    }
}

extension WorkoutDocument {
    /// Derive the aggregate `status` + `end` from the current logs. A log that is
    /// `.completed` or `.skipped` counts as *resolved*; a workout whose logs are all
    /// resolved is finished (`.completed`, with `end` stamped). This is the single
    /// source of the status-from-logs rule — every screen that mutates logs calls it,
    /// so an ended workout (remaining exercises skipped) stays finished no matter which
    /// screen the next edit comes from.
    mutating func recomputeStatusFromLogs(now: Date = Date()) {
        let statuses: [WorkoutStatus] = logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
        let isResolved: (WorkoutStatus) -> Bool = { $0 == .completed || $0 == .skipped }

        let allResolved = !statuses.isEmpty && statuses.allSatisfy(isResolved)
        let anyStarted = statuses.contains { $0 != .notStarted }

        if allResolved {
            // Only stamp the finish time on the *transition* into completed. A workout
            // that was already completed (with an `end`) keeps that original end, so
            // editing a finished workout — which re-runs this recompute with all logs
            // still resolved — doesn't jump its end forward to now. `now` is injectable
            // so the pure merge planner can recompute deterministically.
            let wasCompletedWithEnd = status == WorkoutStatus.completed.rawValue && end != nil
            status = WorkoutStatus.completed.rawValue
            if !wasCompletedWithEnd { end = now }
        } else if anyStarted {
            status = WorkoutStatus.inProgress.rawValue
            end = nil
        } else {
            status = WorkoutStatus.notStarted.rawValue
            end = nil
        }
    }

    /// End the workout now, keeping progress: mark every not-completed log as skipped, then
    /// recompute so it resolves to `.completed` (with `end` stamped). This is the
    /// "End Workout → Save" operation, shared by the in-workout menu and the
    /// start-a-new-routine prompt.
    mutating func endKeepingProgress() {
        for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
            logs[i].transition(to: .skipped)
        }
        recomputeStatusFromLogs()
        updatedAt = Date()
    }
}

struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
    var id: String                 // ULID
    var exerciseName: String
    var order: Int
    var sets: Int
    var reps: Int
    var weight: Double             // unit-less plan snapshot; fractional since schema v4
    var loadType: Int
    var durationSeconds: Int       // total seconds (0 when not a timed exercise)
    var currentStateIndex: Int
    var status: String             // WorkoutStatus raw value
    var notes: String?
    var date: Date
    /// Snapshot of the exercise's machine comfort settings at plan time (like the
    /// sets/reps/weight snapshot). `nil` → not a machine exercise; empty → a machine
    /// exercise with nothing recorded yet.
    var machineSettings: [MachineSetting]? = nil
    /// When the log first moved to `.inProgress` / last moved to `.completed`.
    /// Optional additions — absent in files written before they existed.
    var startedAt: Date? = nil
    var completedAt: Date? = nil
    /// Per-set actuals, in set order. `nil` → legacy file / nothing recorded;
    /// entries are appended as sets complete (pre-filled from the plan) and only
    /// diverge from it when the user adjusts one. Cleared on reset to `.notStarted`.
    var setEntries: [SetEntry]? = nil
    /// Reserved for the per-log merge H1's conflict resolution needs (a log edited
    /// on two devices merges by the newer `updatedAt`). Nothing writes it yet;
    /// absent in older files.
    var updatedAt: Date? = nil
    /// Plan-time snapshot of the exercise's `libraryName` (like sets/reps/weight).
    /// Optional and deliberately NOT schema-bumped, same rationale as
    /// `RoutineDocument.activityType`: an older app dropping it on rewrite only
    /// strands the figure/info link for a renamed exercise, which is preferable to
    /// quarantining the whole workout.
    var libraryName: String? = nil
}

extension WorkoutLogDocument {
    /// The name to resolve figure/info/cues against — see `ExerciseDocument.libraryExerciseName`.
    var libraryExerciseName: String { libraryName ?? exerciseName }

    /// Build a fresh plan-time log from a routine's exercise. Snapshots the plan
    /// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a
    /// running workout carries the exercise's settings as its own editable copy —
    /// `nil` stays `nil` (not a machine exercise), and `[]` stays `[]` (machine
    /// exercise, nothing recorded yet). Every "start a workout" / "add exercise to
    /// a workout" path funnels through here so the snapshot can't drift per site.
    init(planFrom exercise: ExerciseDocument, order: Int, date: Date) {
        self.init(
            id: ULID.make(),
            exerciseName: exercise.name,
            order: order,
            sets: exercise.sets,
            reps: exercise.reps,
            weight: exercise.weight,
            loadType: exercise.loadType,
            durationSeconds: exercise.durationSeconds,
            currentStateIndex: 0,
            status: WorkoutStatus.notStarted.rawValue,
            notes: nil,
            date: date,
            machineSettings: exercise.machineSettings,
            libraryName: exercise.libraryName
        )
    }

    /// Stamp the per-log modification time for a content-only edit — one that changes a
    /// log's fields (order, notes, machine settings, an adjusted entry) without flipping its
    /// status. Status flips stamp `updatedAt` inside `transition(to:)`; this is the hook the
    /// other edit sites call so the merge can order every kind of per-log change.
    mutating func touch(_ now: Date = Date()) { updatedAt = now }

    /// Move to a new status, keeping the started/completed timestamps consistent.
    /// Every screen that flips a log's status (phone and watch) goes through here,
    /// so the timestamps can't drift per-call-site. Completion is derived from
    /// `status` (`completed == (status == WorkoutStatus.completed.rawValue)`), so
    /// there's no separate flag to keep in sync.
    mutating func transition(to newStatus: WorkoutStatus) {
        status = newStatus.rawValue
        // Stamp the per-log modification time on every status flip — the merge orders
        // concurrent edits by it. Content-only edits (order, notes, machine settings,
        // adjusted entries) stamp `updatedAt` at their own call sites.
        updatedAt = Date()
        switch newStatus {
        case .notStarted:
            // A full reset — the run never happened, so recorded actuals go too.
            startedAt = nil
            completedAt = nil
            setEntries = nil
        case .inProgress:
            if startedAt == nil { startedAt = Date() }
            completedAt = nil
        case .completed:
            if completedAt == nil { completedAt = Date() }
            // Sets that completed without an explicit record (list checkbox,
            // completeExercise) get plan-default entries; recorded ones are kept.
            fillSetEntries(upTo: sets, at: completedAt ?? Date())
        case .skipped:
            // A skipped log keeps its partial entries — "End Workout, keeping
            // progress" must not discard the sets that were done.
            completedAt = nil
        }
    }

    /// Append plan-default entries for sets that completed without an explicit
    /// record, up to `count` in total. Only the missing tail is filled — entries
    /// already recorded are never overwritten — and a zero `count` on a log with
    /// no entries leaves `setEntries` nil (nothing was performed).
    mutating func fillSetEntries(upTo count: Int, at date: Date) {
        var entries = setEntries ?? []
        guard entries.count < count else { return }
        while entries.count < count {
            entries.append(planSetEntry(completedAt: date))
        }
        setEntries = entries
    }

    /// One plan-default entry, shaped by the log's `LoadType`:
    /// weight → reps + weight; none (bodyweight) → reps; duration → seconds.
    func planSetEntry(completedAt date: Date) -> SetEntry {
        switch LoadType(rawValue: loadType) ?? .weight {
        case .weight:   SetEntry(reps: reps, weight: weight, completedAt: date)
        case .none:     SetEntry(reps: reps, completedAt: date)
        case .duration: SetEntry(seconds: durationSeconds, completedAt: date)
        }
    }

    /// The entries read paths consume — the single fallback rule for legacy files:
    /// recorded actuals when any exist, else plan-synthesized entries for a
    /// *completed* log (what `fillSetEntries` would have recorded), else empty
    /// (an unfinished log with nothing recorded performed nothing).
    var effectiveSetEntries: [SetEntry] {
        if let setEntries, !setEntries.isEmpty { return setEntries }
        guard status == WorkoutStatus.completed.rawValue else { return [] }
        let date = completedAt ?? date
        return (0..<max(0, sets)).map { _ in planSetEntry(completedAt: date) }
    }
}

// MARK: - Workout health metrics

/// Health metrics for one finished workout. Embedded in `WorkoutDocument` so they
/// ride the existing iCloud + Watch-bridge paths with the rest of the workout.
/// Every field except `source`/`recordedAt` is optional — a phone estimate carries
/// only calories + volume, while a watch session fills in heart rate too.
struct WorkoutMetrics: Codable, Sendable, Equatable {
    var activeEnergyKcal: Double?
    var avgHeartRate: Double?      // bpm
    var maxHeartRate: Double?      // bpm
    var minHeartRate: Double?      // bpm
    var totalVolume: Double?       // Σ sets×reps×weight across weighted logs
    var hrZoneSeconds: [Double]?   // seconds spent in each of 5 HR zones (low→high)
    var healthKitWorkoutUUID: String?
    var source: MetricSource
    var recordedAt: Date
}

// MARK: - Schedule

/// The plan layer: one routine + a recurrence + an optional goal tag, persisted as
/// `Schedules/<ULID>.json`. The schedule is the join object between routines and
/// goals — scheduling never touches the routine file (starter routines are
/// immutable fixed-ULID seeds).
struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable {
    var schemaVersion: Int
    var id: String            // ULID
    var routineID: String
    var routineName: String   // denormalized display fallback (routine may be deleted)
    var goal: String?         // GoalKind raw value; nil = unassigned
    var recurrence: String    // ScheduleRecurrence raw value
    var weekdays: [Int]?      // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only
    var date: Date? = nil     // the one-off day; .once only
    /// Reminder time as minutes from midnight (local wall-clock, 0–1439); nil = no
    /// reminder. Optional addition — decode-compatible, deliberately NOT schema-bumped
    /// (an older app dropping it on rewrite loses only a notification preference).
    var reminderMinutes: Int? = nil
    var order: Int
    var createdAt: Date
    var updatedAt: Date

    static let currentSchemaVersion = 1

    var relativePath: String { "Schedules/\(id).json" }
}

extension ScheduleDocument {
    /// The tagged goal, or nil when unassigned / an unknown raw value.
    var goalKind: GoalKind? { goal.flatMap(GoalKind.init(rawValue:)) }

    /// The recurrence, defaulting to `.daily` on an unknown raw value.
    var recurrenceEnum: ScheduleRecurrence { ScheduleRecurrence(rawValue: recurrence) ?? .daily }

    /// Human-readable recurrence line ("Jul 12, 2026" / "Daily" / "Mon & Thu").
    var recurrenceSummary: String {
        recurrenceEnum.summary(weekdays: weekdays, date: date)
    }
}

// MARK: - Forward-compatibility gate

// `VersionedDocument` (the `isReadable` quarantine gate for files written by a
// newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now.
extension RoutineDocument: VersionedDocument {}
extension WorkoutDocument: VersionedDocument {}
extension ScheduleDocument: VersionedDocument {}
