//
//  PipedAPI.swift
//  Yattee
//
//  Piped API implementation for YouTube content.
//  API Documentation: https://docs.piped.video/docs/api-documentation/
//

@preconcurrency import Foundation

/// Piped API client for fetching YouTube content.
actor PipedAPI: InstanceAPI {
    private let httpClient: HTTPClient

    /// Cache of tab data from channel responses, keyed by channel ID.
    /// Populated when `channel()` or `channelVideos()` fetches `/channel/{id}`.
    private var channelTabsCache: [String: [PipedChannelTab]] = [:]

    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }

    // MARK: - InstanceAPI

    func trending(instance: Instance) async throws -> [Video] {
        let endpoint = GenericEndpoint.get("/trending", query: ["region": "US"])
        let response: [PipedVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
        return response.map { $0.toVideo(instanceURL: instance.url) }
    }

    func popular(instance: Instance) async throws -> [Video] {
        // Piped doesn't have a separate popular endpoint, use trending
        try await trending(instance: instance)
    }

    func search(query: String, instance: Instance, page: Int, filters: SearchFilters) async throws -> SearchResult {
        let endpoint = GenericEndpoint.get("/search", query: [
            "q": query,
            "filter": "all"
        ])
        let response: PipedSearchResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)

        var videos: [Video] = []
        var channels: [Channel] = []
        var playlists: [Playlist] = []
        var orderedItems: [OrderedSearchItem] = []

        for item in response.items {
            switch item.type {
            case "stream":
                let video = item.toVideo(instanceURL: instance.url)
                videos.append(video)
                orderedItems.append(.video(video))
            case "channel":
                let channel = item.toChannel()
                channels.append(channel)
                orderedItems.append(.channel(channel))
            case "playlist":
                let playlist = item.toPlaylist()
                playlists.append(playlist)
                orderedItems.append(.playlist(playlist))
            default:
                break
            }
        }

        return SearchResult(
            videos: videos,
            channels: channels,
            playlists: playlists,
            orderedItems: orderedItems,
            nextPage: response.nextpage != nil ? page + 1 : nil
        )
    }

    func searchSuggestions(query: String, instance: Instance) async throws -> [String] {
        let endpoint = GenericEndpoint.get("/suggestions", query: [
            "query": query
        ])
        let response: [String] = try await httpClient.fetch(endpoint, baseURL: instance.url)
        return response
    }

    func video(id: String, instance: Instance) async throws -> Video {
        let endpoint = GenericEndpoint.get("/streams/\(id)")
        let response: PipedStreamResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
        return response.toVideo(instanceURL: instance.url, videoId: id)
    }

    func channel(id: String, instance: Instance) async throws -> Channel {
        let endpoint = GenericEndpoint.get("/channel/\(id)")
        let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
        if let tabs = response.tabs {
            channelTabsCache[id] = tabs
        }
        return response.toChannel()
    }

    func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
        if let continuation {
            // Fetch next page of channel videos
            let endpoint = GenericEndpoint.get("/nextpage/channel/\(id)", query: ["nextpage": continuation])
            let response: PipedNextPageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
            return ChannelVideosPage(
                videos: response.relatedStreams.compactMap { item in
                    if case .video(let video) = item { return video.toVideo(instanceURL: instance.url) }
                    return nil
                },
                continuation: response.nextpage
            )
        } else {
            // Initial fetch - get channel data (also caches tabs)
            let endpoint = GenericEndpoint.get("/channel/\(id)")
            let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
            if let tabs = response.tabs {
                channelTabsCache[id] = tabs
            }
            return ChannelVideosPage(
                videos: response.relatedStreams?.compactMap { item in
                    if case .video(let video) = item { return video.toVideo(instanceURL: instance.url) }
                    return nil
                } ?? [],
                continuation: response.nextpage
            )
        }
    }

    func playlist(id: String, instance: Instance) async throws -> Playlist {
        let endpoint = GenericEndpoint.get("/playlists/\(id)")
        let firstResponse: PipedPlaylistResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
        var allStreams = firstResponse.relatedStreams ?? []
        let maxPages = 50

        var nextpage = firstResponse.nextpage
        var page = 1
        while let token = nextpage, page < maxPages {
            let nextEndpoint = GenericEndpoint.get("/nextpage/playlists/\(id)", query: ["nextpage": token])
            let nextResponse: PipedPlaylistNextPageResponse = try await httpClient.fetch(nextEndpoint, baseURL: instance.url)
            let pageStreams = nextResponse.relatedStreams ?? []
            if pageStreams.isEmpty { break }
            allStreams.append(contentsOf: pageStreams)
            nextpage = nextResponse.nextpage
            page += 1
        }

        return PipedPlaylistResponse(
            name: firstResponse.name,
            description: firstResponse.description,
            uploader: firstResponse.uploader,
            uploaderUrl: firstResponse.uploaderUrl,
            uploaderAvatar: firstResponse.uploaderAvatar,
            videos: firstResponse.videos,
            relatedStreams: allStreams,
            thumbnailUrl: firstResponse.thumbnailUrl,
            nextpage: nil
        ).toPlaylist(instanceURL: instance.url)
    }

    func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage {
        let path = continuation != nil ? "/nextpage/comments/\(videoID)" : "/comments/\(videoID)"
        var query: [String: String] = [:]
        if let continuation {
            query["nextpage"] = continuation
        }
        let endpoint = GenericEndpoint.get(path, query: query)
        let response: PipedCommentsResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)

        if response.disabled == true {
            throw APIError.commentsDisabled
        }

        return CommentsPage(
            comments: response.comments.map { $0.toComment(instanceURL: instance.url) },
            continuation: response.nextpage
        )
    }

    func streams(videoID: String, instance: Instance) async throws -> [Stream] {
        let endpoint = GenericEndpoint.get("/streams/\(videoID)")
        let response: PipedStreamResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
        return response.toStreams()
    }

    // MARK: - Channel Tabs

    func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
        let page = try await fetchTab(name: "shorts", channelID: id, instance: instance, continuation: continuation)
        let videos: [Video] = page.items.compactMap {
            if case .stream(let video) = $0 { return video.toVideo(instanceURL: instance.url) }
            return nil
        }
        return ChannelVideosPage(videos: videos, continuation: page.continuation)
    }

    func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage {
        let page = try await fetchTab(name: "livestreams", channelID: id, instance: instance, continuation: continuation)
        let videos: [Video] = page.items.compactMap {
            if case .stream(let video) = $0 { return video.toVideo(instanceURL: instance.url) }
            return nil
        }
        return ChannelVideosPage(videos: videos, continuation: page.continuation)
    }

    func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage {
        let page = try await fetchTab(name: "playlists", channelID: id, instance: instance, continuation: continuation)
        let playlists: [Playlist] = page.items.compactMap {
            if case .playlist(let p) = $0 { return p.toPlaylist() }
            return nil
        }
        return ChannelPlaylistsPage(playlists: playlists, continuation: page.continuation)
    }

    // MARK: - Tab Fetching

    /// Fetches tab content for a channel, handling both initial load and pagination.
    private func fetchTab(name: String, channelID: String, instance: Instance, continuation: String?) async throws -> PipedTabPage {
        if let continuation {
            // Decode the continuation token which contains both tabData and nextpage
            let tabContinuation = try PipedTabContinuation.decode(from: continuation)
            let endpoint = GenericEndpoint.get("/channels/tabs", query: [
                "data": tabContinuation.tabData,
                "nextpage": tabContinuation.nextpage
            ])
            let response: PipedTabResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
            let nextContinuation = response.nextpage.map {
                PipedTabContinuation(tabData: tabContinuation.tabData, nextpage: $0).encode()
            }
            return PipedTabPage(items: response.content, continuation: nextContinuation)
        } else {
            // Initial load - get tab data from cache (or fetch channel to populate it)
            var tabs = channelTabsCache[channelID]
            if tabs == nil {
                let endpoint = GenericEndpoint.get("/channel/\(channelID)")
                let response: PipedChannelResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
                channelTabsCache[channelID] = response.tabs
                tabs = response.tabs
            }

            guard let tabData = tabs?.first(where: { $0.name == name })?.data else {
                // Tab not available for this channel
                return PipedTabPage(items: [], continuation: nil)
            }

            let endpoint = GenericEndpoint.get("/channels/tabs", query: ["data": tabData])
            let response: PipedTabResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
            let nextContinuation = response.nextpage.map {
                PipedTabContinuation(tabData: tabData, nextpage: $0).encode()
            }
            return PipedTabPage(items: response.content, continuation: nextContinuation)
        }
    }

    // MARK: - Authentication

    /// Logs in to a Piped instance and returns the auth token.
    /// - Parameters:
    ///   - username: The user's username
    ///   - password: The user's password
    ///   - instance: The Piped instance to log in to
    /// - Returns: The auth token for subsequent authenticated requests
    func login(username: String, password: String, instance: Instance) async throws -> String {
        struct LoginRequest: Encodable, Sendable {
            let username: String
            let password: String
        }

        let body = LoginRequest(username: username, password: password)
        let endpoint = GenericEndpoint.post("/login", body: body)

        do {
            let response: PipedLoginResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
            return response.token
        } catch let error as APIError {
            // Map HTTP 401/403 to unauthorized error
            if case .httpError(let statusCode, _) = error, statusCode == 401 || statusCode == 403 {
                throw APIError.unauthorized
            }
            throw error
        }
    }

    /// Fetches the subscription feed for a logged-in user.
    /// - Parameters:
    ///   - instance: The Piped instance
    ///   - authToken: The auth token from login
    /// - Returns: Array of videos from subscribed channels
    func feed(instance: Instance, authToken: String) async throws -> [Video] {
        // Piped feed uses authToken as a query parameter
        let endpoint = GenericEndpoint.get("/feed", query: ["authToken": authToken])
        let response: [PipedVideo] = try await httpClient.fetch(endpoint, baseURL: instance.url)
        return response.map { $0.toVideo(instanceURL: instance.url) }
    }

    /// Fetches the user's subscriptions.
    /// - Parameters:
    ///   - instance: The Piped instance
    ///   - authToken: The auth token from login
    /// - Returns: Array of subscribed channels
    func subscriptions(instance: Instance, authToken: String) async throws -> [PipedSubscription] {
        let endpoint = GenericEndpoint(
            path: "/subscriptions",
            method: .get,
            headers: ["Authorization": authToken]
        )
        let response: [PipedSubscription] = try await httpClient.fetch(endpoint, baseURL: instance.url)
        return response
    }

    /// Subscribes to a channel.
    /// - Parameters:
    ///   - channelID: The YouTube channel ID to subscribe to
    ///   - instance: The Piped instance
    ///   - authToken: The auth token from login
    func subscribe(channelID: String, instance: Instance, authToken: String) async throws {
        struct SubscribeRequest: Encodable, Sendable {
            let channelId: String
        }

        let bodyData = try JSONEncoder().encode(SubscribeRequest(channelId: channelID))
        let endpoint = GenericEndpoint(
            path: "/subscribe",
            method: .post,
            headers: ["Authorization": authToken, "Content-Type": "application/json"],
            body: bodyData
        )

        // Returns {"message": "ok"} on success
        let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
    }

    /// Unsubscribes from a channel.
    /// - Parameters:
    ///   - channelID: The YouTube channel ID to unsubscribe from
    ///   - instance: The Piped instance
    ///   - authToken: The auth token from login
    func unsubscribe(channelID: String, instance: Instance, authToken: String) async throws {
        struct UnsubscribeRequest: Encodable, Sendable {
            let channelId: String
        }

        let bodyData = try JSONEncoder().encode(UnsubscribeRequest(channelId: channelID))
        let endpoint = GenericEndpoint(
            path: "/unsubscribe",
            method: .post,
            headers: ["Authorization": authToken, "Content-Type": "application/json"],
            body: bodyData
        )

        let _: PipedMessageResponse = try await httpClient.fetch(endpoint, baseURL: instance.url)
    }

    /// Fetches the user's playlists.
    /// - Parameters:
    ///   - instance: The Piped instance
    ///   - authToken: The auth token from login
    /// - Returns: Array of user playlists (without videos)
    func userPlaylists(instance: Instance, authToken: String) async throws -> [Playlist] {
        let endpoint = GenericEndpoint(
            path: "/user/playlists",
            method: .get,
            headers: ["Authorization": authToken]
        )
        let response: [PipedUserPlaylist] = try await httpClient.fetch(endpoint, baseURL: instance.url)
        return response.map { $0.toPlaylist() }
    }

    /// Fetches a user playlist with its videos.
    /// - Parameters:
    ///   - id: The playlist ID (UUID)
    ///   - instance: The Piped instance
    ///   - authToken: The auth token from login
    /// - Returns: Playlist with videos
    func userPlaylist(id: String, instance: Instance, authToken: String) async throws -> Playlist {
        let headers = ["Authorization": authToken]
        let firstEndpoint = GenericEndpoint(
            path: "/playlists/\(id)",
            method: .get,
            headers: headers
        )
        let firstResponse: PipedPlaylistResponse = try await httpClient.fetch(firstEndpoint, baseURL: instance.url)
        var allStreams = firstResponse.relatedStreams ?? []
        let maxPages = 50

        var nextpage = firstResponse.nextpage
        var page = 1
        while let token = nextpage, page < maxPages {
            let nextEndpoint = GenericEndpoint(
                path: "/nextpage/playlists/\(id)",
                queryItems: [URLQueryItem(name: "nextpage", value: token)],
                headers: headers
            )
            let nextResponse: PipedPlaylistNextPageResponse = try await httpClient.fetch(nextEndpoint, baseURL: instance.url)
            let pageStreams = nextResponse.relatedStreams ?? []
            if pageStreams.isEmpty { break }
            allStreams.append(contentsOf: pageStreams)
            nextpage = nextResponse.nextpage
            page += 1
        }

        return PipedPlaylistResponse(
            name: firstResponse.name,
            description: firstResponse.description,
            uploader: firstResponse.uploader,
            uploaderUrl: firstResponse.uploaderUrl,
            uploaderAvatar: firstResponse.uploaderAvatar,
            videos: firstResponse.videos,
            relatedStreams: allStreams,
            thumbnailUrl: firstResponse.thumbnailUrl,
            nextpage: nil
        ).toPlaylist(instanceURL: instance.url, playlistID: id)
    }
}

