import Foundation
import Observation
#if canImport(UIKit)
import UIKit
#endif

struct DownloadedTrack: Codable, Hashable {
    let ino: String
    let filename: String
    let localPath: String  // relative to AppPaths.downloadsDirectory
    let durationSeconds: Double

    enum CodingKeys: String, CodingKey {
        case ino, filename, localPath, durationSeconds
    }

    init(ino: String, filename: String, localPath: String, durationSeconds: Double) {
        self.ino = ino
        self.filename = filename
        self.localPath = localPath
        self.durationSeconds = durationSeconds
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        ino = try c.decode(String.self, forKey: .ino)
        filename = try c.decode(String.self, forKey: .filename)
        localPath = try c.decode(String.self, forKey: .localPath)
        durationSeconds = try c.decodeIfPresent(Double.self, forKey: .durationSeconds) ?? 0
    }
}

struct DownloadedItem: Codable, Hashable {
    let itemId: String
    var episodeId: String?
    let title: String
    let author: String
    let durationSeconds: Double
    let tracks: [DownloadedTrack]

    enum CodingKeys: String, CodingKey {
        case itemId, episodeId, title, author, durationSeconds, tracks
    }

    init(itemId: String, episodeId: String? = nil, title: String, author: String, durationSeconds: Double, tracks: [DownloadedTrack]) {
        self.itemId = itemId
        self.episodeId = episodeId
        self.title = title
        self.author = author
        self.durationSeconds = durationSeconds
        self.tracks = tracks
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        itemId = try c.decode(String.self, forKey: .itemId)
        episodeId = try c.decodeIfPresent(String.self, forKey: .episodeId)
        title = try c.decode(String.self, forKey: .title)
        author = try c.decode(String.self, forKey: .author)
        durationSeconds = try c.decode(Double.self, forKey: .durationSeconds)
        tracks = try c.decode([DownloadedTrack].self, forKey: .tracks)
    }

    var downloadKey: String {
        if let episodeId { return "\(itemId)|\(episodeId)" }
        return itemId
    }
}

@Observable
@MainActor
final class DownloadManager: NSObject, @unchecked Sendable {
    private let client: ABSClient
    /// Keyed by downloadKey (itemId or "itemId|episodeId").
    private(set) var states: [String: DownloadState] = [:]
    private(set) var downloadedItems: [String: DownloadedItem] = [:]
    /// Items currently being downloaded (removed on completion or cancellation).
    /// Persisted so the list survives an app kill while a background download
    /// is still in flight.
    private(set) var pendingItems: [String: LibraryItem] = [:]
    /// Per-download in-flight bytes for the currently active track(s). Background
    /// session may have multiple parallel tasks; we sum the writes per key.
    private(set) var inFlightBytes: [String: Int64] = [:]
    /// Completed tracks per active download, keyed by track index. Persisted so
    /// we can stitch the final DownloadedItem together if the app was killed.
    private var completedTracks: [String: [Int: DownloadedTrack]] = [:]
    /// Expected total track count per active download (persisted).
    private var totalTrackCount: [String: Int] = [:]
    /// In-flight byte counts per track (downloadKey -> trackIndex -> bytesWritten).
    /// Background sessions may run several tasks for the same download in
    /// parallel; we keep per-track counts so the sum is accurate even when
    /// multiple tracks are mid-flight.
    private var perTrackBytes: [String: [Int: Int64]] = [:]
    /// In-flight fractions per track (downloadKey -> trackIndex -> 0…1).
    /// Drives a smooth progress ring even for single-track audiobooks where
    /// `completedTracks` stays at 0 for the entire download.
    private var perTrackFractions: [String: [Int: Double]] = [:]
    /// Global FIFO queue of waiting tasks across ALL downloads. When the user
    /// has parallel mode off, only `maxConcurrentDownloads` tasks run at any
    /// time globally — so starting Book 2 while Book 1 is in flight leaves
    /// Book 2 entirely queued until Book 1 finishes.
    private var globalQueue: [QueuedUnit] = []
    /// Number of URLSession tasks we've handed off to the OS and that haven't
    /// reported completion yet. Compared against `maxConcurrentDownloads` to
    /// decide whether to drain the queue.
    private var activeTaskCount: Int = 0
    /// Per-track URLRequest cache — used so we can rebuild a download task
    /// even after the queue has shifted.
    private var pendingRequests: [String: [Int: URLRequest]] = [:]

