import Foundation

/// Reads the public Vercel blob route catalogs that route-explorer's
/// website also reads. No auth, no Turnstile — just plain GETs against
/// a public CDN.
///
/// Endpoints used:
///   * `/data/routes/<IATA>.json` — per-origin catalog: every destination
///     the airport serves, with carriers, equipment, weekly frequency,
///     distance, average duration, and effective-date windows. No
///     per-flight departure times.
///   * `/data/airports-with-routes.json` — airport metadata (name, city,
///     country, lat/lng, ICAO, timezone) keyed by IATA. Used to enrich
///     row labels in the "Where can I go?" list.
///
/// Cache strategy: per-origin in-memory dictionary backed by an on-disk
/// JSON cache (Caches directory) with a 24-hour TTL. Catalog data only
/// changes weekly when route-explorer regenerates it, so 24h is generous
/// without ever serving genuinely stale info.
actor BlobRouteClient {

    // MARK: - Errors

    enum ClientError: Error, LocalizedError {
        case fetchFailed(status: Int)
        case decodingFailed(underlying: Error)
        case unknownOrigin(iata: String)

        var errorDescription: String? {
            switch self {
            case .fetchFailed(let status):
                return "Route catalog fetch failed (HTTP \(status))."
            case .decodingFailed(let error):
                return "Could not parse route catalog: \(error.localizedDescription)"
            case .unknownOrigin(let iata):
                return "No route catalog available for \(iata)."
            }
        }
    }

    // MARK: - Properties

    private let session: URLSession
    private var inMemoryCache: [String: BlobRouteCatalog] = [:]
    private static let blobBase = URL(string:
        "https://g80l6xxwjkrjoai7.public.blob.vercel-storage.com")!
    private static let cacheTTL: TimeInterval = 24 * 60 * 60

    // MARK: - Init

    init() {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 30
        // Catalogs are ~50KB-6MB each — let URLSession deflate on the
        // wire even though we don't read the Content-Encoding header.
        config.httpAdditionalHeaders = [
            "Accept": "application/json",
            "Accept-Encoding": "gzip, deflate, br",
        ]
        session = URLSession(configuration: config)
    }

    // MARK: - Public API

    /// Return the full route catalog for `origin`, using the in-memory
    /// cache first, then the on-disk cache (if not expired), then the
    /// network as a last resort.
    func catalog(for origin: String) async throws -> BlobRouteCatalog {
        let key = origin.uppercased()
        if let cached = inMemoryCache[key] { return cached }
        if let disk = loadDiskCache(origin: key) {
            inMemoryCache[key] = disk
            return disk
        }
        let fresh = try await fetchCatalog(origin: key)
        inMemoryCache[key] = fresh
        saveDiskCache(origin: key, catalog: fresh)
        return fresh
    }

    /// List of IATAs that `origin` serves as destinations, sorted by
    /// weekly frequency (busiest first). Used by the "Where can I go?"
    /// view to populate the destination list.
    func destinations(from origin: String) async throws -> [BlobRoute] {
        let catalog = try await catalog(for: origin)
        return catalog.routes.sorted { $0.freq > $1.freq }
    }

    /// `true` if `origin` directly serves `destination` per the catalog.
    /// Used by the connection finder to validate the second leg of a
    /// candidate (`origin` → `via` → `destination`).
    func serves(origin: String, destination: String) async -> Bool {
        do {
            let catalog = try await catalog(for: origin)
            return catalog.routes.contains { $0.dest == destination.uppercased() }
        } catch {
            return false
        }
    }

    // MARK: - Network

    private func fetchCatalog(origin: String) async throws -> BlobRouteCatalog {
        let url = Self.blobBase
            .appendingPathComponent("data")
            .appendingPathComponent("routes")
            .appendingPathComponent("\(origin).json")
        let (data, response) = try await session.data(from: url)
        guard let http = response as? HTTPURLResponse else {
            throw ClientError.fetchFailed(status: -1)
        }
        // 404 means no per-origin catalog — small airport not in the
        // bundle. Surface as a clean "unknown" rather than a generic error.
        if http.statusCode == 404 {
            throw ClientError.unknownOrigin(iata: origin)
        }
        guard (200..<300).contains(http.statusCode) else {
            throw ClientError.fetchFailed(status: http.statusCode)
        }
        do {
            return try JSONDecoder().decode(BlobRouteCatalog.self, from: data)
        } catch {
            throw ClientError.decodingFailed(underlying: error)
        }
    }

    // MARK: - Disk cache

    /// Cache file path: `<Caches>/BlobRouteCatalog/<IATA>.json`.
    private func diskCacheURL(origin: String) -> URL? {
        guard let cacheDir = FileManager.default.urls(
            for: .cachesDirectory, in: .userDomainMask
        ).first else { return nil }
        let dir = cacheDir.appendingPathComponent("BlobRouteCatalog", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir.appendingPathComponent("\(origin).json")
    }

    private func loadDiskCache(origin: String) -> BlobRouteCatalog? {
        guard let url = diskCacheURL(origin: origin) else { return nil }
        guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
              let modDate = attrs[.modificationDate] as? Date,
              Date().timeIntervalSince(modDate) < Self.cacheTTL,
              let data = try? Data(contentsOf: url)
        else { return nil }
        return try? JSONDecoder().decode(BlobRouteCatalog.self, from: data)
    }

    private func saveDiskCache(origin: String, catalog: BlobRouteCatalog) {
        guard let url = diskCacheURL(origin: origin) else { return }
        if let data = try? JSONEncoder().encode(catalog) {
            try? data.write(to: url, options: .atomic)
        }
    }
}

// MARK: - Models (mirrors Vercel blob shape)

struct BlobRouteCatalog: Codable, Sendable {
    let airport: String
    let updated: String?
    let stats: BlobRouteStats
    let routes: [BlobRoute]
}

struct BlobRouteStats: Codable, Sendable {
    let destinations: Int
    let airlines: Int
    let countries: Int
    let totalWeeklyFlights: Int
    let totalWeeklySeats: Int?
    let avgDistance: Int?
    let seasonalRoutes: Int?
}

struct BlobRoute: Codable, Sendable, Identifiable {
    let dest: String
    let airlines: [String]
    let freq: Int
    let dist: Int
    let totalSeats: Int?
    let avgDuration: Int?
    let equipment: [String]?
    let bodyTypes: [String]?
    let isSeasonal: Bool?
    let mealService: String?
    let effectiveDates: [BlobEffectiveDate]?
    let daysOfWeek: String?

    var id: String { dest }

    /// Returns `true` when the requested date falls inside any of the
    /// effective-date windows AND the day-of-week is in `daysOfWeek`.
    /// Used to gate seasonal routes off the "Where can I go?" list and
    /// out of connection candidates on dates the route isn't operating.
    func isOperating(on date: Date) -> Bool {
        // Day-of-week check: catalog uses ISO-8601 (1=Mon … 7=Sun).
        // `Calendar.component(.weekday, ...)` returns 1=Sun … 7=Sat — map
        // Sun→7, others shift by -1.
        let calendar = Calendar(identifier: .gregorian)
        let weekdaySun1 = calendar.component(.weekday, from: date)
        let iso = weekdaySun1 == 1 ? 7 : weekdaySun1 - 1
        if let dow = daysOfWeek, !dow.contains(String(iso)) { return false }

        guard let windows = effectiveDates, !windows.isEmpty else {
            // No window list provided — assume year-round.
            return true
        }
        let df = DateFormatter()
        df.dateFormat = "yyyyMMdd"
        df.calendar = Calendar(identifier: .gregorian)
        df.timeZone = TimeZone(identifier: "UTC")
        for window in windows {
            guard let from = df.date(from: window.from),
                  let to = df.date(from: window.to) else { continue }
            // Inclusive on both ends — the catalog's intent.
            let endOfTo = Calendar(identifier: .gregorian)
                .date(byAdding: .day, value: 1, to: to) ?? to
            if date >= from && date < endOfTo { return true }
        }
        return false
    }
}

struct BlobEffectiveDate: Codable, Sendable, Hashable {
    let from: String
    let to: String
}
