import Foundation

// Pure planning logic for the developer-facing "duplicate cleanup" tool. No I/O
// and no `SyncEngine` dependency, so it's fully unit-testable in isolation:
// `SyncEngine.scanForDuplicates()` gathers the already-decoded documents and the
// referenced-routine-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and
// `SyncEngine.performCleanup(_:)` executes the resulting plan file by file.
//
// Duplicates are detected by a *content* fingerprint that deliberately ignores
// identity (ids), timestamps, and cosmetic fields — two routines/workouts that were
// created independently (e.g. by a sync hiccup, a restore, or manual testing) but
// carry the same real content are duplicates even though every id differs.

// MARK: - Plan

struct DuplicateCleanupPlan: Sendable {
    struct RoutineGroup: Sendable, Identifiable {
        /// Members that survive — always non-empty when the group is emitted.
        var keep: [RoutineDocument]
        /// Members slated for deletion — always non-empty when the group is emitted.
        var delete: [RoutineDocument]

        var id: String {
            keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",")
        }
    }

    struct WorkoutGroup: Sendable, Identifiable {
        var keep: WorkoutDocument
        var delete: [WorkoutDocument]

        var id: String { keep.id }
    }

    var routineGroups: [RoutineGroup]
    var workoutGroups: [WorkoutGroup]

    var isEmpty: Bool { routineGroups.isEmpty && workoutGroups.isEmpty }

    /// Total number of documents this plan would delete across every group.
    var deleteCount: Int {
        routineGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
    }
}

// MARK: - Planner

enum DuplicateCleanupPlanner {
    /// Builds a cleanup plan from already-decoded, already-`isReadable`-filtered
    /// documents. `referencedRoutineIDs` must include every routine id any workout
    /// or schedule (readable or quarantined) points at, plus any clone-redirect
    /// targets — the caller (`SyncEngine.scanForDuplicates`) is responsible for
    /// assembling it.
    static func plan(
        routines: [RoutineDocument],
        workouts: [WorkoutDocument],
        referencedRoutineIDs: Set<String>,
        isSeed: (String) -> Bool
    ) -> DuplicateCleanupPlan {
        DuplicateCleanupPlan(
            routineGroups: planRoutines(routines, referencedRoutineIDs: referencedRoutineIDs, isSeed: isSeed),
            workoutGroups: planWorkouts(workouts)
        )
    }

    // MARK: Routines

    /// Hashable projection of a `MachineSetting`. The fingerprints keep the
    /// nil / empty distinction (non-machine vs. machine-with-nothing-recorded)
    /// and the user-defined order, both of which are real content.
    private struct MachineSettingFingerprint: Hashable {
        var name: String
        var value: String
    }

    private static func fingerprint(_ settings: [MachineSetting]?) -> [MachineSettingFingerprint]? {
        settings.map { $0.map { MachineSettingFingerprint(name: $0.name, value: $0.value) } }
    }

    /// Content-only projection of an exercise: ignores id and order (order is
    /// captured by the parent's sort, not the element itself).
    private struct ExerciseFingerprint: Hashable {
        var name: String
        var sets: Int
        var reps: Int
        var weight: Double
        var loadType: Int
        var durationSeconds: Int
        var machineSettings: [MachineSettingFingerprint]?
    }

    /// Content-only projection of a routine: ignores id, dates, color, systemImage,
    /// order, and activityType.
    private struct RoutineFingerprint: Hashable {
        var name: String
        var exercises: [ExerciseFingerprint]
    }

