import Foundation
import Observation

@Observable
@MainActor
final class AppState {
    let auth: AuthStore
    let client: ABSClient
    let network: NetworkMonitor
    let downloads: DownloadManager
    let sync: ProgressSyncManager
    let player: PlayerEngine
    let history: HistoryManager
    let bookmarks: BookmarkManager

    var currentItem: LibraryItem?
    var isPreparingPlayback: Bool = false
    var language: String = UserDefaults.standard.string(forKey: "appLanguage") ?? "de" {
        didSet { UserDefaults.standard.set(language, forKey: "appLanguage") }
    }

    /// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
    /// Used to show progress bars on covers in the library views.
    var progressCache: [String: PlaybackProgress] = [:]

    /// Cached LibraryItem details (cover, title, author, chapters) per syncKey.
    /// Populated whenever the user actually plays an item, persisted to disk so
    /// the Home screen can show "recently played" cards on first launch without
    /// any network round-trip.
    var recentItemsCache: [String: LibraryItem] = [:]

    /// Server-progress that's newer than local but we haven't applied yet because
    /// playback is active/paused. Offered via alert on next play; cleared on
    /// item change.
    var pendingServerProgress: PlaybackProgress?

    private var syncTimer: Timer?
    private var pullTimer: Timer?
    private var lastReportedSecond: Double = -10
    private var lastPushedAt: Date = .distantPast

    init() {
        let auth = AuthStore()
        let client = ABSClient(auth: auth)
        self.auth = auth
        self.client = client
        self.network = NetworkMonitor()
        self.downloads = DownloadManager(client: client)
        self.sync = ProgressSyncManager(client: client)
        self.player = PlayerEngine()
        self.history = HistoryManager()
        self.bookmarks = BookmarkManager()
        // Route lockscreen/Control-Center seeks through AppState so history is
        // recorded — otherwise remote skips bypass history entirely.
        self.player.onRemoteSkip = { [weak self] seconds in
            self?.skip(by: seconds)
        }
        self.player.onRemoteSeek = { [weak self] target in
            self?.seekAbsolute(target)
        }
        // PlayerEngine reports chapter transitions from the AVPlayer time
        // observer — fires reliably in background/locked, unlike the 5s Timer.
        self.player.onChapterChanged = { [weak self] chapter in
            self?.recordChapterEntry(chapter)
        }
    }

    private func recordChapterEntry(_ chapter: Chapter) {
        guard let item = currentItem,
              UserDefaults.standard.bool(forKey: "historyEnabled") else { return }
        history.record(item: item, position: chapter.start, chapters: item.chapters)
    }

    func bootstrap() async {
        #if os(iOS)
        await NotificationManager.shared.requestPermissionIfNeeded()
        #endif
        auth.restoreSession()
        loadRecentItemsCache()
        network.start { [weak self] online in
            guard let self else { return }
            if online {
                Task { [weak self] in
                    await self?.sync.drain()
                    await self?.refreshProgressCache()
                }
            }
        }
        if auth.isLoggedIn {
            let ok = await client.validateToken()
            if !ok {
                auth.logout()
            } else {
                await sync.drain()
                await refreshProgressCache()
            }
        }
        startPullTimer()
    }

    /// Called by ContentView on scenePhase == .active. Immediate pull so we
    /// notice updates from other devices the moment the app comes forward.
    func onScenePhaseActive() {
        Task { await pullAndReconcile() }
    }

