//
//  GameDAGRouter.swift
//  SportsTime
//
//  DAG-based route finding with multi-dimensional diversity.
//
//  Key insight: This is NOT "which subset of N games should I attend?"
//  This IS: "what time-respecting paths exist through a graph of games?"
//
//  The algorithm:
//    1. Bucket games by calendar day
//    2. Build directed edges where time moves forward AND driving is feasible
//    3. Generate routes via beam search
//    4. Diversity pruning: ensure routes span full range of games, cities, miles, and days
//
//  The diversity system ensures users see:
//    - Short trips (2-3 cities) AND long trips (5+ cities)
//    - Quick trips (2-3 games) AND packed trips (5+ games)
//    - Low mileage AND high mileage options
//    - Short duration AND long duration trips
//

import Foundation
import CoreLocation

enum GameDAGRouter {

    // MARK: - Configuration

    /// Default beam width during expansion (for typical datasets)
    private static let defaultBeamWidth = 100

    /// Maximum options to return (diverse sample)
    private static let maxOptions = 75

    /// Dynamically scales beam width based on dataset size for performance
    private static func effectiveBeamWidth(gameCount: Int, requestedWidth: Int) -> Int {
        // For large datasets, reduce beam width to prevent exponential blowup
        // Tuned to balance diversity preservation vs computation time
        if gameCount >= 5000 {
            return min(requestedWidth, 25)  // Very aggressive for 5K+ games
        } else if gameCount >= 2000 {
            return min(requestedWidth, 30)  // Aggressive for 2K+ games
        } else if gameCount >= 800 {
            return min(requestedWidth, 50)  // Moderate for 800+ games (includes 1K test)
        } else {
            return requestedWidth
        }
    }

    /// Buffer time after game ends before we can depart (hours)
    private static let gameEndBufferHours: Double = 3.0

    /// Maximum days ahead to consider for next game (1 = next day only, 5 = allows multi-day drives)
    private static let maxDayLookahead = 5

    // MARK: - Route Profile

    /// Captures the key metrics of a route for diversity analysis
    private struct RouteProfile {
        let route: [Game]
        let gameCount: Int
        let cityCount: Int
        let totalMiles: Double
        let tripDays: Int

        // Bucket indices for stratified sampling
        var gameBucket: Int { min(gameCount - 1, 5) }  // 1, 2, 3, 4, 5, 6+
        var cityBucket: Int { min(cityCount - 1, 5) }  // 1, 2, 3, 4, 5, 6+
        var milesBucket: Int {
            switch totalMiles {
            case ..<500: return 0        // Short
            case 500..<1000: return 1    // Medium
            case 1000..<2000: return 2   // Long
            case 2000..<3000: return 3   // Very long
            default: return 4            // Epic
            }
        }
        var daysBucket: Int { min(tripDays - 1, 6) }  // 1-7+ days

        /// Composite key for exact deduplication
        var uniqueKey: String {
            route.map { $0.id }.joined(separator: "-")
        }
    }

    // MARK: - Public API