    private static func fingerprint(_ doc: RoutineDocument) -> RoutineFingerprint {
        RoutineFingerprint(
            name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines),
            exercises: doc.exercises.sorted { $0.order < $1.order }.map {
                ExerciseFingerprint(
                    name: $0.name, sets: $0.sets, reps: $0.reps, weight: $0.weight,
                    loadType: $0.loadType, durationSeconds: $0.durationSeconds,
                    machineSettings: fingerprint($0.machineSettings)
                )
            }
        )
    }

    /// Survivor rules (safety-critical — see file header):
    ///  1. A member referenced by any workout is always kept.
    ///  2. A member that's a bundled seed is always kept.
    ///  3. If neither rule protects anyone, the lexicographically smallest id
    ///     (ULIDs sort chronologically) survives — deterministic across devices.
    /// Everyone else in the group is deleted. A group with nothing to delete
    /// (e.g. every member is protected) is dropped entirely.
    private static func planRoutines(
        _ routines: [RoutineDocument],
        referencedRoutineIDs: Set<String>,
        isSeed: (String) -> Bool
    ) -> [DuplicateCleanupPlan.RoutineGroup] {
        let grouped = Dictionary(grouping: routines, by: fingerprint)
        var groups: [DuplicateCleanupPlan.RoutineGroup] = []

        for (_, members) in grouped where members.count > 1 {
            var keep: [RoutineDocument] = []
            var candidates: [RoutineDocument] = []
            for member in members {
                if referencedRoutineIDs.contains(member.id) || isSeed(member.id) {
                    keep.append(member)
                } else {
                    candidates.append(member)
                }
            }
            if keep.isEmpty, let survivorID = candidates.map(\.id).min() {
                keep = candidates.filter { $0.id == survivorID }
                candidates.removeAll { $0.id == survivorID }
            }
            guard !candidates.isEmpty else { continue }
            groups.append(DuplicateCleanupPlan.RoutineGroup(
                keep: keep.sorted { $0.id < $1.id },
                delete: candidates.sorted { $0.id < $1.id }
            ))
        }

        return groups.sorted { $0.id < $1.id }
    }

    // MARK: Workouts

    /// Content-only projection of a log: ignores id and the started/completed
    /// timestamps.
    private struct WorkoutLogFingerprint: Hashable {
        var exerciseName: String
        var order: Int
        var sets: Int
        var reps: Int
        var weight: Double
        var loadType: Int
        var durationSeconds: Int
        var status: String
        var currentStateIndex: Int
        var notes: String
        var machineSettings: [MachineSettingFingerprint]?
    }

    /// Content-only projection of a workout: ignores id, createdAt/updatedAt,
    /// startedAt/completedAt, exact start/end time (only the calendar day of
    /// `start` matters), and metrics.
    private struct WorkoutFingerprint: Hashable {
        var routineID: String
        var routineName: String
        var year: Int
        var month: Int
        var day: Int
        var status: String
        var logs: [WorkoutLogFingerprint]
    }

    private static func fingerprint(_ doc: WorkoutDocument) -> WorkoutFingerprint {
        let comps = Calendar.current.dateComponents([.year, .month, .day], from: doc.start)
        return WorkoutFingerprint(
            routineID: doc.routineID ?? "",
            routineName: doc.routineName ?? "",
            year: comps.year ?? 0,
            month: comps.month ?? 0,
            day: comps.day ?? 0,
            status: doc.status,
            logs: doc.logs.sorted { $0.order < $1.order }.map {
                WorkoutLogFingerprint(
                    exerciseName: $0.exerciseName, order: $0.order, sets: $0.sets, reps: $0.reps,
                    weight: $0.weight, loadType: $0.loadType, durationSeconds: $0.durationSeconds,
                    status: $0.status, currentStateIndex: $0.currentStateIndex,
                    notes: $0.notes ?? "",
                    machineSettings: fingerprint($0.machineSettings)
                )
            }
        )
    }

    /// Ranks a workout for survivorship — lower sorts first (wins): (1) holds the
    /// HealthKit link (deleting that copy would cascade-delete the real Health
    /// sample), (2) has metrics at all, (3) smallest id.
    private static func survivorRank(_ doc: WorkoutDocument) -> (Int, Int, String) {
        (doc.metrics?.healthKitWorkoutUUID != nil ? 0 : 1,
         doc.metrics != nil ? 0 : 1,
         doc.id)
    }

    /// If any member of a duplicate group is in progress, the whole group is
    /// dropped — never touch an active workout, since the watch matches its live
    /// run by workout id. Otherwise the survivor is picked by `survivorRank` and
    /// everyone else is deleted.
    private static func planWorkouts(_ workouts: [WorkoutDocument]) -> [DuplicateCleanupPlan.WorkoutGroup] {
        let grouped = Dictionary(grouping: workouts, by: fingerprint)
        var groups: [DuplicateCleanupPlan.WorkoutGroup] = []

        for (_, members) in grouped where members.count > 1 {
            if members.contains(where: { $0.status == WorkoutStatus.inProgress.rawValue }) { continue }

            let ranked = members.sorted { a, b in
                let ra = survivorRank(a), rb = survivorRank(b)
                if ra.0 != rb.0 { return ra.0 < rb.0 }
                if ra.1 != rb.1 { return ra.1 < rb.1 }
                return ra.2 < rb.2
            }
            guard let survivor = ranked.first else { continue }
            let toDelete = ranked.dropFirst().sorted { $0.id < $1.id }
            groups.append(DuplicateCleanupPlan.WorkoutGroup(keep: survivor, delete: Array(toDelete)))
        }

        return groups.sorted { $0.id < $1.id }
    }
}