// MARK: - Piped Authentication Response Models

/// Login response from Piped API.
private struct PipedLoginResponse: Decodable, Sendable {
    let token: String
}

/// Generic message response from Piped API (used by subscribe/unsubscribe).
private struct PipedMessageResponse: Decodable, Sendable {
    let message: String
}

/// Subscription info from Piped API.
struct PipedSubscription: Decodable, Sendable {
    let url: String
    let name: String
    let avatar: String?
    let verified: Bool?

    var channelId: String {
        url.replacingOccurrences(of: "/channel/", with: "")
    }

    func toChannel() -> Channel {
        Channel(
            id: .global(channelId),
            name: name,
            thumbnailURL: avatar.flatMap { URL(string: $0) },
            isVerified: verified ?? false
        )
    }
}

// MARK: - HTML Stripping

/// Strips HTML tags from Piped descriptions, converting them to plain text.
private func stripHTML(_ html: String) -> String {
    var text = html

    // Convert <br> variants to newlines
    text = text.replacingOccurrences(
        of: "<br\\s*/?>",
        with: "\n",
        options: .regularExpression
    )

    // Extract link text from <a> tags (keep visible text, drop markup)
    text = text.replacingOccurrences(
        of: "<a[^>]*>(.*?)</a>",
        with: "$1",
        options: .regularExpression
    )

    // Strip all remaining HTML tags
    text = text.replacingOccurrences(
        of: "<[^>]+>",
        with: "",
        options: .regularExpression
    )

    // Decode common HTML entities
    text = text
        .replacingOccurrences(of: "&amp;", with: "&")
        .replacingOccurrences(of: "&lt;", with: "<")
        .replacingOccurrences(of: "&gt;", with: ">")
        .replacingOccurrences(of: "&quot;", with: "\"")
        .replacingOccurrences(of: "&#39;", with: "'")
        .replacingOccurrences(of: "&#x27;", with: "'")
        .replacingOccurrences(of: "&apos;", with: "'")
        .replacingOccurrences(of: "&nbsp;", with: " ")

    // Trim excessive blank lines (3+ newlines → 2)
    text = text.replacingOccurrences(
        of: "\\n{3,}",
        with: "\n\n",
        options: .regularExpression
    )

    return text.trimmingCharacters(in: .whitespacesAndNewlines)
}