    private func startPullTimer() {
        pullTimer?.invalidate()
        let timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in
            Task { @MainActor [weak self] in await self?.pullAndReconcile() }
        }
        RunLoop.main.add(timer, forMode: .common)
        pullTimer = timer
    }

    /// Pulls server progress and reconciles against local state:
    /// - currentItem == nil: just refresh the cache (library covers).
    /// - server is newer than local: stash for the resume-prompt.
    /// - server is older than local: push our state immediately.
    func pullAndReconcile() async {
        guard network.isOnline, auth.isLoggedIn else { return }
        await refreshProgressCache()

        guard let current = currentItem else { return }
        guard let server = progressCache[current.syncKey] else { return }

        let local = player.absoluteCurrentTime
        let positionDelta = abs(server.currentTime - local)
        // Treat <8 s delta as identical to absorb own-update echoes, clock skew,
        // and reporting granularity.
        guard positionDelta > 8 else { return }

        let serverIsNewer = server.updatedAt > lastPushedAt.addingTimeInterval(5)
        if serverIsNewer {
            pendingServerProgress = server
        } else {
            reportProgress(force: true)
        }
    }

    func acceptPendingServerProgress() {
        guard let p = pendingServerProgress else { return }
        pendingServerProgress = nil
        player.seekAbsolute(p.currentTime)
        player.play()
    }

    func dismissPendingServerProgress() {
        pendingServerProgress = nil
        player.play()
    }

    /// Pulls the entire progress map from the server (via /api/me).
    func refreshProgressCache() async {
        guard network.isOnline, auth.isLoggedIn else { return }
        do {
            let all = try await client.fetchAllProgress()
            progressCache = Dictionary(all.map { ($0.syncKey, $0) }, uniquingKeysWith: { _, new in new })
        } catch {
            // non-fatal
        }
    }

    /// Local update for the cache while we're actively playing.
    func cacheProgress(itemId: String, episodeId: String?, currentTime: Double, duration: Double, isFinished: Bool) {
        let p = PlaybackProgress(
            itemId: itemId, episodeId: episodeId,
            currentTime: currentTime, duration: duration,
            isFinished: isFinished, updatedAt: Date()
        )
        progressCache[p.syncKey] = p
    }

    func progress(for item: LibraryItem) -> PlaybackProgress? {
        progressCache[item.syncKey]
    }

    func progressFraction(itemId: String, episodeId: String? = nil) -> Double {
        let key = episodeId.map { "\(itemId)|\($0)" } ?? itemId
        guard let p = progressCache[key], p.duration > 0 else { return 0 }
        if p.isFinished { return 1.0 }
        return min(1, max(0, p.currentTime / p.duration))
    }

    func play(item: LibraryItem, overrideStartAt: Double? = nil) async {
        // Clear any stash from a previous item — only carry stashes per-item.
        pendingServerProgress = nil
        if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
            if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() }
            return
        }
        // Record position before switching to a new item
        if let current = currentItem, player.absoluteCurrentTime > 5,
           UserDefaults.standard.bool(forKey: "historyEnabled") {
            history.record(item: current, position: player.absoluteCurrentTime, chapters: current.chapters)
        }
        stopPlayback(reportFinal: true)
        isPreparingPlayback = true
        defer { isPreparingPlayback = false }

        var workItem = item
        // Always fetch detail when online so chapters are loaded — also for
        // already-downloaded items (the persisted DownloadedItem doesn't store
        // chapter metadata, so streaming the detail is the only source).
        if !workItem.isPodcast && network.isOnline {
            if let detail = try? await client.fetchItemDetail(itemId: item.id) {
                workItem = detail
            }
        }

        var startAt: Double = overrideStartAt ?? 0
        if overrideStartAt == nil && network.isOnline {
            if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) {
                // Replaying a finished item (or one with progress essentially at the end)
                // should start from the beginning, not drop the user at the last few seconds.
                let nearEnd = p.duration > 0 && p.currentTime >= p.duration - 10
                if !p.isFinished && !nearEnd {
                    startAt = p.currentTime
                }
            }
        }
        if network.isOnline {
            // Load bookmarks from server for this item
            if let serverBMs = try? await client.fetchBookmarks(itemId: workItem.id, episodeId: workItem.episodeId) {
                bookmarks.mergeFromServer(serverBMs, for: workItem)
            }
        }

        currentItem = workItem
        // Cache full item details for the Home screen — we paid the
        // fetchItemDetail cost above anyway, no reason to redo it later.
        recentItemsCache[workItem.syncKey] = workItem
        persistRecentItems()
        player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
        if player.errorMessage == nil {
            player.play()
            startSyncTimer()
            if UserDefaults.standard.bool(forKey: "historyEnabled") {
                history.record(item: workItem, position: startAt, chapters: workItem.chapters)
            }
        }
    }

    func playFromHistory(_ entry: HistoryEntry) async {
        if let current = currentItem,
           current.id == entry.itemId,
           current.episodeId == entry.episodeId {
            seekAbsolute(entry.position)
            return
        }
        guard network.isOnline,
              let detail = try? await client.fetchItemDetail(itemId: entry.itemId) else { return }
        var item = detail
        item.episodeId = entry.episodeId
        await play(item: item, overrideStartAt: entry.position)
    }

    /// Removes a media-progress entry from the server AND the local caches
    /// (`progressCache`, `recentItemsCache`, pending sync queue). The Home
    /// screen reacts immediately because it reads from `progressCache`. If
    /// the server call fails we still clear the local cache — the next
    /// `refreshProgressCache()` will re-sync from authoritative state (and
    /// the entry would come back if the server delete didn't go through).
    func removeProgress(itemId: String, episodeId: String? = nil) async {
        let key: String
        if let episodeId { key = "\(itemId)|\(episodeId)" } else { key = itemId }

        // Grab the server-assigned progress UUID BEFORE we wipe the cache —
        // Audiobookshelf's DELETE endpoint matches by this UUID, not by
        // libraryItemId, so we need it to actually hit the right entry.
        var progressId = progressCache[key]?.id

        progressCache.removeValue(forKey: key)
        recentItemsCache.removeValue(forKey: key)
        persistRecentItems()

        guard network.isOnline else { return }

        // Cache might be from before the `id` field was added — fall back to
        // a fresh fetchProgress to obtain it.
        if progressId == nil {
            progressId = (try? await client.fetchProgress(itemId: itemId, episodeId: episodeId))?.id
        }
        guard let pid = progressId else { return }
        try? await client.deleteProgress(progressId: pid)
    }

    /// Plays an item from the Home screen / recent-items cache. Books resume
    /// via the normal flow, but podcasts need an extra step: the cached
    /// LibraryItem carries an `episodeId` but no `audioFiles` (those live on
    /// the PodcastEpisode, not the podcast container). We fetch the episode
    /// list, locate the matching one, and hand off to `play(podcast:episode:)`.
    func playFromRecents(_ item: LibraryItem) async {
        guard item.isPodcastEpisode, let episodeId = item.episodeId else {
            await play(item: item)
            return
        }
        guard network.isOnline,
              let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: item.id),
              let episode = episodes.first(where: { $0.id == episodeId }) else { return }
        await play(podcast: podcast, episode: episode)
    }

    /// Convenience for podcast episodes.
    func play(podcast: LibraryItem, episode: PodcastEpisode) async {
        var synthetic = LibraryItem(
            id: podcast.id,
            title: episode.title,
            author: podcast.title,
            durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
            audioFiles: [episode.audioFile]
        )
        synthetic.mediaType = "podcast"
        synthetic.episodeId = episode.id
        await play(item: synthetic)
    }

    func stopPlayback(reportFinal: Bool = true) {
        if reportFinal { reportProgress(force: true) }
        syncTimer?.invalidate()
        syncTimer = nil
        player.teardown()
        currentItem = nil
        pendingServerProgress = nil
        lastReportedSecond = -10
    }

    func togglePlay() {
        guard currentItem != nil else { return }
        player.togglePlay()
        if !player.isPlaying { reportProgress(force: true) }
    }

    func skip(by seconds: Double) {
        guard let item = currentItem else { return }
        if UserDefaults.standard.bool(forKey: "historyEnabled") {
            history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters)
        }
        player.skip(by: seconds)
        reportProgress(force: true)
    }

    func seekAbsolute(_ target: Double) {
        guard let item = currentItem else { return }
        if UserDefaults.standard.bool(forKey: "historyEnabled") {
            history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters)
        }
        player.seekAbsolute(target)
        reportProgress(force: true)
    }

    func setRate(_ newRate: Float) {
        player.setRate(newRate)
    }

    func addBookmark(title: String) {
        guard let item = currentItem else { return }
        let t = player.absoluteCurrentTime
        bookmarks.add(item: item, time: t, title: title, chapters: item.chapters)
        if network.isOnline {
            Task { try? await client.createBookmark(itemId: item.id, time: t, title: title) }
        }
    }

    func deleteBookmark(_ bookmark: Bookmark) {
        bookmarks.delete(bookmark)
        if network.isOnline {
            Task { try? await client.deleteBookmark(itemId: bookmark.itemId, time: bookmark.time) }
        }
    }

    private func startSyncTimer() {
        syncTimer?.invalidate()
        let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
            Task { @MainActor [weak self] in
                self?.reportProgress(force: false)
            }
        }
        RunLoop.main.add(timer, forMode: .common)
        syncTimer = timer
    }

    // MARK: - Recent items cache

    private var recentItemsFile: URL {
        AppPaths.supportDirectory.appendingPathComponent("recent-items.json")
    }

    func loadRecentItemsCache() {
        guard let data = try? Data(contentsOf: recentItemsFile),
              let decoded = try? JSONDecoder().decode([String: LibraryItem].self, from: data) else { return }
        recentItemsCache = decoded
    }

    private func persistRecentItems() {
        // Cap at 100 most-recently-touched syncKeys (based on progressCache) so
        // the file doesn't grow unbounded for users with massive listening history.
        let keep = Set(progressCache.values
            .sorted { $0.updatedAt > $1.updatedAt }
            .prefix(100)
            .map { $0.syncKey })
        let trimmed = recentItemsCache.filter { keep.contains($0.key) || recentItemsCache.count <= 100 }
        do {
            try FileManager.default.createDirectory(at: AppPaths.supportDirectory, withIntermediateDirectories: true)
            let data = try JSONEncoder().encode(trimmed)
            try data.write(to: recentItemsFile, options: .atomic)
            recentItemsCache = trimmed
        } catch {
            // non-fatal
        }
    }

    /// Fills `recentItemsCache` for the top-N most recent progress entries that
    /// don't yet have details. Pulls from downloaded items first (no network),
    /// then `fetchItemDetail` for the rest. Called by HomeView on appear.
    func fillRecentItemsCache(limit: Int = 20) async {
        let topKeys = progressCache.values
            .filter { !$0.isFinished }
            .sorted { $0.updatedAt > $1.updatedAt }
            .prefix(limit)
            .map { $0.syncKey }

        var newlyAdded = false
        for key in topKeys {
            // Skip if already cached AND has audio files. A cached podcast
            // item with empty audioFiles is a stale entry from the old
            // fillRecentItemsCache path (fetchItemDetail returned a container,
            // not an episode) — re-fetch via fetchEpisodes below.
            if let existing = recentItemsCache[key], !existing.audioFiles.isEmpty { continue }
            // 1) Try downloaded items first
            if let downloaded = downloads.downloadedItems[key] {
                let files: [AudioFile] = downloaded.tracks.enumerated().map { idx, t in
                    AudioFile(ino: t.ino, filename: t.filename, ext: "", durationSeconds: t.durationSeconds, index: idx)
                }
                var li = LibraryItem(
                    id: downloaded.itemId,
                    title: downloaded.title,
                    author: downloaded.author,
                    durationSeconds: downloaded.durationSeconds,
                    audioFiles: files
                )
                if let episodeId = downloaded.episodeId {
                    li.mediaType = "podcast"
                    li.episodeId = episodeId
                }
                recentItemsCache[key] = li
                newlyAdded = true
                continue
            }
            // 2) Fetch from server
            guard network.isOnline,
                  let progress = progressCache[key] else { continue }

            if let episodeId = progress.episodeId {
                // Podcast episode: fetch episodes and synthesize a LibraryItem
                // that carries the episode's title + audioFile (analogous to
                // `play(podcast:episode:)`). Without this the cached item has
                // the podcast container's title, not the episode title.
                guard let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: progress.itemId),
                      let episode = episodes.first(where: { $0.id == episodeId }) else { continue }
                var synthetic = LibraryItem(
                    id: podcast.id,
                    title: episode.title,
                    author: podcast.title,
                    durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
                    audioFiles: [episode.audioFile]
                )
                synthetic.mediaType = "podcast"
                synthetic.episodeId = episode.id
                recentItemsCache[key] = synthetic
                newlyAdded = true
            } else {
                // Audiobook
                guard let detail = try? await client.fetchItemDetail(itemId: progress.itemId) else { continue }
                recentItemsCache[key] = detail
                newlyAdded = true
            }
        }
        if newlyAdded { persistRecentItems() }
    }

    private func reportProgress(force: Bool) {
        guard let item = currentItem else { return }
        let t = player.absoluteCurrentTime
        let d = player.totalDuration
        guard d > 0 else { return }
        if !force && abs(t - lastReportedSecond) < 3 { return }
        lastReportedSecond = t
        let finished = (d - t) < 30

        cacheProgress(
            itemId: item.id,
            episodeId: item.episodeId,
            currentTime: t,
            duration: d,
            isFinished: finished
        )
        lastPushedAt = Date()

        Task {
            await sync.report(
                itemId: item.id,
                episodeId: item.episodeId,
                currentTime: t,
                duration: d,
                isFinished: finished,
                isOnline: network.isOnline
            )
        }
    }
}

extension LibraryItem {
    /// Matches PlaybackProgress.syncKey for cache lookups.
    var syncKey: String {
        if let episodeId { return "\(id)|\(episodeId)" }
        return id
    }

    /// The DownloadManager keys downloads by this composite identifier,
    /// allowing the same podcast item to host multiple per-episode downloads.
    var downloadKey: String { syncKey }
}
