//
//  DownloadManager.swift
//  Yattee
//
//  Manages video downloads with background support.
//

import Foundation
import SwiftUI

#if !os(tvOS)

/// Per-video download progress for efficient SwiftUI observation.
/// Views can observe individual video progress without triggering re-renders for all videos.
struct DownloadProgressInfo: Equatable {
    var progress: Double        // 0.0 to 1.0
    var isIndeterminate: Bool   // true if total size unknown
}

/// Manages video downloads with background session support.
@Observable
@MainActor
final class DownloadManager: NSObject {
    // MARK: - Properties

    /// Active downloads (queued, downloading, paused).
    var activeDownloads: [Download] = []

    /// Completed downloads.
    var completedDownloads: [Download] = []

    /// Cached set of downloaded video IDs for O(1) lookup.
    var downloadedVideoIDs: Set<VideoID> = []

    /// Cached set of downloading (active) video IDs for O(1) lookup.
    var downloadingVideoIDs: Set<VideoID> = []

    /// Per-video download progress for efficient thumbnail observation.
    /// SwiftUI's @Observable tracks dictionary access per-key, so views only
    /// re-render when their specific video's progress changes (not all videos).
    var downloadProgressByVideo: [VideoID: DownloadProgressInfo] = [:]

    /// Total storage used by downloads in bytes.
    var storageUsed: Int64 = 0

    /// Maximum concurrent downloads (reads from settings, defaults to 2).
    var maxConcurrentDownloads: Int {
        downloadSettings?.maxConcurrentDownloads ?? 2
    }

    /// IDs of downloads that are part of the current batch (suppresses individual toasts).
    /// Downloads are added when enqueued and removed when completed, failed, or cancelled.
    var batchDownloadIDs: Set<UUID> = []

    /// Retry delays between attempts (3 delays = 4 total attempts).
    /// Matches playback retry timing for consistency.
    let retryDelays: [TimeInterval] = [1, 3, 5]

    /// Maximum retry attempts (derived from retryDelays).
    var maxRetryAttempts: Int { retryDelays.count }

    /// Minimum valid file size in bytes (files smaller than this are considered failed).
    let minimumValidFileSize: Int64 = 1024  // 1 KB

    var urlSession: URLSession!
    /// Tracks video download tasks by download ID
    var videoTasks: [UUID: URLSessionDownloadTask] = [:]
    /// Tracks audio download tasks by download ID
    var audioTasks: [UUID: URLSessionDownloadTask] = [:]
    /// Tracks caption download tasks by download ID
    var captionTasks: [UUID: URLSessionDownloadTask] = [:]
    /// Tracks active storyboard download tasks by download ID
    var storyboardTasks: [UUID: Task<Void, Never>] = [:]
    /// Tracks active thumbnail download tasks by download ID
    var thumbnailTasks: [UUID: Task<Void, Never>] = [:]

    /// Thread-safe storage for mapping task identifiers to (downloadID, phase).
    /// Accessed from URLSession delegate callbacks which run on arbitrary threads.
    let taskIDStorage = LockedStorage<[Int: (downloadID: UUID, phase: DownloadPhase)]>([:])
    /// Track previous bytes to detect resets at URLSession level
    let previousBytesStorage = LockedStorage<[Int: Int64]>([:])
    /// Track last progress update time per download to throttle UI updates (0.3s interval)
    let lastProgressUpdateStorage = LockedStorage<[UUID: Date]>([:])
    let fileManager = FileManager.default
    /// Cached downloads directory URL (created once on first access)
    private static var _cachedDownloadsDirectory: URL?
    weak var toastManager: ToastManager?
    weak var downloadSettings: DownloadSettings?

    /// Debounced save task to prevent excessive JSON encoding
    var saveTask: Task<Void, Never>?

    /// Thread-safe setter for task ID mapping.
    nonisolated func setTaskInfo(_ downloadID: UUID, phase: DownloadPhase, forTask taskID: Int) {
        taskIDStorage.write { $0[taskID] = (downloadID, phase) }
    }

    /// Thread-safe getter for task ID mapping.
    nonisolated func getTaskInfo(forTask taskID: Int) -> (downloadID: UUID, phase: DownloadPhase)? {
        taskIDStorage.read { $0[taskID] }
    }

    // MARK: - Initialization

    override init() {
        super.init()
        loadDownloads()
        // Note: Session setup and resumeInterruptedDownloads are deferred to setDownloadSettings()
        // to ensure we have the correct cellular access setting before creating the session.
    }

    private func setupSession() {
        let config = URLSessionConfiguration.background(
            withIdentifier: AppIdentifiers.downloadSession
        )
        config.isDiscretionary = false
        config.sessionSendsLaunchEvents = true

        #if os(iOS)
        let allowCellular = downloadSettings?.allowCellularDownloads ?? false
        config.allowsCellularAccess = allowCellular
        LoggingService.shared.logDownload(
            "[Downloads] Session setup",
            details: "allowsCellularAccess: \(allowCellular), hasSettings: \(downloadSettings != nil)"
        )
        #else
        config.allowsCellularAccess = true
        #endif

        urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }

    private func resumeInterruptedDownloads() {
        // Resume any downloads that were in progress when app was terminated
        Task {
            for download in activeDownloads where download.status == .downloading {
                await startDownload(for: download.id)
            }
        }
    }