// MARK: - Piped Response Models

private struct PipedVideo: Decodable, Sendable {
    let url: String
    let title: String
    let description: String?
    let uploaderName: String?
    let uploaderUrl: String?
    let uploaderAvatar: String?
    let duration: Int
    let uploaded: Int64?
    let uploadedDate: String?
    let views: Int64?
    let thumbnail: String?
    let uploaderVerified: Bool?
    let isShort: Bool?

    var videoId: String {
        url.replacingOccurrences(of: "/watch?v=", with: "")
    }

    var channelId: String? {
        uploaderUrl?.replacingOccurrences(of: "/channel/", with: "")
    }

    nonisolated func toVideo(instanceURL: URL) -> Video {
        Video(
            id: .global(videoId),
            title: title,
            description: description.map { stripHTML($0) },
            author: Author(
                id: channelId ?? "",
                name: uploaderName ?? "",
                thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
            ),
            duration: TimeInterval(duration),
            publishedAt: uploaded.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) },
            publishedText: uploadedDate,
            viewCount: views.map { Int($0) },
            likeCount: nil,
            thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
            isLive: duration == -1,
            isUpcoming: false,
            scheduledStartTime: nil
        )
    }
}

private struct PipedStreamResponse: Decodable, Sendable {
    let title: String
    let description: String?
    let uploader: String
    let uploaderUrl: String?
    let uploaderAvatar: String?
    let uploaderVerified: Bool?
    let uploaderSubscriberCount: Int64?
    let duration: Int
    let uploaded: Int64?
    let uploadDate: String?
    let views: Int64?
    let likes: Int64?
    let dislikes: Int64?
    let thumbnailUrl: String?
    let hls: String?
    let dash: String?
    let livestream: Bool?
    let videoStreams: [PipedVideoStream]?
    let audioStreams: [PipedAudioStream]?
    let relatedStreams: [PipedVideoItem]?

