//
//  JSONImporterTests.swift
//  Trio
//
//  Created by Cengiz Deniz on 21.04.25.
//
import CoreData
import Foundation
import Swinject
import Testing

@testable import Trio

class BundleReference {}

@Suite("JSON Importer Tests", .serialized) struct JSONImporterTests: Injectable {
    var coreDataStack: CoreDataStack!
    var context: NSManagedObjectContext!
    var importer: JSONImporter!

    init() async throws {
        // In-memory Core Data for tests
        coreDataStack = try await CoreDataStack.createForTests()
        context = coreDataStack.newTaskContext()
        importer = JSONImporter(context: context, coreDataStack: coreDataStack)
    }

    @Test("Import glucose history with value checks") func testImportGlucoseHistoryDetails() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let path = testBundle.path(forResource: "glucose", ofType: "json")!
        let url = URL(filePath: path)

        let now = Date("2025-04-28T19:32:52.000Z")!
        try await importer.importGlucoseHistory(url: url, now: now)
        // run the import againt to check our deduplication logic
        try await importer.importGlucoseHistory(url: url, now: now)

        let allReadings = try await coreDataStack.fetchEntitiesAsync(
            ofType: GlucoseStored.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "date",
            ascending: false
        ) as? [GlucoseStored] ?? []

        #expect(allReadings.count == 274)
        #expect(allReadings.first?.glucose == 115)
        #expect(allReadings.first?.date == Date("2025-04-28T19:32:51.727Z"))
        #expect(allReadings.last?.glucose == 127)
        #expect(allReadings.last?.date == Date("2025-04-27T19:37:50.327Z"))