    private var maxConcurrentDownloads: Int {
        let parallel = UserDefaults.standard.object(forKey: "downloadsParallel") as? Bool ?? true
        return parallel ? Int.max : 1
    }

    private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
    private var pendingFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-pending.json") }

    #if os(iOS)
    private let activityManager = DownloadActivityManager()
    /// Set when a download fails while the app is in the foreground.
    /// The UI observes this to show an in-app alert; cleared on dismissal.
    var downloadFailureAlertTitle: String? = nil
    #endif

    /// Stashed by the AppDelegate when iOS wakes the app to handle a completed
    /// background download. Called from `urlSessionDidFinishEvents`.
    nonisolated(unsafe) static var pendingBackgroundCompletion: (() -> Void)?

    /// Last MainActor-progress-update timestamp per URLSession task identifier
    /// (in mach nanoseconds). Used to throttle `didWriteData` callbacks —
    /// accessed only from URLSession's serial delegate queue so it's safe.
    nonisolated(unsafe) static var lastProgressNs: [Int: UInt64] = [:]

    /// Background URLSession used for all download tasks. On iOS this keeps
    /// downloads alive when the app is suspended or terminated; on macOS it
    /// behaves like a normal session. Set in `init` after `super.init()`
    /// because `self` is needed as the delegate; @ObservationIgnored because
    /// the @Observable macro can't synthesize accessors for IUO properties.
    @ObservationIgnored
    private var downloadSession: URLSession!

    private func makeDownloadSession() -> URLSession {
        let identifier = "com.local.ABS-Client.bgDownloads"
        #if os(iOS)
        let config = URLSessionConfiguration.background(withIdentifier: identifier)
        config.sessionSendsLaunchEvents = true
        config.isDiscretionary = false
        config.networkServiceType = .responsiveData
        config.httpMaximumConnectionsPerHost = 6
        #else
        // macOS hat keinen Background-Session-Daemon mit Auto-Retry —
        // gleiche Pool-/Service-Settings wie iOS, sonst hängt sich ein
        // parallel laufender Download bei geteilter Bandbreite zu lange ohne
        // Bytes auf und failed mit Timeout.
        let config = URLSessionConfiguration.default
        config.networkServiceType = .responsiveData
        config.httpMaximumConnectionsPerHost = 6
        #endif
        config.allowsCellularAccess = true
        config.waitsForConnectivity = true
        // 300s statt 60s: bei zwei parallelen großen Tracks kann eine Task
        // temporär für deutlich mehr als 60s ohne Bytes bleiben, besonders
        // auf macOS-Foreground-Sessions ohne iOS-Background-Retry-Logik.
        config.timeoutIntervalForRequest = 300
        config.timeoutIntervalForResource = 60 * 60 * 24 // 24h — big audiobooks
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }

    init(client: ABSClient) {
        self.client = client
        super.init()
        // Created here (not as a property initializer) so the URLSession can
        // pass `self` as its delegate and reconnect to any tasks left running
        // by a previous app launch.
        self.downloadSession = makeDownloadSession()
        try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true)
        loadIndex()
        loadPending()
    }

    func state(for downloadKey: String) -> DownloadState {
        states[downloadKey] ?? .notDownloaded
    }

    func isDownloaded(downloadKey: String) -> Bool {
        if case .downloaded = state(for: downloadKey) { return true }
        return false
    }

    func downloadedBytes(for downloadKey: String) -> Int64 {
        if let item = downloadedItems[downloadKey] {
            return item.tracks.reduce(Int64(0)) { sum, track in
                let url = AppPaths.downloadsDirectory.appendingPathComponent(track.localPath)
                return sum + fileSize(at: url)
            }
        }
        if let pending = pendingItems[downloadKey] {
            let onDisk = folderSize(at: AppPaths.downloadsDirectory.appendingPathComponent(pending.id))
            let inFlight = inFlightBytes[downloadKey] ?? 0
            return onDisk + inFlight
        }
        return 0
    }

    private func fileSize(at url: URL) -> Int64 {
        guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 }
        if let n = attrs[.size] as? NSNumber { return n.int64Value }
        if let i = attrs[.size] as? Int { return Int64(i) }
        return 0
    }

    func localTrackURLs(for downloadKey: String) -> [URL]? {
        guard let item = downloadedItems[downloadKey] else { return nil }
        return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) }
    }

    /// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile).
    func startDownload(item: LibraryItem) {
        let key = item.downloadKey
        // Already downloaded or in progress — no-op
        if downloadedItems[key] != nil { return }
        if case .downloading = states[key] ?? .notDownloaded { return }

        states[key] = .downloading(progress: 0)
        pendingItems[key] = item
        #if os(iOS)
        activityManager.startActivity(downloadKey: key, itemTitle: item.title)
        #endif

        Task { @MainActor [weak self] in
            guard let self else { return }
            var workItem = item

            if !workItem.isPodcast && workItem.audioFiles.isEmpty {
                do {
                    workItem = try await self.client.fetchItemDetail(itemId: item.id)
                } catch {
                    self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
                    self.pendingItems.removeValue(forKey: key)
                    #if os(iOS)
                    self.handleDownloadFailure(itemTitle: item.title, downloadKey: key)
                    #endif
                    return
                }
            }
            if workItem.audioFiles.isEmpty {
                self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
                self.pendingItems.removeValue(forKey: key)
                #if os(iOS)
                self.handleDownloadFailure(itemTitle: workItem.title, downloadKey: key)
                #endif
                return
            }
            self.enqueueDownload(workItem: workItem, downloadKey: key)
        }
    }

    /// Builds the URLRequest + TaskMeta pairs for every audio file of `workItem`
    /// and appends them to the global FIFO queue. Then `drainQueue` starts as
    /// many as the current concurrency limit allows — in sequential mode that's
    /// 1, so Book 2's tracks wait until Book 1 fully finishes.
    private func enqueueDownload(workItem: LibraryItem, downloadKey: String) {
        let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
        try? FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)

        pendingItems[downloadKey] = workItem
        totalTrackCount[downloadKey] = workItem.audioFiles.count
        completedTracks[downloadKey] = [:]
        persistPending()

        let allowCellular = UserDefaults.standard.object(forKey: "downloadsOverMobile") as? Bool ?? true

        var requestMap: [Int: URLRequest] = [:]
        var units: [QueuedUnit] = []

        for (idx, file) in workItem.audioFiles.enumerated() {
            guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
            var request = URLRequest(url: url)
            for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
            request.allowsCellularAccess = allowCellular

            let ext = file.ext.isEmpty ? "mp3" : file.ext
            let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"

            let meta = TaskMeta(
                downloadKey: downloadKey,
                trackIndex: idx,
                itemId: workItem.id,
                episodeId: workItem.episodeId,
                destName: destName,
                ino: file.ino,
                filename: file.filename,
                durationSeconds: file.durationSeconds
            )
            requestMap[idx] = request
            units.append(QueuedUnit(meta: meta, request: request))
        }

        pendingRequests[downloadKey] = requestMap
        globalQueue.append(contentsOf: units)
        drainQueue()
    }

    /// Starts as many queued tasks as the current concurrency limit allows.
    /// Reads the user setting fresh each call so a toggle takes effect on the
    /// next slot that opens up.
    private func drainQueue() {
        let limit = maxConcurrentDownloads
        while activeTaskCount < limit, !globalQueue.isEmpty {
            let next = globalQueue.removeFirst()
            activeTaskCount += 1
            startTask(meta: next.meta, request: next.request)
        }
    }

    private func startTask(meta: TaskMeta, request: URLRequest) {
        let task = downloadSession.downloadTask(with: request)
        task.taskDescription = meta.encoded()
        task.resume()
    }

    /// Called when a task is cancelled (via our cancel/delete or the OS) and
    /// no track-completion or track-failure handler will fire for it. Keeps
    /// `activeTaskCount` accurate so the next queued task can start.
    fileprivate func handleTaskAborted() {
        activeTaskCount = max(0, activeTaskCount - 1)
        drainQueue()
    }

    func cancel(downloadKey: String) {
        states[downloadKey] = .notDownloaded
        pendingItems.removeValue(forKey: downloadKey)
        inFlightBytes.removeValue(forKey: downloadKey)
        perTrackBytes.removeValue(forKey: downloadKey)
        perTrackFractions.removeValue(forKey: downloadKey)
        completedTracks.removeValue(forKey: downloadKey)
        totalTrackCount.removeValue(forKey: downloadKey)
        pendingRequests.removeValue(forKey: downloadKey)
        // Drop any queued (not-yet-started) tasks for this download.
        globalQueue.removeAll { $0.meta.downloadKey == downloadKey }
        persistPending()
        #if os(iOS)
        activityManager.endActivity(downloadKey: downloadKey)
        #endif
        // Cancel all in-flight URLSession tasks for this download (async).
        downloadSession.getAllTasks { tasks in
            for task in tasks {
                if let meta = TaskMeta.decode(task.taskDescription),
                   meta.downloadKey == downloadKey {
                    task.cancel()
                }
            }
        }
    }

    func delete(downloadKey: String) {
        pendingItems.removeValue(forKey: downloadKey)
        cancel(downloadKey: downloadKey)
        if let item = downloadedItems[downloadKey] {
            let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
            try? FileManager.default.removeItem(at: dir)
            if item.episodeId != nil {
                let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId)
                if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty {
                    try? FileManager.default.removeItem(at: parent)
                }
            }
        }
        downloadedItems.removeValue(forKey: downloadKey)
        states[downloadKey] = .notDownloaded
        persistIndex()
    }

    private func folderSize(at url: URL) -> Int64 {
        guard let enumerator = FileManager.default.enumerator(
            atPath: url.path
        ) else { return 0 }
        var total: Int64 = 0
        for case let relPath as String in enumerator {
            let fileURL = url.appendingPathComponent(relPath)
            total += fileSize(at: fileURL)
        }
        return total
    }

    private func directoryURL(itemId: String, episodeId: String?) -> URL {
        var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true)
        if let episodeId {
            dir = dir.appendingPathComponent(episodeId, isDirectory: true)
        }
        return dir
    }

    private func relativePath(itemId: String, episodeId: String?, fileName: String) -> String {
        if let episodeId { return "\(itemId)/\(episodeId)/\(fileName)" }
        return "\(itemId)/\(fileName)"
    }

    #if os(iOS)
    /// Ends the Live Activity and routes the failure to either an in-app alert
    /// (foreground) or a local notification (background).
    private func handleDownloadFailure(itemTitle: String, downloadKey: String) {
        activityManager.endActivity(downloadKey: downloadKey)
        if UIApplication.shared.applicationState == .active {
            // Only set if no alert is already pending — prevents a second
            // concurrent failure from overwriting an unread message.
            if downloadFailureAlertTitle == nil {
                downloadFailureAlertTitle = itemTitle
            }
        } else {
            Task { await NotificationManager.shared.sendDownloadFailedNotification(itemTitle: itemTitle) }
        }
    }
    #endif

    // MARK: - Background-session bookkeeping

    /// Sums per-task in-flight byte counts (the background session may have
    /// several tasks for the same download) and recomputes the overall progress
    /// ring. The progress includes already-completed tracks PLUS the fraction
    /// of each in-flight track — without this, single-track audiobooks stay
    /// at 0 % until the whole file is on disk.
    fileprivate func applyTaskProgress(meta: TaskMeta, totalBytesWritten: Int64, expectedBytes: Int64) {
        perTrackBytes[meta.downloadKey, default: [:]][meta.trackIndex] = totalBytesWritten
        if expectedBytes > 0 {
            let fraction = min(1, max(0, Double(totalBytesWritten) / Double(expectedBytes)))
            perTrackFractions[meta.downloadKey, default: [:]][meta.trackIndex] = fraction
        }
        let trackSum = perTrackBytes[meta.downloadKey]?.values.reduce(Int64(0), +) ?? 0
        inFlightBytes[meta.downloadKey] = trackSum

        if let total = totalTrackCount[meta.downloadKey], total > 0 {
            let completed = Double(completedTracks[meta.downloadKey]?.count ?? 0)
            let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0
            let progress = min(0.999, (completed + inflightSum) / Double(total))
            states[meta.downloadKey] = .downloading(progress: progress)
            #if os(iOS)
            activityManager.updateActivity(
                downloadKey: meta.downloadKey,
                progress: progress,
                bytesDownloaded: downloadedBytes(for: meta.downloadKey)
            )
            #endif
        }
    }

    /// Called when a single track completes successfully (its file has already
    /// been moved to the final destination by the delegate). Records the track,
    /// decrements the global active-task counter, and asks the queue to drain —
    /// in sequential mode that frees the single slot for the next queued track.
    fileprivate func handleTrackCompleted(meta: TaskMeta) {
        activeTaskCount = max(0, activeTaskCount - 1)

        // Track is now on disk — clear its in-flight byte/fraction counters so
        // we don't double-count (folderSize and completedTracks already include
        // this track).
        perTrackBytes[meta.downloadKey]?[meta.trackIndex] = 0
        perTrackFractions[meta.downloadKey]?[meta.trackIndex] = nil
        let inFlight = perTrackBytes[meta.downloadKey]?.values.reduce(Int64(0), +) ?? 0
        inFlightBytes[meta.downloadKey] = inFlight

        let track = DownloadedTrack(
            ino: meta.ino,
            filename: meta.filename,
            localPath: relativePath(itemId: meta.itemId, episodeId: meta.episodeId, fileName: meta.destName),
            durationSeconds: meta.durationSeconds
        )
        completedTracks[meta.downloadKey, default: [:]][meta.trackIndex] = track
        persistPending()

        guard let total = totalTrackCount[meta.downloadKey],
              let workItem = pendingItems[meta.downloadKey] else {
            drainQueue()
            return
        }
        let completed = completedTracks[meta.downloadKey] ?? [:]

        if completed.count == total {
            finalizeDownload(workItem: workItem, downloadKey: meta.downloadKey, completed: completed)
        } else {
            let inflightSum = perTrackFractions[meta.downloadKey]?.values.reduce(0, +) ?? 0
            let progress = min(0.999, (Double(completed.count) + inflightSum) / Double(total))
            states[meta.downloadKey] = .downloading(progress: progress)
            #if os(iOS)
            activityManager.updateActivity(
                downloadKey: meta.downloadKey,
                progress: progress,
                bytesDownloaded: downloadedBytes(for: meta.downloadKey)
            )
            #endif
        }

        drainQueue()
    }

    private func finalizeDownload(workItem: LibraryItem, downloadKey: String, completed: [Int: DownloadedTrack]) {
        let sortedTracks = completed.sorted(by: { $0.key < $1.key }).map { $0.value }
        let downloaded = DownloadedItem(
            itemId: workItem.id,
            episodeId: workItem.episodeId,
            title: workItem.title,
            author: workItem.author,
            durationSeconds: workItem.durationSeconds,
            tracks: sortedTracks
        )
        downloadedItems[downloadKey] = downloaded
        states[downloadKey] = .downloaded
        pendingItems.removeValue(forKey: downloadKey)
        inFlightBytes.removeValue(forKey: downloadKey)
        perTrackBytes.removeValue(forKey: downloadKey)
        perTrackFractions.removeValue(forKey: downloadKey)
        completedTracks.removeValue(forKey: downloadKey)
        totalTrackCount.removeValue(forKey: downloadKey)
        pendingRequests.removeValue(forKey: downloadKey)
        #if os(iOS)
        activityManager.endActivity(downloadKey: downloadKey)
        #endif
        persistIndex()
        persistPending()
    }

    /// Called by the delegate when a track fails irrecoverably. iOS's background
    /// URLSession already retries transient network errors; if we still end up
    /// here, the user will need to retry manually.
    fileprivate func handleTrackFailed(meta: TaskMeta, errorMessage: String) {
        activeTaskCount = max(0, activeTaskCount - 1)
        defer { drainQueue() }

        // Only react once per download — if we already failed/cancelled the
        // overall download, ignore further per-track failures.
        guard pendingItems[meta.downloadKey] != nil else { return }
        let title = pendingItems[meta.downloadKey]?.title ?? "Download"
        states[meta.downloadKey] = .failed(message: errorMessage)
        pendingItems.removeValue(forKey: meta.downloadKey)
        perTrackBytes.removeValue(forKey: meta.downloadKey)
        perTrackFractions.removeValue(forKey: meta.downloadKey)
        completedTracks.removeValue(forKey: meta.downloadKey)
        totalTrackCount.removeValue(forKey: meta.downloadKey)
        inFlightBytes.removeValue(forKey: meta.downloadKey)
        pendingRequests.removeValue(forKey: meta.downloadKey)
        globalQueue.removeAll { $0.meta.downloadKey == meta.downloadKey }
        persistPending()
        #if os(iOS)
        handleDownloadFailure(itemTitle: title, downloadKey: meta.downloadKey)
        #endif

        // Cancel any sibling tasks for the same download so we don't keep
        // pulling bytes for a download we've already given up on.
        let cancelKey = meta.downloadKey
        downloadSession.getAllTasks { tasks in
            for task in tasks {
                if let m = TaskMeta.decode(task.taskDescription), m.downloadKey == cancelKey {
                    task.cancel()
                }
            }
        }
    }

    private func loadIndex() {
        guard let data = try? Data(contentsOf: indexFile),
              let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return }
        var rekeyed: [String: DownloadedItem] = [:]
        for (_, item) in decoded {
            if item.tracks.isEmpty { continue }
            rekeyed[item.downloadKey] = item
        }
        downloadedItems = rekeyed
        for k in rekeyed.keys {
            states[k] = .downloaded
        }
        for (oldKey, item) in decoded where item.tracks.isEmpty {
            let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey)
            try? FileManager.default.removeItem(at: dir)
        }
        if rekeyed.count != decoded.count {
            persistIndex()
        }
    }

    private func persistIndex() {
        do {
            let data = try JSONEncoder().encode(downloadedItems)
            try data.write(to: indexFile, options: .atomic)
        } catch {
            // non-fatal
        }
    }

    // MARK: - Pending-download persistence

    /// Snapshot written to disk so that a background download surviving an app
    /// kill can be picked up again on next launch.
    private struct PendingSnapshot: Codable {
        var pendingItems: [String: LibraryItem]
        var completedTracks: [String: [Int: DownloadedTrack]]
        var totalTrackCount: [String: Int]
    }

    private func persistPending() {
        let snapshot = PendingSnapshot(
            pendingItems: pendingItems,
            completedTracks: completedTracks,
            totalTrackCount: totalTrackCount
        )
        do {
            let data = try JSONEncoder().encode(snapshot)
            try data.write(to: pendingFile, options: .atomic)
        } catch {
            // non-fatal
        }
    }

    private func loadPending() {
        guard let data = try? Data(contentsOf: pendingFile),
              let snapshot = try? JSONDecoder().decode(PendingSnapshot.self, from: data)
        else { return }
        pendingItems = snapshot.pendingItems
        completedTracks = snapshot.completedTracks
        totalTrackCount = snapshot.totalTrackCount
        // Restore states from progress (won't be `.downloaded` since persistIndex
        // is the only path that flips that).
        for (key, item) in pendingItems {
            if let total = totalTrackCount[key], total > 0 {
                let done = Double(completedTracks[key]?.count ?? 0)
                states[key] = .downloading(progress: min(0.999, done / Double(total)))
            } else {
                states[key] = .downloading(progress: 0)
            }
            _ = item // referenced for clarity
        }
    }
}