    var videoId: String? {
        // Extract from thumbnail URL as fallback
        guard let thumbnailUrl else { return nil }
        // Thumbnail format: https://pipedproxy.example.com/vi/VIDEO_ID/...
        let components = thumbnailUrl.components(separatedBy: "/vi/")
        guard components.count > 1 else { return nil }
        return components[1].components(separatedBy: "/").first
    }

    var channelId: String? {
        uploaderUrl?.replacingOccurrences(of: "/channel/", with: "")
    }

    nonisolated func toVideo(instanceURL: URL, videoId: String? = nil) -> Video {
        // Convert related streams, skipping malformed items, limiting to 12
        let related: [Video]? = relatedStreams?.compactMap { item -> Video? in
            if case .video(let video) = item {
                return video.toVideo(instanceURL: instanceURL)
            }
            return nil
        }.prefix(12).map { $0 }

        let resolvedVideoId = videoId ?? self.videoId ?? ""
        let thumbnails: [Thumbnail] = {
            if !resolvedVideoId.isEmpty,
               let url = URL(string: "https://i.ytimg.com/vi/\(resolvedVideoId)/maxresdefault.jpg") {
                return Thumbnail.fallbackChain(for: url)
            }
            // Fallback to proxy URL if video ID not available
            if let proxyURL = thumbnailUrl.flatMap({ URL(string: $0) }) {
                return [Thumbnail(url: proxyURL, quality: .high)]
            }
            return []
        }()

        return Video(
            id: .global(resolvedVideoId),
            title: title,
            description: description.map { stripHTML($0) },
            author: Author(
                id: channelId ?? "",
                name: uploader,
                thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
            ),
            duration: TimeInterval(duration),
            publishedAt: uploaded.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) },
            publishedText: uploadDate,
            viewCount: views.map { Int($0) },
            likeCount: likes.map { Int($0) },
            thumbnails: thumbnails,
            isLive: livestream ?? false,
            isUpcoming: false,
            scheduledStartTime: nil,
            relatedVideos: related
        )
    }

    nonisolated func toStreams() -> [Stream] {
        var streams: [Stream] = []

        // Add HLS stream (preferred - works for both live and on-demand content)
        if let hls, let url = URL(string: hls) {
            streams.append(Stream(
                url: url,
                resolution: nil,
                format: "hls",
                isLive: livestream ?? false,
                mimeType: "application/x-mpegURL"
            ))
        }

        // Add video streams
        if let videoStreams {
            streams.append(contentsOf: videoStreams.compactMap { $0.toStream() })
        }

        // Add audio streams
        if let audioStreams {
            streams.append(contentsOf: audioStreams.compactMap { $0.toStream() })
        }

        return streams
    }
}

