//
// MainService.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//

import Foundation

/// Platform-agnostic callback so MainService doesn't import AppKit/UIKit.
/// macOS sets this to drive MenuManager + StatusItemManager + NotificationManager;
/// iOS sets it to invalidate its @Observable view model.
@MainActor
protocol MainServiceObserver: AnyObject {
    /// Fired after every successful scoreboard / standings / bracket update.
    func mainServiceDidUpdate()

    /// Fired when a future game transitions to live. macOS shows a notification;
    /// iOS v1 ignores (no notifications).
    func mainServiceDidDetectGameStart(_ game: Scoreboard.Game)

    /// Fired when a goal is detected. `scorer` may be nil if play-by-play hasn't
    /// caught up yet.
    func mainServiceDidDetectGoal(
        _ game: Scoreboard.Game,
        scoringTeam: Scoreboard.Game.Team,
        scorer: GoalScorer?
    )

    /// Fired when a game transitions to a final state (OVER/FINAL/OFF).
    func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game)
}

extension MainServiceObserver {
    func mainServiceDidDetectGameStart(_: Scoreboard.Game) {}
    func mainServiceDidDetectGoal(_: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, scorer: GoalScorer?) {}
    func mainServiceDidDetectGameEnd(_: Scoreboard.Game) {}
}

class MainService: @unchecked Sendable {
    private let logger = IceGlassLogger(
        subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
        category: "MainService"
    )

    static let shared = MainService()

    private lazy var settings = AppSettings.shared
    private let cache = NHLDataCache()

    /// Set by each platform's app entry to receive update callbacks.
    /// Always invoked on the main actor by the methods below; the property
    /// itself is unisolated so MainService.shared can be referenced from
    /// nonisolated contexts (e.g. AppDelegate's stored property init).
    nonisolated(unsafe) weak var observer: (any MainServiceObserver)?

    private var pollingTimer: Timer?
    private var scoreboardApi: ApiService?
    private var standingsApi: ApiService?
    private var bracketApi: ApiService?
    private var bracketApiSeasonYear: Int?

    /// All game days from the API (full window: yesterday/today/tomorrow)
    private var allGamesByDate: [Scoreboard.GameDay] = []

    /// Current standings data
    var standings: Standings?

    /// Current playoff bracket (nil during regular season or before first fetch)
    var bracket: PlayoffBracket?

    /// Timestamp of the most recent successful fetch (any endpoint).
    /// Surface this in the iOS UI as the "as of" indicator.
    private(set) var lastUpdated: Date?

    /// Previous game snapshots for detecting state/score changes (keyed by game ID)
    private var previousGameStates: [Int: GameSnapshot] = [:]

    /// Whether this is the first fetch (suppress notifications on initial load)
    private var isFirstFetch = true

    /// Set during change detection when a playoff game transitions to a final state,
    /// so the next scoreboard cycle can force a bracket refresh.
    private var hadPlayoffFinalTransition = false

    struct GameSnapshot {
        let gameState: String
        let awayScore: Int?
        let homeScore: Int?
    }

    /// Game days filtered by display option
    var gamesByDate: [Scoreboard.GameDay] {
        let includedDates = settings.displayOption.includedDates()
        return allGamesByDate.filter { includedDates.contains($0.date) }
    }

    /// Status bar text based on current settings
    var statusBarText: String {
        switch settings.statusBarOption {
        case .gameCount:
            let gameDays = gamesByDate
            if gameDays.isEmpty { return "" }
            return gameDays.map { "\($0.games.count)" }.joined(separator: "/")

        case .gamesPlayed:
            return "\(standings?.totalGamesPlayed ?? 0)"

        case .gamesPlayedTotal:
            return "\(standings?.totalGamesPlayed ?? 0)/\(Standings.totalRegularSeasonGames)"
        }
    }

    /// Whether any game across all days is currently live
    var anyGameLive: Bool {
        allGamesByDate.flatMap(\.games).contains { $0.parsedGameState.isLive }
    }

    /// Whether any game is in pre-game state
    var anyGamePre: Bool {
        allGamesByDate.flatMap(\.games).contains { $0.parsedGameState == .pre }
    }

    /// Whether any game today is scheduled (future)
    var anyGameToday: Bool {
        let today = Date.todayET
        return allGamesByDate.contains { $0.date == today && !$0.games.isEmpty }
    }

    /// Every active playoff round paired with its matched series, ascending by
    /// round (1…current). Each series carries its next scheduled game from the
    /// fetched window (if any). Empty during the regular season.
    var roundSeriesGroups: [RoundGroup] {
        guard let bracket = bracket else { return [] }
        return bracket.activeRounds.map { round in
            RoundGroup(round: round, items: roundSeriesItems(for: bracket.matchedSeries(inRound: round)))
        }
    }

