import Foundation

enum ABSClientError: LocalizedError {
    case noAuth
    case invalidURL
    case httpStatus(Int)
    case decoding(Error)

    var errorDescription: String? {
        switch self {
        case .noAuth: return "Nicht angemeldet."
        case .invalidURL: return "Ungültige URL."
        case .httpStatus(let code): return "HTTP-Status \(code)."
        case .decoding(let err): return "Antwort konnte nicht gelesen werden: \(err.localizedDescription)"
        }
    }
}

// Accepts any server certificate so that private servers with self-signed or
// custom-CA certificates work without needing the CA installed on-device.
// Acceptable because the user explicitly configures the server URL themselves.
private final class AnyServerTrustDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
           let trust = challenge.protectionSpace.serverTrust {
            completionHandler(.useCredential, URLCredential(trust: trust))
        } else {
            completionHandler(.performDefaultHandling, nil)
        }
    }
}

@MainActor
final class ABSClient {
    private let auth: AuthStore
    private let sessionDelegate = AnyServerTrustDelegate()
    private(set) var session: URLSession = URLSession.shared

    init(auth: AuthStore) {
        self.auth = auth
        let config = URLSessionConfiguration.default
        config.requestCachePolicy = .reloadIgnoringLocalCacheData
        config.waitsForConnectivity = false
        self.session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil)
    }

    private func makeRequest(path: String, method: String = "GET", body: Data? = nil) throws -> URLRequest {
        guard !auth.token.isEmpty, !auth.serverURL.isEmpty else { throw ABSClientError.noAuth }
        guard let url = URL(string: auth.serverURL + path) else { throw ABSClientError.invalidURL }
        var req = URLRequest(url: url)
        req.httpMethod = method
        req.setValue("Bearer \(auth.token)", forHTTPHeaderField: "Authorization")
        if let body {
            req.setValue("application/json", forHTTPHeaderField: "Content-Type")
            req.httpBody = body
        }
        return req
    }

    private func perform<T: Decodable>(_ req: URLRequest, as: T.Type) async throws -> T {
        let (data, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse else { throw ABSClientError.httpStatus(0) }
        guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
        do {
            return try JSONDecoder().decode(T.self, from: data)
        } catch {
            throw ABSClientError.decoding(error)
        }
    }

    func fetchLibraries() async throws -> [Library] {
        let req = try makeRequest(path: "/api/libraries")
        let dto = try await perform(req, as: LibrariesResponseDTO.self)
        return dto.libraries.map { Library(id: $0.id, name: Self.decodeHTMLEntities($0.name), mediaType: $0.mediaType) }
    }

    func fetchItems(libraryId: String) async throws -> [LibraryItem] {
        let req = try makeRequest(path: "/api/libraries/\(libraryId)/items?limit=500&sort=media.metadata.title")
        let dto = try await perform(req, as: LibraryItemsResponseDTO.self)
        return dto.results.map { Self.toLibraryItem(from: $0) }
    }

    /// Audiobookshelf serves podcast titles straight from the RSS feed, which
    /// often contains HTML entities (`&uuml;`, `&euro;`, `&#8364;` etc.).
    /// SwiftUI renders those literally, so we decode them here once at the
    /// API boundary — every title/author/chapter/library name goes through
    /// this before reaching the UI.
    static func decodeHTMLEntities(_ s: String) -> String {
        guard s.contains("&") else { return s }
        var result = s
        let named: [String: String] = [
            "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
            "&apos;": "'", "&nbsp;": " ",
            "&euro;": "€", "&pound;": "£", "&yen;": "¥",
            "&copy;": "©", "&reg;": "®", "&trade;": "™",
            "&hellip;": "…", "&mdash;": "—", "&ndash;": "–",
            "&laquo;": "«", "&raquo;": "»",
            "&bdquo;": "„", "&ldquo;": "\u{201C}", "&rdquo;": "\u{201D}",
            "&lsquo;": "\u{2018}", "&rsquo;": "\u{2019}",
            "&auml;": "ä", "&Auml;": "Ä",
            "&ouml;": "ö", "&Ouml;": "Ö",
            "&uuml;": "ü", "&Uuml;": "Ü",
            "&szlig;": "ß",
            "&eacute;": "é", "&Eacute;": "É",
            "&egrave;": "è", "&Egrave;": "È",
            "&agrave;": "à", "&Agrave;": "À",
            "&acirc;": "â", "&Acirc;": "Â",
            "&ecirc;": "ê", "&Ecirc;": "Ê",
            "&icirc;": "î", "&Icirc;": "Î",
            "&ocirc;": "ô", "&Ocirc;": "Ô",
            "&ucirc;": "û", "&Ucirc;": "Û",
            "&ntilde;": "ñ", "&Ntilde;": "Ñ",
            "&ccedil;": "ç", "&Ccedil;": "Ç",
        ]
        for (entity, char) in named {
            result = result.replacingOccurrences(of: entity, with: char)
        }
        // Numeric refs: &#1234; (decimal) or &#xABCD; (hex)
        if result.contains("&#"),
           let regex = try? NSRegularExpression(pattern: "&#(x?)([0-9A-Fa-f]+);") {
            let nsRange = NSRange(result.startIndex..., in: result)
            // Reverse so prior replacements don't shift later ranges.
            let matches = regex.matches(in: result, range: nsRange).reversed()
            for match in matches {
                guard let full = Range(match.range, in: result),
                      let hexFlag = Range(match.range(at: 1), in: result),
                      let num = Range(match.range(at: 2), in: result) else { continue }
                let isHex = !result[hexFlag].isEmpty
                let numStr = String(result[num])
                let code = isHex ? UInt32(numStr, radix: 16) : UInt32(numStr)
                if let code, let scalar = Unicode.Scalar(code) {
                    result.replaceSubrange(full, with: String(scalar))
                }
            }
        }
        return result
    }

    private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
        let meta = raw.media?.metadata
        let mediaType = raw.mediaType ?? "book"
        let files: [AudioFile] = (raw.media?.audioFiles ?? []).enumerated().map { idx, f in
            AudioFile(
                ino: f.ino,
                filename: f.metadata?.filename ?? "track-\(idx).mp3",
                ext: (f.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: ".")),
                durationSeconds: f.duration ?? 0,
                index: f.index ?? idx
            )
        }.sorted { $0.index < $1.index }
        let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
        var item = LibraryItem(
            id: raw.id,
            title: decodeHTMLEntities(meta?.title ?? "Unbekannt"),
            author: decodeHTMLEntities(authorString),
            durationSeconds: raw.media?.duration ?? 0,
            audioFiles: files
        )
        item.mediaType = mediaType
        item.description = meta?.description.map { decodeHTMLEntities($0) }
        item.chapters = (raw.media?.chapters ?? []).compactMap { c in
            guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil }
            return Chapter(id: id, start: start, end: end, title: decodeHTMLEntities(title))
        }
        return item
    }

    func fetchEpisodes(podcastItemId: String) async throws -> (LibraryItem, [PodcastEpisode]) {
        let req = try makeRequest(path: "/api/items/\(podcastItemId)?expanded=1")
        let raw = try await perform(req, as: LibraryItemDTO.self)
        let item = Self.toLibraryItem(from: raw)
        let episodes: [PodcastEpisode] = (raw.media?.episodes ?? []).compactMap { ep in
            guard let af = ep.audioFile else { return nil }
            let ext = (af.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: "."))
            let audioFile = AudioFile(
                ino: af.ino,
                filename: af.metadata?.filename ?? "episode.\(ext)",
                ext: ext,
                durationSeconds: af.duration ?? ep.duration ?? 0,
                index: 0
            )
            return PodcastEpisode(
                id: ep.id,
                title: Self.decodeHTMLEntities(ep.title ?? "Folge"),
                pubDate: ep.pubDate,
                publishedAtMillis: ep.publishedAt,
                season: ep.season,
                episode: ep.episode,
                durationSeconds: ep.duration ?? af.duration ?? 0,
                audioFile: audioFile
            )
        }.sorted { lhs, rhs in
            (lhs.publishedAtMillis ?? 0) > (rhs.publishedAtMillis ?? 0)
        }
        return (item, episodes)
    }

    func fetchItemDetail(itemId: String) async throws -> LibraryItem {
        let req = try makeRequest(path: "/api/items/\(itemId)?expanded=1")
        let raw = try await perform(req, as: LibraryItemDTO.self)
        return Self.toLibraryItem(from: raw)
    }

    private func progressPath(itemId: String, episodeId: String?) -> String {
        if let episodeId { return "/api/me/progress/\(itemId)/\(episodeId)" }
        return "/api/me/progress/\(itemId)"
    }

    func fetchProgress(itemId: String, episodeId: String? = nil) async throws -> PlaybackProgress? {
        let req = try makeRequest(path: progressPath(itemId: itemId, episodeId: episodeId))
        let (data, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse else { return nil }
        if http.statusCode == 404 { return nil }
        guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
        let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data)
        return PlaybackProgress(
            itemId: itemId,
            episodeId: episodeId,
            currentTime: dto.currentTime ?? 0,
            duration: dto.duration ?? 0,
            isFinished: dto.isFinished ?? false,
            updatedAt: Self.parseLastUpdate(dto.lastUpdate),
            id: dto.id
        )
    }

    private static func parseLastUpdate(_ ms: Double?) -> Date {
        guard let ms, ms > 0 else { return Date() }
        return Date(timeIntervalSince1970: ms / 1000)
    }

    func saveProgress(_ progress: PlaybackProgress) async throws {
        let body: [String: Any] = [
            "currentTime": progress.currentTime,
            "duration": progress.duration,
            "isFinished": progress.isFinished,
        ]
        let data = try JSONSerialization.data(withJSONObject: body)
        let req = try makeRequest(
            path: progressPath(itemId: progress.itemId, episodeId: progress.episodeId),
            method: "PATCH",
            body: data
        )
        let (_, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
        }
    }

    /// Removes a media-progress entry on the server. Audiobookshelf's
    /// `DELETE /api/me/progress/:id` matches by the progress entry's UUID,
    /// NOT by libraryItemId (which is what PATCH uses) — this is the source
    /// of the long-running "delete didn't stick" bug. 404 is treated as
    /// success (entry already gone).
    func deleteProgress(progressId: String) async throws {
        let req = try makeRequest(
            path: "/api/me/progress/\(progressId)",
            method: "DELETE"
        )
        let (_, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse else {
            throw ABSClientError.httpStatus(0)
        }
        if http.statusCode == 404 { return }
        guard (200..<300).contains(http.statusCode) else {
            throw ABSClientError.httpStatus(http.statusCode)
        }
    }

    func fetchAllProgress() async throws -> [PlaybackProgress] {
        let req = try makeRequest(path: "/api/me")
        let dto = try await perform(req, as: MeResponseDTO.self)
        return (dto.mediaProgress ?? []).compactMap { p in
            guard let itemId = p.libraryItemId else { return nil }
            return PlaybackProgress(
                itemId: itemId,
                episodeId: p.episodeId,
                currentTime: p.currentTime ?? 0,
                duration: p.duration ?? 0,
                isFinished: p.isFinished ?? false,
                updatedAt: Self.parseLastUpdate(p.lastUpdate),
                id: p.id
            )
        }
    }

    func validateToken() async -> Bool {
        guard let req = try? makeRequest(path: "/api/me") else { return false }
        do {
            let (_, response) = try await session.data(for: req)
            return ((response as? HTTPURLResponse)?.statusCode ?? 0) < 400
        } catch {
            return false
        }
    }

    func coverURL(itemId: String) -> URL? {
        guard !auth.serverURL.isEmpty else { return nil }
        var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/cover")
        comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
        return comps?.url
    }

    func audioFileURL(itemId: String, ino: String) -> URL? {
        var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/file/\(ino)")
        comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
        return comps?.url
    }

    var bearerHeader: [String: String] { ["Authorization": "Bearer \(auth.token)"] }

    // MARK: - Bookmarks

    func fetchBookmarks(itemId: String, episodeId: String? = nil) async throws -> [ServerBookmark] {
        let req = try makeRequest(path: progressPath(itemId: itemId, episodeId: episodeId))
        let (data, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse else { return [] }
        if http.statusCode == 404 { return [] }
        guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
        let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data)
        return (dto.bookmarks ?? []).compactMap { b in
            guard let title = b.title, let time = b.time else { return nil }
            return ServerBookmark(title: title, time: time, createdAt: b.createdAt)
        }
    }

    func createBookmark(itemId: String, time: Double, title: String) async throws {
        let body = try JSONSerialization.data(withJSONObject: ["title": title, "time": time])
        let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark", method: "POST", body: body)
        let (_, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
        }
    }

    func deleteBookmark(itemId: String, time: Double) async throws {
        let timeInt = Int(time)
        let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark/\(timeInt)", method: "DELETE")
        let (_, response) = try await session.data(for: req)
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
        }
    }
}

struct ServerBookmark {
    let title: String
    let time: Double
    let createdAt: Double?
}