private struct PipedVideoStream: Decodable, Sendable {
    let url: String
    let format: String?
    let quality: String?
    let mimeType: String?
    let codec: String?
    let videoOnly: Bool?
    let bitrate: Int?
    let width: Int?
    let height: Int?
    let contentLength: Int64?
    let fps: Int?

    nonisolated func toStream() -> Stream? {
        guard let streamUrl = URL(string: url) else { return nil }

        let resolution: StreamResolution?
        if let width, let height {
            resolution = StreamResolution(width: width, height: height)
        } else if let quality {
            resolution = StreamResolution(heightLabel: quality)
        } else {
            resolution = nil
        }

        let audioCodec: String? = (videoOnly ?? true) ? nil : "aac"

        return Stream(
            url: streamUrl,
            resolution: resolution,
            format: format ?? "unknown",
            videoCodec: codec,
            audioCodec: audioCodec,
            bitrate: bitrate,
            fileSize: contentLength,
            isAudioOnly: false,
            mimeType: mimeType
        )
    }
}

private struct PipedAudioStream: Decodable, Sendable {
    let url: String
    let format: String?
    let quality: String?
    let mimeType: String?
    let codec: String?
    let bitrate: Int?
    let contentLength: Int64?
    let audioTrackId: String?
    let audioTrackName: String?
    let audioTrackLocale: String?
    let audioTrackType: String?

