import Foundation
import SwiftData
import Testing
import IndieSync
@testable import Workouts

/// Round-trips `ScheduleDocument` through `DocumentCoder` (the exact JSON codec every
/// document in this app's iCloud container is written with), pins its on-disk path and
/// the `isReadable` forward-gate, and checks the convenience accessors + the recurrence
/// summary shared with the `Schedule` cache entity. Also exercises the document ↔ cache
/// mapper both directions, mirroring `WorkoutDocumentMapperTests` for the schedule aggregate.
struct ScheduleDocumentTests {
    /// Whole-second epochs so ISO-8601 round-trips without fractional-second loss.
    private static let created = Date(timeIntervalSince1970: 1_699_999_000)
    private static let updated = Date(timeIntervalSince1970: 1_700_004_000)
    /// A fixed one-off day for `.once` schedules.
    private static let onceDay = Date(timeIntervalSince1970: 1_752_105_600)

    private static let gregorianShortWeekdays = Calendar(identifier: .gregorian).shortWeekdaySymbols

    // MARK: - Codable

    /// A fully-populated `.fixedDays` schedule round-trips byte-for-byte through the codec,
    /// keeps its `Schedules/<id>.json` path, and stays readable at the current version.
    @Test func encodeDecodeRoundTripFixedDays() throws {
        let original = ScheduleDocument(
            schemaVersion: ScheduleDocument.currentSchemaVersion,
            id: ULID.make(),
            routineID: "01ROUTINEAAAAAAAAAAAAAAAAAA",
            routineName: "Push Day",
            goal: GoalKind.strength.rawValue,
            recurrence: ScheduleRecurrence.fixedDays.rawValue,
            weekdays: [2, 5],
            reminderMinutes: 7 * 60 + 30,
            order: 3,
            createdAt: Self.created,
            updatedAt: Self.updated
        )

        let data = try DocumentCoder.encode(original)
        let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)

