//
//  SuggestedTripsGeneratorTests.swift
//  SportsTimeTests
//
//  TDD specification tests for SuggestedTripsGenerator types.
//

import Testing
import Foundation
@testable import SportsTime

// MARK: - SuggestedTrip Tests

@Suite("SuggestedTrip")
struct SuggestedTripTests {

    // MARK: - Test Data

    private func makeTrip() -> Trip {
        Trip(
            name: "Test Trip",
            preferences: TripPreferences(
                planningMode: .dateRange,
                sports: [.mlb],
                startDate: Date(),
                endDate: Date().addingTimeInterval(86400 * 7),
                leisureLevel: .moderate
            ),
            stops: [],
            travelSegments: [],
            totalGames: 3,
            totalDistanceMeters: 1000,
            totalDrivingSeconds: 3600
        )
    }

    private func makeSuggestedTrip(
        region: Region = .east,
        isSingleSport: Bool = true,
        sports: Set<Sport> = [.mlb]
    ) -> SuggestedTrip {
        SuggestedTrip(
            id: UUID(),
            region: region,
            isSingleSport: isSingleSport,
            trip: makeTrip(),
            richGames: [:],
            sports: sports
        )
    }

    // MARK: - Specification Tests: displaySports

    /// - Expected Behavior: Returns sorted array of sports
    @Test("displaySports: returns sorted sports array")
    func displaySports_sorted() {
        let suggested = makeSuggestedTrip(sports: [.nhl, .mlb, .nba])
        let display = suggested.displaySports

        #expect(display.count == 3)
        // Sports should be sorted by rawValue
        let sortedExpected = [Sport.mlb, .nba, .nhl].sorted { $0.rawValue < $1.rawValue }
        #expect(display == sortedExpected)
    }

    @Test("displaySports: single sport returns array of one")
    func displaySports_singleSport() {
        let suggested = makeSuggestedTrip(sports: [.mlb])
        #expect(suggested.displaySports.count == 1)
        #expect(suggested.displaySports.first == .mlb)
    }

    @Test("displaySports: empty sports returns empty array")
    func displaySports_empty() {
        let suggested = makeSuggestedTrip(sports: [])
        #expect(suggested.displaySports.isEmpty)
    }

    // MARK: - Specification Tests: sportLabel

    /// - Expected Behavior: Single sport returns sport rawValue
    @Test("sportLabel: returns sport name for single sport")
    func sportLabel_singleSport() {
        let suggested = makeSuggestedTrip(sports: [.mlb])
        #expect(suggested.sportLabel == "MLB")
    }

    /// - Expected Behavior: Multiple sports returns "Multi-Sport"
    @Test("sportLabel: returns 'Multi-Sport' for multiple sports")
    func sportLabel_multipleSports() {
        let suggested = makeSuggestedTrip(sports: [.mlb, .nba])
        #expect(suggested.sportLabel == "Multi-Sport")
    }

    @Test("sportLabel: returns 'Multi-Sport' for three sports")
    func sportLabel_threeSports() {
        let suggested = makeSuggestedTrip(sports: [.mlb, .nba, .nhl])
        #expect(suggested.sportLabel == "Multi-Sport")
    }

    /// - Expected Behavior: Empty sports returns "Multi-Sport" (no single sport to display)
    @Test("sportLabel: returns Multi-Sport for no sports")
    func sportLabel_noSports() {
        let suggested = makeSuggestedTrip(sports: [])
        #expect(suggested.sportLabel == "Multi-Sport")
    }

    // MARK: - Specification Tests: Properties

    @Test("SuggestedTrip: stores region")
    func suggestedTrip_region() {
        let suggested = makeSuggestedTrip(region: .west)
        #expect(suggested.region == .west)
    }

    @Test("SuggestedTrip: stores isSingleSport")
    func suggestedTrip_isSingleSport() {
        let single = makeSuggestedTrip(isSingleSport: true)
        let multi = makeSuggestedTrip(isSingleSport: false)
        #expect(single.isSingleSport == true)
        #expect(multi.isSingleSport == false)
    }

    // MARK: - Invariant Tests

    /// - Invariant: sports.count == 1 implies sportLabel is sport rawValue (uppercase)
    @Test("Invariant: single sport implies specific label")
    func invariant_singleSportImpliesSpecificLabel() {
        let singleSports: [Sport] = [.mlb, .nba, .nhl, .nfl]
        for sport in singleSports {
            let suggested = makeSuggestedTrip(sports: [sport])
            if suggested.sports.count == 1 {
                #expect(suggested.sportLabel == sport.rawValue)  // rawValue is uppercase (e.g., "MLB")
            }
        }
    }