    nonisolated func toStream() -> Stream? {
        guard let streamUrl = URL(string: url) else { return nil }

        return Stream(
            url: streamUrl,
            resolution: nil,
            format: format ?? "unknown",
            videoCodec: nil,
            audioCodec: codec,
            bitrate: bitrate,
            fileSize: contentLength,
            isAudioOnly: true,
            mimeType: mimeType,
            audioLanguage: audioTrackId,
            audioTrackName: audioTrackName,
            isOriginalAudio: audioTrackType == "ORIGINAL"
        )
    }
}

private struct PipedSearchResponse: Decodable, Sendable {
    let items: [PipedSearchItem]
    let nextpage: String?
}

private struct PipedSearchItem: Decodable, Sendable {
    let type: String
    let url: String?
    let name: String?
    let title: String?
    let description: String?
    let thumbnail: String?
    let uploaderName: String?
    let uploaderUrl: String?
    let uploaderAvatar: String?
    let uploaderVerified: Bool?
    let duration: Int?
    let uploaded: Int64?
    let uploadedDate: String?
    let views: Int64?
    let videos: Int64?
    let subscribers: Int64?

    var videoId: String? {
        url?.replacingOccurrences(of: "/watch?v=", with: "")
    }

    var channelId: String? {
        url?.replacingOccurrences(of: "/channel/", with: "")
    }

    var playlistId: String? {
        url?.replacingOccurrences(of: "/playlist?list=", with: "")
    }