    func setToastManager(_ manager: ToastManager) {
        self.toastManager = manager
    }

    func setDownloadSettings(_ settings: DownloadSettings) {
        let isInitialSetup = self.downloadSettings == nil
        self.downloadSettings = settings

        // Create the background session only once. The background URLSession's
        // identifier is process-global, so tearing it down here would cancel any
        // in-flight downloads. Cellular-access changes are handled separately via
        // refreshCellularAccessSetting(), which migrates active downloads safely.
        if urlSession == nil {
            setupSession()
        }

        // Resume interrupted downloads only on initial setup
        if isInitialSetup {
            resumeInterruptedDownloads()
        }
    }

    #if os(iOS)
    /// Refreshes the URLSession configuration when cellular settings change.
    /// Call this after the user toggles the "Allow Downloads on Cellular" setting.
    func refreshCellularAccessSetting() {
        Task {
            await recreateSessionWithNewCellularSetting()
        }
    }

    /// Recreates the URLSession with updated cellular access setting.
    /// Properly pauses active downloads, invalidates the old session, and resumes downloads.
    private func recreateSessionWithNewCellularSetting() async {
        let allowCellular = downloadSettings?.allowCellularDownloads ?? false
        LoggingService.shared.logDownload(
            "[Downloads] Cellular setting changed",
            details: "allowCellular: \(allowCellular)"
        )

        // 1. Collect downloads that are currently downloading
        let downloadingIDs = activeDownloads
            .filter { $0.status == .downloading }
            .map { $0.id }

        // 2. Pause all active downloads (saves resume data)
        for downloadID in downloadingIDs {
            if let download = activeDownloads.first(where: { $0.id == downloadID }) {
                await pause(download)
            }
        }

        // 3. Invalidate the old session (use finishTasksAndInvalidate since downloads are already paused)
        urlSession?.finishTasksAndInvalidate()
        urlSession = nil

        // 4. Create new session with updated cellular config
        setupSession()

        LoggingService.shared.logDownload(
            "[Downloads] Session recreated",
            details: "Migrating \(downloadingIDs.count) downloads"
        )

        // 5. Mark paused downloads as queued so they restart
        for downloadID in downloadingIDs {
            if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
                activeDownloads[index].status = .queued
            }
        }

        saveDownloads()

