import Foundation
import SwiftData

// Stateless translation between the on-disk `*Document` wire format and the
// SwiftData cache entities. The only code that knows both shapes.
//
//   • `init(from: entity)`  — cache → document. Used by the write path (build a
//     document to save) and the iPhone↔Watch bridge.
//   • `CacheMapper.upsert*` — document → cache. Used ONLY by the metadata
//     observer (the sole cache mutator) and cache rebuilds. Embedded children
//     are reconciled by id (update / insert / delete) so frequent in-workout
//     edits don't churn unrelated rows.

// MARK: - Cache → Document

extension ExerciseDocument {
    init(from e: Exercise) {
        self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
                  weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
                  machineSettings: e.machineSettings, libraryName: e.libraryName)
    }
}

extension RoutineDocument {
    init(from routine: Routine) {
        self.init(schemaVersion: Self.currentSchemaVersion, id: routine.id, name: routine.name,
                  color: routine.color, systemImage: routine.systemImage, order: routine.order,
                  createdAt: routine.createdAt, updatedAt: routine.updatedAt,
                  exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)),
                  activityType: routine.activityTypeRaw,
                  restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance,
                  targetHeartRate: routine.targetHeartRate)
    }
}

extension WorkoutLogDocument {
    init(from log: WorkoutLog) {
        self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets,
                  reps: log.reps, weight: log.weight, loadType: log.loadType,
                  durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex,
                  status: log.statusRaw, notes: log.notes, date: log.date,
                  machineSettings: log.machineSettings,
                  startedAt: log.startedAt, completedAt: log.completedAt,
                  setEntries: log.setEntries, updatedAt: log.logUpdatedAt,
                  libraryName: log.libraryName)
    }
}

extension WorkoutDocument {
    init(from workout: Workout) {
        self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, routineID: workout.routineID,
                  routineName: workout.routineName, start: workout.start, end: workout.end,
                  status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
                  logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
                  metrics: workout.metrics,
                  deletedLogIDs: workout.deletedLogIDs,
                  restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance,
                  targetHeartRate: workout.targetHeartRate)
    }

    /// Maps a *live* cache entity to a document, or `nil` when that entity has already
    /// been deleted from its SwiftData context. Reading a persisted property on a
    /// deleted `@Model` traps (`_InitialBackingData.getValue` → `assertionFailure`), and
    /// a closure-based `NavigationLink` builds its destination view — and so runs this
    /// map — eagerly for *every* row in the parent list, including during the view-graph
    /// update that fires the instant a workout is deleted. Any map of an entity not
    /// already known to be live must go through this guard.
    ///
    /// `isDeleted` alone is not enough: it is true only between `context.delete()` and
    /// `context.save()`. Once the deletion is saved the model becomes *unregistered* —
    /// `isDeleted` reads false again, `modelContext` goes nil, and any persisted-property
    /// read still traps. A `@Model` retained across time (not freshly fetched) can reach
    /// this map in that state, so check both.
    init?(fromLive workout: Workout) {
        guard workout.isLive else { return nil }
        self.init(from: workout)
    }

    /// An inert stand-in for a destination whose backing entity is gone: the row is
    /// being removed, so its detail screen is never actually shown. Reads nothing from
    /// any entity, so it is safe to substitute for a deleted `@Model`.
    static let deletedPlaceholder = WorkoutDocument(
        schemaVersion: WorkoutDocument.currentSchemaVersion,
        id: "",
        start: Date(timeIntervalSinceReferenceDate: 0),
        status: WorkoutStatus.notStarted.rawValue,
        createdAt: Date(timeIntervalSinceReferenceDate: 0),
        updatedAt: Date(timeIntervalSinceReferenceDate: 0),
        logs: [])
}

extension ScheduleDocument {
    init(from schedule: Schedule) {
        self.init(schemaVersion: Self.currentSchemaVersion, id: schedule.id,
                  routineID: schedule.routineID, routineName: schedule.routineName,
                  goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw,
                  weekdays: schedule.weekdays,
                  date: schedule.date, reminderMinutes: schedule.reminderMinutes,
                  order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt)
    }
}

// MARK: - Document → Cache (upsert)