    /// Finds routes through the game graph with multi-dimensional diversity.
    ///
    /// Returns a curated sample that spans the full range of:
    /// - Number of games (2-game quickies to 6+ game marathons)
    /// - Number of cities (2-city to 6+ city routes)
    /// - Total miles (short drives to cross-country epics)
    /// - Trip duration (weekend getaways to week-long adventures)
    ///
    /// - Parameters:
    ///   - games: All games to consider
    ///   - stadiums: Dictionary mapping stadium IDs to Stadium objects
    ///   - constraints: Driving constraints (max hours per day)
    ///   - anchorGameIds: Games that MUST appear in every valid route
    ///   - allowRepeatCities: If false, each city can only appear once in a route
    ///   - beamWidth: How many partial routes to keep during expansion
    ///
    /// - Returns: Array of diverse route options
    ///
    static func findRoutes(
        games: [Game],
        stadiums: [String: Stadium],
        constraints: DrivingConstraints,
        anchorGameIds: Set<String> = [],
        allowRepeatCities: Bool = true,
        beamWidth: Int = defaultBeamWidth
    ) -> [[Game]] {

        // Edge cases
        guard !games.isEmpty else { return [] }
        if games.count == 1 {
            if anchorGameIds.isEmpty || anchorGameIds.contains(games[0].id) {
                return [games]
            }
            return []
        }
        if games.count == 2 {
            let sorted = games.sorted { $0.startTime < $1.startTime }
            if canTransition(from: sorted[0], to: sorted[1], stadiums: stadiums, constraints: constraints) {
                if anchorGameIds.isSubset(of: Set(sorted.map { $0.id })) {
                    return [sorted]
                }
            }
            if anchorGameIds.isEmpty {
                return [[sorted[0]], [sorted[1]]]
            }
            return []
        }

        // Step 1: Sort games chronologically
        let sortedGames = games.sorted { $0.startTime < $1.startTime }

        // Step 2: Bucket games by calendar day
        let buckets = bucketByDay(games: sortedGames)
        let sortedDays = buckets.keys.sorted()

        guard !sortedDays.isEmpty else { return [] }

        // Step 2.5: Calculate effective beam width for this dataset size
        let scaledBeamWidth = effectiveBeamWidth(gameCount: games.count, requestedWidth: beamWidth)

        // Step 3: Initialize beam with first few days' games as starting points
        var beam: [[Game]] = []
        for dayIndex in sortedDays.prefix(maxDayLookahead) {
            if let dayGames = buckets[dayIndex] {
                for game in dayGames {
                    beam.append([game])
                }
            }
        }

        // Step 4: Expand beam day by day with early termination
        for dayIndex in sortedDays.dropFirst() {
            // Early termination: if beam has enough diverse routes, stop expanding
            // More aggressive for smaller datasets to hit tight performance targets
            let earlyTerminationThreshold = games.count >= 5000 ? scaledBeamWidth * 3 : scaledBeamWidth * 2
            if beam.count >= earlyTerminationThreshold {
                break
            }

            let todaysGames = buckets[dayIndex] ?? []
            var nextBeam: [[Game]] = []

            for path in beam {
                guard let lastGame = path.last else { continue }
                let lastGameDay = dayIndexFor(lastGame.startTime, referenceDate: sortedGames[0].startTime)

                // Skip if this day is too far ahead for this route
                if dayIndex > lastGameDay + maxDayLookahead {
                    nextBeam.append(path)
                    continue
                }

                // Try adding each of today's games
                for candidate in todaysGames {
                    // Check for repeat city violation
                    if !allowRepeatCities {
                        let candidateCity = stadiums[candidate.stadiumId]?.city ?? ""
                        let pathCities = Set(path.compactMap { stadiums[$0.stadiumId]?.city })
                        if pathCities.contains(candidateCity) {
                            continue
                        }
                    }

                    if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
                        nextBeam.append(path + [candidate])
                    }
                }

                // Keep the path without adding a game today
                nextBeam.append(path)
            }

            // Diversity-aware pruning during expansion
            beam = diversityPrune(nextBeam, stadiums: stadiums, targetCount: scaledBeamWidth)
        }

        // Step 5: Filter routes that contain all anchors
        let routesWithAnchors = beam.filter { path in
            let pathGameIds = Set(path.map { $0.id })
            return anchorGameIds.isSubset(of: pathGameIds)
        }

