// Swift side of the differential test. Run once per IANA zone with TZ=<zone>.
//
// The members of HarnessPayload marked VERBATIM are copied character-for-character
// from ios/AdelanteWidgets/AdelanteWidgetShared.swift (Adelante af1aea2). Only
// SwiftUI/WidgetKit-typed fields (colours, fonts, WidgetFamily) are omitted,
// and Timeline<AdelanteEntry> is replaced by HarnessTimeline in adelanteTimeline.
import Foundation

struct AdelanteQuoteEntry {
    let id: String
    let tier: Int
}

struct HarnessPayload {
    var configurationId: String = ""
    var scheduleDates: [Date] = []
    var dailyMinute: Int? = nil
    var manualSelection: [String: String] = [:]
    var authoritativePool: Bool = false
    var initialQuoteOnly: Bool = false
    let pool: [AdelanteQuoteEntry]
    let anchor: Date
    let interval: TimeInterval   // seconds; 0 == manual (no self-rotation)
    let showDate: Bool
    let syncLockAndHome: Bool

    var current: AdelanteQuoteEntry { pool.first ?? AdelanteQuoteEntry(id: "sample", tier: 0) }

    // VERBATIM (AdelanteWidgetShared.swift lines 103-163)
    func quote(at date: Date) -> AdelanteQuoteEntry {
        return quote(at: date, offset: 0, maxTier: 2)
    }

    func quote(at date: Date, offset: Int, maxTier: Int) -> AdelanteQuoteEntry {
        let scheduled = scheduledQuote(at: date, offset: offset, maxTier: maxTier)
        if manualSelection["period"] == period(at: date),
           let selected = pool.first(where: { $0.id == manualSelection["id"] }) { return selected }
        return scheduled
    }

    func period(at date: Date) -> String {
        let next = upcomingChanges(after: date, count: 1).first?.timeIntervalSince1970
            ?? (interval > 0 ? anchor.addingTimeInterval(Double(max(0, Int(date.timeIntervalSince(anchor) / interval)) + 1) * interval).timeIntervalSince1970 : 0)
        return "\(scheduledQuote(at: date, offset: 0, maxTier: 2).id):\(Int(next))"
    }

    func scheduledQuote(at date: Date, offset: Int, maxTier: Int) -> AdelanteQuoteEntry {
        let compatible = syncLockAndHome ? pool.filter { $0.tier == 0 } : pool
        let pool = compatible.isEmpty ? self.pool : compatible
        let eligible = pool.filter { $0.tier <= maxTier }
        // v1 publication owns eligibility and ordering. Refiltering here
        // changes both the selected ID and the rotation period versus the app.
        let list = authoritativePool ? self.pool : (eligible.isEmpty ? pool : eligible)
        guard list.count > 1 else { return list.first ?? current }
        let base: Int
        if !scheduleDates.isEmpty {
            base = dailyStep(at: date)
        } else if interval > 0 {
            let elapsed = date.timeIntervalSince(anchor)
            base = elapsed <= 0 ? 0 : Int(elapsed / interval)
        } else {
            base = 0
        }
        if initialQuoteOnly {
            if base == 0 { return list[0] }
            return list[1 + ((base - 1) % (list.count - 1))]
        }
        let index = ((base + offset) % list.count + list.count) % list.count
        return list[index]
    }

    func dailyStep(at date: Date, calendar: Calendar = .current) -> Int {
        let published = scheduleDates.prefix { $0 <= date }.count
        guard let last = scheduleDates.last, date > last, let minute = dailyMinute else { return published }
        let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: last), to: calendar.startOfDay(for: date)).day ?? 0
        let boundary = calendar.date(bySettingHour: minute / 60, minute: minute % 60, second: 0, of: date) ?? date
        return published + max(0, days - (date < boundary ? 1 : 0))
    }

    func upcomingChanges(after date: Date, count: Int, calendar: Calendar = .current) -> [Date] {
        var result = Array(scheduleDates.filter { $0 > date }.prefix(count))
        guard let minute = dailyMinute else { return result }
        var cursor = result.last ?? date
        while result.count < count {
            guard let next = calendar.nextDate(after: cursor, matching: DateComponents(hour: minute / 60, minute: minute % 60), matchingPolicy: .nextTime) else { break }
            result.append(next)
            cursor = next
        }
        return result
    }
}

struct HarnessEntry { let date: Date; let quote: AdelanteQuoteEntry }
struct HarnessTimeline { let entries: [HarnessEntry]; let refreshAt: Date }

// VERBATIM body of adelanteTimeline (AdelanteWidgetShared.swift lines 382-412),
// return type changed from Timeline<AdelanteEntry> to HarnessTimeline.
func adelanteTimeline(payload: HarnessPayload, now: Date, calendar: Calendar = .current) -> HarnessTimeline {
    var dates: Set<Date> = [now]
    var refreshAt = now.addingTimeInterval(3600)
    if !payload.scheduleDates.isEmpty, payload.pool.count > 1 {
        let upcoming = payload.upcomingChanges(after: now, count: 13, calendar: calendar)
        dates.formUnion(upcoming.prefix(12))
        refreshAt = upcoming.dropFirst(12).first ?? now.addingTimeInterval(3600)
    } else if payload.interval > 0, payload.pool.count > 1 {
        let step = Int(max(0, now.timeIntervalSince(payload.anchor)) / payload.interval)
        for offset in 1..<12 {
            dates.insert(payload.anchor.addingTimeInterval(Double(step + offset) * payload.interval))
        }
        refreshAt = payload.anchor.addingTimeInterval(Double(step + 12) * payload.interval)
    }
    if payload.showDate {
        let horizon = calendar.date(byAdding: .day, value: 7, to: now) ?? refreshAt
        refreshAt = min(refreshAt, horizon)
        dates = Set(dates.filter { $0 < refreshAt })
        var midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))
        while let date = midnight, date <= refreshAt {
            dates.insert(date)
            midnight = calendar.date(byAdding: .day, value: 1, to: date)
        }
    }
    let entries = dates.sorted().map { date in
        HarnessEntry(date: date, quote: payload.quote(at: date))
    }
    return HarnessTimeline(entries: entries, refreshAt: refreshAt)
}