        #expect(decoded == original)
        #expect(decoded.schemaVersion == 1)
        #expect(decoded.relativePath == "Schedules/\(original.id).json")
        #expect(decoded.isReadable)
        #expect(decoded.goalKind == .strength)
        #expect(decoded.recurrenceEnum == .fixedDays)
        #expect(decoded.reminderMinutes == 450)
    }

    /// The nil-heavy cases round-trip too: a `.daily` schedule with no goal and no
    /// weekdays. Absent optionals decode back to nil (not phantom values).
    @Test func encodeDecodeRoundTripDailyWithNils() throws {
        let original = ScheduleDocument(
            schemaVersion: ScheduleDocument.currentSchemaVersion,
            id: ULID.make(),
            routineID: "01ROUTINEBBBBBBBBBBBBBBBBBB",
            routineName: "Recovery",
            goal: nil,
            recurrence: ScheduleRecurrence.daily.rawValue,
            weekdays: nil,
            order: 0,
            createdAt: Self.created,
            updatedAt: Self.updated
        )

        let data = try DocumentCoder.encode(original)
        let json = String(decoding: data, as: UTF8.self)
        // nil optionals are omitted from the JSON entirely. ("date" is checked as a
        // quoted key — "updatedAt" contains it as a substring.)
        #expect(!json.contains("goal"))
        #expect(!json.contains("weekdays"))
        #expect(!json.contains("\"date\""))
        #expect(!json.contains("reminderMinutes"))

        let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
        #expect(decoded == original)
        #expect(decoded.goal == nil)
        #expect(decoded.goalKind == nil)
        #expect(decoded.weekdays == nil)
        #expect(decoded.date == nil)
        #expect(decoded.reminderMinutes == nil)
    }

    /// A `.once` schedule carries its one-off day and drops the recurring-only fields.
    @Test func encodeDecodeRoundTripOnce() throws {
        let original = ScheduleDocument(
            schemaVersion: ScheduleDocument.currentSchemaVersion,
            id: ULID.make(), routineID: "R", routineName: "Trail Run",
            goal: GoalKind.cardio.rawValue,
            recurrence: ScheduleRecurrence.once.rawValue,
            weekdays: nil, date: Self.onceDay, order: 2,
            createdAt: Self.created, updatedAt: Self.updated
        )
        let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original))
        #expect(decoded == original)
        #expect(decoded.recurrenceEnum == .once)
        #expect(decoded.date == Self.onceDay)
        #expect(decoded.weekdays == nil)
    }

    // MARK: - Forward-compatibility gate

    /// A schedule written by a newer app version (schemaVersion + 1) fails the `isReadable`
    /// gate, so the sync layer quarantines rather than downgrades it; at (and below) the
    /// current version it reads.
    @Test func forwardGateQuarantinesNewerVersions() {
        func doc(schema: Int) -> ScheduleDocument {
            ScheduleDocument(schemaVersion: schema, id: "01X", routineID: "R", routineName: "N",
                             goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
                             weekdays: nil, order: 0,
                             createdAt: Self.created, updatedAt: Self.updated)
        }
        #expect(doc(schema: ScheduleDocument.currentSchemaVersion).isReadable)
        #expect(doc(schema: ScheduleDocument.currentSchemaVersion - 1).isReadable)
        #expect(!doc(schema: ScheduleDocument.currentSchemaVersion + 1).isReadable)
    }

    // MARK: - Recurrence summary

    /// The recurrence summary formats each recurrence kind and shares its logic across the
    /// document and the cache entity. Weekday names are pulled from a fixed Gregorian
    /// calendar (1 = Sun … 7 = Sat) so the mapping is locale-order-stable.
    @Test func recurrenceSummaryFormatsEachKind() {
        let sym = Self.gregorianShortWeekdays

        // Once — the one-off day, medium-date formatted (routing check; the format
        // itself is `Date.formatDate()`'s). Degenerate nil date → the display name.
        #expect(summary(.once, weekdays: nil, date: Self.onceDay) == Self.onceDay.formatDate())
        #expect(summary(.once, weekdays: nil) == "Once")

        // Daily.
        #expect(summary(.daily, weekdays: nil) == "Daily")

        // Fixed days — sorted, joined with "&"; weekday 2 = Mon, 5 = Thu.
        #expect(summary(.fixedDays, weekdays: [5, 2]) == "\(sym[1]) & \(sym[4])")
        // Single day → just the name.
        #expect(summary(.fixedDays, weekdays: [2]) == sym[1])
        // Three days → Oxford-style "A, B & C".
        #expect(summary(.fixedDays, weekdays: [2, 4, 6]) == "\(sym[1]), \(sym[3]) & \(sym[5])")
        // Degenerate (no weekdays) → falls back to the recurrence display name.
        #expect(summary(.fixedDays, weekdays: []) == "Fixed Days")
        #expect(summary(.fixedDays, weekdays: nil) == "Fixed Days")
        // Out-of-range weekday numbers are ignored.
        #expect(summary(.fixedDays, weekdays: [0, 2, 9]) == sym[1])
    }

    private func summary(_ r: ScheduleRecurrence, weekdays: [Int]?, date: Date? = nil) -> String {
        let doc = ScheduleDocument(
            schemaVersion: ScheduleDocument.currentSchemaVersion, id: "X", routineID: "R", routineName: "N",
            goal: nil, recurrence: r.rawValue, weekdays: weekdays,
            date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated
        )
        // The entity must format identically to the document — this is the shared-logic check.
        let entity = Schedule(id: "X", routineID: "R", routineName: "N", goalRaw: nil,
                              recurrenceRaw: r.rawValue, weekdays: weekdays,
                              date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated,
                              jsonRelativePath: "")
        #expect(doc.recurrenceSummary == entity.recurrenceSummary)
        return doc.recurrenceSummary
    }

    // MARK: - Mapper (document ↔ cache)

    @MainActor
    private func makeContext() throws -> ModelContext {
        let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self, Schedule.self])
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
        return ModelContext(try ModelContainer(for: schema, configurations: [config]))
    }

    /// A document pushed through `upsertSchedule` (document → cache) and rebuilt via
    /// `ScheduleDocument(from:)` (cache → document) preserves every field in both directions.
    @MainActor
    @Test func mapperRoundTripPreservesEveryField() throws {
        let context = try makeContext()
        let original = ScheduleDocument(
            schemaVersion: ScheduleDocument.currentSchemaVersion,
            id: "01SCHEDULEROUNDTRIP0000001",
            routineID: "01ROUTINEXYZ00000000000000",
            routineName: "Leg Day",
            goal: GoalKind.mobility.rawValue,
            recurrence: ScheduleRecurrence.fixedDays.rawValue,
            weekdays: [2, 4, 6],
            reminderMinutes: 18 * 60 + 45,
            order: 7,
            createdAt: Self.created,
            updatedAt: Self.updated
        )

        CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context)
        let entity = try #require(CacheMapper.fetchSchedule(id: original.id, in: context))

        // Entity mirrors the document field-for-field.
        #expect(entity.routineID == original.routineID)
        #expect(entity.routineName == original.routineName)
        #expect(entity.goalRaw == original.goal)
        #expect(entity.goalKind == .mobility)
        #expect(entity.recurrenceRaw == original.recurrence)
        #expect(entity.recurrenceEnum == .fixedDays)
        #expect(entity.weekdays == [2, 4, 6])
        #expect(entity.reminderMinutes == 1125)
        #expect(entity.order == 7)
        #expect(entity.jsonRelativePath == "Schedules/\(original.id).json")

        // Cache → document round-trips back to the original.
        let rebuilt = ScheduleDocument(from: entity)
        #expect(rebuilt == original)
    }

    /// The one-off day survives document → cache (`upsertSchedule`) → document — both on
    /// first insert and when re-upserting clears it back to nil.
    @MainActor
    @Test func mapperPreservesOnceDate() throws {
        let context = try makeContext()
        let original = ScheduleDocument(
            schemaVersion: ScheduleDocument.currentSchemaVersion,
            id: "01SCHEDULEONCE000000000001", routineID: "R", routineName: "Trail Run",
            goal: nil, recurrence: ScheduleRecurrence.once.rawValue,
            weekdays: nil, date: Self.onceDay, order: 0,
            createdAt: Self.created, updatedAt: Self.updated
        )
        CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context)
        let entity = try #require(CacheMapper.fetchSchedule(id: original.id, in: context))
        #expect(entity.date == Self.onceDay)
        #expect(entity.recurrenceEnum == .once)
        #expect(ScheduleDocument(from: entity) == original)

        // Editing it into a recurring schedule drops the day on re-upsert.
        var second = original
        second.recurrence = ScheduleRecurrence.daily.rawValue
        second.date = nil
        CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context)
        #expect(entity.date == nil)
        #expect(ScheduleDocument(from: entity) == second)
    }

    /// Re-upserting a changed document updates the existing entity in place (matched by
    /// id) rather than inserting a duplicate — the observer's re-import shape.
    @MainActor
    @Test func reUpsertUpdatesInPlace() throws {
        let context = try makeContext()
        let first = ScheduleDocument(
            schemaVersion: ScheduleDocument.currentSchemaVersion,
            id: "01SCHEDULEREUPSERT0000001", routineID: "R", routineName: "Old Name",
            goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
            weekdays: nil, order: 0,
            createdAt: Self.created, updatedAt: Self.created
        )
        CacheMapper.upsertSchedule(first, relativePath: first.relativePath, into: context)
        try context.save()

        var second = first
        second.routineName = "New Name"
        second.goal = GoalKind.strength.rawValue
        second.recurrence = ScheduleRecurrence.fixedDays.rawValue
        second.weekdays = [2, 4]
        second.updatedAt = Self.updated
        CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context)
        try context.save()

        let all = try context.fetch(FetchDescriptor<Schedule>())
        #expect(all.count == 1)   // updated in place, not duplicated
        let entity = try #require(all.first)
        #expect(entity.routineName == "New Name")
        #expect(entity.goalKind == .strength)
        #expect(entity.recurrenceEnum == .fixedDays)
        #expect(entity.weekdays == [2, 4])
        #expect(ScheduleDocument(from: entity) == second)
    }
}