        // Step 6: Final diversity selection
        let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions)

        print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)")

        return finalRoutes
    }

    /// Compatibility wrapper that matches GeographicRouteExplorer's interface.
    static func findAllSensibleRoutes(
        from games: [Game],
        stadiums: [String: Stadium],
        anchorGameIds: Set<String> = [],
        allowRepeatCities: Bool = true,
        stopBuilder: ([Game], [String: Stadium]) -> [ItineraryStop]
    ) -> [[Game]] {
        let constraints = DrivingConstraints.default
        return findRoutes(
            games: games,
            stadiums: stadiums,
            constraints: constraints,
            anchorGameIds: anchorGameIds,
            allowRepeatCities: allowRepeatCities
        )
    }

    // MARK: - Multi-Dimensional Diversity Selection

    /// Selects routes that maximize diversity across all dimensions.
    /// Uses stratified sampling to ensure representation of:
    /// - Short trips (2-3 games) AND long trips (5+ games)
    /// - Few cities (2-3) AND many cities (5+)
    /// - Low mileage AND high mileage
    /// - Short duration AND long duration
    private static func selectDiverseRoutes(
        _ routes: [[Game]],
        stadiums: [String: Stadium],
        maxCount: Int
    ) -> [[Game]] {
        guard !routes.isEmpty else { return [] }

        // Build profiles for all routes
        let profiles = routes.map { route in
            buildProfile(for: route, stadiums: stadiums)
        }

        // Remove duplicates
        var uniqueProfiles: [RouteProfile] = []
        var seenKeys = Set<String>()
        for profile in profiles {
            if !seenKeys.contains(profile.uniqueKey) {
                seenKeys.insert(profile.uniqueKey)
                uniqueProfiles.append(profile)
            }
        }

        // Stratified selection: ensure representation across all buckets
        var selected: [RouteProfile] = []
        var selectedKeys = Set<String>()

        // Pass 1: Ensure at least one route per game count bucket (2, 3, 4, 5, 6+)
        let byGames = Dictionary(grouping: uniqueProfiles) { $0.gameBucket }
        for bucket in byGames.keys.sorted() {
            if selected.count >= maxCount { break }
            if let candidates = byGames[bucket]?.sorted(by: { $0.totalMiles < $1.totalMiles }) {
                if let best = candidates.first, !selectedKeys.contains(best.uniqueKey) {
                    selected.append(best)
                    selectedKeys.insert(best.uniqueKey)
                }
            }
        }

        // Pass 2: Ensure at least one route per city count bucket (2, 3, 4, 5, 6+)
        let byCities = Dictionary(grouping: uniqueProfiles) { $0.cityBucket }
        for bucket in byCities.keys.sorted() {
            if selected.count >= maxCount { break }
            if let candidates = byCities[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
                if let best = candidates.sorted(by: { $0.totalMiles < $1.totalMiles }).first {
                    selected.append(best)
                    selectedKeys.insert(best.uniqueKey)
                }
            }
        }

        // Pass 3: Ensure at least one route per mileage bucket
        let byMiles = Dictionary(grouping: uniqueProfiles) { $0.milesBucket }
        for bucket in byMiles.keys.sorted() {
            if selected.count >= maxCount { break }
            if let candidates = byMiles[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
                if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
                    selected.append(best)
                    selectedKeys.insert(best.uniqueKey)
                }
            }
        }

        // Pass 4: Ensure at least one route per duration bucket
        let byDays = Dictionary(grouping: uniqueProfiles) { $0.daysBucket }
        for bucket in byDays.keys.sorted() {
            if selected.count >= maxCount { break }
            if let candidates = byDays[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
                if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
                    selected.append(best)
                    selectedKeys.insert(best.uniqueKey)
                }
            }
        }

        // Pass 5: Fill remaining slots with diverse combinations
        // Create composite buckets for more granular diversity
        let remaining = uniqueProfiles.filter { !selectedKeys.contains($0.uniqueKey) }
        let byComposite = Dictionary(grouping: remaining) { profile in
            "\(profile.gameBucket)-\(profile.cityBucket)-\(profile.milesBucket)"
        }

        // Round-robin from composite buckets
        let compositeKeys = Array(byComposite.keys).sorted()
        var indices: [String: Int] = [:]
        while selected.count < maxCount && !compositeKeys.isEmpty {
            var addedAny = false
            for key in compositeKeys {
                if selected.count >= maxCount { break }
                let idx = indices[key] ?? 0
                if let candidates = byComposite[key], idx < candidates.count {
                    let profile = candidates[idx]
                    if !selectedKeys.contains(profile.uniqueKey) {
                        selected.append(profile)
                        selectedKeys.insert(profile.uniqueKey)
                        addedAny = true
                    }
                    indices[key] = idx + 1
                }
            }
            if !addedAny { break }
        }

        // Pass 6: If still need more, add remaining sorted by efficiency
        if selected.count < maxCount {
            let stillRemaining = uniqueProfiles
                .filter { !selectedKeys.contains($0.uniqueKey) }
                .sorted { efficiency(for: $0) > efficiency(for: $1) }

            for profile in stillRemaining.prefix(maxCount - selected.count) {
                selected.append(profile)
            }
        }

        return selected.map { $0.route }
    }

    /// Diversity-aware pruning during beam expansion.
    /// Keeps routes that span the diversity space rather than just high-scoring ones.
    private static func diversityPrune(
        _ paths: [[Game]],
        stadiums: [String: Stadium],
        targetCount: Int
    ) -> [[Game]] {
        // Remove exact duplicates first
        var uniquePaths: [[Game]] = []
        var seen = Set<String>()
        for path in paths {
            let key = path.map { $0.id }.joined(separator: "-")
            if !seen.contains(key) {
                seen.insert(key)
                uniquePaths.append(path)
            }
        }

        guard uniquePaths.count > targetCount else { return uniquePaths }

        // Build profiles
        let profiles = uniquePaths.map { buildProfile(for: $0, stadiums: stadiums) }

        // Group by game count to ensure length diversity
        let byGames = Dictionary(grouping: profiles) { $0.gameBucket }
        let slotsPerBucket = max(2, targetCount / max(1, byGames.count))

        var selected: [RouteProfile] = []
        var selectedKeys = Set<String>()

        // Take from each game count bucket
        for bucket in byGames.keys.sorted() {
            if let candidates = byGames[bucket] {
                // Within bucket, prioritize geographic diversity
                let byCities = Dictionary(grouping: candidates) { $0.cityBucket }
                var bucketSelected = 0

                for cityBucket in byCities.keys.sorted() {
                    if bucketSelected >= slotsPerBucket { break }
                    if let cityCandidates = byCities[cityBucket] {
                        for profile in cityCandidates.prefix(2) {
                            if !selectedKeys.contains(profile.uniqueKey) {
                                selected.append(profile)
                                selectedKeys.insert(profile.uniqueKey)
                                bucketSelected += 1
                                if bucketSelected >= slotsPerBucket { break }
                            }
                        }
                    }
                }
            }
        }

        // Fill remaining with efficiency-sorted paths
        if selected.count < targetCount {
            let remaining = profiles.filter { !selectedKeys.contains($0.uniqueKey) }
                .sorted { efficiency(for: $0) > efficiency(for: $1) }

            for profile in remaining.prefix(targetCount - selected.count) {
                selected.append(profile)
            }
        }

        return selected.map { $0.route }
    }

    /// Builds a profile for a route.
    private static func buildProfile(for route: [Game], stadiums: [String: Stadium]) -> RouteProfile {
        let gameCount = route.count
        let cities = Set(route.compactMap { stadiums[$0.stadiumId]?.city })
        let cityCount = cities.count

        // Calculate total miles
        var totalMiles: Double = 0
        for i in 0..<(route.count - 1) {
            totalMiles += estimateDistanceMiles(from: route[i], to: route[i + 1], stadiums: stadiums)
        }

        // Calculate trip duration in days
        let tripDays: Int
        if let firstGame = route.first, let lastGame = route.last {
            let calendar = Calendar.current
            let days = calendar.dateComponents([.day], from: firstGame.startTime, to: lastGame.startTime).day ?? 1
            tripDays = max(1, days + 1)
        } else {
            tripDays = 1
        }

        return RouteProfile(
            route: route,
            gameCount: gameCount,
            cityCount: cityCount,
            totalMiles: totalMiles,
            tripDays: tripDays
        )
    }

    /// Calculates efficiency score (games per hour of driving).
    private static func efficiency(for profile: RouteProfile) -> Double {
        let drivingHours = profile.totalMiles / 60.0  // 60 mph average
        guard drivingHours > 0 else { return Double(profile.gameCount) * 100 }
        return Double(profile.gameCount) / drivingHours
    }

    // MARK: - Day Bucketing

    private static func bucketByDay(games: [Game]) -> [Int: [Game]] {
        guard let firstGame = games.first else { return [:] }
        let referenceDate = firstGame.startTime

        var buckets: [Int: [Game]] = [:]
        for game in games {
            let dayIndex = dayIndexFor(game.startTime, referenceDate: referenceDate)
            buckets[dayIndex, default: []].append(game)
        }
        return buckets
    }

    private static func dayIndexFor(_ date: Date, referenceDate: Date) -> Int {
        let calendar = Calendar.current
        let refDay = calendar.startOfDay(for: referenceDate)
        let dateDay = calendar.startOfDay(for: date)
        return calendar.dateComponents([.day], from: refDay, to: dateDay).day ?? 0
    }

    // MARK: - Transition Feasibility

    private static func canTransition(
        from: Game,
        to: Game,
        stadiums: [String: Stadium],
        constraints: DrivingConstraints
    ) -> Bool {
        // Time must move forward
        guard to.startTime > from.startTime else { return false }

        // Same stadium = always feasible
        if from.stadiumId == to.stadiumId { return true }

        // Get stadiums
        guard let fromStadium = stadiums[from.stadiumId],
              let toStadium = stadiums[to.stadiumId] else {
            return false
        }

        // Calculate driving time
        let distanceMiles = TravelEstimator.haversineDistanceMiles(
            from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
            to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
        ) * 1.3  // Road routing factor

        let drivingHours = distanceMiles / 60.0

        // Check if same calendar day
        let calendar = Calendar.current
        let daysBetween = calendar.dateComponents(
            [.day],
            from: calendar.startOfDay(for: from.startTime),
            to: calendar.startOfDay(for: to.startTime)
        ).day ?? 0

        // Buffer logic:
        // - Same stadium same day: 2hr post-game (doubleheader)
        // - Different stadiums same day: 2hr post-game (regional day trip)
        // - Different days: 3hr post-game (standard multi-day trip)
        let postGameBuffer: Double
        let preGameBuffer: Double

        if daysBetween == 0 {
            // Same-day games: use shorter buffers (doubleheader or day trip)
            postGameBuffer = 2.0  // Leave 2 hours after first game
            preGameBuffer = 0.5   // Arrive 30min before next game
        } else {
            // Different days: use standard buffers
            postGameBuffer = gameEndBufferHours  // 3.0 hours
            preGameBuffer = 1.0                  // 1 hour before game
        }

        // Calculate available time
        let departureTime = from.startTime.addingTimeInterval(postGameBuffer * 3600)
        let deadline = to.startTime.addingTimeInterval(-preGameBuffer * 3600)
        let availableHours = deadline.timeIntervalSince(departureTime) / 3600.0

        // Calculate driving hours available
        // For same-day games: enforce both time availability AND daily driving limit
        // For multi-day trips: use total available driving hours across days
        let maxDrivingHoursAvailable = daysBetween == 0
            ? min(max(0, availableHours), constraints.maxDailyDrivingHours)
            : Double(daysBetween) * constraints.maxDailyDrivingHours

        let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours

        // Debug output for rejected transitions
        if !feasible && drivingHours > 10 {
            print("🔍 DAG canTransition REJECTED: \(fromStadium.city) → \(toStadium.city)")
            print("🔍   drivingHours=\(String(format: "%.1f", drivingHours)), daysBetween=\(daysBetween), maxAvailable=\(String(format: "%.1f", maxDrivingHoursAvailable)), availableHours=\(String(format: "%.1f", availableHours))")
        }

        return feasible
    }

    // MARK: - Distance Estimation

    private static func estimateDistanceMiles(
        from: Game,
        to: Game,
        stadiums: [String: Stadium]
    ) -> Double {
        if from.stadiumId == to.stadiumId { return 0 }

        guard let fromStadium = stadiums[from.stadiumId],
              let toStadium = stadiums[to.stadiumId] else {
            return 300  // Fallback estimate
        }

        return TravelEstimator.haversineDistanceMiles(
            from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
            to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
        ) * 1.3
    }
}