        let manualCount = allReadings.filter({ $0.isManual }).count
        #expect(manualCount == 1)
    }

    @Test("Skip importing old glucose values") func testSkipImportOldGlucoseValues() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let path = testBundle.path(forResource: "glucose", ofType: "json")!
        let url = URL(filePath: path)

        // more than 24 hours in the future from the most recent entry
        let now = Date("2025-04-29T19:32:52.000Z")!
        try await importer.importGlucoseHistory(url: url, now: now)

        let allReadings = try await coreDataStack.fetchEntitiesAsync(
            ofType: GlucoseStored.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "date",
            ascending: false
        ) as? [GlucoseStored] ?? []

        #expect(allReadings.isEmpty)
    }

    @Test("Import pump history with value checks") func testImportPumpHistoryDetails() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let path = testBundle.path(forResource: "pumphistory-24h-zoned", ofType: "json")!
        let url = URL(filePath: path)

        let now = Date("2025-04-29T01:33:58.000Z")!
        try await importer.importPumpHistory(url: url, now: now)
        // test out deduplication logic
        try await importer.importPumpHistory(url: url, now: now)

        let allReadings = try await coreDataStack.fetchEntitiesAsync(
            ofType: PumpEventStored.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "timestamp",
            ascending: false
        ) as? [PumpEventStored] ?? []

        let objectIds = allReadings.map(\.objectID)
        let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)

        var bolusTotal = 0.0
        var bolusCount = 0
        var smbCount = 0
        var rateTotal = 0.0
        var tempBasalCount = 0
        var durationTotal = 0
        var suspendCount = 0
        var resumeCount = 0
        for event in parsedHistory {
            switch event.type {
            case .bolus:
                bolusTotal += event.amount.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
                bolusCount += 1
                if event.isSMB == true {
                    smbCount += 1
                }
            case .tempBasal:
                rateTotal += event.rate.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
                tempBasalCount += 1
            case .tempBasalDuration:
                durationTotal += event.durationMin ?? 0
            case .pumpSuspend:
                suspendCount += 1
            case .pumpResume:
                resumeCount += 1
            default:
                fatalError("unhandled pump event")
            }
        }

        // see the scripts/pump-history-stats.py file for where these come from
        #expect(parsedHistory.count == 77)
        #expect(bolusCount == 23)
        #expect(smbCount == 21)
        #expect(bolusTotal.isApproximatelyEqual(to: 8.1, epsilon: 0.01))
        #expect(tempBasalCount == 26)
        #expect(rateTotal.isApproximatelyEqual(to: 20.08, epsilon: 0.001))
        #expect(durationTotal == 900)
        #expect(suspendCount == 1)
        #expect(resumeCount == 1)
    }

    @Test("Skipping old pump history entries") func testSkipOldPumpHistoryEntries() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let path = testBundle.path(forResource: "pumphistory-24h-zoned", ofType: "json")!
        let url = URL(filePath: path)

        let now = Date("2025-04-30T01:33:58.000Z")!
        try await importer.importPumpHistory(url: url, now: now)

        let allReadings = try await coreDataStack.fetchEntitiesAsync(
            ofType: PumpEventStored.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "timestamp",
            ascending: false
        ) as? [PumpEventStored] ?? []

        #expect(allReadings.isEmpty)
    }

    @Test("Import pump history with external insulin") func testImportPumpHistoryWithExternalInsulin() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let path = testBundle.path(forResource: "pumphistory-with-external", ofType: "json")!
        let url = URL(filePath: path)

        let now = Date("2025-05-04T04:37:44.654Z")!
        try await importer.importPumpHistory(url: url, now: now)

        let allReadings = try await coreDataStack.fetchEntitiesAsync(
            ofType: PumpEventStored.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "timestamp",
            ascending: false
        ) as? [PumpEventStored] ?? []

        let objectIds = allReadings.map(\.objectID)
        let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)

        #expect(parsedHistory.count == 1)

        let bolus = parsedHistory.first
        #expect(bolus?.type == .bolus)
        #expect(bolus?.isExternal == true)
        let amount = bolus?.amount.map { ($0 as NSDecimalNumber).doubleValue }
        #expect(amount?.isApproximatelyEqual(to: 0.88, epsilon: 0.01) == true)
    }

    @Test("Import carb history with value checks") func testImportCarbHistoryDetails() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let path = testBundle.path(forResource: "carbhistory", ofType: "json")!
        let url = URL(filePath: path)

        let now = Date("2025-04-28T19:32:52.000Z")!
        try await importer.importCarbHistory(url: url, now: now)
        // run the import againt to check our deduplication logic
        try await importer.importCarbHistory(url: url, now: now)

        let allCarbEntries = try await coreDataStack.fetchEntitiesAsync(
            ofType: CarbEntryStored.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "date",
            ascending: false
        ) as? [CarbEntryStored] ?? []

        #expect(allCarbEntries.count == 8)
        #expect(allCarbEntries.first?.carbs == 10)
        #expect(allCarbEntries.first?.note == "Snack 🍪")
        #expect(allCarbEntries.first?.date == Date("2025-04-28T18:36:06.968Z"))
        #expect(allCarbEntries.last?.carbs == 25)
        #expect(allCarbEntries.last?.date == Date("2025-04-28T05:03:43.332Z"))
    }

    @Test("Skip importing old carb entries") func testSkipImportOldCarbEntries() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let path = testBundle.path(forResource: "carbhistory", ofType: "json")!
        let url = URL(filePath: path)

        // more than 24 hours in the future from the most recent entry
        let now = Date("2025-04-29T19:32:52.000Z")!
        try await importer.importCarbHistory(url: url, now: now)

        let allCarbEntries = try await coreDataStack.fetchEntitiesAsync(
            ofType: CarbEntryStored.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "date",
            ascending: false
        ) as? [CarbEntryStored] ?? []

        #expect(allCarbEntries.isEmpty)
    }

    @Test("Import determination data with value checks") func testImportDeterminationDetails() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
        let enactedUrl = URL(filePath: enactedPath)
        let suggestedPath = testBundle.path(forResource: "suggested", ofType: "json")!
        let suggestedUrl = URL(filePath: suggestedPath)

        let now = Date("2025-04-28T20:50:00.000Z")!
        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
        // run the import againt to check our deduplication logic
        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)

        let determinations = try await coreDataStack.fetchEntitiesAsync(
            ofType: OrefDetermination.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "deliverAt",
            ascending: false
        ) as? [OrefDetermination] ?? []

        #expect(determinations.count == 1) // single determination, as enacted.deliverAt and suggested.deliverAt match

        let determination = determinations.first!

        #expect(determination.deliverAt == Date("2025-04-28T19:41:43.564Z"))
        #expect(determination.timestamp == Date("2025-04-28T19:41:48.453Z"))
        #expect(determination.enacted == true)
        #expect(determination.reason?.starts(with: "Autosens ratio: 0.99") == true)
        #expect(determination.insulinReq == Decimal(string: "0.29").map(NSDecimalNumber.init))
        #expect(determination.eventualBG! == NSDecimalNumber(160))
        #expect(determination.sensitivityRatio == Decimal(string: "0.9863849810728643").map(NSDecimalNumber.init))
        #expect(determination.rate == Decimal(string: "0").map(NSDecimalNumber.init))
        #expect(determination.duration == NSDecimalNumber(60))
        #expect(determination.iob == Decimal(string: "1.249").map(NSDecimalNumber.init))
        #expect(determination.cob == 34)
        #expect(determination.temp == "absolute")
        #expect(determination.glucose == NSDecimalNumber(85))
        #expect(determination.reservoir == Decimal(string: "3735928559").map(NSDecimalNumber.init))
        #expect(determination.insulinSensitivity == Decimal(string: "4.6").map(NSDecimalNumber.init))
        #expect(determination.currentTarget == Decimal(string: "94").map(NSDecimalNumber.init))
        #expect(determination.minDelta == NSDecimalNumber(5))
        #expect(determination.expectedDelta == Decimal(string: "-5.9").map(NSDecimalNumber.init))
        #expect(determination.threshold == Decimal(string: "3.7").map(NSDecimalNumber.init))
        #expect(determination.carbRatio == nil) // not present in JSON

        let forecasts = try await coreDataStack.fetchEntitiesAsync(
            ofType: Forecast.self,
            onContext: context,
            predicate: NSPredicate(format: "orefDetermination = %@", determination.objectID),
            key: "type",
            ascending: true,
            relationshipKeyPathsForPrefetching: ["forecastValues"]
        )

        var forecastHierarchy: [(forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []

        await context.perform {
            if let forecasts = forecasts as? [Forecast] {
                for forecast in forecasts {
                    // Use the helper property that already sorts by index
                    let sortedValues = forecast.forecastValuesArray
                    forecastHierarchy.append((
                        forecastID: forecast.objectID,
                        forecastValueIDs: sortedValues.map(\.objectID)
                    ))
                }
            }

            for entry in forecastHierarchy {
                var forecastValueTuple: (Forecast?, [ForecastValue]) = (nil, [])

                var forecast: Forecast?
                var forecastValues: [ForecastValue] = []

                do {
                    // Fetch the forecast object
                    forecast = try context.existingObject(with: entry.forecastID) as? Forecast

                    // Fetch the first 3h of forecast values
                    for forecastValueID in entry.forecastValueIDs.prefix(36) {
                        if let forecastValue = try context.existingObject(with: forecastValueID) as? ForecastValue {
                            forecastValues.append(forecastValue)
                        }
                    }
                } catch {
                    debugPrint(
                        "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast Values with error: \(error.localizedDescription)"
                    )
                }
                forecastValueTuple = (forecast, forecastValues)

                // Basic checks
                #expect(forecastValueTuple.0 != nil)
                #expect(forecastValueTuple.1.isNotEmpty == true)

                if let forecast = forecastValueTuple.0 {
                    let sortedValues = forecastValueTuple.1.sorted { $0.index < $1.index }
                    let prefix = sortedValues.prefix(5).compactMap(\.value)
                    let type = forecast.type?.lowercased()

                    switch type {
                    case "zt":
                        #expect(prefix == [85, 78, 71, 64, 58])
                    case "iob":
                        #expect(prefix == [85, 89, 92, 95, 97])
                    case "uam":
                        #expect(prefix == [85, 89, 93, 96, 99])
                    case "cob":
                        #expect(prefix == [85, 90, 94, 99, 103])
                    default:
                        break // Skip unknown forecast types silently
                    }
                }
            }
        }
    }

    @Test("Skip importing old determinations") func testSkipImportOldDeterminationData() async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
        let enactedUrl = URL(filePath: enactedPath)
        let suggestedPath = testBundle.path(forResource: "suggested", ofType: "json")!
        let suggestedUrl = URL(filePath: suggestedPath)

        // more than 24 hours in the future from the most recent entry
        let now = Date("2025-04-29T22:00:00.000Z")!

        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)

        let determinations = try await coreDataStack.fetchEntitiesAsync(
            ofType: OrefDetermination.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "deliverAt",
            ascending: false
        ) as? [OrefDetermination] ?? []

        #expect(determinations.isEmpty)
    }

    @Test("Import determination data with suggested newer than enacted") func testImportDeterminationDetailsWithNewerSuggested(
    ) async throws {
        let testBundle = Bundle(for: BundleReference.self)
        let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
        let enactedUrl = URL(filePath: enactedPath)
        let suggestedPath = testBundle.path(forResource: "newerSuggested", ofType: "json")!
        let suggestedUrl = URL(filePath: suggestedPath)

        let now = Date("2025-04-28T20:50:00.000Z")!
        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)

        let determinations = try await coreDataStack.fetchEntitiesAsync(
            ofType: OrefDetermination.self,
            onContext: context,
            predicate: NSPredicate(format: "TRUEPREDICATE"),
            key: "deliverAt",
            ascending: false
        ) as? [OrefDetermination] ?? []

        #expect(determinations.count == 2) // two determinations, suggested is more recent than enacted

        let suggested = determinations.first(where: { !$0.enacted && $0.deliverAt == $0.timestamp })!
        let enacted = determinations.first(where: { $0.enacted })!

        #expect(suggested.deliverAt == Date("2025-04-28T19:51:48.453Z"))
        #expect(enacted.timestamp == Date("2025-04-28T19:41:48.453Z"))
    }
}

extension Double {
    func isApproximatelyEqual(to other: Double, epsilon: Double?) -> Bool {
        // If no epsilon provided, require exact match
        guard let epsilon = epsilon else {
            return self == other
        }

        // Handle exact equality
        if self == other {
            return true
        }

        // Handle infinity and NaN
        if isInfinite || other.isInfinite || isNaN || other.isNaN {
            return self == other
        }

        // For values, use simple absolute difference
        return abs(self - other) <= epsilon
    }
}