    /// - Invariant: sports.count > 1 implies sportLabel is "Multi-Sport"
    @Test("Invariant: multiple sports implies Multi-Sport label")
    func invariant_multipleSportsImpliesMultiSportLabel() {
        let suggested = makeSuggestedTrip(sports: [.mlb, .nba])
        if suggested.sports.count > 1 {
            #expect(suggested.sportLabel == "Multi-Sport")
        }
    }

    /// - Invariant: displaySports.count == sports.count
    @Test("Invariant: displaySports count matches sports count")
    func invariant_displaySportsCountMatchesSportsCount() {
        let testCases: [Set<Sport>] = [
            [],
            [.mlb],
            [.mlb, .nba],
            [.mlb, .nba, .nhl]
        ]

        for sports in testCases {
            let suggested = makeSuggestedTrip(sports: sports)
            #expect(suggested.displaySports.count == sports.count)
        }
    }
}

// MARK: - Haversine Distance Tests

@Suite("Haversine Distance")
struct HaversineDistanceTests {

    // Note: haversineDistance is a private static function in SuggestedTripsGenerator
    // These tests document the expected behavior for distance calculations

    // MARK: - Specification Tests: Known Distances

    /// - Expected Behavior: Distance between same points is 0
    @Test("Distance: same point returns 0")
    func distance_samePoint() {
        // New York to New York
        let distance = calculateHaversine(
            lat1: 40.7128, lon1: -74.0060,
            lat2: 40.7128, lon2: -74.0060
        )
        #expect(distance == 0)
    }

    /// - Expected Behavior: NYC to LA is approximately 2,450 miles
    @Test("Distance: NYC to LA approximately 2450 miles")
    func distance_nycToLa() {
        // New York: 40.7128, -74.0060
        // Los Angeles: 34.0522, -118.2437
        let distance = calculateHaversine(
            lat1: 40.7128, lon1: -74.0060,
            lat2: 34.0522, lon2: -118.2437
        )
        // Allow 5% tolerance
        #expect(distance > 2300 && distance < 2600)
    }

    /// - Expected Behavior: NYC to Boston is approximately 190 miles
    @Test("Distance: NYC to Boston approximately 190 miles")
    func distance_nycToBoston() {
        // New York: 40.7128, -74.0060
        // Boston: 42.3601, -71.0589
        let distance = calculateHaversine(
            lat1: 40.7128, lon1: -74.0060,
            lat2: 42.3601, lon2: -71.0589
        )
        // Allow 10% tolerance
        #expect(distance > 170 && distance < 220)
    }

    // MARK: - Invariant Tests

    /// - Invariant: Distance is symmetric (A to B == B to A)
    @Test("Invariant: distance is symmetric")
    func invariant_symmetric() {
        let distanceAB = calculateHaversine(
            lat1: 40.7128, lon1: -74.0060,
            lat2: 34.0522, lon2: -118.2437
        )
        let distanceBA = calculateHaversine(
            lat1: 34.0522, lon1: -118.2437,
            lat2: 40.7128, lon2: -74.0060
        )
        #expect(abs(distanceAB - distanceBA) < 0.001)
    }

    /// - Invariant: Distance is always non-negative
    @Test("Invariant: distance is non-negative")
    func invariant_nonNegative() {
        let testCases: [(lat1: Double, lon1: Double, lat2: Double, lon2: Double)] = [
            (0, 0, 0, 0),
            (40.0, -74.0, 34.0, -118.0),
            (-33.9, 151.2, 51.5, -0.1), // Sydney to London
            (90, 0, -90, 0) // North to South pole
        ]

        for (lat1, lon1, lat2, lon2) in testCases {
            let distance = calculateHaversine(lat1: lat1, lon1: lon1, lat2: lat2, lon2: lon2)
            #expect(distance >= 0)
        }
    }

    // MARK: - Test Helper (mirrors implementation)

    private func calculateHaversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
        let R = 3959.0 // Earth radius in miles
        let dLat = (lat2 - lat1) * .pi / 180
        let dLon = (lon2 - lon1) * .pi / 180
        let a = sin(dLat/2) * sin(dLat/2) +
                cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
                sin(dLon/2) * sin(dLon/2)
        let c = 2 * atan2(sqrt(a), sqrt(1-a))
        return R * c
    }
}

// MARK: - Cross-Country Feature Trip Tests

