import Foundation

// Pure decision logic for bringing an install's on-disk starter-routine library in
// line with the current app bundle. No I/O and no `SyncEngine` dependency, so the
// safety-critical part is fully unit-testable in isolation: `SyncEngine.reconcileSeeds()`
// gathers the live seed-path files, stub set, and live routine names, hands each seed to
// `SeedReconcilePlanner.decision(...)`, and executes the returned skip / upgrade / write.
//
// Why this exists: starter seeds ship as byte-canonical `RoutineDocument` JSON under
// FIXED ULIDs (see `SeedLibrary`). `autoSeedIfEmpty` only ever writes them into a
// verifiably empty container, so an existing install can never pick up an upgraded
// seed revision or a newly-shipped seed. This planner decides, per seed, whether the
// live fixed-ULID file is up to date, stale (needs the current bundle bytes), deleted
// (vetoed), or missing (write it).
//
// Safety invariant this relies on: a live file at a seed's fixed ULID can NEVER hold
// user-authored content — every user edit forks the seed to a fresh random ULID
// (clone-on-edit), and fixed-ULID files only exist on app versions that have
// clone-on-edit. So overwriting a seed-path file with current bundle bytes never
// destroys user data; at worst it upgrades an older seed revision.

// MARK: - Input

/// Everything the planner needs to decide one seed, pre-gathered by the engine.
struct SeedReconcileInput: Equatable {
    /// The seed's fixed ULID.
    let seedID: String
    /// The bundle seed document (canonical current content).
    let seedDoc: RoutineDocument
    /// The decoded live file currently sitting at the seed's fixed path, or `nil`
    /// when no live file exists there (deleted, or never written).
    let liveDoc: RoutineDocument?
    /// Whether a tombstone stub exists for this seed's id.
    let hasStub: Bool
}

// MARK: - Decision

enum SeedReconcileDecision: Equatable {
    /// Do nothing — the reason is carried for logging and tests.
    case skip(SeedSkipReason)
    /// A live file exists at the seed path but differs from the bundle: overwrite it
    /// with the verbatim bundle bytes (an older seed revision).
    case upgrade
    /// No live file, no veto, no name collision: write the verbatim bundle bytes (a
    /// newly-shipped seed, or a pre-fixed-ULID install missing the file).
    case write
}

enum SeedSkipReason: Equatable {
    /// The live file already matches the bundle seed (schemaVersion-normalized).
    case upToDate
    /// The live file was written by a newer app version — never downgrade-rewrite it.
    case quarantined
    /// No live file, but a stub exists: the user deleted this seed. Respect the veto.
    case vetoed
    /// No live file and no stub, but a live routine already uses this seed's name
    /// (e.g. a legacy runtime-built or user-cloned "Upper Body"). Writing the seed
    /// would duplicate it by name.
    case nameCollision
}

// MARK: - Planner

enum SeedReconcilePlanner {
    /// The automatic on-connect decision for one seed (see the decision table in the
    /// file header). `liveRoutineNames` must be the names of every live routine currently
    /// in the container/cache after reconcile — the name-collision guard consults it.
    static func decision(
        for input: SeedReconcileInput,
        liveRoutineNames: Set<String>
    ) -> SeedReconcileDecision {
        if let live = input.liveDoc {
            // A live file sits at the seed path. Never touch one a newer app version
            // wrote (the forward-compat quarantine), otherwise compare content.
            guard live.isReadable else { return .skip(.quarantined) }
            return semanticallyEqual(live: live, seed: input.seedDoc)
                ? .skip(.upToDate)
                : .upgrade
        }
        // No live file at the seed path.
        if input.hasStub { return .skip(.vetoed) }
        if liveRoutineNames.contains(input.seedDoc.name) { return .skip(.nameCollision) }
        return .write
    }

    /// Semantic (not byte) equality of a live seed file against the bundle seed.
    ///
    /// A byte compare is wrong here: a container file may have been written by an
    /// older app version, carrying extra now-removed keys and a lower `schemaVersion`.
    /// Decoding both into the current `RoutineDocument` already drops unknown keys, so
    /// the only remaining artificial difference is the version stamp — normalize the
    /// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of
    /// identical content reads as up to date (no churn), while a genuine content
    /// change reads as different (upgrade).
    ///
    /// `order` and `updatedAt` are likewise normalized away: dragging a starter to
    /// reorder the Library list writes a new `order` (and stamps `updatedAt`) onto the
    /// seed's fixed-ULID file. That is presentation, not content — a live seed whose
    /// only drift is the user's ordering must read `.upToDate` so the on-connect
    /// reconcile never clobbers it back to the bundle's default order. A genuine content
    /// change still differs and upgrades. (Accepted tradeoff: a real bundle-shipped seed
    /// revision does upgrade verbatim and resets that one seed's order — rare, harmless.)
    static func semanticallyEqual(live: RoutineDocument, seed: RoutineDocument) -> Bool {
        var normalized = live
        normalized.schemaVersion = seed.schemaVersion
        normalized.order = seed.order
        normalized.updatedAt = seed.updatedAt
        return normalized == seed
    }

    /// Whether the deliberate "Restore Starter Routines" action should (re)write a seed:
    /// only when no live file sits at its path and no live routine already uses its name.
    /// Unlike `decision(...)` a stub does NOT veto here — restore lifts the veto — but
    /// the engine still removes the stub as a separate execution step. A seed whose
    /// live file already exists is left for `decision(...)` to upgrade.
    static func shouldRestore(
        hasLiveFile: Bool,
        seedName: String,
        liveRoutineNames: Set<String>
    ) -> Bool {
        !hasLiveFile && !liveRoutineNames.contains(seedName)
    }
}