// Decoding mirrors AdelantePayload.load (lines 209-237) over the catalog "data"
// dictionary, which production also obtains through JSONSerialization.
func double(_ values: [String: Any], key: String, fallback: Double) -> Double {
    let value = values[key]
    if let double = value as? Double { return double }
    if let number = value as? NSNumber { return number.doubleValue }
    return fallback
}

func loadPayload(_ values: [String: Any]) -> HarnessPayload {
    let raw = values["adelante_quote_pool"] as? String ?? "[]"
    let array = (try? JSONSerialization.jsonObject(with: raw.data(using: .utf8)!) as? [[String: Any]]) ?? []
    let pool = array.map { AdelanteQuoteEntry(id: $0["id"] as? String ?? "", tier: ($0["tier"] as? Int) ?? 0) }
    let updatedAt = (values["adelante_updated_at"] as? String).flatMap { { raw in
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return formatter.date(from: raw) ?? ISO8601DateFormatter().date(from: raw)
    }($0) }
    let epoch = (values["adelante_schedule_anchor_epoch"] as? NSNumber)?.doubleValue
        ?? (values["adelante_updated_epoch"] as? NSNumber)?.doubleValue
    let anchor = epoch.map { Date(timeIntervalSince1970: $0) } ?? updatedAt ?? Date()
    let dates = ((values["adelante_schedule_dates"] as? String)?.data(using: .utf8)).flatMap { try? JSONDecoder().decode([Double].self, from: $0) } ?? []
    return HarnessPayload(
        configurationId: values["adelante_configuration_id"] as? String ?? "",
        scheduleDates: dates.map { Date(timeIntervalSince1970: $0) },
        dailyMinute: (values["adelante_daily_minute"] as? NSNumber)?.intValue,
        authoritativePool: values["adelante_authoritative_pool"] as? Bool ?? false,
        initialQuoteOnly: values["adelante_initial_quote_only"] as? Bool ?? false,
        pool: pool,
        anchor: anchor,
        interval: double(values, key: "adelante_refresh_interval_seconds", fallback: 0),
        showDate: false,
        syncLockAndHome: values["adelante_sync_lock_and_home"] as? Bool ?? true
    )
}

@main
struct Main {
    static func main() throws {
        let args = CommandLine.arguments
        let zone = ProcessInfo.processInfo.environment["TZ"] ?? "unset"
        let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
        let root = try JSONSerialization.jsonObject(with: data) as! [String: Any]
        let results = root["results"] as! [[String: Any]]
        let maxTierArg = args.count > 3 ? Int(args[3])! : 2
        let clock = ContinuousClock()
        var instant: [[Int]] = []
        var shown: [[Int]] = []
        var queries = 0
        let elapsed = clock.measure {
            for r in results {
                let p = loadPayload(r["payload"] as! [String: Any])
                let qs = (r["queries"] as! [NSNumber]).map { Date(timeIntervalSince1970: $0.doubleValue / 1000) }
                let idx: (AdelanteQuoteEntry) -> Int = { e in p.pool.firstIndex { $0.id == e.id } ?? -1 }
                instant.append(qs.map { idx(p.scheduledQuote(at: $0, offset: 0, maxTier: maxTierArg)) })
                // Displayed quote: WidgetKit shows the last timeline entry at or
                // before t; a new timeline is requested exactly at refreshAt (idealised).
                let t0 = Date(timeIntervalSince1970: (r["t0"] as! NSNumber).doubleValue / 1000 + 0.001)
                var tl = adelanteTimeline(payload: p, now: t0)
                var start = t0
                var col: [Int] = []
                for q in qs {
                    if q < start { col.append(-2); continue }
                    var guardCount = 0
                    while q >= tl.refreshAt && guardCount < 2_000_000 {
                        start = tl.refreshAt
                        tl = adelanteTimeline(payload: p, now: start)
                        guardCount += 1
                    }
                    let e = tl.entries.last { $0.date <= q }
                    col.append(e.map { idx($0.quote) } ?? -3)
                }
                shown.append(col)
                queries += qs.count
            }
        }
        let tzv = TimeZone.timeZoneDataVersion
        let out: [String: Any] = [
            "runtime": "swift (Foundation)", "zone": zone, "tzCurrent": TimeZone.current.identifier,
            "tzDataVersion": tzv, "queries": queries,
            "elapsedMicros": elapsed.components.seconds * 1_000_000 + elapsed.components.attoseconds / 1_000_000_000_000,
            "ids": results.map { $0["id"] as! String }, "instant": instant, "shown": shown,
        ]
        try JSONSerialization.data(withJSONObject: out).write(to: URL(fileURLWithPath: args[2]))
        print("\(zone) [\(TimeZone.current.identifier)] \(results.count) scenarios \(queries) queries")
    }
}