@Suite("SuggestedTripsGenerator Cross-Country")
struct CrossCountryFeatureTripTests {

    private struct CitySeed {
        let name: String
        let state: String
        let latitude: Double
        let longitude: Double
    }

    // 20 known US cities spanning east/central/west regions.
    private let citySeeds: [CitySeed] = [
        CitySeed(name: "Boston", state: "MA", latitude: 42.3601, longitude: -71.0589),
        CitySeed(name: "New York", state: "NY", latitude: 40.7128, longitude: -74.0060),
        CitySeed(name: "Philadelphia", state: "PA", latitude: 39.9526, longitude: -75.1652),
        CitySeed(name: "Baltimore", state: "MD", latitude: 39.2904, longitude: -76.6122),
        CitySeed(name: "Washington", state: "DC", latitude: 38.9072, longitude: -77.0369),
        CitySeed(name: "Charlotte", state: "NC", latitude: 35.2271, longitude: -80.8431),
        CitySeed(name: "Atlanta", state: "GA", latitude: 33.7490, longitude: -84.3880),
        CitySeed(name: "Nashville", state: "TN", latitude: 36.1627, longitude: -86.7816),
        CitySeed(name: "St Louis", state: "MO", latitude: 38.6270, longitude: -90.1994),
        CitySeed(name: "Chicago", state: "IL", latitude: 41.8781, longitude: -87.6298),
        CitySeed(name: "Minneapolis", state: "MN", latitude: 44.9778, longitude: -93.2650),
        CitySeed(name: "Kansas City", state: "MO", latitude: 39.0997, longitude: -94.5786),
        CitySeed(name: "Dallas", state: "TX", latitude: 32.7767, longitude: -96.7970),
        CitySeed(name: "Denver", state: "CO", latitude: 39.7392, longitude: -104.9903),
        CitySeed(name: "Albuquerque", state: "NM", latitude: 35.0844, longitude: -106.6504),
        CitySeed(name: "Phoenix", state: "AZ", latitude: 33.4484, longitude: -112.0740),
        CitySeed(name: "Las Vegas", state: "NV", latitude: 36.1699, longitude: -115.1398),
        CitySeed(name: "Los Angeles", state: "CA", latitude: 34.0522, longitude: -118.2437),
        CitySeed(name: "San Diego", state: "CA", latitude: 32.7157, longitude: -117.1611),
        CitySeed(name: "Seattle", state: "WA", latitude: 47.6062, longitude: -122.3321),
    ]

    private func canonicalToken(_ value: String) -> String {
        value
            .lowercased()
            .replacingOccurrences(of: " ", with: "_")
            .replacingOccurrences(of: ".", with: "")
    }

    private func makeStadium(from city: CitySeed) -> Stadium {
        let token = canonicalToken(city.name)
        return Stadium(
            id: "stadium_test_\(token)",
            name: "\(city.name) Test Stadium",
            city: city.name,
            state: city.state,
            latitude: city.latitude,
            longitude: city.longitude,
            capacity: 40000,
            sport: .mlb
        )
    }

    private func makeTeams(for stadium: Stadium) -> [Team] {
        let token = canonicalToken(stadium.city)
        let home = Team(
            id: "team_test_home_\(token)",
            name: "\(stadium.city) Home",
            abbreviation: String(token.prefix(3)).uppercased(),
            sport: .mlb,
            city: stadium.city,
            stadiumId: stadium.id
        )
        let away = Team(
            id: "team_test_away_\(token)",
            name: "\(stadium.city) Away",
            abbreviation: "A\(String(token.prefix(2)).uppercased())",
            sport: .mlb,
            city: stadium.city,
            stadiumId: stadium.id
        )
        return [home, away]
    }

    private func makeGames(
        from stadiums: [Stadium],
        startDate: Date,
        spacingDays: Int = 1,
        idPrefix: String
    ) -> [Game] {
        var games: [Game] = []
        let calendar = Calendar.current

        for (index, stadium) in stadiums.enumerated() {
            let gameDate = calendar.date(byAdding: .day, value: index * spacingDays, to: startDate) ?? startDate
            let token = canonicalToken(stadium.city)
            games.append(
                Game(
                    id: "game_test_\(idPrefix)_\(token)_\(index)",
                    homeTeamId: "team_test_home_\(token)",
                    awayTeamId: "team_test_away_\(token)",
                    stadiumId: stadium.id,
                    dateTime: gameDate,
                    sport: .mlb,
                    season: "2026"
                )
            )
        }
        return games
    }