enum CacheMapper {
    static func fetchRoutine(id: String, in context: ModelContext) -> Routine? {
        var d = FetchDescriptor<Routine>(predicate: #Predicate { $0.id == id })
        d.fetchLimit = 1
        return try? context.fetch(d).first
    }

    static func fetchWorkout(id: String, in context: ModelContext) -> Workout? {
        var d = FetchDescriptor<Workout>(predicate: #Predicate { $0.id == id })
        d.fetchLimit = 1
        return try? context.fetch(d).first
    }

    static func fetchSchedule(id: String, in context: ModelContext) -> Schedule? {
        var d = FetchDescriptor<Schedule>(predicate: #Predicate { $0.id == id })
        d.fetchLimit = 1
        return try? context.fetch(d).first
    }

    // MARK: Routine

    static func upsertRoutine(_ doc: RoutineDocument, relativePath: String, into context: ModelContext) {
        let routine: Routine
        if let existing = fetchRoutine(id: doc.id, in: context) {
            routine = existing
        } else {
            routine = Routine(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
                          order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
                          jsonRelativePath: relativePath)
            context.insert(routine)
        }
        routine.name = doc.name
        routine.color = doc.color
        routine.systemImage = doc.systemImage
        routine.order = doc.order
        routine.createdAt = doc.createdAt
        routine.updatedAt = doc.updatedAt
        routine.jsonRelativePath = relativePath
        routine.activityTypeRaw = doc.activityType ?? 0
        routine.restSeconds = doc.restSeconds
        routine.autoAdvance = doc.autoAdvance
        routine.targetHeartRate = doc.targetHeartRate

        let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
        var keep = Set<String>()
        for ed in doc.exercises {
            keep.insert(ed.id)
            if let e = existing[ed.id] {
                apply(ed, to: e)
            } else {
                let e = makeExercise(ed)
                e.routine = routine
                context.insert(e)
            }
        }
        for e in Array(routine.exercises) where !keep.contains(e.id) {
            context.delete(e)
        }
    }

    private static func makeExercise(_ d: ExerciseDocument) -> Exercise {
        Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
                 loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
                 machineSettings: d.machineSettings, libraryName: d.libraryName)
    }

    private static func apply(_ d: ExerciseDocument, to e: Exercise) {
        e.name = d.name
        e.order = d.order
        e.sets = d.sets
        e.reps = d.reps
        e.weight = d.weight
        e.loadType = d.loadType
        e.durationTotalSeconds = d.durationSeconds
        e.machineSettings = d.machineSettings
        e.libraryName = d.libraryName
    }

    // MARK: Workout

    static func upsertWorkout(_ doc: WorkoutDocument, relativePath: String, into context: ModelContext) {
        let workout: Workout
        if let existing = fetchWorkout(id: doc.id, in: context) {
            workout = existing
        } else {
            workout = Workout(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
                              start: doc.start, end: doc.end, statusRaw: doc.status,
                              createdAt: doc.createdAt, updatedAt: doc.updatedAt,
                              jsonRelativePath: relativePath)
            context.insert(workout)
        }
        workout.routineID = doc.routineID
        workout.routineName = doc.routineName
        workout.start = doc.start
        workout.end = doc.end
        workout.statusRaw = doc.status
        workout.createdAt = doc.createdAt
        workout.updatedAt = doc.updatedAt
        workout.jsonRelativePath = relativePath
        workout.metrics = doc.metrics
        workout.deletedLogIDs = doc.deletedLogIDs
        workout.restSeconds = doc.restSeconds
        workout.autoAdvance = doc.autoAdvance
        workout.targetHeartRate = doc.targetHeartRate

        let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
        var keep = Set<String>()
        for ld in doc.logs {
            keep.insert(ld.id)
            if let l = existing[ld.id] {
                apply(ld, to: l)
            } else {
                let l = makeLog(ld)
                l.workout = workout
                context.insert(l)
            }
        }
        for l in Array(workout.logs) where !keep.contains(l.id) {
            context.delete(l)
        }
    }

    private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog {
        WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps,
                   weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
                   currentStateIndex: d.currentStateIndex, statusRaw: d.status,
                   notes: d.notes, date: d.date, machineSettings: d.machineSettings,
                   startedAt: d.startedAt, completedAt: d.completedAt,
                   setEntries: d.setEntries, logUpdatedAt: d.updatedAt, libraryName: d.libraryName)
    }

    private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
        l.exerciseName = d.exerciseName
        l.order = d.order
        l.sets = d.sets
        l.reps = d.reps
        l.weight = d.weight
        l.loadType = d.loadType
        l.durationTotalSeconds = d.durationSeconds
        l.currentStateIndex = d.currentStateIndex
        l.statusRaw = d.status
        l.notes = d.notes
        l.date = d.date
        l.machineSettings = d.machineSettings
        l.startedAt = d.startedAt
        l.completedAt = d.completedAt
        l.setEntries = d.setEntries
        l.logUpdatedAt = d.updatedAt
        l.libraryName = d.libraryName
    }

    // MARK: Schedule

    static func upsertSchedule(_ doc: ScheduleDocument, relativePath: String, into context: ModelContext) {
        let schedule: Schedule
        if let existing = fetchSchedule(id: doc.id, in: context) {
            schedule = existing
        } else {
            schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
                                goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays,
                                date: doc.date, reminderMinutes: doc.reminderMinutes, order: doc.order,
                                createdAt: doc.createdAt, updatedAt: doc.updatedAt,
                                jsonRelativePath: relativePath)
            context.insert(schedule)
        }
        schedule.routineID = doc.routineID
        schedule.routineName = doc.routineName
        schedule.goalRaw = doc.goal
        schedule.recurrenceRaw = doc.recurrence
        schedule.weekdays = doc.weekdays
        schedule.date = doc.date
        schedule.reminderMinutes = doc.reminderMinutes
        schedule.order = doc.order
        schedule.createdAt = doc.createdAt
        schedule.updatedAt = doc.updatedAt
        schedule.jsonRelativePath = relativePath
    }
}