        // 6. Restart downloads on new session
        await startNextDownloadIfNeeded()
    }
    #endif

    // MARK: - Auto Download

    /// Automatically enqueue a video for download using the preferred quality setting.
    /// This fetches streams, selects the best match, and starts the download without user interaction.
    ///
    /// - Parameters:
    ///   - video: The video to download
    ///   - preferredQuality: The maximum quality to download
    ///   - preferredAudioLanguage: Preferred audio language code (from playback settings)
    ///   - preferredSubtitlesLanguage: Preferred subtitles language code (from playback settings)
    ///   - includeSubtitles: Whether to include subtitles
    ///   - contentService: The content service to fetch streams
    ///   - instance: The instance to fetch from
    ///   - suppressStartToast: Whether to suppress the "download started" toast (for batch downloads)
    func autoEnqueue(
        _ video: Video,
        preferredQuality: DownloadQuality,
        preferredAudioLanguage: String?,
        preferredSubtitlesLanguage: String?,
        includeSubtitles: Bool,
        contentService: ContentService,
        instance: Instance,
        suppressStartToast: Bool = false
    ) async throws {
        // Fetch streams and captions
        let fetchedVideo: Video
        let streams: [Stream]
        let captions: [Caption]
        let storyboards: [Storyboard]

        if case .extracted(_, let originalURL) = video.id.source {
            // Extracted videos need re-extraction via /api/v1/extract
            let result = try await contentService.extractURL(originalURL, instance: instance)
            fetchedVideo = result.video
            streams = result.streams
            captions = result.captions
            storyboards = []
        } else {
            let result = try await contentService.videoWithProxyStreamsAndCaptionsAndStoryboards(
                id: video.id.videoID,
                instance: instance
            )
            fetchedVideo = result.video
            streams = result.streams
            captions = result.captions
            storyboards = result.storyboards
        }

        // Filter and select video stream
        let videoStream = selectBestVideoStream(
            from: streams,
            maxQuality: preferredQuality
        )

        guard let videoStream else {
            throw DownloadError.noStreamAvailable
        }

        // Select audio stream if needed (for video-only streams)
        var audioStream: Stream?
        if videoStream.isVideoOnly {
            audioStream = selectBestAudioStream(
                from: streams,
                preferredLanguage: preferredAudioLanguage
            )
        }

        // Select caption if enabled
        var caption: Caption?
        if includeSubtitles, let preferredSubtitlesLanguage {
            caption = selectBestCaption(
                from: captions,
                preferredLanguage: preferredSubtitlesLanguage
            )
        }

        // Get storyboard
        let storyboard = storyboards.highest()

        // Enqueue the download
        let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : audioStream?.audioCodec
        let audioBitrate = videoStream.isMuxed ? nil : audioStream?.bitrate

        try await enqueue(
            fetchedVideo,
            quality: videoStream.qualityLabel,
            formatID: videoStream.format,
            streamURL: videoStream.url,
            audioStreamURL: videoStream.isVideoOnly ? audioStream?.url : nil,
            captionURL: caption?.url,
            audioLanguage: audioStream?.audioLanguage,
            captionLanguage: caption?.languageCode,
            httpHeaders: videoStream.httpHeaders,
            storyboard: storyboard,
            dislikeCount: nil,
            videoCodec: videoStream.videoCodec,
            audioCodec: audioCodec,
            videoBitrate: videoStream.bitrate,
            audioBitrate: audioBitrate,
            suppressStartToast: suppressStartToast
        )
    }

    /// Automatically enqueue a media source video (SMB/WebDAV/local) for download.
    /// Unlike autoEnqueue, this doesn't make API calls - it uses the direct file URL.
    ///
    /// - For SMB files: Downloads using libsmbclient (not URLSession) since URLSession doesn't support smb:// URLs.
    /// - For local folder files: Copies the file to the downloads directory.
    /// - For WebDAV files: Uses URLSession with HTTP/HTTPS (existing approach works).
    func autoEnqueueMediaSource(
        _ video: Video,
        mediaSourcesManager: MediaSourcesManager,
        webDAVClient: WebDAVClient,
        smbClient: SMBClient
    ) async throws {
        guard case .extracted(_, let originalURL) = video.id.source else {
            throw DownloadError.noStreamAvailable
        }

        // For SMB files: download using SMBClient (not URLSession)
        // URLSession doesn't support smb:// URLs - it only supports HTTP/HTTPS
        if video.isFromSMB {
            guard let sourceID = video.mediaSourceID,
                  let filePath = video.mediaSourceFilePath,
                  let source = mediaSourcesManager.sources.first(where: { $0.id == sourceID }) else {
                throw DownloadError.noStreamAvailable
            }

            let password = mediaSourcesManager.password(for: source)

            LoggingService.shared.logDownload(
                "[Downloads] Starting SMB download",
                details: "video: \(video.title), path: \(filePath)"
            )

            // Download using libsmbclient
            let (localURL, fileSize) = try await smbClient.downloadFileToDownloads(
                filePath: filePath,
                source: source,
                password: password,
                downloadsDirectory: downloadsDirectory()
            )

            // Create completed download record
            try await createCompletedDownload(
                video: video,
                localURL: localURL,
                fileSize: fileSize
            )
            return
        }

        // For local folder files: copy to downloads directory
        if video.isFromLocalFolder {
            LoggingService.shared.logDownload(
                "[Downloads] Copying local file",
                details: "video: \(video.title), url: \(originalURL.path)"
            )

            let localURL = try copyLocalFileToDownloads(from: originalURL)
            let fileSize = self.fileSize(at: localURL.path)

            try await createCompletedDownload(
                video: video,
                localURL: localURL,
                fileSize: fileSize
            )
            return
        }

        // For WebDAV: use URLSession (HTTP-based, existing code works)
        var authHeaders: [String: String]?

        if video.isFromWebDAV,
           let sourceID = video.mediaSourceID,
           let source = mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
            let password = mediaSourcesManager.password(for: source)
            authHeaders = await webDAVClient.authHeaders(for: source, password: password)
        }

        let fileExtension = originalURL.pathExtension.lowercased()

        try await enqueue(
            video,
            quality: "Original",
            formatID: fileExtension.isEmpty ? "video" : fileExtension,
            streamURL: originalURL,
            httpHeaders: authHeaders,
            audioCodec: "aac"  // Mark as muxed since local files typically have both tracks
        )
    }

    // MARK: - Batch Download

    /// Result of a batch download operation.
    struct BatchDownloadResult: Sendable {
        let successCount: Int
        let skippedCount: Int
        let failedVideos: [(title: String, error: String)]
    }

    /// Batch enqueue multiple videos for download.
    ///
    /// This method processes videos sequentially to avoid overwhelming the API.
    /// It skips videos that are already downloaded or downloading, and reports
    /// errors via the `onError` callback which can pause execution for user input.
    ///
    /// - Parameters:
    ///   - videos: The videos to download
    ///   - preferredQuality: Maximum quality to download
    ///   - preferredAudioLanguage: Preferred audio language code
    ///   - preferredSubtitlesLanguage: Preferred subtitles language code
    ///   - includeSubtitles: Whether to include subtitles
    ///   - contentService: The content service to fetch streams
    ///   - instance: The instance to fetch from
    ///   - onProgress: Called after each video with (current, total)
    ///   - onError: Called when a video fails, returns true to continue or false to stop
    /// - Returns: Summary of the batch operation
    func batchAutoEnqueue(
        videos: [Video],
        preferredQuality: DownloadQuality,
        preferredAudioLanguage: String?,
        preferredSubtitlesLanguage: String?,
        includeSubtitles: Bool,
        contentService: ContentService,
        instance: Instance,
        onProgress: @escaping @Sendable (Int, Int) async -> Void,
        onError: @escaping @Sendable (Video, Error) async -> Bool,
        onEnqueued: (@Sendable (UUID) async -> Void)? = nil
    ) async -> BatchDownloadResult {
        var successCount = 0
        var skippedCount = 0
        var failedVideos: [(title: String, error: String)] = []

        for (index, video) in videos.enumerated() {
            // Report progress
            await onProgress(index + 1, videos.count)

            // Skip if already downloaded or downloading
            if downloadedVideoIDs.contains(video.id) || downloadingVideoIDs.contains(video.id) {
                skippedCount += 1
                LoggingService.shared.logDownload(
                    "[Batch] Skipped: \(video.id.id)",
                    details: "Already downloaded or downloading"
                )
                continue
            }

            do {
                try await autoEnqueue(
                    video,
                    preferredQuality: preferredQuality,
                    preferredAudioLanguage: preferredAudioLanguage,
                    preferredSubtitlesLanguage: preferredSubtitlesLanguage,
                    includeSubtitles: includeSubtitles,
                    contentService: contentService,
                    instance: instance,
                    suppressStartToast: true
                )

                // Report the download ID that was just created
                if let download = activeDownloads.first(where: { $0.videoID == video.id }) {
                    await onEnqueued?(download.id)
                }

                successCount += 1
            } catch {
                LoggingService.shared.logDownload(
                    "[Batch] Failed: \(video.id.id)",
                    details: error.localizedDescription
                )
                failedVideos.append((title: video.title, error: error.localizedDescription))

                // Ask user if they want to continue
                let shouldContinue = await onError(video, error)
                if !shouldContinue {
                    LoggingService.shared.logDownload(
                        "[Batch] Stopped by user",
                        details: "After \(index + 1) of \(videos.count) videos"
                    )
                    break
                }
            }
        }

        LoggingService.shared.logDownload(
            "[Batch] Complete",
            details: "Success: \(successCount), Skipped: \(skippedCount), Failed: \(failedVideos.count)"
        )

        return BatchDownloadResult(
            successCount: successCount,
            skippedCount: skippedCount,
            failedVideos: failedVideos
        )
    }

    // MARK: - Stream Selection Helpers

    /// Selects the best video stream up to the specified quality.
    /// Respects device hardware capabilities when selecting codecs.
    private func selectBestVideoStream(
        from streams: [Stream],
        maxQuality: DownloadQuality
    ) -> Stream? {
        let maxRes = maxQuality.maxResolution

        // Filter to downloadable video streams (exclude HLS/DASH)
        let videoStreams = streams
            .filter { !$0.isAudioOnly && $0.resolution != nil }
            .filter {
                let format = StreamFormat.detect(from: $0)
                return format != .hls && format != .dash
            }
            .sorted { s1, s2 in
                // Sort by resolution (higher first), then by codec priority
                let res1 = s1.resolution ?? .p360
                let res2 = s2.resolution ?? .p360
                if res1 != res2 { return res1 > res2 }
                // Prefer muxed streams
                if s1.isMuxed != s2.isMuxed { return s1.isMuxed }
                // Then by codec quality (respecting hardware capabilities)
                return HardwareCapabilities.shared.codecPriority(for: s1.videoCodec) >
                       HardwareCapabilities.shared.codecPriority(for: s2.videoCodec)
            }

        // If maxRes is nil (best quality), return the highest quality stream
        guard let maxRes else {
            return videoStreams.first
        }

        // Find the best stream that doesn't exceed maxRes
        if let stream = videoStreams.first(where: { ($0.resolution ?? .p360) <= maxRes }) {
            return stream
        }

        // Fallback: return the lowest quality stream if all exceed maxRes
        return videoStreams.last
    }

    /// Selects the best audio stream for the preferred language.
    private func selectBestAudioStream(
        from streams: [Stream],
        preferredLanguage: String?
    ) -> Stream? {
        let audioStreams = streams.filter { $0.isAudioOnly }

        if let preferred = preferredLanguage {
            if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
                return match
            }
        }

        // Fallback to original audio or first available
        if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
            return original
        }

        return audioStreams.first
    }

    /// Selects the best caption for the preferred language.
    private func selectBestCaption(
        from captions: [Caption],
        preferredLanguage: String
    ) -> Caption? {
        // Prefer exact match, then prefix match, then base language match
        if let exact = captions.first(where: { $0.languageCode == preferredLanguage }) {
            return exact
        }
        if let prefix = captions.first(where: { $0.languageCode.hasPrefix(preferredLanguage) || $0.baseLanguageCode == preferredLanguage }) {
            return prefix
        }
        return nil
    }

    // MARK: - Queue Management

    /// Add a video to the download queue.
    func enqueue(
        _ video: Video,
        quality: String,
        formatID: String,
        streamURL: URL,
        audioStreamURL: URL? = nil,
        captionURL: URL? = nil,
        audioLanguage: String? = nil,
        captionLanguage: String? = nil,
        httpHeaders: [String: String]? = nil,
        storyboard: Storyboard? = nil,
        dislikeCount: Int? = nil,
        priority: DownloadPriority = .normal,
        videoCodec: String? = nil,
        audioCodec: String? = nil,
        videoBitrate: Int? = nil,
        audioBitrate: Int? = nil,
        suppressStartToast: Bool = false
    ) async throws {
        // Check if already downloading
        guard !activeDownloads.contains(where: { $0.videoID == video.id }) else {
            throw DownloadError.alreadyDownloading
        }

        // Check if already downloaded
        guard !completedDownloads.contains(where: { $0.videoID == video.id }) else {
            throw DownloadError.alreadyDownloaded
        }

        let download = Download(
            video: video,
            quality: quality,
            formatID: formatID,
            streamURL: streamURL,
            audioStreamURL: audioStreamURL,
            captionURL: captionURL,
            audioLanguage: audioLanguage,
            captionLanguage: captionLanguage,
            httpHeaders: httpHeaders,
            storyboard: storyboard,
            dislikeCount: dislikeCount,
            priority: priority,
            videoCodec: videoCodec,
            audioCodec: audioCodec,
            videoBitrate: videoBitrate,
            audioBitrate: audioBitrate
        )

        activeDownloads.append(download)
        downloadingVideoIDs.insert(download.videoID)
        downloadProgressByVideo[download.videoID] = DownloadProgressInfo(progress: 0, isIndeterminate: true)
        sortQueue()
        saveDownloads()

        var details = "Quality: \(quality)"
        if audioStreamURL != nil { details += ", Audio: \(audioLanguage ?? "default")" }
        if captionURL != nil { details += ", Caption: \(captionLanguage ?? "unknown")" }
        if storyboard != nil { details += ", Storyboard: \(storyboard!.storyboardCount) sheets" }
        LoggingService.shared.logDownload("Queued: \(download.videoID.id)", details: details)

        if !suppressStartToast {
            toastManager?.show(
                category: .download,
                title: String(localized: "toast.download.started.title"),
                subtitle: video.title,
                icon: "arrow.down.circle",
                iconColor: .blue,
                autoDismissDelay: 2.0
            )
        }

        // Start download if under concurrent limit
        await startNextDownloadIfNeeded()
    }

    /// Pause a download.
    func pause(_ download: Download) async {
        guard let index = activeDownloads.firstIndex(where: { $0.id == download.id }) else {
            return
        }

        // Cancel all active tasks with resume data
        if let task = videoTasks[download.id] {
            let resumeData = await task.cancelByProducingResumeData()
            if let idx = activeDownloads.firstIndex(where: { $0.id == download.id }) {
                activeDownloads[idx].resumeData = resumeData
            }
            videoTasks.removeValue(forKey: download.id)
        }

        if let task = audioTasks[download.id] {
            let resumeData = await task.cancelByProducingResumeData()
            if let idx = activeDownloads.firstIndex(where: { $0.id == download.id }) {
                activeDownloads[idx].audioResumeData = resumeData
            }
            audioTasks.removeValue(forKey: download.id)
        }

        if let task = captionTasks[download.id] {
            task.cancel()
            captionTasks.removeValue(forKey: download.id)
        }

        if let task = storyboardTasks[download.id] {
            task.cancel()
            storyboardTasks.removeValue(forKey: download.id)
        }

        if let task = thumbnailTasks[download.id] {
            task.cancel()
            thumbnailTasks.removeValue(forKey: download.id)
        }

        activeDownloads[index].status = .paused
        saveDownloads()
        LoggingService.shared.logDownload("Paused: \(download.videoID.id)")
    }

    /// Resume a paused download.
    func resume(_ download: Download) async {
        guard let index = activeDownloads.firstIndex(where: { $0.id == download.id }),
              activeDownloads[index].status == .paused || activeDownloads[index].status == .failed else {
            return
        }

        activeDownloads[index].status = .queued
        activeDownloads[index].error = nil
        activeDownloads[index].retryCount = 0  // Reset retry count on manual resume
        saveDownloads()

        LoggingService.shared.logDownload("Resumed: \(download.videoID.id)")
        await startNextDownloadIfNeeded()
    }

    /// Cancel and remove a download.
    func cancel(_ download: Download) async {
        // Cancel all tasks
        if let task = videoTasks[download.id] {
            task.cancel()
            videoTasks.removeValue(forKey: download.id)
        }
        if let task = audioTasks[download.id] {
            task.cancel()
            audioTasks.removeValue(forKey: download.id)
        }
        if let task = captionTasks[download.id] {
            task.cancel()
            captionTasks.removeValue(forKey: download.id)
        }
        if let task = storyboardTasks[download.id] {
            task.cancel()
            storyboardTasks.removeValue(forKey: download.id)
        }

        if let task = thumbnailTasks[download.id] {
            task.cancel()
            thumbnailTasks.removeValue(forKey: download.id)
        }

        activeDownloads.removeAll { $0.id == download.id }
        downloadingVideoIDs.remove(download.videoID)
        downloadProgressByVideo.removeValue(forKey: download.videoID)

        // Remove from batch tracking if this was a batch download
        batchDownloadIDs.remove(download.id)

        // Clean up partial files
        if let path = download.localVideoPath {
            try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
        }
        if let path = download.localAudioPath {
            try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
        }
        if let path = download.localCaptionPath {
            try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
        }
        if let path = download.localStoryboardPath {
            try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
        }
        if let path = download.localThumbnailPath {
            try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
        }
        if let path = download.localChannelThumbnailPath {
            try? fileManager.removeItem(at: downloadsDirectory().appendingPathComponent(path))
        }

        saveDownloads()
        LoggingService.shared.logDownload("Cancelled: \(download.videoID.id)")

        // Start next queued download
        await startNextDownloadIfNeeded()
    }

    /// Delete a completed download.
    func delete(_ download: Download) async {
        completedDownloads.removeAll { $0.id == download.id }
        downloadedVideoIDs.remove(download.videoID)

        // Delete all associated files (video, audio, caption)
        var deletedFiles: [String] = []

        if let videoPath = download.localVideoPath {
            let videoURL = downloadsDirectory().appendingPathComponent(videoPath)
            if fileManager.fileExists(atPath: videoURL.path) {
                try? fileManager.removeItem(at: videoURL)
                deletedFiles.append("video: \(videoPath)")
            }
        }

        if let audioPath = download.localAudioPath {
            let audioURL = downloadsDirectory().appendingPathComponent(audioPath)
            if fileManager.fileExists(atPath: audioURL.path) {
                try? fileManager.removeItem(at: audioURL)
                deletedFiles.append("audio: \(audioPath)")
            }
        }

        if let captionPath = download.localCaptionPath {
            let captionURL = downloadsDirectory().appendingPathComponent(captionPath)
            if fileManager.fileExists(atPath: captionURL.path) {
                try? fileManager.removeItem(at: captionURL)
                deletedFiles.append("caption: \(captionPath)")
            }
        }

        if let storyboardPath = download.localStoryboardPath {
            let storyboardURL = downloadsDirectory().appendingPathComponent(storyboardPath)
            if fileManager.fileExists(atPath: storyboardURL.path) {
                try? fileManager.removeItem(at: storyboardURL)
                deletedFiles.append("storyboard: \(storyboardPath)")
            }
        }

        if let thumbnailPath = download.localThumbnailPath {
            let thumbnailURL = downloadsDirectory().appendingPathComponent(thumbnailPath)
            if fileManager.fileExists(atPath: thumbnailURL.path) {
                try? fileManager.removeItem(at: thumbnailURL)
                deletedFiles.append("thumbnail: \(thumbnailPath)")
            }
        }

        if let channelThumbnailPath = download.localChannelThumbnailPath {
            let channelThumbnailURL = downloadsDirectory().appendingPathComponent(channelThumbnailPath)
            if fileManager.fileExists(atPath: channelThumbnailURL.path) {
                try? fileManager.removeItem(at: channelThumbnailURL)
                deletedFiles.append("channelThumbnail: \(channelThumbnailPath)")
            }
        }

        await calculateStorageUsed()
        saveDownloads()
        LoggingService.shared.logDownload("Deleted: \(download.videoID.id)", details: deletedFiles.joined(separator: ", "))
    }

    /// Move a download in the queue.
    func moveInQueue(_ download: Download, to position: Int) async {
        guard let currentIndex = activeDownloads.firstIndex(where: { $0.id == download.id }) else {
            return
        }

        let item = activeDownloads.remove(at: currentIndex)
        let targetIndex = min(max(0, position), activeDownloads.count)
        activeDownloads.insert(item, at: targetIndex)

        saveDownloads()
    }

    // MARK: - Batch Operations

    /// Pause all active downloads.
    func pauseAll() async {
        for download in activeDownloads where download.status == .downloading {
            await pause(download)
        }
    }

    /// Resume all paused downloads.
    func resumeAll() async {
        for download in activeDownloads where download.status == .paused {
            await resume(download)
        }
    }

    /// Delete all completed downloads.
    func deleteAllCompleted() async {
        for download in completedDownloads {
            await delete(download)
        }
    }

    /// Delete downloads for videos that have been watched.
    /// - Parameter watchedVideoIDs: Set of video IDs (strings) that are considered watched.
    func deleteWatchedDownloads(watchedVideoIDs: Set<String>) async {
        let watchedDownloads = completedDownloads.filter {
            watchedVideoIDs.contains($0.videoID.videoID)
        }
        for download in watchedDownloads {
            await delete(download)
        }
    }

    // MARK: - Queries

    /// Check if a video is downloaded using cached Set for O(1) lookup.
    func isDownloaded(_ videoID: VideoID) -> Bool {
        downloadedVideoIDs.contains(videoID)
    }

    /// Check if a video is currently downloading using cached Set for O(1) lookup.
    func isDownloading(_ videoID: VideoID) -> Bool {
        downloadingVideoIDs.contains(videoID)
    }

    /// Get the download for a video if it exists.
    func download(for videoID: VideoID) -> Download? {
        completedDownloads.first { $0.videoID == videoID } ??
        activeDownloads.first { $0.videoID == videoID }
    }

    /// Get the local file URL for a completed download.
    func localURL(for videoID: VideoID) -> URL? {
        guard let download = completedDownloads.first(where: { $0.videoID == videoID }),
              let fileName = download.localVideoPath else {
            return nil
        }
        return downloadsDirectory().appendingPathComponent(fileName)
    }

    /// Resolve the full file URL for a download's local path.
    func resolveLocalURL(for download: Download) -> URL? {
        guard let fileName = download.localVideoPath else {
            return nil
        }
        return downloadsDirectory().appendingPathComponent(fileName)
    }

    /// Creates a Video and Stream for playing a downloaded video.
    /// Returns nil if the download doesn't have a valid local file.
    /// Also returns the stored dislike count, audio stream, and caption URL if available.
    func videoAndStream(for download: Download) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? {
        guard let fileURL = resolveLocalURL(for: download) else {
            LoggingService.shared.warning("[Downloads] resolveLocalURL returned nil for \(download.videoID), localVideoPath=\(download.localVideoPath ?? "nil")", category: .downloads)
            return nil
        }
        guard FileManager.default.fileExists(atPath: fileURL.path) else {
            LoggingService.shared.warning("[Downloads] Local file does not exist at \(fileURL.path) for \(download.videoID)", category: .downloads)
            return nil
        }

        let video = Video(
            id: download.videoID,
            title: download.title,
            description: download.description,
            author: Author(
                id: download.channelID,
                name: download.channelName,
                thumbnailURL: download.channelThumbnailURL,
                subscriberCount: download.channelSubscriberCount
            ),
            duration: download.duration,
            publishedAt: download.publishedAt,
            publishedText: download.publishedText,
            viewCount: download.viewCount,
            likeCount: download.likeCount,
            thumbnails: Thumbnail.fallbackChain(for: download.thumbnailURL),
            isLive: false,
            isUpcoming: false,
            scheduledStartTime: nil
        )

        // Determine video format from file extension
        let format = fileURL.pathExtension.isEmpty ? "mp4" : fileURL.pathExtension
        let mimeType = format == "webm" ? "video/webm" : "video/mp4"

        // Parse resolution from quality string (e.g., "1080p" -> StreamResolution)
        let resolution = StreamResolution(heightLabel: download.quality)

        // If no separate audio file, this is a muxed stream - use stored or default audioCodec
        let isMuxedDownload = download.localAudioPath == nil
        let streamAudioCodec = isMuxedDownload ? (download.audioCodec ?? "aac") : nil

        let stream = Stream(
            url: fileURL,
            resolution: resolution,
            format: format,
            videoCodec: download.videoCodec,
            audioCodec: streamAudioCodec,
            bitrate: download.videoBitrate,
            fileSize: download.videoTotalBytes > 0 ? download.videoTotalBytes : nil,
            isLive: false,
            mimeType: mimeType
        )

        // Create audio stream if we have a separate audio file
        var audioStream: Stream?
        if let audioPath = download.localAudioPath {
            let audioURL = downloadsDirectory().appendingPathComponent(audioPath)
            if FileManager.default.fileExists(atPath: audioURL.path) {
                let audioFormat = audioURL.pathExtension.isEmpty ? "m4a" : audioURL.pathExtension
                let audioMimeType = audioFormat == "webm" ? "audio/webm" : "audio/mp4"
                audioStream = Stream(
                    url: audioURL,
                    resolution: nil,
                    format: audioFormat,
                    audioCodec: download.audioCodec,
                    bitrate: download.audioBitrate,
                    fileSize: download.audioTotalBytes > 0 ? download.audioTotalBytes : nil,
                    isAudioOnly: true,
                    isLive: false,
                    mimeType: audioMimeType,
                    audioLanguage: download.audioLanguage
                )
            }
        }

        // Get caption URL if we have a caption file
        var captionURL: URL?
        if let captionPath = download.localCaptionPath {
            let url = downloadsDirectory().appendingPathComponent(captionPath)
            if FileManager.default.fileExists(atPath: url.path) {
                captionURL = url
            }
        }

        return (video, stream, audioStream, captionURL, download.dislikeCount)
    }

    /// Creates a Video and Stream for playing a downloaded video by video ID.
    /// Returns nil if the video is not downloaded or the file doesn't exist.
    /// Also returns the stored dislike count, audio stream, and caption URL if available.
    func videoAndStream(for videoID: VideoID) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? {
        guard let download = completedDownloads.first(where: { $0.videoID == videoID }) else {
            return nil
        }
        return videoAndStream(for: download)
    }

    // MARK: - Private Helpers

    func sortQueue() {
        activeDownloads.sort { $0.priority.rawValue > $1.priority.rawValue }
    }

    func fileSize(at path: String) -> Int64 {
        do {
            let attrs = try fileManager.attributesOfItem(atPath: path)
            return attrs[.size] as? Int64 ?? 0
        } catch {
            return 0
        }
    }

    /// Returns the downloads directory URL (cached for performance).
    func downloadsDirectory() -> URL {
        if let cached = Self._cachedDownloadsDirectory {
            return cached
        }
        let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
        let downloadsURL = documentsURL.appendingPathComponent("Downloads", isDirectory: true)
        if !fileManager.fileExists(atPath: downloadsURL.path) {
            try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true)
        }
        Self._cachedDownloadsDirectory = downloadsURL
        return downloadsURL
    }

    // MARK: - Media Source Download Helpers

    /// Creates a completed download record for a file already downloaded to local storage.
    /// Used for SMB/local files that bypass URLSession.
    private func createCompletedDownload(
        video: Video,
        localURL: URL,
        fileSize: Int64
    ) async throws {
        // Check if already downloaded
        guard !completedDownloads.contains(where: { $0.videoID == video.id }) else {
            throw DownloadError.alreadyDownloaded
        }

        // Check if already downloading
        guard !activeDownloads.contains(where: { $0.videoID == video.id }) else {
            throw DownloadError.alreadyDownloading
        }

        let relativePath = localURL.lastPathComponent
        let fileExtension = localURL.pathExtension.lowercased()

        var download = Download(
            video: video,
            quality: "Original",
            formatID: fileExtension.isEmpty ? "video" : fileExtension,
            streamURL: localURL,  // Not used for completed downloads but required by init
            audioCodec: "aac"     // Mark as muxed since local files typically have both tracks
        )

        download.status = .completed
        download.localVideoPath = relativePath
        download.videoTotalBytes = fileSize
        download.videoDownloadedBytes = fileSize
        download.totalBytes = fileSize
        download.downloadedBytes = fileSize
        download.completedAt = Date()

        completedDownloads.append(download)
        downloadedVideoIDs.insert(video.id)
        await calculateStorageUsed()
        saveDownloads()

        LoggingService.shared.logDownload(
            "[Downloads] Created completed download record",
            details: "video: \(video.title), path: \(relativePath), size: \(fileSize)"
        )

        // Note: Media source downloads (SMB/local) complete synchronously during enqueue,
        // so they won't be in batchDownloadIDs by the time this runs.
        // The check is kept for consistency and future-proofing.
        if !batchDownloadIDs.contains(download.id) {
            toastManager?.show(
                category: .download,
                title: String(localized: "toast.download.completed.title"),
                subtitle: video.title,
                icon: "checkmark.circle.fill",
                iconColor: .green,
                autoDismissDelay: 2.0
            )
        }
    }

    /// Copies a local file to the downloads directory.
    /// Generates a unique filename if the destination already exists.
    private func copyLocalFileToDownloads(from sourceURL: URL) throws -> URL {
        let fileName = sourceURL.lastPathComponent
        var destURL = downloadsDirectory().appendingPathComponent(fileName)

        // Handle name collision by appending number
        destURL = uniqueDestinationURL(for: destURL)

        try fileManager.copyItem(at: sourceURL, to: destURL)

        LoggingService.shared.logDownload(
            "[Downloads] Copied local file",
            details: "from: \(sourceURL.path), to: \(destURL.path)"
        )

        return destURL
    }

    /// Generates a unique file URL by appending numbers if the file already exists.
    private func uniqueDestinationURL(for url: URL) -> URL {
        guard fileManager.fileExists(atPath: url.path) else {
            return url
        }

        let directory = url.deletingLastPathComponent()
        let baseName = url.deletingPathExtension().lastPathComponent
        let fileExtension = url.pathExtension

        var counter = 1
        var newURL = url

        while fileManager.fileExists(atPath: newURL.path) {
            let newName = fileExtension.isEmpty
                ? "\(baseName) (\(counter))"
                : "\(baseName) (\(counter)).\(fileExtension)"
            newURL = directory.appendingPathComponent(newName)
            counter += 1
        }

        return newURL
    }
}

