//
//  GameDAGRouterTests.swift
//  SportsTimeTests
//
//  TDD specification + property tests for GameDAGRouter.
//

import Testing
import CoreLocation
@testable import SportsTime

@Suite("GameDAGRouter")
struct GameDAGRouterTests {

    // MARK: - Test Data

    private let constraints = DrivingConstraints.default  // 1 driver, 8 hrs/day

    // Stadium locations (real coordinates)
    private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
    private let bostonCoord = CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972)
    private let phillyCoord = CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1652)
    private let chicagoCoord = CLLocationCoordinate2D(latitude: 41.8827, longitude: -87.6233)
    private let laCoord = CLLocationCoordinate2D(latitude: 34.0141, longitude: -118.2879)
    private let seattleCoord = CLLocationCoordinate2D(latitude: 47.5914, longitude: -122.3316)

    private let calendar = Calendar.current

    // MARK: - Specification Tests: Edge Cases

    @Test("findRoutes: empty games returns empty")
    func findRoutes_emptyGames_returnsEmpty() {
        let routes = GameDAGRouter.findRoutes(
            games: [],
            stadiums: [:],
            constraints: constraints
        )
        #expect(routes.isEmpty)
    }

    @Test("findRoutes: single game with no anchors returns single-game route")
    func findRoutes_singleGame_noAnchors_returnsSingleRoute() {
        let (game, stadium) = makeGameAndStadium(city: "New York", date: Date())

        let routes = GameDAGRouter.findRoutes(
            games: [game],
            stadiums: [stadium.id: stadium],
            constraints: constraints
        )

        #expect(routes.count == 1)
        #expect(routes.first?.count == 1)
        #expect(routes.first?.first?.id == game.id)
    }

    @Test("findRoutes: single game matching anchor returns single-game route")
    func findRoutes_singleGame_matchingAnchor_returnsSingleRoute() {
        let (game, stadium) = makeGameAndStadium(city: "New York", date: Date())

        let routes = GameDAGRouter.findRoutes(
            games: [game],
            stadiums: [stadium.id: stadium],
            constraints: constraints,
            anchorGameIds: [game.id]
        )

        #expect(routes.count == 1)
        #expect(routes.first?.first?.id == game.id)
    }

    @Test("findRoutes: single game not matching anchor returns empty")
    func findRoutes_singleGame_notMatchingAnchor_returnsEmpty() {
        let (game, stadium) = makeGameAndStadium(city: "New York", date: Date())

        let routes = GameDAGRouter.findRoutes(
            games: [game],
            stadiums: [stadium.id: stadium],
            constraints: constraints,
            anchorGameIds: ["different-game-id"]
        )

        #expect(routes.isEmpty)
    }

    // MARK: - Specification Tests: Two Games

    @Test("findRoutes: two feasible games returns combined route")
    func findRoutes_twoFeasibleGames_returnsCombinedRoute() {
        let today = calendar.startOfDay(for: Date())
        let game1Date = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
        let game2Date = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: today)!

        // NYC to Philly is ~95 miles, ~1.5 hours - very feasible same day
        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "Philadelphia", date: game2Date, coord: phillyCoord)

        let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2],
            stadiums: stadiums,
            constraints: constraints
        )

        // Should have at least one route with both games
        let combinedRoute = routes.first(where: { $0.count == 2 })
        #expect(combinedRoute != nil)

        // First game should be earlier one
        #expect(combinedRoute?.first?.startTime ?? Date.distantFuture < combinedRoute?.last?.startTime ?? Date.distantPast)
    }

    @Test("findRoutes: two infeasible same-day games returns separate routes when no anchors")
    func findRoutes_twoInfeasibleGames_noAnchors_returnsSeparateRoutes() {
        let today = calendar.startOfDay(for: Date())
        let game1Date = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
        let game2Date = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: today)!  // Only 2 hours later

        // NYC to Chicago is ~790 miles - impossible in 2 hours
        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Date, coord: chicagoCoord)

        let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2],
            stadiums: stadiums,
            constraints: constraints
        )

        // Should return separate single-game routes
        #expect(routes.count == 2)
        #expect(routes.allSatisfy { $0.count == 1 })
    }

    @Test("findRoutes: two infeasible games with both as anchors returns empty")
    func findRoutes_twoInfeasibleGames_bothAnchors_returnsEmpty() {
        let today = calendar.startOfDay(for: Date())
        let game1Date = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
        let game2Date = calendar.date(bySettingHour: 15, minute: 0, second: 0, of: today)!

        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Date, coord: chicagoCoord)

        let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2],
            stadiums: stadiums,
            constraints: constraints,
            anchorGameIds: [game1.id, game2.id]  // Both must be in route
        )

        #expect(routes.isEmpty)  // Can't satisfy both anchors
    }

    // MARK: - Specification Tests: Anchor Games

    @Test("findRoutes: routes contain all anchor games")
    func findRoutes_routesContainAllAnchors() {
        let today = calendar.startOfDay(for: Date())
        let dates = (0..<5).map { dayOffset in
            calendar.date(byAdding: .day, value: dayOffset, to: today)!
        }

        // Create 5 games over 5 days, all nearby (East Coast)
        let gamesAndStadiums = [
            makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord),
            makeGameAndStadium(city: "Philadelphia", date: dates[1], coord: phillyCoord),
            makeGameAndStadium(city: "Boston", date: dates[2], coord: bostonCoord),
            makeGameAndStadium(city: "New York2", date: dates[3], coord: nycCoord),
            makeGameAndStadium(city: "Philadelphia2", date: dates[4], coord: phillyCoord)
        ]

        let games = gamesAndStadiums.map { $0.0 }
        var stadiums: [String: Stadium] = [:]
        for (_, stadium) in gamesAndStadiums {
            stadiums[stadium.id] = stadium
        }

        // Use first and third games as anchors
        let anchorIds: Set<String> = [games[0].id, games[2].id]

        let routes = GameDAGRouter.findRoutes(
            games: games,
            stadiums: stadiums,
            constraints: constraints,
            anchorGameIds: anchorIds
        )

        // All routes must contain both anchor games
        for route in routes {
            let routeIds = Set(route.map { $0.id })
            #expect(anchorIds.isSubset(of: routeIds), "Route must contain all anchors")
        }
    }

    // MARK: - Specification Tests: Repeat Cities

    @Test("findRoutes: allowRepeatCities=false excludes routes with duplicate cities")
    func findRoutes_disallowRepeatCities_excludesDuplicates() {
        let today = calendar.startOfDay(for: Date())
        let dates = (0..<3).map { dayOffset in
            calendar.date(byAdding: .day, value: dayOffset, to: today)!
        }

        // NYC -> Boston -> NYC would be a repeat
        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: dates[1], coord: bostonCoord)
        let (game3, stadium3) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord)  // Same city as game1

        let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2, game3],
            stadiums: stadiums,
            constraints: constraints,
            allowRepeatCities: false
        )

        // No route should have both game1 and game3 (same city)
        for route in routes {
            let cities = route.compactMap { game in stadiums[game.stadiumId]?.city }
            let uniqueCities = Set(cities)
            #expect(cities.count == uniqueCities.count, "No duplicate cities when allowRepeatCities=false")
        }
    }

    @Test("findRoutes: allowRepeatCities=true allows routes with duplicate cities")
    func findRoutes_allowRepeatCities_allowsDuplicates() {
        let today = calendar.startOfDay(for: Date())
        let dates = (0..<3).map { dayOffset in
            calendar.date(byAdding: .day, value: dayOffset, to: today)!
        }

        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: dates[1], coord: bostonCoord)
        let (game3, stadium3) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord)  // Same city

        // Use same stadium ID for same city
        let nyStadium = stadium1
        let bosStadium = stadium2
        var game3Modified = game3
        game3Modified = Game(
            id: game3.id,
            homeTeamId: game3.homeTeamId,
            awayTeamId: game3.awayTeamId,
            stadiumId: nyStadium.id,  // Same stadium as game1
            dateTime: game3.dateTime,
            sport: game3.sport,
            season: game3.season,
            isPlayoff: game3.isPlayoff
        )

        let stadiums = [nyStadium.id: nyStadium, bosStadium.id: bosStadium]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2, game3Modified],
            stadiums: stadiums,
            constraints: constraints,
            allowRepeatCities: true
        )

        // Should have routes with all 3 games
        let fullRoutes = routes.filter { $0.count == 3 }
        #expect(!fullRoutes.isEmpty, "Should allow routes with same city when allowRepeatCities=true")
    }

    // MARK: - Specification Tests: Chronological Order

    @Test("findRoutes: all routes are chronologically ordered")
    func findRoutes_allRoutesChronological() {
        let today = calendar.startOfDay(for: Date())
        let dates = (0..<5).map { dayOffset in
            calendar.date(byAdding: .day, value: dayOffset, to: today)!
        }

        let gamesAndStadiums = [
            makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord),
            makeGameAndStadium(city: "Philadelphia", date: dates[1], coord: phillyCoord),
            makeGameAndStadium(city: "Boston", date: dates[2], coord: bostonCoord),
            makeGameAndStadium(city: "New York2", date: dates[3], coord: nycCoord),
            makeGameAndStadium(city: "Philadelphia2", date: dates[4], coord: phillyCoord)
        ]

        let games = gamesAndStadiums.map { $0.0 }
        var stadiums: [String: Stadium] = [:]
        for (_, stadium) in gamesAndStadiums {
            stadiums[stadium.id] = stadium
        }

        let routes = GameDAGRouter.findRoutes(
            games: games,
            stadiums: stadiums,
            constraints: constraints
        )

        for route in routes {
            for i in 1..<route.count {
                #expect(route[i].startTime > route[i-1].startTime,
                       "Games must be in chronological order")
            }
        }
    }

    // MARK: - Specification Tests: Driving Constraints

    @Test("findRoutes: respects maxDailyDrivingHours for same-day games")
    func findRoutes_respectsSameDayDrivingLimit() {
        let today = calendar.startOfDay(for: Date())
        let game1Time = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
        let game2Time = calendar.date(bySettingHour: 20, minute: 0, second: 0, of: today)!

        // NYC to Chicago: ~790 miles, ~13 hours driving
        // With 7 hours between games and 2hr buffer, only 5 hours available
        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Time, coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Time, coord: chicagoCoord)

        let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2],
            stadiums: stadiums,
            constraints: constraints  // 8 hr max per day
        )

        // Should NOT have a route with both games on same day
        let sameDayRoutes = routes.filter { $0.count == 2 }
        #expect(sameDayRoutes.isEmpty, "NYC to Chicago same-day should be infeasible")
    }

    @Test("findRoutes: multi-day trips allow longer total driving")
    func findRoutes_multiDayTrips_allowLongerDriving() {
        let today = calendar.startOfDay(for: Date())
        let game1Date = today
        let game2Date = calendar.date(byAdding: .day, value: 2, to: today)!  // 2 days later

        // NYC to Chicago: ~790 miles, ~13 hours driving
        // With 2 days between, should be feasible (16 hours available)
        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: game1Date, coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "Chicago", date: game2Date, coord: chicagoCoord)

        let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2],
            stadiums: stadiums,
            constraints: constraints
        )

        // Should have a route with both games
        let combinedRoutes = routes.filter { $0.count == 2 }
        #expect(!combinedRoutes.isEmpty, "NYC to Chicago over 2 days should be feasible")
    }

    // MARK: - Property Tests

    @Test("Property: route count never exceeds maxOptions (75)")
    func property_routeCountNeverExceedsMax() {
        let today = calendar.startOfDay(for: Date())

        // Create many games to stress test
        var games: [Game] = []
        var stadiums: [String: Stadium] = [:]

        for dayOffset in 0..<10 {
            let date = calendar.date(byAdding: .day, value: dayOffset, to: today)!
            for (city, coord) in [("NYC", nycCoord), ("BOS", bostonCoord), ("PHI", phillyCoord)] {
                let (game, stadium) = makeGameAndStadium(city: "\(city)_\(dayOffset)", date: date, coord: coord)
                games.append(game)
                stadiums[stadium.id] = stadium
            }
        }

        let routes = GameDAGRouter.findRoutes(
            games: games,
            stadiums: stadiums,
            constraints: constraints
        )

        #expect(routes.count <= 75, "Should never exceed 75 routes")
    }

    @Test("Property: all routes satisfy constraints")
    func property_allRoutesSatisfyConstraints() {
        let today = calendar.startOfDay(for: Date())
        let dates = (0..<5).map { calendar.date(byAdding: .day, value: $0, to: today)! }

        let gamesAndStadiums = [
            makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord),
            makeGameAndStadium(city: "Philadelphia", date: dates[1], coord: phillyCoord),
            makeGameAndStadium(city: "Boston", date: dates[2], coord: bostonCoord)
        ]

        let games = gamesAndStadiums.map { $0.0 }
        var stadiums: [String: Stadium] = [:]
        for (_, stadium) in gamesAndStadiums {
            stadiums[stadium.id] = stadium
        }

        let routes = GameDAGRouter.findRoutes(
            games: games,
            stadiums: stadiums,
            constraints: constraints
        )

        // Every consecutive pair in every route should be feasible
        for route in routes {
            for i in 1..<route.count {
                let from = route[i-1]
                let to = route[i]

                // Time must move forward
                #expect(to.startTime > from.startTime, "Time must move forward")

                // If different stadiums, distance must be reasonable
                if from.stadiumId != to.stadiumId {
                    guard let fromStadium = stadiums[from.stadiumId],
                          let toStadium = stadiums[to.stadiumId] else {
                        Issue.record("Missing stadium data")
                        continue
                    }

                    let distance = TravelEstimator.haversineDistanceMiles(
                        from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
                        to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
                    ) * 1.3

                    let daysAvailable = calendar.dateComponents([.day], from: from.startTime, to: to.startTime).day ?? 1
                    let maxMiles = Double(max(1, daysAvailable)) * constraints.maxDailyDrivingHours * 60  // 60 mph

                    #expect(distance <= maxMiles * 1.5, "Distance should be achievable")  // Allow some buffer
                }
            }
        }
    }

    // MARK: - Edge Case Tests

    @Test("Edge: games at same stadium always feasible")
    func edge_sameStadium_alwaysFeasible() {
        let today = calendar.startOfDay(for: Date())
        let game1Time = calendar.date(bySettingHour: 13, minute: 0, second: 0, of: today)!
        let game2Time = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: today)!  // Doubleheader

        let stadium = makeStadium(city: "New York", coord: nycCoord)
        let game1 = makeGame(stadiumId: stadium.id, date: game1Time)
        let game2 = makeGame(stadiumId: stadium.id, date: game2Time)

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2],
            stadiums: [stadium.id: stadium],
            constraints: constraints
        )

        let bothGamesRoute = routes.first(where: { $0.count == 2 })
        #expect(bothGamesRoute != nil, "Same stadium games should always be feasible")
    }

    @Test("Edge: games out of order are sorted chronologically")
    func edge_unsortedGames_areSorted() {
        let today = calendar.startOfDay(for: Date())
        let game1Date = calendar.date(byAdding: .day, value: 2, to: today)!
        let game2Date = today
        let game3Date = calendar.date(byAdding: .day, value: 1, to: today)!

        // Pass games out of order
        let (game1, stadium1) = makeGameAndStadium(city: "City1", date: game1Date, coord: nycCoord)
        let (game2, stadium2) = makeGameAndStadium(city: "City2", date: game2Date, coord: phillyCoord)
        let (game3, stadium3) = makeGameAndStadium(city: "City3", date: game3Date, coord: bostonCoord)

        let stadiums = [stadium1.id: stadium1, stadium2.id: stadium2, stadium3.id: stadium3]

        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2, game3],  // Out of order
            stadiums: stadiums,
            constraints: constraints
        )

        // All routes should be chronological
        for route in routes {
            for i in 1..<route.count {
                #expect(route[i].startTime > route[i-1].startTime)
            }
        }
    }

    @Test("Edge: missing stadium for game is handled gracefully")
    func edge_missingStadium_handledGracefully() {
        let (game1, stadium1) = makeGameAndStadium(city: "New York", date: Date(), coord: nycCoord)
        let game2 = makeGame(stadiumId: "nonexistent-stadium", date: Date().addingTimeInterval(86400))

        // Only provide stadium for game1
        let stadiums = [stadium1.id: stadium1]

        // Should not crash
        let routes = GameDAGRouter.findRoutes(
            games: [game1, game2],
            stadiums: stadiums,
            constraints: constraints
        )

        // May return routes with just game1, or empty
        #expect(routes.allSatisfy { route in
            route.allSatisfy { game in stadiums[game.stadiumId] != nil || game.id == game2.id }
        })
    }

    // MARK: - Helper Methods

    private func makeGameAndStadium(
        city: String,
        date: Date,
        coord: CLLocationCoordinate2D = CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0)
    ) -> (Game, Stadium) {
        let stadiumId = "stadium-\(UUID().uuidString)"
        let stadium = Stadium(
            id: stadiumId,
            name: "\(city) Stadium",
            city: city,
            state: "XX",
            latitude: coord.latitude,
            longitude: coord.longitude,
            capacity: 40000,
            sport: .mlb,
            timeZoneIdentifier: "America/New_York"
        )
        let game = Game(
            id: "game-\(UUID().uuidString)",
            homeTeamId: "team1",
            awayTeamId: "team2",
            stadiumId: stadiumId,
            dateTime: date,
            sport: .mlb,
            season: "2026",
            isPlayoff: false
        )
        return (game, stadium)
    }

    private func makeStadium(
        city: String,
        coord: CLLocationCoordinate2D
    ) -> Stadium {
        Stadium(
            id: "stadium-\(UUID().uuidString)",
            name: "\(city) Stadium",
            city: city,
            state: "XX",
            latitude: coord.latitude,
            longitude: coord.longitude,
            capacity: 40000,
            sport: .mlb,
            timeZoneIdentifier: "America/New_York"
        )
    }

    private func makeGame(
        stadiumId: String,
        date: Date
    ) -> Game {
        Game(
            id: "game-\(UUID().uuidString)",
            homeTeamId: "team1",
            awayTeamId: "team2",
            stadiumId: stadiumId,
            dateTime: date,
            sport: .mlb,
            season: "2026",
            isPlayoff: false
        )
    }
}