    nonisolated func toVideo(instanceURL: URL) -> Video {
        Video(
            id: .global(videoId ?? ""),
            title: title ?? name ?? "",
            description: description,
            author: Author(
                id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "",
                name: uploaderName ?? "",
                thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
            ),
            duration: TimeInterval(duration ?? 0),
            publishedAt: uploaded.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) },
            publishedText: uploadedDate,
            viewCount: views.map { Int($0) },
            likeCount: nil,
            thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
            isLive: duration == -1,
            isUpcoming: false,
            scheduledStartTime: nil
        )
    }

    nonisolated func toChannel() -> Channel {
        Channel(
            id: .global(channelId ?? ""),
            name: name ?? "",
            description: description,
            subscriberCount: subscribers.map { Int($0) },
            videoCount: videos.map { Int($0) },
            thumbnailURL: thumbnail.flatMap { URL(string: $0) },
            isVerified: uploaderVerified ?? false
        )
    }

    nonisolated func toPlaylist() -> Playlist {
        Playlist(
            id: .global(playlistId ?? ""),
            title: name ?? "",
            author: uploaderName.map { Author(id: "", name: $0) },
            videoCount: videos.map { Int($0) } ?? 0,
            thumbnailURL: thumbnail.flatMap { URL(string: $0) }
        )
    }
}

private struct PipedChannelResponse: Decodable, Sendable {
    let id: String
    let name: String
    let description: String?
    let subscriberCount: Int64?
    let verified: Bool?
    let avatarUrl: String?
    let bannerUrl: String?
    let relatedStreams: [PipedVideoItem]?
    let nextpage: String?
    let tabs: [PipedChannelTab]?

    nonisolated func toChannel() -> Channel {
        Channel(
            id: .global(id),
            name: name,
            description: description,
            subscriberCount: subscriberCount.map { Int($0) },
            thumbnailURL: avatarUrl.flatMap { URL(string: $0) },
            bannerURL: bannerUrl.flatMap { URL(string: $0) },
            isVerified: verified ?? false
        )
    }
}

/// Tab entry from the Piped channel response.
/// Each tab has a name (e.g. "shorts", "livestreams", "playlists") and an opaque data string
/// that must be passed to `/channels/tabs?data=...` to fetch tab content.
private struct PipedChannelTab: Decodable, Sendable {
    let name: String
    let data: String
}

/// Response from `/nextpage/channel/{id}?nextpage=...` for paginated channel videos.
private struct PipedNextPageResponse: Decodable, Sendable {
    let relatedStreams: [PipedVideoItem]
    let nextpage: String?
}

/// Response from `/channels/tabs?data=...` for tab content.
private struct PipedTabResponse: Decodable, Sendable {
    let content: [PipedTabItem]
    let nextpage: String?
}

/// Item in a tab response - can be a stream (video/short/livestream) or a playlist.
private enum PipedTabItem: Decodable, Sendable {
    case stream(PipedVideo)
    case playlist(PipedTabPlaylist)
    case unknown

    private enum CodingKeys: String, CodingKey {
        case type
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(String.self, forKey: .type)
        switch type {
        case "stream":
            self = .stream(try PipedVideo(from: decoder))
        case "playlist":
            self = .playlist(try PipedTabPlaylist(from: decoder))
        default:
            self = .unknown
        }
    }
}

/// Playlist item from a channel tab response.
private struct PipedTabPlaylist: Decodable, Sendable {
    let url: String?
    let name: String?
    let thumbnail: String?
    let uploaderName: String?
    let uploaderUrl: String?
    let videos: Int64?

    var playlistId: String? {
        url?.replacingOccurrences(of: "/playlist?list=", with: "")
    }

    nonisolated func toPlaylist() -> Playlist {
        Playlist(
            id: .global(playlistId ?? ""),
            title: name ?? "",
            author: uploaderName.map { Author(id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "", name: $0) },
            videoCount: videos.map { Int($0) } ?? 0,
            thumbnailURL: thumbnail.flatMap { URL(string: $0) }
        )
    }
}

/// Encodes tab data + nextpage token into a single continuation string for round-tripping.
private struct PipedTabContinuation {
    let tabData: String
    let nextpage: String