#else
// tvOS stub - downloads not supported
@Observable
@MainActor
final class DownloadManager {
    private(set) var activeDownloads: [Download] = []
    private(set) var completedDownloads: [Download] = []
    private(set) var storageUsed: Int64 = 0
    var maxConcurrentDownloads: Int { 2 }

    func enqueue(_ video: Video, quality: String, formatID: String, streamURL: URL, audioStreamURL: URL? = nil, captionURL: URL? = nil, audioLanguage: String? = nil, captionLanguage: String? = nil, httpHeaders: [String: String]? = nil, storyboard: Storyboard? = nil, dislikeCount: Int? = nil, priority: DownloadPriority = .normal, videoCodec: String? = nil, audioCodec: String? = nil, videoBitrate: Int? = nil, audioBitrate: Int? = nil, suppressStartToast: Bool = false) async throws {
        throw DownloadError.notSupported
    }

    func autoEnqueue(_ video: Video, preferredQuality: DownloadQuality, preferredAudioLanguage: String?, preferredSubtitlesLanguage: String?, includeSubtitles: Bool, contentService: ContentService, instance: Instance, suppressStartToast: Bool = false) async throws {
        throw DownloadError.notSupported
    }

    func autoEnqueueMediaSource(_ video: Video, mediaSourcesManager: MediaSourcesManager, webDAVClient: WebDAVClient, smbClient: SMBClient) async throws {
        throw DownloadError.notSupported
    }

    func pause(_ download: Download) async {}
    func resume(_ download: Download) async {}
    func cancel(_ download: Download) async {}
    func delete(_ download: Download) async {}
    func moveInQueue(_ download: Download, to position: Int) async {}
    func pauseAll() async {}
    func resumeAll() async {}
    func deleteAllCompleted() async {}
    func calculateStorageUsed() async -> Int64 { 0 }
    func getAvailableStorage() -> Int64 { 0 }
    func deleteWatchedDownloads(watchedVideoIDs: Set<String>) async {}
    func isDownloaded(_ videoID: VideoID) -> Bool { false }
    func isDownloading(_ videoID: VideoID) -> Bool { false }
    func download(for videoID: VideoID) -> Download? { nil }
    func localURL(for videoID: VideoID) -> URL? { nil }
    func resolveLocalURL(for download: Download) -> URL? { nil }
    func videoAndStream(for download: Download) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? { nil }
    func videoAndStream(for videoID: VideoID) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? { nil }
    func setToastManager(_ manager: ToastManager) {}
    func setDownloadSettings(_ settings: DownloadSettings) {}
    func downloadsDirectory() -> URL { URL(fileURLWithPath: NSTemporaryDirectory()) }
}
#endif
