import Foundation
import Observation

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 {
    private let client: ABSClient
    /// Keyed by downloadKey (itemId or "itemId|episodeId").
    private(set) var states: [String: DownloadState] = [:]
    private(set) var downloadedItems: [String: DownloadedItem] = [:]

    private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
    private var activeTasks: [String: Task<Void, Never>] = [:]

    init(client: ABSClient) {
        self.client = client
        try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true)
        loadIndex()
    }

    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 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
        guard activeTasks[key] == nil else { return }
        states[key] = .downloading(progress: 0)

        let task = 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.activeTasks[key] = nil
                    return
                }
            }
            if workItem.audioFiles.isEmpty {
                self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
                self.activeTasks[key] = nil
                return
            }
            await self.performDownload(workItem: workItem, downloadKey: key)
            self.activeTasks[key] = nil
        }
        activeTasks[key] = task
    }

    func cancel(downloadKey: String) {
        activeTasks[downloadKey]?.cancel()
        activeTasks[downloadKey] = nil
        states[downloadKey] = .notDownloaded
    }

    func delete(downloadKey: String) {
        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 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)"
    }

    private func performDownload(workItem: LibraryItem, downloadKey: String) async {
        let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
        do {
            try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
        } catch {
            states[downloadKey] = .failed(message: error.localizedDescription)
            return
        }

        var tracks: [DownloadedTrack] = []
        let total = max(workItem.audioFiles.count, 1)

        for (idx, file) in workItem.audioFiles.enumerated() {
            if Task.isCancelled {
                states[downloadKey] = .notDownloaded
                return
            }
            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) }

            let tempURL: URL
            do {
                tempURL = try await downloadWithRetry(request: request, filename: file.filename)
            } catch is CancellationError {
                states[downloadKey] = .notDownloaded
                return
            } catch {
                states[downloadKey] = .failed(message: error.localizedDescription)
                return
            }

            let ext = file.ext.isEmpty ? "mp3" : file.ext
            let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
            let dest = itemDir.appendingPathComponent(destName)
            do {
                try? FileManager.default.removeItem(at: dest)
                try FileManager.default.moveItem(at: tempURL, to: dest)
            } catch {
                states[downloadKey] = .failed(message: error.localizedDescription)
                return
            }
            tracks.append(DownloadedTrack(
                ino: file.ino,
                filename: file.filename,
                localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName),
                durationSeconds: file.durationSeconds
            ))
            states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
        }

        let downloaded = DownloadedItem(
            itemId: workItem.id,
            episodeId: workItem.episodeId,
            title: workItem.title,
            author: workItem.author,
            durationSeconds: workItem.durationSeconds,
            tracks: tracks
        )
        downloadedItems[downloadKey] = downloaded
        states[downloadKey] = .downloaded
        persistIndex()
    }

    /// Downloads with up to `maxAttempts` retries and resume-data support so a brief
    /// network dropout picks up where it left off. Uses the client session so that
    /// self-signed server certificates are accepted.
    private func downloadWithRetry(request: URLRequest, filename: String, maxAttempts: Int = 5) async throws -> URL {
        let session = client.session
        var resumeData: Data? = nil
        var lastError: Error = URLError(.unknown)

        for attempt in 0..<maxAttempts {
            try Task.checkCancellation()
            do {
                let (tempURL, response): (URL, URLResponse)
                if let resume = resumeData {
                    (tempURL, response) = try await session.download(resumeFrom: resume)
                } else {
                    (tempURL, response) = try await session.download(for: request)
                }
                if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
                    try? FileManager.default.removeItem(at: tempURL)
                    throw URLError(.badServerResponse)
                }
                return tempURL
            } catch is CancellationError {
                throw CancellationError()
            } catch let error as NSError {
                resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
                lastError = error
                if attempt < maxAttempts - 1 {
                    // Exponential backoff: 1 s, 2 s, 4 s, 8 s …
                    let delay = UInt64(min(pow(2.0, Double(attempt)), 30)) * 1_000_000_000
                    try await Task.sleep(nanoseconds: delay)
                }
            }
        }
        throw lastError
    }

    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
        }
    }
}