    /// Pairs each series with its next unplayed game from the fetched window.
    private func roundSeriesItems(for seriesList: [PlayoffBracket.Series]) -> [RoundSeriesItem] {
        let windowGames = allGamesByDate.flatMap(\.games)
        return seriesList.map { series in
            let nextGame = windowGames
                .filter { !$0.parsedGameState.isOver && series.involves(away: $0.awayTeam.abbrev, home: $0.homeTeam.abbrev) }
                .min { $0.date < $1.date }
            return RoundSeriesItem(series: series, nextGame: nextGame)
        }
    }

    struct RoundGroup {
        let round: Int
        let items: [RoundSeriesItem]
    }

    struct RoundSeriesItem {
        let series: PlayoffBracket.Series
        let nextGame: Scoreboard.Game?
    }

    /// The best polling interval based on current game states
    var bestPollingInterval: PollingInterval {
        if anyGameLive { return .liveGame }
        if anyGamePre { return .preGame }
        if anyGameToday { return .gameDay }
        return .idle
    }

    private init() {
        logger.debug("Initializing")
        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }

            Task { @MainActor in
                await self.loadFromCache()
                self.initApis()
                self.reschedulePollingTimer(.bootstrap)
                await self.fetchScoreboard()
                await self.fetchStandings()
            }
        }
    }

    // MARK: - Cache

    /// Loads the persisted snapshot (if any) so the UI can paint last-known
    /// data immediately. The fresh fetch in `init` then overwrites in-memory
    /// state and the cache file.
    private func loadFromCache() async {
        guard let snapshot = await cache.load() else { return }
        if let scoreboard = snapshot.scoreboard {
            let yesterday = Date.yesterdayET
            let today = Date.todayET
            let tomorrow = Date.tomorrowET
            let windowDates = Set([yesterday, today, tomorrow])
            let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
            self.allGamesByDate = mergeWithMonotonicState(filtered)
            self.updateSnapshots(from: self.allGamesByDate)
        }
        self.standings = snapshot.standings
        self.bracket = snapshot.bracket
        self.lastUpdated = snapshot.lastUpdated
        logger.info("Loaded cached snapshot from \(snapshot.lastUpdated)")
        Task { @MainActor in self.observer?.mainServiceDidUpdate() }
    }

    private func persistCache() {
        // Reconstruct a synthetic Scoreboard from the windowed allGamesByDate
        // so the cached payload is exactly what we'd render on next launch.
        let scoreboard = Scoreboard(
            focusedDate: Date.todayET,
            focusedDateCount: allGamesByDate.first { $0.date == Date.todayET }?.games.count ?? 0,
            gamesByDate: allGamesByDate
        )
        let snapshot = CachedSnapshot(
            lastUpdated: Date(),
            scoreboard: scoreboard,
            standings: standings,
            bracket: bracket
        )
        Task { [cache] in
            await cache.save(snapshot)
        }
    }

    private func initApis() {
        scoreboardApi = ApiService(
            url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now")
        ) { [weak self] data, _ in
            guard let self = self else { return }

            do {
                let scoreboard = try JSONDecoder().decode(Scoreboard.self, from: data)

                let yesterday = Date.yesterdayET
                let today = Date.todayET
                let tomorrow = Date.tomorrowET
                let windowDates = Set([yesterday, today, tomorrow])

                let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
                let merged = self.mergeWithMonotonicState(filtered)

                if !self.isFirstFetch {
                    self.detectChanges(in: merged)
                }

                self.allGamesByDate = merged
                self.updateSnapshots(from: merged)
                self.lastUpdated = Date()

                if self.isFirstFetch {
                    self.isFirstFetch = false
                }

                self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))")

                let interval = self.bestPollingInterval
                let shouldForceBracket = self.hadPlayoffFinalTransition
                self.hadPlayoffFinalTransition = false
                Task { @MainActor in
                    self.reschedulePollingTimer(interval)
                    await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
                }

                self.persistCache()
                self.notifyObserverDidUpdate()
            } catch {
                self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
            }
        }

        standingsApi = ApiService(
            url: URL(string: "https://api-web.nhle.com/v1/standings/\(Date.todayET)")
        ) { [weak self] data, _ in
            guard let self = self else { return }

            do {
                self.standings = try JSONDecoder().decode(Standings.self, from: data)
                self.lastUpdated = Date()
                self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
                self.persistCache()
                self.notifyObserverDidUpdate()
            } catch {
                self.logger.error("Failed to decode standings: \(error.localizedDescription)")
            }
        }
    }

    private func notifyObserverDidUpdate() {
        Task { @MainActor in
            self.observer?.mainServiceDidUpdate()
        }
    }

    /// Fires `mainServiceDidUpdate` without triggering a network fetch — call
    /// after settings changes that affect what the existing data renders as.
    func updateUI() {
        notifyObserverDidUpdate()
    }

    // MARK: - Change Detection

    private func detectChanges(in gameDays: [Scoreboard.GameDay]) {
        for gameDay in gameDays {
            for game in gameDay.games {
                guard let previous = previousGameStates[game.id] else { continue }

                let previousState = GameState(rawValue: previous.gameState)
                let currentState = game.parsedGameState

                if let prevState = previousState, prevState.isFuture, currentState.isLive {
                    logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
                    let captured = game
                    Task { @MainActor in self.observer?.mainServiceDidDetectGameStart(captured) }
                }

                // Game ended: transition to any over-state (OVER/FINAL/OFF)
                if let prevState = previousState, !prevState.isOver, currentState.isOver {
                    logger.info("Game \(game.id) ended: \(game.awayTeam.abbrev) \(game.awayTeam.score ?? 0) — \(game.homeTeam.abbrev) \(game.homeTeam.score ?? 0)")
                    let captured = game
                    Task { @MainActor in self.observer?.mainServiceDidDetectGameEnd(captured) }
                }

                if game.gameType == 3,
                   let prevState = previousState, !prevState.isOver, currentState.isOver {
                    hadPlayoffFinalTransition = true
                }

                if let prevAway = previous.awayScore, let prevHome = previous.homeScore,
                   let curAway = game.awayTeam.score, let curHome = game.homeTeam.score {
                    if curAway > prevAway {
                        logger.info("Goal! \(game.awayTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)")
                        handleGoal(game: game, scoringTeam: game.awayTeam, awayScore: curAway, homeScore: curHome)
                    }
                    if curHome > prevHome {
                        logger.info("Goal! \(game.homeTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)")
                        handleGoal(game: game, scoringTeam: game.homeTeam, awayScore: curAway, homeScore: curHome)
                    }
                }
            }
        }
    }

    private func handleGoal(game: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, awayScore: Int, homeScore: Int) {
        Task { [weak self] in
            guard let self = self else { return }
            let scorer = await self.fetchGoalScorer(
                gameId: game.id,
                awayScore: awayScore,
                homeScore: homeScore
            )
            await MainActor.run {
                self.observer?.mainServiceDidDetectGoal(game, scoringTeam: scoringTeam, scorer: scorer)
            }
        }
    }

    private func fetchGoalScorer(gameId: Int, awayScore: Int, homeScore: Int) async -> GoalScorer? {
        guard let url = URL(string: "https://api-web.nhle.com/v1/gamecenter/\(gameId)/play-by-play") else {
            return nil
        }
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let pbp = try JSONDecoder().decode(PlayByPlay.self, from: data)
            guard let goal = pbp.goal(matchingAwayScore: awayScore, homeScore: homeScore),
                  let playerId = goal.details?.scoringPlayerId,
                  let player = pbp.player(id: playerId) else {
                logger.info("Play-by-play not yet caught up for goal at \(awayScore)-\(homeScore) in game \(gameId)")
                return nil
            }
            let scoringTeamIsAway = goal.details?.eventOwnerTeamId == pbp.awayTeam.id
            let strength = PlayByPlay.strengthTag(
                situationCode: goal.situationCode,
                scoringTeamIsAway: scoringTeamIsAway
            )
            let firstInitial = player.firstName.default.first.map { "\($0)." } ?? ""
            let name = "\(firstInitial) \(player.lastName.default)".trimmingCharacters(in: .whitespaces)
            return GoalScorer(name: name, sweaterNumber: player.sweaterNumber, strength: strength)
        } catch {
            logger.error("Failed to fetch play-by-play for game \(gameId): \(error.localizedDescription)")
            return nil
        }
    }

    /// Reject API responses that downgrade a known game to an earlier state in
    /// the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression. The NHL `/scoreboard/now`
    /// CDN occasionally serves yesterday's finished games as `FUT` during the
    /// daily focusedDate rollover; without this guard, iOS opens cold in the
    /// morning, sees that one bad response, and shows `-:-` until the API
    /// self-corrects. macOS polls through it but iOS only gets one chance.
    private func mergeWithMonotonicState(_ incoming: [Scoreboard.GameDay]) -> [Scoreboard.GameDay] {
        let previousById = Dictionary(
            uniqueKeysWithValues: allGamesByDate.flatMap(\.games).map { ($0.id, $0) }
        )
        return incoming.map { day in
            let games = day.games.map { newGame -> Scoreboard.Game in
                guard let prev = previousById[newGame.id] else { return newGame }
                let prevRank = GameState.progressionRank(of: prev.gameState)
                let newRank = GameState.progressionRank(of: newGame.gameState)
                guard prevRank > newRank else { return newGame }
                logger.warning("Rejecting state regression for game \(newGame.id) (\(newGame.awayTeam.abbrev)@\(newGame.homeTeam.abbrev)): API \(newGame.gameState) < held \(prev.gameState)")
                return prev
            }
            return Scoreboard.GameDay(date: day.date, games: games)
        }
    }

    private func updateSnapshots(from gameDays: [Scoreboard.GameDay]) {
        for gameDay in gameDays {
            for game in gameDay.games {
                previousGameStates[game.id] = GameSnapshot(
                    gameState: game.gameState,
                    awayScore: game.awayTeam.score,
                    homeScore: game.homeTeam.score
                )
            }
        }
    }

    // MARK: - Polling

    private func reschedulePollingTimer(_ pollingInterval: PollingInterval) {
        if !Thread.isMainThread {
            logger.warning("reschedulePollingTimer called not on main thread")
            return
        }

        let rescheduleTolerance: TimeInterval = 3
        var needsRescheduling = false

        if self.pollingTimer == nil {
            needsRescheduling = true
        } else if !self.pollingTimer!.isValid {
            needsRescheduling = true
        } else if abs(self.pollingTimer!.fireDate.timeIntervalSinceNow - pollingInterval.rawValue) > rescheduleTolerance {
            needsRescheduling = true
        }

        if needsRescheduling {
            self.logger.debug("Rescheduling polling timer to \(pollingInterval.rawValue)s")
            self.pollingTimer = Timer.startTimer(
                timer: self.pollingTimer,
                interval: pollingInterval.rawValue
            ) { [weak self] _ in
                Task { @MainActor in
                    await self?.fetchScoreboard()
                }
            }
        }
    }

    /// Cancel the polling timer (call from iOS when entering background).
    @MainActor
    func suspendPolling() {
        pollingTimer?.invalidate()
        pollingTimer = nil
        logger.debug("Polling suspended")
    }

    /// Resume polling at the appropriate interval (call from iOS on scenePhase=.active).
    @MainActor
    func resumePolling() {
        reschedulePollingTimer(bestPollingInterval)
    }

    private func fetchScoreboard() async {
        await scoreboardApi?.fetch()
    }

    private func fetchStandings() async {
        await standingsApi?.fetch()
    }

    func fetchAll() async {
        await fetchScoreboard()
        await fetchStandings()
    }

    // MARK: - Playoff Bracket

    private func refreshBracketIfNeeded(from gameDays: [Scoreboard.GameDay], force: Bool) async {
        let playoffGames = gameDays.flatMap(\.games).filter { $0.gameType == 3 }
        guard let playoffGame = playoffGames.first else {
            bracket = nil
            return
        }

        let seasonYear = playoffGame.season / 10_000
        let seasonChanged = bracketApi == nil || bracketApiSeasonYear != seasonYear
        if seasonChanged {
            bracketApiSeasonYear = seasonYear
            bracketApi = ApiService(
                url: URL(string: "https://api-web.nhle.com/v1/playoff-bracket/\(seasonYear + 1)")
            ) { [weak self] data, _ in
                guard let self = self else { return }
                do {
                    self.bracket = try JSONDecoder().decode(PlayoffBracket.self, from: data)
                    self.lastUpdated = Date()
                    self.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))")
                    self.persistCache()
                    self.notifyObserverDidUpdate()
                } catch {
                    self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
                }
            }
        }

        if bracket == nil || seasonChanged || force || isBracketStale(against: playoffGames) {
            await bracketApi?.fetch()
        }
    }

    /// True when a finished playoff game in the window implies more completed
    /// games than the cached bracket has recorded for that series — i.e. the
    /// bracket is behind the scoreboard. Catches both a stale cached bracket
    /// loaded on cold launch and the lag between a game going final and the NHL
    /// bracket endpoint updating its win counts. Self-clears once the bracket
    /// catches up, so it stops triggering fetches as soon as the two agree.
    private func isBracketStale(against playoffGames: [Scoreboard.Game]) -> Bool {
        guard let bracket = bracket else { return false }
        for game in playoffGames where game.parsedGameState.isOver {
            guard let context = game.playoffContext,
                  let series = bracket.series(for: game) else { continue }
            if series.topSeedWins + series.bottomSeedWins < context.gameInSeries {
                return true
            }
        }
        return false
    }
}