    func encode() -> String {
        let payload = ["t": tabData, "n": nextpage]
        guard let data = try? JSONSerialization.data(withJSONObject: payload),
              let string = String(data: data, encoding: .utf8) else {
            return ""
        }
        return Data(string.utf8).base64EncodedString()
    }

    static func decode(from continuation: String) throws -> PipedTabContinuation {
        guard let data = Data(base64Encoded: continuation),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: String],
              let tabData = json["t"],
              let nextpage = json["n"] else {
            throw APIError.decodingError("Invalid tab continuation token")
        }
        return PipedTabContinuation(tabData: tabData, nextpage: nextpage)
    }
}

/// Internal result type for tab fetching.
private struct PipedTabPage {
    let items: [PipedTabItem]
    let continuation: String?
}

/// Wrapper for PipedVideo that gracefully handles malformed items.
private enum PipedVideoItem: Decodable, Sendable {
    case video(PipedVideo)
    case unknown

    init(from decoder: Decoder) throws {
        do {
            self = .video(try PipedVideo(from: decoder))
        } catch {
            self = .unknown
        }
    }
}

private struct PipedPlaylistResponse: Decodable, Sendable {
    let name: String
    let description: String?
    let uploader: String?
    let uploaderUrl: String?
    let uploaderAvatar: String?
    let videos: Int?
    let relatedStreams: [PipedVideoItem]?
    let thumbnailUrl: String?
    let nextpage: String?

    nonisolated func toPlaylist(instanceURL: URL, playlistID: String? = nil) -> Playlist {
        // Extract only valid videos, skipping malformed items
        let validVideos: [Video] = relatedStreams?.compactMap { item in
            if case .video(let video) = item {
                return video.toVideo(instanceURL: instanceURL)
            }
            return nil
        } ?? []

        return Playlist(
            id: .global(playlistID ?? UUID().uuidString),
            title: name,
            description: description,
            author: uploader.map {
                Author(
                    id: uploaderUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "",
                    name: $0,
                    thumbnailURL: uploaderAvatar.flatMap { URL(string: $0) }
                )
            },
            videoCount: videos ?? validVideos.count,
            thumbnailURL: thumbnailUrl.flatMap { URL(string: $0) },
            videos: validVideos
        )
    }
}

/// Response from `/nextpage/playlists/{id}?nextpage=...` for paginated playlist videos.
private struct PipedPlaylistNextPageResponse: Decodable, Sendable {
    let relatedStreams: [PipedVideoItem]?
    let nextpage: String?
}

/// User playlist from Piped `/user/playlists` endpoint.
private struct PipedUserPlaylist: Decodable, Sendable {
    let id: String
    let name: String
    let shortDescription: String?
    let thumbnail: String?
    let videos: Int?

    nonisolated func toPlaylist() -> Playlist {
        Playlist(
            id: .global(id),
            title: name,
            description: shortDescription,
            videoCount: videos ?? 0,
            thumbnailURL: thumbnail.flatMap { URL(string: $0) }
        )
    }
}

private struct PipedCommentsResponse: Decodable, Sendable {
    let comments: [PipedComment]
    let nextpage: String?
    let disabled: Bool?
    let commentCount: Int?
}

private struct PipedComment: Decodable, Sendable {
    let commentId: String
    let author: String
    let commentorUrl: String?
    let thumbnail: String?
    let commentText: String
    let commentedTime: String?
    let likeCount: Int?
    let pinned: Bool?
    let hearted: Bool?
    let creatorReplied: Bool?
    let replyCount: Int?
    let repliesPage: String?
    let channelOwner: Bool?

    nonisolated func toComment(instanceURL: URL) -> Comment {
        Comment(
            id: commentId,
            author: Author(
                id: commentorUrl?.replacingOccurrences(of: "/channel/", with: "") ?? "",
                name: author,
                thumbnailURL: thumbnail.flatMap { URL(string: $0) }
            ),
            content: stripHTML(commentText),
            publishedText: commentedTime,
            likeCount: likeCount,
            isPinned: pinned ?? false,
            isCreatorComment: channelOwner ?? false,
            hasCreatorHeart: hearted ?? false,
            replyCount: replyCount ?? 0,
            repliesContinuation: repliesPage
        )
    }
}