// MARK: - Queued unit

/// One entry in `DownloadManager.globalQueue`. Holds everything needed to spin
/// up the URLSessionDownloadTask later, including the prepared URLRequest with
/// its bearer headers and cellular-allowance flag already applied.
fileprivate struct QueuedUnit {
    let meta: TaskMeta
    let request: URLRequest
}

// MARK: - Task metadata

/// Encoded into `URLSessionTask.taskDescription` so the delegate has everything
/// it needs to route bytes back to the right download / track on the iOS
/// background queue (which has no access to MainActor state).
///
/// Uses a hand-rolled tab-separated encoding rather than `Codable` so we don't
/// need a nonisolated conformance — the project's
/// `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` would otherwise pin Encodable
/// to MainActor, which can't be called from the URLSession delegate queue.
fileprivate struct TaskMeta: Sendable {
    let downloadKey: String
    let trackIndex: Int
    let itemId: String
    let episodeId: String?
    let destName: String
    let ino: String
    let filename: String
    let durationSeconds: Double

    private nonisolated static let separator = "\u{1F}" // ASCII unit separator — won't appear in IDs/filenames

    nonisolated func encoded() -> String {
        let parts: [String] = [
            downloadKey,
            String(trackIndex),
            itemId,
            episodeId ?? "",
            destName,
            ino,
            filename,
            String(durationSeconds)
        ]
        return parts.joined(separator: Self.separator)
    }

    nonisolated static func decode(_ description: String?) -> TaskMeta? {
        guard let description else { return nil }
        let parts = description.components(separatedBy: separator)
        guard parts.count == 8,
              let trackIndex = Int(parts[1]),
              let durationSeconds = Double(parts[7])
        else { return nil }
        return TaskMeta(
            downloadKey: parts[0],
            trackIndex: trackIndex,
            itemId: parts[2],
            episodeId: parts[3].isEmpty ? nil : parts[3],
            destName: parts[4],
            ino: parts[5],
            filename: parts[6],
            durationSeconds: durationSeconds
        )
    }
}