    private func makeDataset(spacingDays: Int = 1) -> (games: [Game], stadiumsById: [String: Stadium], teamsById: [String: Team]) {
        let stadiums = citySeeds.map(makeStadium)
        let teams = stadiums.flatMap(makeTeams)
        let sortedEastToWest = stadiums.sorted { $0.longitude > $1.longitude }
        let sortedWestToEast = Array(sortedEastToWest.reversed())
        let baseDate = Date(timeIntervalSince1970: 1_736_000_000) // Fixed baseline for deterministic test behavior

        let eastToWestGames = makeGames(
            from: sortedEastToWest,
            startDate: baseDate,
            spacingDays: spacingDays,
            idPrefix: "e2w"
        )

        let secondLegStart = Calendar.current.date(
            byAdding: .day,
            value: (sortedEastToWest.count * spacingDays) + 2,
            to: baseDate
        ) ?? baseDate

        let westToEastGames = makeGames(
            from: sortedWestToEast,
            startDate: secondLegStart,
            spacingDays: spacingDays,
            idPrefix: "w2e"
        )

        let games = eastToWestGames + westToEastGames

        let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { partialResult, stadium in
            partialResult[stadium.id] = stadium
        }
        let teamsById = teams.reduce(into: [String: Team]()) { partialResult, team in
            partialResult[team.id] = team
        }

        return (games: games, stadiumsById: stadiumsById, teamsById: teamsById)
    }

    private func routeRegions(for trip: SuggestedTrip, stadiumsById: [String: Stadium]) -> Set<Region> {
        let gameIdsInTrip = Set(trip.trip.stops.flatMap(\.games))
        let tripGames = trip.richGames.values
            .map(\.game)
            .filter { gameIdsInTrip.contains($0.id) }

        return Set(tripGames.compactMap { game in
            stadiumsById[game.stadiumId]?.region
        })
    }

    @Test("Cross-country (20 cities): east-to-west generates a valid coast-to-coast trip")
    func crossCountry_eastToWest_fromTwentyCities() {
        let (games, stadiumsById, teamsById) = makeDataset()

        let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip(
            games: games,
            stadiums: stadiumsById,
            teams: teamsById,
            eastToWest: true
        )

        #expect(trip != nil)
        guard let trip else { return }

        #expect(trip.region == .crossCountry)
        #expect(trip.trip.stops.count >= 3)
        #expect(trip.trip.totalGames >= 3)

        let regions = routeRegions(for: trip, stadiumsById: stadiumsById)
        #expect(regions.contains(.east))
        #expect(regions.contains(.west))
    }

    @Test("Cross-country (20 cities): west-to-east generates a valid coast-to-coast trip")
    func crossCountry_westToEast_fromTwentyCities() {
        let (games, stadiumsById, teamsById) = makeDataset()

        let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip(
            games: games,
            stadiums: stadiumsById,
            teams: teamsById,
            eastToWest: false
        )

        #expect(trip != nil)
        guard let trip else { return }

        #expect(trip.region == .crossCountry)
        #expect(trip.trip.stops.count >= 3)
        #expect(trip.trip.totalGames >= 3)

        let regions = routeRegions(for: trip, stadiumsById: stadiumsById)
        #expect(regions.contains(.east))
        #expect(regions.contains(.west))
    }

    @Test("Cross-country performance: 20-city dataset stays under target average runtime")
    func crossCountry_generationPerformance_twentyCityDataset() {
        let (games, stadiumsById, teamsById) = makeDataset()
        let iterations = 20
        var elapsedMillis: [Double] = []

        for _ in 0..<iterations {
            let start = DispatchTime.now().uptimeNanoseconds
            let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip(
                games: games,
                stadiums: stadiumsById,
                teams: teamsById,
                eastToWest: true
            )
            let end = DispatchTime.now().uptimeNanoseconds
            #expect(trip != nil)

            let millis = Double(end - start) / 1_000_000
            elapsedMillis.append(millis)
        }

        let averageMillis = elapsedMillis.reduce(0, +) / Double(elapsedMillis.count)
        let worstMillis = elapsedMillis.max() ?? 0

        print("Cross-country benchmark (20-city): avg=\(averageMillis)ms worst=\(worstMillis)ms over \(iterations) runs")

        #expect(averageMillis < 250.0, "Average generation time was \(averageMillis)ms")
        #expect(worstMillis < 500.0, "Worst generation time was \(worstMillis)ms")
    }
}