// MARK: - URLSessionDownloadDelegate

extension DownloadManager: URLSessionDownloadDelegate {

    nonisolated func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didWriteData bytesWritten: Int64,
        totalBytesWritten: Int64,
        totalBytesExpectedToWrite: Int64
    ) {
        guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return }
        // Throttle: only hop to MainActor at most every ~250 ms per task. The
        // OS fires didWriteData many times a second; uncapped this floods
        // SwiftUI's diff loop and starves the main thread (which slows the
        // download too, since URLSession shares the same process).
        let now = DispatchTime.now().uptimeNanoseconds
        let lastNs = Self.lastProgressNs[downloadTask.taskIdentifier] ?? 0
        if now - lastNs < 250_000_000 { return }
        Self.lastProgressNs[downloadTask.taskIdentifier] = now
        let expected = totalBytesExpectedToWrite
        Task { @MainActor [weak self] in
            self?.applyTaskProgress(meta: meta, totalBytesWritten: totalBytesWritten, expectedBytes: expected)
        }
    }

    nonisolated func urlSession(
        _ session: URLSession,
        downloadTask: URLSessionDownloadTask,
        didFinishDownloadingTo location: URL
    ) {
        guard let meta = TaskMeta.decode(downloadTask.taskDescription) else { return }
        // Must move synchronously here — the OS removes `location` immediately
        // after this method returns.
        let downloadsDir = AppPaths.downloadsDirectory
        let itemDir: URL = {
            var dir = downloadsDir.appendingPathComponent(meta.itemId, isDirectory: true)
            if let episodeId = meta.episodeId {
                dir = dir.appendingPathComponent(episodeId, isDirectory: true)
            }
            return dir
        }()
        let destURL = itemDir.appendingPathComponent(meta.destName)
        do {
            try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
            try? FileManager.default.removeItem(at: destURL)
            try FileManager.default.moveItem(at: location, to: destURL)
        } catch {
            // didCompleteWithError will follow; bail out of completion reporting.
            return
        }
        Task { @MainActor [weak self] in
            self?.handleTrackCompleted(meta: meta)
        }
    }

    nonisolated func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didCompleteWithError error: Error?
    ) {
        // Free the throttle slot for this task identifier — tasks come and go,
        // and this dict would otherwise grow without bound.
        Self.lastProgressNs.removeValue(forKey: task.taskIdentifier)

        // Success path: `didFinishDownloadingTo` already fired and will decrement
        // the active-task counter via `handleTrackCompleted`.
        guard let error else { return }

        let nsError = error as NSError
        if nsError.code == NSURLErrorCancelled {
            // We initiated cancellation (cancel/delete/handleTrackFailed). The
            // active counter still needs to come down so the next queued task
            // can start.
            Task { @MainActor [weak self] in
                self?.handleTaskAborted()
            }
            return
        }
        guard let meta = TaskMeta.decode(task.taskDescription) else {
            Task { @MainActor [weak self] in self?.handleTaskAborted() }
            return
        }
        let message = error.localizedDescription
        Task { @MainActor [weak self] in
            self?.handleTrackFailed(meta: meta, errorMessage: message)
        }
    }

    #if os(iOS)
    nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
        Task { @MainActor in
            let handler = DownloadManager.pendingBackgroundCompletion
            DownloadManager.pendingBackgroundCompletion = nil
            handler?()
        }
    }
    #endif
}
