//
//  ChannelView.swift
//  Yattee
//
//  Channel view with zoom/scale header and video grid.
//

import SwiftUI
import NukeUI

struct ChannelView: View {
    @Environment(\.appEnvironment) private var appEnvironment
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass

    let channelID: String
    let source: ContentSource
    /// URL for external channel extraction (nil for YouTube/Invidious channels)
    var channelURL: URL? = nil

    /// Whether this is an external channel that requires extraction
    private var isExternalChannel: Bool {
        channelURL != nil
    }

    @Namespace private var sheetTransition
    @State private var channel: Channel?
    @State private var selectedTab: ChannelTab = .videos
    @State private var isLoading = true
    @State private var isLoadingMore = false
    @State private var errorMessage: String?
    @State private var subscription: Subscription?
    @State private var isSubscribed = false
    @State private var showingUnsubscribeConfirmation = false
    @State private var scrollOffset: CGFloat = 0
    @State private var scrollToTop: Bool = false
    @State private var cachedHeader: CachedChannelData?

    // View options (persisted)
    @AppStorage("channel.layout") private var layout: VideoListLayout = .list
    @AppStorage("channel.rowStyle") private var rowStyle: VideoRowStyle = .regular
    @AppStorage("channel.gridColumns") private var gridColumns = 2
    @AppStorage("channel.hideWatched") private var hideWatched = false

    /// List style from centralized settings.
    private var listStyle: VideoListStyle {
        appEnvironment?.settingsManager.listStyle ?? .inset
    }

    // UI state for view options
    @State private var showViewOptions = false
    @State private var viewWidth: CGFloat = 0
    @State private var watchEntriesMap: [String: WatchEntry] = [:]

    // Videos tab state
    @State private var videos: [Video] = []
    @State private var videosContinuation: String?
    @State private var videosLoaded = false

    // Playlists tab state
    @State private var playlists: [Playlist] = []
    @State private var playlistsContinuation: String?
    @State private var playlistsLoaded = false

    // Shorts tab state
    @State private var shorts: [Video] = []
    @State private var shortsContinuation: String?
    @State private var shortsLoaded = false

    // Streams tab state
    @State private var streams: [Video] = []
    @State private var streamsContinuation: String?
    @State private var streamsLoaded = false

    // Search state
    @State private var searchText = ""
    @State private var isSearchActive = false
    @State private var hasSearched = false  // True after user submits a search query
    @State private var searchResults: ChannelSearchPage = .empty
    @State private var isSearchLoading = false

    // External channel state (page-based pagination for extracted channels)
    @State private var externalCurrentPage = 1

    #if os(tvOS)
    // tvOS-specific state
    @State private var isDescriptionScrollLocked = false
    @State private var tvOSShowSearchTab = false
    @FocusState private var isSubscribeFocused: Bool
    @FocusState private var isTVSearchFieldFocused: Bool
    @FocusState private var tvOSFocusedTab: TvOSChannelFocusTarget?

    private enum TvOSChannelFocusTarget: Hashable {
        case tab(ChannelTab)
        case searchTab
    }
    #endif

    // Header configuration
    private let baseHeaderHeight: CGFloat = 280
    private let searchBarExtraHeight: CGFloat = 70
    private let collapsedHeaderHeight: CGFloat = 60
    private let avatarSize: CGFloat = 80
    private let collapsedAvatarSize: CGFloat = 36
    
    /// Whether search bar adjustments are needed (only on compact/iPhone where search bar overlays content)
    private var needsSearchBarAdjustment: Bool {
        supportsChannelSearch && horizontalSizeClass == .compact
    }
    
    /// Header height adjusted for search bar on iPhone (iOS 18+ places search bar in navigation area)
    private var headerHeight: CGFloat {
        baseHeaderHeight + (needsSearchBarAdjustment ? searchBarExtraHeight : 0)
    }

    private var accentColor: Color {
        appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor
    }

    // Grid layout configuration
    private var gridConfig: GridLayoutConfiguration {
        GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns)
    }

    // Filtered video arrays (for hideWatched)
    private var filteredVideos: [Video] {
        hideWatched ? videos.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : videos
    }

    private var filteredShorts: [Video] {
        hideWatched ? shorts.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : shorts
    }

    private var filteredStreams: [Video] {
        hideWatched ? streams.filter { watchEntriesMap[$0.id.videoID]?.isFinished != true } : streams
    }

    /// Whether inset grouped background should be shown.
    private var showInsetBackground: Bool {
        layout == .list && listStyle == .inset
    }

    /// Background color for the view based on list style.
    private var viewBackgroundColor: Color {
        showInsetBackground ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color
    }

    private var viewOptionsSheetContent: some View {
        ViewOptionsSheet(
            layout: $layout,
            rowStyle: $rowStyle,
            gridColumns: $gridColumns,
            hideWatched: $hideWatched,
            maxGridColumns: gridConfig.maxColumns
        )
    }

    /// View options button lives on the leading edge on macOS, trailing elsewhere.
    private var viewOptionsPlacement: ToolbarItemPlacement {
        #if os(macOS)
        .navigation
        #else
        .primaryAction
        #endif
    }

    var body: some View {
        // The search modifier is hoisted above this state-switching Group so the
        // search bar is present in every load state (loading spinner / cached header
        // / loaded content) instead of only appearing once `channel != nil`.
        channelSearchable {
            Group {
                if let channel {
                    channelContent(channel)
                } else if let cachedHeader {
                    // Show header with cached data + spinner for content area
                    loadingContent(cachedHeader)
                } else if isLoading {
                    ProgressView()
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else if let error = errorMessage {
                    errorView(error)
                }
            }
        }
        .background(showInsetBackground ? viewBackgroundColor : .clear)
        .task {
            await loadChannel()
        }
        .onAppear {
            // Update Handoff activity for this specific channel
            appEnvironment?.handoffManager.updateActivity(for: .channel(channelID, source))
            loadWatchEntries()
        }
        .onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
            loadWatchEntries()
        }
    }

    /// Applies the channel `.searchable` field and its search handlers around the given
    /// content. Kept outside `iOSChannelContent` so the search bar shows regardless of
    /// channel load state. On tvOS there is no `.searchable` (it uses a custom search
    /// tab), so this is a passthrough.
    @ViewBuilder
    private func channelSearchable(@ViewBuilder _ content: () -> some View) -> some View {
        #if os(tvOS)
        content()
        #else
        content()
            #if os(iOS)
            .if(supportsChannelSearch) { view in
                // iPhone: search bar drawer in the navigation area (header reserves
                // space for it). iPad: toolbar search button — the drawer would add
                // a resting scroll position above the banner, letting the view
                // scroll past the banner top.
                view.searchable(
                    text: $searchText,
                    isPresented: $isSearchActive,
                    placement: horizontalSizeClass == .compact
                        ? .navigationBarDrawer(displayMode: .automatic)
                        : .toolbar,
                    prompt: Text("channel.search.placeholder")
                )
            }
            #elseif os(macOS)
            .if(supportsChannelSearch) { view in
                view.searchable(
                    text: $searchText,
                    isPresented: $isSearchActive,
                    placement: .toolbar,
                    prompt: Text("channel.search.placeholder")
                )
            }
            #endif
            .onSubmit(of: .search) {
                Task {
                    await performSearch()
                }
            }
            .onChange(of: searchText) { _, newValue in
                if newValue.isEmpty && isSearchActive {
                    // User cleared the search text, reset search state
                    searchResults = .empty
                }
            }
            .onChange(of: isSearchActive) { _, isActive in
                if !isActive {
                    // Search was dismissed, clear results
                    hasSearched = false
                    searchResults = .empty
                    searchText = ""
                }
            }
        #endif
    }

    private func loadWatchEntries() {
        watchEntriesMap = appEnvironment?.dataManager.watchEntriesMap() ?? [:]
    }

    private func watchProgress(for video: Video) -> Double? {
        guard let entry = watchEntriesMap[video.id.videoID] else { return nil }
        let progress = entry.progress
        return progress > 0 && progress < 1 ? progress : nil
    }

    // MARK: - Computed Properties for Scroll Animation

    /// Progress from 0 (fully expanded) to 1 (fully collapsed)
    private var collapseProgress: CGFloat {
        let progress = scrollOffset / (headerHeight - collapsedHeaderHeight)
        return min(max(progress, 0), 1)
    }

    /// Current avatar size interpolated between full and collapsed
    private var currentAvatarSize: CGFloat {
        avatarSize - (avatarSize - collapsedAvatarSize) * collapseProgress
    }

    /// Opacity for expanded content (fades out as we scroll)
    private var expandedContentOpacity: CGFloat {
        1 - min(collapseProgress * 2, 1)
    }

    /// Opacity for collapsed title (fades in as we scroll)
    private var collapsedTitleOpacity: CGFloat {
        max((collapseProgress - 0.5) * 2, 0)
    }



    // MARK: - Channel Content

    @ViewBuilder
    private func channelContent(_ channel: Channel) -> some View {
        #if os(tvOS)
        tvOSChannelContent(channel)
        #else
        iOSChannelContent(channel)
        #endif
    }

    #if !os(tvOS)
    @ViewBuilder
    private func iOSChannelContent(_ channel: Channel) -> some View {
        GeometryReader { geometry in
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(spacing: 0) {
                        // Only hide header and tabs after user submits a search query
                        if !(isSearchActive && hasSearched) {
                            // Header with zoom/scale effect
                            header(channel)
                                .id("channelTop")

                            // Content based on instance type
                            // On macOS the content type picker lives in the toolbar instead.
                            #if os(macOS)
                            // Breathing room between the banner and the content below.
                            Color.clear.frame(height: 16)
                            if !supportsChannelTabs {
                                channelDescription(channel)
                            }
                            #else
                            if supportsChannelTabs {
                                // Pill-style content type switcher
                                contentTypePicker
                                    .padding(.horizontal)
                                    .padding(.vertical, 8)
                            } else {
                                // Non-tab instances: show description
                                channelDescription(channel)
                            }
                            #endif
                        }

                        // Tab content or search results
                        // Only show search results after user has submitted a query
                        if isSearchActive && hasSearched {
                            // Spacer to push content below nav bar + search bar
                            // Since we use .ignoresSafeArea(edges: .top), we need manual spacing
                            // iOS: safe area (~59pt) + nav bar (~44pt) + search bar (~56pt) = ~130pt extra
                            // macOS: safe area + toolbar (~52pt)
                            #if os(iOS)
                            Spacer()
                                .frame(height: geometry.safeAreaInsets.top + 130)
                            #elseif os(macOS)
                            Spacer()
                                .frame(height: geometry.safeAreaInsets.top + 52)
                            #else
                            Spacer()
                                .frame(height: geometry.safeAreaInsets.top)
                            #endif
                            
                            searchResultsContent
                        } else if supportsChannelTabs {
                            tabContent
                        } else {
                            videosGrid
                        }
                    }
                }
                .onChange(of: scrollToTop) { _, shouldScroll in
                    if shouldScroll {
                        withAnimation {
                            proxy.scrollTo("channelTop", anchor: .top)
                        }
                        scrollToTop = false
                    }
                }
            }
            .onChange(of: geometry.size.width, initial: true) { _, newWidth in
                viewWidth = newWidth
            }
        }
        .background(viewBackgroundColor)
        .ignoresSafeArea(edges: .top)
        .softTopScrollEdgeEffect()
        #if os(macOS)
        .overlay(alignment: .top) {
            legacyToolbarScrim
        }
        #endif
        .animation(.easeInOut(duration: 0.25), value: isSearchActive)
        .modifier(ChannelScrollOffsetModifier(
            scrollOffset: $scrollOffset,
            isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
        ))
        .toolbar {
            #if os(macOS)
            // Keep the content picker in the center `.principal` slot even while
            // searching. Its presence is what pins the trailing `.searchable`
            // field to the right edge — dropping it (or making it zero-width)
            // lets the search field re-center. During search it's disabled so the
            // user can't switch tabs while the content area shows search results.
            if supportsChannelTabs {
                ToolbarItem(placement: .principal) {
                    contentTypePicker
                        .fixedSize()
                        .disabled(isSearchActive && hasSearched)
                }
            }
            #else
            ToolbarItem(placement: .principal) {
                // Collapsed title in toolbar
                HStack(spacing: 8) {
                    if collapseProgress > 0.5 {
                        LazyImage(url: channel.thumbnailURL) { state in
                            if let image = state.image {
                                image
                                    .resizable()
                                    .aspectRatio(contentMode: .fill)
                            } else {
                                Circle()
                                    .fill(.quaternary)
                            }
                        }
                        .frame(width: 28, height: 28)
                        .clipShape(Circle())
                    }

                    Text(channel.name)
                        .font(.headline)
                        .lineLimit(1)
                }
                .opacity(collapsedTitleOpacity)
            }
            #endif

            ToolbarItem(placement: viewOptionsPlacement) {
                Button {
                    showViewOptions = true
                } label: {
                    Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
                }
                .liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
                #if os(macOS)
                .popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
                    viewOptionsSheetContent
                }
                #endif
            }

            // macOS: channel avatar + name sit on the leading edge, next to view
            // options. Excluded from the shared glass background so the view
            // options button keeps its own capsule and the title renders plain.
            #if os(macOS)
            if #available(macOS 26, *) {
                ToolbarItem(placement: .navigation) {
                    collapsedToolbarTitle(name: channel.name, thumbnailURL: channel.thumbnailURL)
                }
                .sharedBackgroundVisibility(.hidden)
            } else {
                ToolbarItem(placement: .navigation) {
                    collapsedToolbarTitle(name: channel.name, thumbnailURL: channel.thumbnailURL)
                }
            }
            #endif

            // iOS keeps a fixed gap between the trailing view options and channel
            // menu buttons; on macOS view options moves to the leading edge, so no
            // gap is needed here.
            #if os(iOS)
            if #available(iOS 26, *) {
                ToolbarSpacer(.fixed, placement: .primaryAction)
            }
            #endif

            ToolbarItem(placement: .primaryAction) {
                channelMenu
            }
        }
        #if !os(macOS)
        .sheet(isPresented: $showViewOptions) {
            viewOptionsSheetContent
                .liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
        }
        #endif
        #if os(iOS)
        .toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar)
        .navigationBarTitleDisplayMode(.inline)
        #endif
        .confirmationDialog(
            String(localized: "channel.unsubscribe.confirmation.title"),
            isPresented: $showingUnsubscribeConfirmation,
            titleVisibility: .visible
        ) {
            Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
                unsubscribe()
            }
            Button(String(localized: "common.cancel"), role: .cancel) {}
        } message: {
            Text(String(localized: "channel.unsubscribe.confirmation.message"))
        }
        .presentationCompactAdaptation(.sheet)
    }
    #endif

    // MARK: - Loading Content

    /// Shows cached header with a spinner below while loading full channel data.
    @ViewBuilder
    private func loadingContent(_ cached: CachedChannelData) -> some View {
        #if os(tvOS)
        tvOSLoadingContent(cached)
        #else
        iOSLoadingContent(cached)
        #endif
    }

    #if !os(tvOS)
    @ViewBuilder
    private func iOSLoadingContent(_ cached: CachedChannelData) -> some View {
        GeometryReader { geometry in
            ScrollView {
                LazyVStack(spacing: 0) {
                    header(name: cached.name, thumbnailURL: cached.thumbnailURL, bannerURL: cached.bannerURL)
                        .id("channelTop")

                    // Show tab picker during loading (doesn't depend on channel)
                    // On macOS the content type picker lives in the toolbar instead.
                    #if os(macOS)
                    Color.clear.frame(height: 16)
                    #else
                    if supportsChannelTabs {
                        contentTypePicker
                            .padding(.horizontal)
                            .padding(.vertical, 8)
                    }
                    #endif

                    // Centered spinner for content area below tabs
                    ProgressView()
                        .frame(maxWidth: .infinity)
                        .padding(.top, 40)
                }
            }
            .onChange(of: geometry.size.width, initial: true) { _, newWidth in
                viewWidth = newWidth
            }
        }
        .background(viewBackgroundColor)
        .ignoresSafeArea(edges: .top)
        .softTopScrollEdgeEffect()
        #if os(macOS)
        .overlay(alignment: .top) {
            legacyToolbarScrim
        }
        #endif
        .toolbar {
            #if os(macOS)
            if supportsChannelTabs {
                ToolbarItem(placement: .principal) {
                    contentTypePicker
                        .fixedSize()
                }
            }
            #else
            ToolbarItem(placement: .principal) {
                // Collapsed title in toolbar (using cached data)
                HStack(spacing: 8) {
                    if collapseProgress > 0.5 {
                        LazyImage(url: cached.thumbnailURL) { state in
                            if let image = state.image {
                                image
                                    .resizable()
                                    .aspectRatio(contentMode: .fill)
                            } else {
                                Circle()
                                    .fill(.ultraThinMaterial)
                            }
                        }
                        .frame(width: collapsedAvatarSize, height: collapsedAvatarSize)
                        .clipShape(Circle())

                        Text(cached.name)
                            .font(.headline)
                            .lineLimit(1)
                    }
                }
                .opacity(collapsedTitleOpacity)
            }
            #endif

            ToolbarItem(placement: viewOptionsPlacement) {
                Button {
                    showViewOptions = true
                } label: {
                    Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
                }
                .liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition)
                #if os(macOS)
                .popover(isPresented: $showViewOptions, arrowEdge: .bottom) {
                    viewOptionsSheetContent
                }
                #endif
            }

            // macOS: channel avatar + name sit on the leading edge, next to view
            // options. Excluded from the shared glass background so the view
            // options button keeps its own capsule and the title renders plain.
            #if os(macOS)
            if #available(macOS 26, *) {
                ToolbarItem(placement: .navigation) {
                    collapsedToolbarTitle(name: cached.name, thumbnailURL: cached.thumbnailURL)
                }
                .sharedBackgroundVisibility(.hidden)
            } else {
                ToolbarItem(placement: .navigation) {
                    collapsedToolbarTitle(name: cached.name, thumbnailURL: cached.thumbnailURL)
                }
            }
            #endif

            // iOS keeps a fixed gap between the trailing view options and channel
            // menu buttons; on macOS view options moves to the leading edge, so no
            // gap is needed here.
            #if os(iOS)
            if #available(iOS 26, *) {
                ToolbarSpacer(.fixed, placement: .primaryAction)
            }
            #endif

            ToolbarItem(placement: .primaryAction) {
                channelMenu
            }
        }
        #if !os(macOS)
        .sheet(isPresented: $showViewOptions) {
            viewOptionsSheetContent
                .liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition)
        }
        #endif
        #if os(iOS)
        .toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar)
        .navigationBarTitleDisplayMode(.inline)
        #endif
        .modifier(ChannelScrollOffsetModifier(
            scrollOffset: $scrollOffset,
            isPlayerExpanded: appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
        ))
        .confirmationDialog(
            String(localized: "channel.unsubscribe.confirmation.title"),
            isPresented: $showingUnsubscribeConfirmation,
            titleVisibility: .visible
        ) {
            Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
                unsubscribe()
            }
            Button(String(localized: "common.cancel"), role: .cancel) {}
        } message: {
            Text(String(localized: "channel.unsubscribe.confirmation.message"))
        }
        .presentationCompactAdaptation(.sheet)
    }
    #endif

    #if os(tvOS)
    // MARK: - tvOS Two-Column Layout

    @ViewBuilder
    private func tvOSChannelContent(_ channel: Channel) -> some View {
        GeometryReader { geometry in
            let leftWidth = geometry.size.width * 0.30
            HStack(alignment: .top, spacing: 40) {
                tvOSLeftColumn(channel: channel, geometry: geometry)
                    .frame(width: leftWidth, alignment: .leading)
                    .focusSection()

                tvOSRightColumn
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                    .focusSection()
            }
            .padding(.horizontal, 60)
            .padding(.vertical, 40)
            .onChange(of: geometry.size.width, initial: true) { _, newWidth in
                viewWidth = newWidth
            }
        }
        .confirmationDialog(
            String(localized: "channel.unsubscribe.confirmation.title"),
            isPresented: $showingUnsubscribeConfirmation,
            titleVisibility: .visible
        ) {
            Button(String(localized: "channel.unsubscribe.confirmation.action"), role: .destructive) {
                unsubscribe()
            }
            Button(String(localized: "common.cancel"), role: .cancel) {}
        } message: {
            Text(String(localized: "channel.unsubscribe.confirmation.message"))
        }
    }

    @ViewBuilder
    private func tvOSLeftColumn(channel: Channel, geometry: GeometryProxy) -> some View {
        tvOSLeftColumnContent(
            name: channel.name,
            thumbnailURL: channel.thumbnailURL,
            subscriberCount: channel.subscriberCount,
            description: channel.description
        )
    }

    @ViewBuilder
    private func tvOSLeftColumnContent(
        name: String,
        thumbnailURL: URL?,
        subscriberCount: Int?,
        description: String?
    ) -> some View {
        VStack(alignment: .leading, spacing: 24) {
            tvOSAvatar(name: name, thumbnailURL: thumbnailURL)
                .frame(maxWidth: .infinity)

            Text(name)
                .font(.title2)
                .fontWeight(.bold)
                .foregroundStyle(.white)
                .fixedSize(horizontal: false, vertical: true)
                .frame(maxWidth: .infinity, alignment: .leading)

            if let subscriberCount {
                Text(CountFormatter.compact(subscriberCount))
                    .fontWeight(.bold)
                + Text(verbatim: " ")
                + Text(String(localized: "channel.subscribers"))
            }

            if !isDescriptionScrollLocked {
                tvOSSubscribeButton
            }

            if let description, !description.isEmpty {
                TVScrollableDescription(
                    description: description,
                    isScrollLocked: $isDescriptionScrollLocked
                )
                .frame(maxHeight: .infinity)
            } else {
                Spacer(minLength: 0)
            }
        }
        .padding(.vertical, 8)
        .frame(maxHeight: .infinity, alignment: .top)
        .animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
    }

    @ViewBuilder
    private func tvOSAvatar(for channel: Channel) -> some View {
        tvOSAvatar(name: channel.name, thumbnailURL: channel.thumbnailURL)
    }

    @ViewBuilder
    private func tvOSAvatar(name: String, thumbnailURL: URL?) -> some View {
        LazyImage(url: thumbnailURL) { state in
            if let image = state.image {
                image
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } else {
                Circle()
                    .fill(.ultraThinMaterial)
                    .overlay {
                        Text(String(name.prefix(1)))
                            .font(.system(size: 56, weight: .semibold))
                            .foregroundStyle(.secondary)
                    }
            }
        }
        .frame(width: 140, height: 140)
        .clipShape(Circle())
        .overlay(
            Circle()
                .stroke(.white.opacity(0.8), lineWidth: 3)
        )
        .shadow(color: .black.opacity(0.4), radius: 10, y: 5)
    }

    private var tvOSSubscribeButton: some View {
        Button {
            toggleSubscription()
        } label: {
            Label(
                isSubscribed
                    ? String(localized: "channel.menu.unsubscribe")
                    : String(localized: "channel.menu.subscribe"),
                systemImage: isSubscribed ? "person.fill.xmark" : "person.badge.plus"
            )
            .frame(maxWidth: .infinity, minHeight: 60)
            .font(.headline)
        }
        .buttonStyle(.borderedProminent)
        .focused($isSubscribeFocused)
    }

    private var tvOSTabButtons: some View {
        HStack(spacing: 12) {
            ForEach([ChannelTab.videos, .shorts, .streams, .playlists]) { tab in
                tvOSTabButton(for: tab)
            }
            if supportsChannelSearch {
                tvOSSearchTabButton
            }
        }
    }

    @ViewBuilder
    private var tvOSSearchTabButton: some View {
        let isSelected = tvOSShowSearchTab
        let action = {
            tvOSFocusedTab = .searchTab
            if !tvOSShowSearchTab {
                tvOSShowSearchTab = true
                isSearchActive = true
            }
        }
        let label = Label(String(localized: "search.title"), systemImage: "magnifyingglass")
            .fontWeight(isSelected ? .bold : .regular)
            .lineLimit(1)

        Button(action: action) { label }
            .buttonStyle(.borderedProminent)
            .tint(isSelected ? accentColor : Color.gray.opacity(0.3))
            .focused($tvOSFocusedTab, equals: .searchTab)
    }

    @ViewBuilder
    private func tvOSTabButton(for tab: ChannelTab) -> some View {
        let isSelected = !tvOSShowSearchTab && selectedTab == tab
        let action = {
            tvOSFocusedTab = .tab(tab)
            if tvOSShowSearchTab {
                tvOSShowSearchTab = false
                isSearchActive = false
            }
            if selectedTab != tab {
                selectedTab = tab
                Task {
                    await loadTabContentIfNeeded(tab)
                }
            }
        }
        let label = Label(tab.title, systemImage: tab.systemImage)
            .fontWeight(isSelected ? .bold : .regular)
            .lineLimit(1)

        Button(action: action) { label }
            .buttonStyle(.borderedProminent)
            .tint(isSelected ? accentColor : Color.gray.opacity(0.3))
            .focused($tvOSFocusedTab, equals: .tab(tab))
    }

    private var tvOSRightColumn: some View {
        VStack(alignment: .leading, spacing: 20) {
            if supportsChannelTabs {
                tvOSTabButtons
            }

            if tvOSShowSearchTab {
                tvOSChannelSearchContent
            } else {
                ScrollView {
                    VStack(alignment: .leading, spacing: 0) {
                        Group {
                            switch selectedTab {
                            case .videos:
                                videosGrid
                            case .shorts:
                                shortsGrid
                            case .streams:
                                streamsGrid
                            case .playlists:
                                playlistsGrid
                            case .about:
                                videosGrid
                            }
                        }
                        .id(selectedTab)
                    }
                    .padding(.vertical, 20)
                }
                .scrollClipDisabled()
            }
        }
    }

    private var tvOSChannelSearchContent: some View {
        VStack(alignment: .leading, spacing: 20) {
            HStack(spacing: 24) {
                TextField(String(localized: "search.placeholder"), text: $searchText)
                    .textFieldStyle(.plain)
                    .focused($isTVSearchFieldFocused)
                    .onSubmit {
                        Task { await performSearch() }
                    }
            }
            .focusSection()

            ScrollView {
                if hasSearched || isSearchLoading {
                    searchResultsContent
                        .padding(.vertical, 20)
                }
            }
            .scrollClipDisabled()
        }
        .onAppear {
            isTVSearchFieldFocused = true
        }
    }

    @ViewBuilder
    private func tvOSLoadingContent(_ cached: CachedChannelData) -> some View {
        GeometryReader { geometry in
            let leftWidth = geometry.size.width * 0.30
            HStack(alignment: .top, spacing: 40) {
                tvOSLeftColumnContent(
                    name: cached.name,
                    thumbnailURL: cached.thumbnailURL,
                    subscriberCount: cached.subscriberCount,
                    description: cached.description
                )
                .frame(width: leftWidth, alignment: .leading)
                .focusSection()

                VStack {
                    Spacer()
                    ProgressView()
                    Spacer()
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
            .padding(.horizontal, 60)
            .padding(.vertical, 40)
            .onChange(of: geometry.size.width, initial: true) { _, newWidth in
                viewWidth = newWidth
            }
        }
    }
    #endif

    // MARK: - Header

    #if os(macOS)
    /// Pre-macOS 26 has no Liquid Glass toolbar capsules, so toolbar items sit
    /// directly on the banner/scrolled content. A fixed window-background scrim
    /// at the top keeps them legible in both appearances.
    @ViewBuilder
    private var legacyToolbarScrim: some View {
        if #unavailable(macOS 26) {
            LinearGradient(
                stops: [
                    .init(color: Color(nsColor: .windowBackgroundColor).opacity(0.9), location: 0),
                    .init(color: Color(nsColor: .windowBackgroundColor).opacity(0.75), location: 0.5),
                    .init(color: .clear, location: 1)
                ],
                startPoint: .top,
                endPoint: .bottom
            )
            .frame(height: 100)
            .frame(maxWidth: .infinity)
            .allowsHitTesting(false)
            .ignoresSafeArea(edges: .top)
        }
    }

    @ViewBuilder
    private func collapsedToolbarTitle(name: String, thumbnailURL: URL?) -> some View {
        let label = HStack(spacing: 8) {
            LazyImage(url: thumbnailURL) { state in
                if let image = state.image {
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                } else {
                    Circle()
                        .fill(.quaternary)
                }
            }
            .frame(width: 28, height: 28)
            .clipShape(Circle())

            Text(name)
                .font(.headline)
                .lineLimit(1)
        }
        .padding(.horizontal, 10)

        // Own glass capsule; the toolbar item opts out of the shared background
        // so this doesn't merge with the view options button.
        if #available(macOS 26, *) {
            label
                .padding(.vertical, 5)
                .glassEffect()
        } else {
            label
        }
    }
    #endif

    private func header(_ channel: Channel) -> some View {
        header(name: channel.name, thumbnailURL: channel.thumbnailURL, bannerURL: channel.bannerURL)
    }

    private func header(name: String, thumbnailURL: URL?, bannerURL: URL?) -> some View {
        GeometryReader { geometry in
            // Calculate avatar vertical position
            let avatarBottomPadding: CGFloat = 20
            #if os(macOS)
            let headerAvatarSize = avatarSize
            let nameSpace: CGFloat = 0
            #else
            // Smoothly interpolate the space for channel name (40pt when visible, 0 when collapsed)
            // Name starts fading at progress 0, fully gone at 0.3
            let nameSpaceProgress = min(collapseProgress / 0.3, 1.0)
            let nameSpace: CGFloat = 40 * (1 - nameSpaceProgress)
            let headerAvatarSize = currentAvatarSize
            #endif
            let avatarContentHeight: CGFloat = headerAvatarSize + nameSpace + avatarBottomPadding
            let idealAvatarY = headerHeight - avatarContentHeight
            // Minimum Y position to prevent going into nav bar
            // On iOS 18+ iPhone, the search bar is present in the navigation area (~56pt + padding)
            // We add extra height to the header, so minAvatarY just needs to clear nav bar + search
            let searchBarReservedHeight: CGFloat = needsSearchBarAdjustment ? 70 : 0
            let minAvatarY: CGFloat = 60 + searchBarReservedHeight
            let clampedAvatarY = max(idealAvatarY, minAvatarY)

            // iPad/Mac uses backgroundExtensionEffect (no zoom/scale needed)
            // iPhone uses zoom/scale + pinOffset to keep banner fixed
            let isRegularSizeClass = horizontalSizeClass != .compact
            // Pin offset: when pulling down (scrollOffset < 0), offset banner up to keep it pinned (iPhone only)
            let pinOffset = (!isRegularSizeClass && scrollOffset < 0) ? scrollOffset : 0
            // Zoom scale for banner and gradient (iPhone only)
            let zoomScale = isRegularSizeClass ? 1 : (1 + max(-scrollOffset / 400, 0))

            ZStack(alignment: .top) {
                // Banner image
                // iPad/Mac: static with backgroundExtensionEffect for Liquid Glass sidebar
                // iPhone: zoom/scale + pinOffset to keep banner fixed at top when pulling down
                bannerImage(url: bannerURL)
                    .frame(width: geometry.size.width, height: headerHeight)
                    .clipped()
                    .scaleEffect(zoomScale, anchor: .top)
                    .offset(y: pinOffset)
                    .modifier(BackgroundExtensionModifier(
                        useBackgroundExtension: isRegularSizeClass
                    ))

                // Gradient overlay for readability - matches banner scale and position
                LinearGradient(
                    colors: [.clear, .black.opacity(0.6)],
                    startPoint: .center,
                    endPoint: .bottom
                )
                .frame(width: geometry.size.width, height: headerHeight * 0.7)
                .offset(y: headerHeight * 0.3)
                .scaleEffect(zoomScale, anchor: .top)
                .offset(y: pinOffset)

                // Avatar and channel name - pinned to bottom of banner
                #if os(macOS)
                let avatarOpacity: CGFloat = 1
                #else
                // Smooth opacity fade based on collapse progress
                let avatarOpacity = max(0, 1.0 - collapseProgress * 1.4)
                // Channel name fades out faster than avatar (starts fading at 0, gone by 0.3)
                let nameOpacity = max(0, 1.0 - collapseProgress * 3.3)
                #endif

                VStack(spacing: 8) {
                    LazyImage(url: thumbnailURL) { state in
                        if let image = state.image {
                            image
                                .resizable()
                                .aspectRatio(contentMode: .fill)
                        } else {
                            Circle()
                                .fill(.ultraThinMaterial)
                                .overlay {
                                    Text(String(name.prefix(1)))
                                        .font(.system(size: headerAvatarSize * 0.4, weight: .semibold))
                                        .foregroundStyle(.secondary)
                                }
                        }
                    }
                    .frame(width: headerAvatarSize, height: headerAvatarSize)
                    .clipShape(Circle())
                    .overlay(
                        Circle()
                            .stroke(.white.opacity(0.8), lineWidth: 3)
                    )
                    .shadow(color: .black.opacity(0.4), radius: 10, y: 5)

                    #if !os(macOS)
                    // Channel name with smooth fade
                    Text(name)
                        .font(.title2)
                        .fontWeight(.bold)
                        .foregroundStyle(.white)
                        .shadow(color: .black.opacity(0.6), radius: 4, y: 2)
                        .opacity(nameOpacity)
                    #endif
                }
                .offset(y: clampedAvatarY + pinOffset)
                .opacity(avatarOpacity)
            }
            .frame(height: headerHeight)
        }
        .frame(height: headerHeight)
    }

    // MARK: - Banner Image

    @ViewBuilder
    private func bannerImage(url bannerURL: URL?) -> some View {
        if let bannerURL {
            LazyImage(url: bannerURL) { state in
                if let image = state.image {
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                } else {
                    LinearGradient(
                        colors: [.gray.opacity(0.3), .gray.opacity(0.5)],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                }
            }
        } else {
            // Gradient placeholder when no banner
            LinearGradient(
                colors: [.accentColor.opacity(0.6), .accentColor.opacity(0.3)],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        }
    }

    // MARK: - Channel Description

    /// Channel description for non-tab instances (shows full description)
    @ViewBuilder
    private func channelDescription(_ channel: Channel) -> some View {
        if let description = channel.description, !description.isEmpty {
            Text(description)
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal)
                .padding(.bottom, 8)
        }
    }

    /// Whether the current instance supports channel tabs (Invidious, Yattee Server, or Piped - not external channels)
    private var supportsChannelTabs: Bool {
        // External channels only show videos (no tabs)
        guard !isExternalChannel else { return false }
        guard let instance = instanceForSource() else {
            return false
        }
        return instance.type == .invidious || instance.type == .yatteeServer || instance.type == .piped
    }

    /// Whether the current instance supports channel search (Invidious or Yattee Server, not external channels or Piped)
    private var supportsChannelSearch: Bool {
        // External channels don't support search
        guard !isExternalChannel else { return false }
        guard let instance = instanceForSource() else { return false }
        return instance.type == .invidious || instance.type == .yatteeServer
    }

    // MARK: - Channel Menu

    /// Icon for the channel menu based on subscription/notification state
    private var channelMenuIcon: String {
        if isSubscribed {
            // Use the immediately-available channel ID so notification status is correct
            // during loading, before the network-loaded `channel` is set.
            let effectiveChannelID = channel?.id.channelID ?? channelID
            let notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: effectiveChannelID) ?? false
            return notificationsEnabled ? "bell.fill" : "person.fill"
        }
        return "person.badge.plus"
    }

    /// Toolbar menu for subscribe/unsubscribe and notification actions
    private var channelMenu: some View {
        Menu {
            // Subscribe/Unsubscribe button
            Button {
                toggleSubscription()
            } label: {
                Label(
                    isSubscribed
                        ? String(localized: "channel.menu.unsubscribe")
                        : String(localized: "channel.menu.subscribe"),
                    systemImage: isSubscribed ? "person.fill.xmark" : "person.badge.plus"
                )
            }

            // Notifications toggle (only visible when subscribed)
            if isSubscribed {
                let effectiveChannelID = channel?.id.channelID ?? channelID
                let notificationsEnabled = appEnvironment?.dataManager.notificationsEnabled(for: effectiveChannelID) ?? false
                Button {
                    toggleNotifications()
                } label: {
                    Label(
                        notificationsEnabled
                            ? String(localized: "channel.menu.disableNotifications")
                            : String(localized: "channel.menu.enableNotifications"),
                        systemImage: notificationsEnabled
                            ? "bell.slash"
                            : "bell"
                    )
                }
            }
        } label: {
            Image(systemName: channelMenuIcon)
        }
    }

    // MARK: - Subscription Actions

    private func toggleSubscription() {
        if isSubscribed {
            showingUnsubscribeConfirmation = true
        } else {
            Task { await subscribe() }
        }
    }

    private func subscribe() async {
        guard let channel,
              let subscriptionService = appEnvironment?.subscriptionService,
              let dataManager = appEnvironment?.dataManager else { return }

        let author = Author(
            id: channel.id.channelID,
            name: channel.name,
            thumbnailURL: channel.thumbnailURL,
            subscriberCount: channel.subscriberCount
        )

        do {
            try await subscriptionService.subscribe(to: author, source: source)
            
            // Set default notification preference for new subscription
            let defaultNotifications = appEnvironment?.settingsManager.defaultNotificationsForNewChannels ?? false
            if defaultNotifications {
                dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
            }
            
            isSubscribed = true
            refreshSubscription()
        } catch {
            appEnvironment?.toastManager.showError(
                String(localized: "channel.subscribe.error.title"),
                subtitle: error.localizedDescription
            )
        }
    }

    private func unsubscribe() {
        guard let channel else { return }

        Task {
            do {
                try await appEnvironment?.subscriptionService.unsubscribe(from: channel.id.channelID)
                isSubscribed = false
                subscription = nil
            } catch {
                appEnvironment?.toastManager.showError(
                    String(localized: "channel.unsubscribe.error.title"),
                    subtitle: error.localizedDescription
                )
            }
        }
    }

    private func toggleNotifications() {
        guard let channel else { return }
        let currentEnabled = appEnvironment?.dataManager.notificationsEnabled(for: channel.id.channelID) ?? false

        if currentEnabled {
            appEnvironment?.dataManager.setNotificationsEnabled(false, for: channel.id.channelID)
        } else {
            Task {
                guard let appEnvironment, await appEnvironment.ensureNotificationsEnabled() else { return }
                appEnvironment.dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
            }
        }
    }

    // MARK: - Content Type Picker

    /// Pill-style segmented picker for switching between content types
    private var contentTypePicker: some View {
        Picker("", selection: $selectedTab) {
            Text(ChannelTab.about.title).tag(ChannelTab.about)
            Text(ChannelTab.videos.title).tag(ChannelTab.videos)
            Text(ChannelTab.shorts.title).tag(ChannelTab.shorts)
            Text(ChannelTab.streams.title).tag(ChannelTab.streams)
            Text(ChannelTab.playlists.title).tag(ChannelTab.playlists)
        }
        .pickerStyle(.segmented)
        .onChange(of: selectedTab) { oldTab, newTab in
            if oldTab != newTab {
                scrollToTop = true
                Task {
                    await loadTabContentIfNeeded(newTab)
                }
            }
        }
    }

    // MARK: - Tab Content

    @ViewBuilder
    private var tabContent: some View {
        switch selectedTab {
        case .about:
            aboutContent
        case .videos:
            videosGrid
        case .playlists:
            playlistsGrid
        case .shorts:
            shortsGrid
        case .streams:
            streamsGrid
        }
    }

    // MARK: - About Content

    @ViewBuilder
    private var aboutContent: some View {
        if let channel {
            let hasSubscriberCount = channel.subscriberCount != nil
            let hasDescription = channel.description?.isEmpty == false

            if hasSubscriberCount || hasDescription {
                let content = VStack(alignment: .leading, spacing: 12) {
                    // Subscriber count (count is bold, "subscribers" is regular)
                    if let subscriberCount = channel.subscriberCount {
                        Text(CountFormatter.compact(subscriberCount))
                            .fontWeight(.bold)
                        + Text(verbatim: " ")
                        + Text(String(localized: "channel.subscribers"))
                    }

                    // Description
                    if let description = channel.description, !description.isEmpty {
                        Text(DescriptionText.attributed(description, linkColor: accentColor))
                            .font(.subheadline)
                            .tint(accentColor)
                    }
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal, 16)
                .padding(.vertical, 12)

                if showInsetBackground {
                    content
                        .background(ListBackgroundStyle.card.color)
                        .clipShape(RoundedRectangle(cornerRadius: 10))
                        .padding(.horizontal, 16)
                } else {
                    content
                }
            } else {
                ContentUnavailableView {
                    Label(String(localized: "channel.noDescription"), systemImage: "text.alignleft")
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .padding(.vertical, 40)
            }
        }
    }

    // MARK: - Videos Grid

    /// Queue source for continuation loading
    private var videosQueueSource: QueueSource {
        .channel(channelID: channelID, source: source, continuation: videosContinuation)
    }

    @ViewBuilder
    private var videosGrid: some View {
        switch layout {
        case .list:
            videosListContent
        case .grid:
            videosGridContent
        }
    }

    @ViewBuilder
    private var videosListContent: some View {
        if videosLoaded && filteredVideos.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noVideos"), systemImage: "play.rectangle")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        } else if !filteredVideos.isEmpty {
            VideoListContent(listStyle: listStyle) {
                ForEach(Array(filteredVideos.enumerated()), id: \.element.id) { index, video in
                    VideoListRow(
                        isLast: index == filteredVideos.count - 1,
                        rowStyle: rowStyle,
                        listStyle: listStyle
                    ) {
                        VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
                            .tappableVideo(
                                video,
                                queueSource: videosQueueSource,
                                sourceLabel: channel?.name,
                                videoList: filteredVideos,
                                videoIndex: index,
                                loadMoreVideos: loadMoreVideosCallback
                            )
                    }
                    #if !os(tvOS)
                    .videoSwipeActions(video: video)
                    #endif
                }

                // External channels use manual load more button
                if isExternalChannel {
                    externalLoadMoreButton
                } else {
                    LoadMoreTrigger(
                        isLoading: isLoadingMore && selectedTab == .videos,
                        hasMore: videosContinuation != nil
                    ) {
                        Task { await loadMoreVideos() }
                    }
                }
            }
        }
    }

    /// Manual load more button for external channels
    private var externalLoadMoreButton: some View {
        Group {
            if videosContinuation != nil {
                Button {
                    Task { await loadMoreVideos() }
                } label: {
                    HStack {
                        if isLoadingMore {
                            ProgressView()
                                .padding(.trailing, 8)
                        }
                        Text(isLoadingMore
                            ? String(localized: "externalChannel.loadingMore")
                            : String(localized: "externalChannel.loadMore"))
                    }
                    .frame(maxWidth: .infinity)
                    .padding()
                }
                .buttonStyle(.bordered)
                .disabled(isLoadingMore)
                .padding()
            }
        }
    }

    @ViewBuilder
    private var videosGridContent: some View {
        VideoGridContent(columns: gridConfig.effectiveColumns) {
            ForEach(Array(filteredVideos.enumerated()), id: \.element.id) { index, video in
                VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
                    .tappableVideo(
                        video,
                        queueSource: videosQueueSource,
                        sourceLabel: channel?.name,
                        videoList: filteredVideos,
                        videoIndex: index,
                        loadMoreVideos: loadMoreVideosCallback
                    )

                // Load more trigger (automatic for regular channels)
                if !isExternalChannel && video.id == filteredVideos.last?.id && videosContinuation != nil && !isLoadingMore {
                    Color.clear
                        .frame(height: 1)
                        .onAppear {
                            Task {
                                await loadMoreVideos()
                            }
                        }
                }
            }
        }

        if videosLoaded && filteredVideos.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noVideos"), systemImage: "play.rectangle")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        }

        // External channels use manual load more button
        if isExternalChannel {
            externalLoadMoreButton
        } else if isLoadingMore && selectedTab == .videos {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
        }
    }

    // MARK: - Playlists Grid

    @ViewBuilder
    private var playlistsGrid: some View {
        switch layout {
        case .list:
            playlistsListContent
        case .grid:
            playlistsGridContent
        }
    }

    @ViewBuilder
    private var playlistsListContent: some View {
        // Loading state
        if !playlistsLoaded && playlists.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
                .onAppear {
                    Task { await loadPlaylists() }
                }
        }

        if playlistsLoaded && playlists.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noPlaylists"), systemImage: "list.bullet.rectangle")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        } else if !playlists.isEmpty {
            VideoListContent(listStyle: listStyle) {
                ForEach(Array(playlists.enumerated()), id: \.element.id) { index, playlist in
                    VideoListRow(
                        isLast: index == playlists.count - 1,
                        rowStyle: rowStyle,
                        listStyle: listStyle
                    ) {
                        ChannelPlaylistRow(playlist: playlist, style: rowStyle)
                    }
                }

                LoadMoreTrigger(
                    isLoading: isLoadingMore && selectedTab == .playlists,
                    hasMore: playlistsContinuation != nil
                ) {
                    Task { await loadMorePlaylists() }
                }
            }
        }
    }

    @ViewBuilder
    private var playlistsGridContent: some View {
        if !playlistsLoaded && playlists.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
                .onAppear {
                    Task {
                        await loadPlaylists()
                    }
                }
        }

        VideoGridContent(columns: gridConfig.effectiveColumns) {
            ForEach(playlists) { playlist in
                NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) {
                    PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
                        .contentShape(Rectangle())
                }
                .zoomTransitionSource(id: playlist.id)
                .buttonStyle(.plain)

                // Load more trigger
                if playlist.id == playlists.last?.id && playlistsContinuation != nil && !isLoadingMore {
                    Color.clear
                        .frame(height: 1)
                        .onAppear {
                            Task {
                                await loadMorePlaylists()
                            }
                        }
                }
            }
        }

        if playlistsLoaded && playlists.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noPlaylists"), systemImage: "list.bullet.rectangle")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        }

        if isLoadingMore && selectedTab == .playlists {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
        }
    }

    // MARK: - Shorts Grid

    private var shortsQueueSource: QueueSource {
        .channel(channelID: channelID, source: source, continuation: shortsContinuation)
    }

    @ViewBuilder
    private var shortsGrid: some View {
        switch layout {
        case .list:
            shortsListContent
        case .grid:
            shortsGridContent
        }
    }

    @ViewBuilder
    private var shortsListContent: some View {
        // Loading state
        if !shortsLoaded && filteredShorts.isEmpty && shorts.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
                .onAppear {
                    Task { await loadShorts() }
                }
        }

        if shortsLoaded && filteredShorts.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noShorts"), systemImage: "bolt")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        } else if !filteredShorts.isEmpty {
            VideoListContent(listStyle: listStyle) {
                ForEach(Array(filteredShorts.enumerated()), id: \.element.id) { index, video in
                    VideoListRow(
                        isLast: index == filteredShorts.count - 1,
                        rowStyle: rowStyle,
                        listStyle: listStyle
                    ) {
                        VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
                            .tappableVideo(
                                video,
                                queueSource: shortsQueueSource,
                                sourceLabel: channel?.name,
                                videoList: filteredShorts,
                                videoIndex: index,
                                loadMoreVideos: loadMoreShortsCallback
                            )
                    }
                    #if !os(tvOS)
                    .videoSwipeActions(video: video)
                    #endif
                }

                LoadMoreTrigger(
                    isLoading: isLoadingMore && selectedTab == .shorts,
                    hasMore: shortsContinuation != nil
                ) {
                    Task { await loadMoreShorts() }
                }
            }
        }
    }

    @ViewBuilder
    private var shortsGridContent: some View {
        if !shortsLoaded && filteredShorts.isEmpty && shorts.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
                .onAppear {
                    Task {
                        await loadShorts()
                    }
                }
        }

        VideoGridContent(columns: gridConfig.effectiveColumns) {
            ForEach(Array(filteredShorts.enumerated()), id: \.element.id) { index, video in
                VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
                    .tappableVideo(
                        video,
                        queueSource: shortsQueueSource,
                        sourceLabel: channel?.name,
                        videoList: filteredShorts,
                        videoIndex: index,
                        loadMoreVideos: loadMoreShortsCallback
                    )

                // Load more trigger
                if video.id == filteredShorts.last?.id && shortsContinuation != nil && !isLoadingMore {
                    Color.clear
                        .frame(height: 1)
                        .onAppear {
                            Task {
                                await loadMoreShorts()
                            }
                        }
                }
            }
        }

        if shortsLoaded && filteredShorts.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noShorts"), systemImage: "bolt")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        }

        if isLoadingMore && selectedTab == .shorts {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
        }
    }

    // MARK: - Streams Grid

    private var streamsQueueSource: QueueSource {
        .channel(channelID: channelID, source: source, continuation: streamsContinuation)
    }

    @ViewBuilder
    private var streamsGrid: some View {
        switch layout {
        case .list:
            streamsListContent
        case .grid:
            streamsGridContent
        }
    }

    @ViewBuilder
    private var streamsListContent: some View {
        // Loading state
        if !streamsLoaded && filteredStreams.isEmpty && streams.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
                .onAppear {
                    Task { await loadStreams() }
                }
        }

        if streamsLoaded && filteredStreams.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noStreams"), systemImage: "video")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        } else if !filteredStreams.isEmpty {
            VideoListContent(listStyle: listStyle) {
                ForEach(Array(filteredStreams.enumerated()), id: \.element.id) { index, video in
                    VideoListRow(
                        isLast: index == filteredStreams.count - 1,
                        rowStyle: rowStyle,
                        listStyle: listStyle
                    ) {
                        VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
                            .tappableVideo(
                                video,
                                queueSource: streamsQueueSource,
                                sourceLabel: channel?.name,
                                videoList: filteredStreams,
                                videoIndex: index,
                                loadMoreVideos: loadMoreStreamsCallback
                            )
                    }
                    #if !os(tvOS)
                    .videoSwipeActions(video: video)
                    #endif
                }

                LoadMoreTrigger(
                    isLoading: isLoadingMore && selectedTab == .streams,
                    hasMore: streamsContinuation != nil
                ) {
                    Task { await loadMoreStreams() }
                }
            }
        }
    }

    @ViewBuilder
    private var streamsGridContent: some View {
        if !streamsLoaded && filteredStreams.isEmpty && streams.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
                .onAppear {
                    Task {
                        await loadStreams()
                    }
                }
        }

        VideoGridContent(columns: gridConfig.effectiveColumns) {
            ForEach(Array(filteredStreams.enumerated()), id: \.element.id) { index, video in
                VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
                    .tappableVideo(
                        video,
                        queueSource: streamsQueueSource,
                        sourceLabel: channel?.name,
                        videoList: filteredStreams,
                        videoIndex: index,
                        loadMoreVideos: loadMoreStreamsCallback
                    )

                // Load more trigger
                if video.id == filteredStreams.last?.id && streamsContinuation != nil && !isLoadingMore {
                    Color.clear
                        .frame(height: 1)
                        .onAppear {
                            Task {
                                await loadMoreStreams()
                            }
                        }
                }
            }
        }

        if streamsLoaded && filteredStreams.isEmpty {
            ContentUnavailableView {
                Label(String(localized: "channel.noStreams"), systemImage: "video")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(.vertical, 40)
        }

        if isLoadingMore && selectedTab == .streams {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
        }
    }

    // MARK: - Search Results

    /// Queue source for search results (no continuation since channel search uses pages)
    private var searchQueueSource: QueueSource {
        .search(query: searchText, continuation: nil)
    }

    @ViewBuilder
    private var searchResultsContent: some View {
        if isSearchLoading && searchResults.items.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding(.vertical, 40)
        } else if searchResults.items.isEmpty {
            ContentUnavailableView.search(text: searchText)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
                .padding(.vertical, 40)
        } else {
            switch layout {
            case .list:
                searchResultsListContent
            case .grid:
                searchResultsGridContent
            }
        }
    }

    @ViewBuilder
    private var searchResultsListContent: some View {
        VideoListContent(listStyle: listStyle) {
            ForEach(Array(searchResults.items.enumerated()), id: \.element.id) { index, item in
                VideoListRow(
                    isLast: index == searchResults.items.count - 1,
                    rowStyle: rowStyle,
                    listStyle: listStyle
                ) {
                    switch item {
                    case .video(let video):
                        VideoRowView(video: video, style: rowStyle, watchProgress: watchProgress(for: video))
                            .tappableVideo(
                                video,
                                queueSource: searchQueueSource,
                                sourceLabel: channel?.name,
                                videoList: searchResultVideos,
                                videoIndex: searchResultVideos.firstIndex(where: { $0.id == video.id }) ?? 0,
                                loadMoreVideos: nil
                            )
                            #if !os(tvOS)
                            .videoSwipeActions(video: video)
                            #endif

                    case .playlist(let playlist):
                        ChannelPlaylistRow(playlist: playlist, style: rowStyle)
                    }
                }
            }

            LoadMoreTrigger(
                isLoading: isSearchLoading,
                hasMore: searchResults.nextPage != nil
            ) {
                Task { await loadMoreSearchResults() }
            }
        }
    }

    @ViewBuilder
    private var searchResultsGridContent: some View {
        VideoGridContent(columns: gridConfig.effectiveColumns) {
            ForEach(searchResults.items) { item in
                switch item {
                case .video(let video):
                    VideoCardView(video: video, watchProgress: watchProgress(for: video), isCompact: gridConfig.isCompactCards)
                        .tappableVideo(
                            video,
                            queueSource: searchQueueSource,
                            sourceLabel: channel?.name,
                            videoList: searchResultVideos,
                            videoIndex: searchResultVideos.firstIndex(where: { $0.id == video.id }) ?? 0,
                            loadMoreVideos: nil
                        )

                case .playlist(let playlist):
                NavigationLink(value: NavigationDestination.playlist(.remote(playlist.id, instance: nil, title: playlist.title))) {
                        PlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards)
                            .contentShape(Rectangle())
                    }
                    .zoomTransitionSource(id: playlist.id)
                    .buttonStyle(.plain)
                }

                // Load more trigger
                if item.id == searchResults.items.last?.id && searchResults.nextPage != nil && !isSearchLoading {
                    Color.clear
                        .frame(height: 1)
                        .onAppear {
                            Task {
                                await loadMoreSearchResults()
                            }
                        }
                }
            }
        }

        if isSearchLoading && !searchResults.items.isEmpty {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
        }
    }

    /// Extracts just the videos from search results for queue navigation
    private var searchResultVideos: [Video] {
        searchResults.items.compactMap { item in
            if case .video(let video) = item {
                return video
            }
            return nil
        }
    }

    // MARK: - Search Loading

    private func performSearch() async {
        guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
              let appEnvironment,
              let instance = instanceForSource() else {
            return
        }

        isSearchLoading = true
        hasSearched = true

        do {
            let results = try await appEnvironment.contentService.channelSearch(
                id: channelID,
                query: searchText,
                instance: instance,
                page: 1
            )
            // Deduplicate items (API may return duplicates)
            var seenIDs = Set<String>()
            let uniqueItems = results.items.filter { seenIDs.insert($0.id).inserted }
            searchResults = ChannelSearchPage(items: uniqueItems, nextPage: results.nextPage)
        } catch {
            // On error, show empty results
            searchResults = .empty
        }

        isSearchLoading = false
    }

    private func loadMoreSearchResults() async {
        guard let nextPage = searchResults.nextPage,
              let appEnvironment,
              let instance = instanceForSource(),
              !isSearchLoading else {
            return
        }

        isSearchLoading = true

        do {
            let moreResults = try await appEnvironment.contentService.channelSearch(
                id: channelID,
                query: searchText,
                instance: instance,
                page: nextPage
            )

            // Append new items, avoiding duplicates
            let existingIDs = Set(searchResults.items.map(\.id))
            let newItems = moreResults.items.filter { !existingIDs.contains($0.id) }

            searchResults = ChannelSearchPage(
                items: searchResults.items + newItems,
                nextPage: newItems.isEmpty ? nil : moreResults.nextPage
            )
        } catch {
            // On error, stop loading more
        }

        isSearchLoading = false
    }

    private func clearSearch() {
        isSearchActive = false
        hasSearched = false
        searchResults = .empty
        searchText = ""
    }

    // MARK: - Error View

    private func errorView(_ error: String) -> some View {
        ContentUnavailableView {
            Label(String(localized: "common.error"), systemImage: "exclamationmark.triangle")
        } description: {
            Text(error)
        } actions: {
            Button(String(localized: "common.retry")) {
                Task {
                    await loadChannel()
                }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    // MARK: - Helpers

    private func formatSubscribers(_ count: Int) -> String {
        let formatted = CountFormatter.compact(count)
        return String(localized: "channel.subscriberCount \(formatted)")
    }

    /// Returns the appropriate instance for the channel's content source
    private func instanceForSource() -> Instance? {
        appEnvironment?.instancesManager.instance(for: source)
    }

    // MARK: - Data Loading

    private func loadChannel() async {
        if isExternalChannel {
            await loadExternalChannel()
        } else {
            await loadRegularChannel()
        }
    }

    private func loadRegularChannel() async {
        guard let appEnvironment,
              let instance = instanceForSource() else {
            errorMessage = "No instances configured"
            isLoading = false
            return
        }

        isLoading = true
        errorMessage = nil

        // Load subscription state: optimistic from the local store, then
        // corrected by the active provider (server accounts differ from local)
        subscription = appEnvironment.dataManager.subscription(for: channelID)
        isSubscribed = subscription != nil
        Task {
            isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: channelID)
        }

        // Load cached header data for immediate display
        cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)

        // Fetch channel and videos independently to handle partial failures gracefully
        async let channelTask: Result<Channel, Error> = await {
            do {
                return .success(try await appEnvironment.contentService.channel(id: channelID, instance: instance))
            } catch {
                return .failure(error)
            }
        }()
        async let videosTask: Result<ChannelVideosPage, Error> = await {
            do {
                return .success(try await appEnvironment.contentService.channelVideos(id: channelID, instance: instance, continuation: nil))
            } catch {
                return .failure(error)
            }
        }()

        let (channelResult, videosResult) = await (channelTask, videosTask)

        await MainActor.run {
            var channelAPIFailed = false
            var videosAPIFailed = false

            // Handle channel result
            switch channelResult {
            case .success(let loadedChannel):
                channel = loadedChannel

                // Update subscription metadata if subscribed
                appEnvironment.dataManager.updateSubscription(for: channelID, with: loadedChannel)

                // Save to recent channels (unless incognito mode is enabled or recent channels disabled)
                if appEnvironment.settingsManager.incognitoModeEnabled != true,
                   appEnvironment.settingsManager.saveRecentChannels {
                    appEnvironment.dataManager.addRecentChannel(loadedChannel)
                }

            case .failure(let error):
                channelAPIFailed = true
                // Channel API failed - build a Channel from cachedHeader so the view can display it
                if let cached = cachedHeader {
                    channel = Channel(
                        id: ChannelID(source: source, channelID: channelID),
                        name: cached.name,
                        description: nil,
                        subscriberCount: cached.subscriberCount,
                        thumbnailURL: cached.thumbnailURL,
                        bannerURL: cached.bannerURL
                    )
                }
                LoggingService.shared.error("[ChannelView] Channel API failed", category: .api, details: error.localizedDescription)
            }

            // Handle videos result
            switch videosResult {
            case .success(let videosPage):
                videos = videosPage.videos
                videosContinuation = videosPage.continuation
                videosLoaded = true

            case .failure(let error):
                videosAPIFailed = true
                LoggingService.shared.error("[ChannelView] Videos API failed", category: .api, details: error.localizedDescription)
            }

            // Only show error if both APIs failed and we have no cached data
            if channelAPIFailed && videosAPIFailed && channel == nil {
                errorMessage = String(localized: "channelView.loadError")
            }

            isLoading = false
        }
    }

    private func loadExternalChannel() async {
        guard let appEnvironment,
              let url = channelURL else {
            errorMessage = "Invalid channel URL"
            isLoading = false
            return
        }

        guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
            errorMessage = String(localized: "externalChannel.noYatteeServer")
            isLoading = false
            return
        }

        isLoading = true
        errorMessage = nil

        do {
            let (fetchedChannel, fetchedVideos, fetchedContinuation) = try await appEnvironment.contentService.extractChannel(
                url: url,
                page: 1,
                instance: instance
            )

            await MainActor.run {
                channel = fetchedChannel
                videos = fetchedVideos
                videosContinuation = fetchedContinuation
                externalCurrentPage = 1
                videosLoaded = true
                isLoading = false

                // Check subscription status using extracted channel ID
                subscription = appEnvironment.dataManager.subscription(for: fetchedChannel.id.channelID)
                isSubscribed = subscription != nil
                Task {
                    isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: fetchedChannel.id.channelID)
                }
            }
        } catch let error as APIError {
            await MainActor.run {
                handleExternalChannelError(error)
                isLoading = false
            }
        } catch {
            await MainActor.run {
                errorMessage = error.localizedDescription
                isLoading = false
            }
        }
    }

    private func handleExternalChannelError(_ error: APIError) {
        switch error {
        case .httpError(let statusCode, let message):
            if statusCode == 422 {
                errorMessage = message ?? "This site doesn't support channel extraction."
            } else if statusCode == 400 {
                errorMessage = message ?? "Invalid channel URL."
            } else {
                errorMessage = message ?? "Server error (\(statusCode))."
            }
        default:
            errorMessage = error.localizedDescription
        }
    }

    private func refreshSubscription() {
        // Use loaded channel's ID if available (important for external channels
        // where navigation channelID is the URL, not the actual channel ID)
        let effectiveChannelID = channel?.id.channelID ?? channelID
        subscription = appEnvironment?.dataManager.subscription(for: effectiveChannelID)
        isSubscribed = subscription != nil
        Task {
            if let service = appEnvironment?.subscriptionService {
                isSubscribed = await service.isSubscribed(to: effectiveChannelID)
            }
        }
    }

    private func loadMoreVideos() async {
        if isExternalChannel {
            await loadMoreExternalVideos()
        } else {
            await loadMoreRegularVideos()
        }
    }

    private func loadMoreRegularVideos() async {
        guard let appEnvironment,
              let instance = instanceForSource(),
              let continuation = videosContinuation,
              !isLoadingMore else {
            return
        }

        isLoadingMore = true

        do {
            let result = try await appEnvironment.contentService.channelVideos(
                id: channelID,
                instance: instance,
                continuation: continuation
            )

            await MainActor.run {
                // Filter out duplicates before appending
                let existingIDs = Set(videos.map(\.id))
                let newVideos = result.videos.filter { !existingIDs.contains($0.id) }

                videos.append(contentsOf: newVideos)
                // Stop pagination if all returned videos are duplicates
                videosContinuation = newVideos.isEmpty ? nil : result.continuation
                isLoadingMore = false
            }
        } catch {
            await MainActor.run {
                isLoadingMore = false
            }
        }
    }

    private func loadMoreExternalVideos() async {
        guard let appEnvironment,
              let url = channelURL,
              let instance = appEnvironment.instancesManager.yatteeServerInstance,
              videosContinuation != nil,
              !isLoadingMore else {
            return
        }

        isLoadingMore = true

        do {
            let nextPage = externalCurrentPage + 1
            let (_, fetchedVideos, fetchedContinuation) = try await appEnvironment.contentService.extractChannel(
                url: url,
                page: nextPage,
                instance: instance
            )

            await MainActor.run {
                videos.append(contentsOf: fetchedVideos)
                videosContinuation = fetchedContinuation
                externalCurrentPage = nextPage
                isLoadingMore = false
            }
        } catch {
            await MainActor.run {
                isLoadingMore = false
            }
        }
    }
    
    /// Callback for loading more videos via continuation (used by VideoInfoView navigation)
    @Sendable
    private func loadMoreVideosCallback() async throws -> ([Video], String?) {
        guard let appEnvironment,
              let instance = instanceForSource(),
              let continuation = videosContinuation else {
            throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
        }
        
        let result = try await appEnvironment.contentService.channelVideos(
            id: channelID,
            instance: instance,
            continuation: continuation
        )
        
        return (result.videos, result.continuation)
    }
    
    /// Callback for loading more shorts via continuation
    @Sendable
    private func loadMoreShortsCallback() async throws -> ([Video], String?) {
        guard let appEnvironment,
              let instance = instanceForSource(),
              let continuation = shortsContinuation else {
            throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
        }
        
        let result = try await appEnvironment.contentService.channelShorts(
            id: channelID,
            instance: instance,
            continuation: continuation
        )
        
        return (result.videos, result.continuation)
    }
    
    /// Callback for loading more streams via continuation
    @Sendable
    private func loadMoreStreamsCallback() async throws -> ([Video], String?) {
        guard let appEnvironment,
              let instance = instanceForSource(),
              let continuation = streamsContinuation else {
            throw NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No continuation available"])
        }
        
        let result = try await appEnvironment.contentService.channelStreams(
            id: channelID,
            instance: instance,
            continuation: continuation
        )
        
        return (result.videos, result.continuation)
    }

    // MARK: - Tab Loading

    private func loadTabContentIfNeeded(_ tab: ChannelTab) async {
        switch tab {
        case .about:
            // No loading needed - description is already available
            break
        case .videos:
            // Already loaded on initial load
            break
        case .playlists:
            if !playlistsLoaded {
                await loadPlaylists()
            }
        case .shorts:
            if !shortsLoaded {
                await loadShorts()
            }
        case .streams:
            if !streamsLoaded {
                await loadStreams()
            }
        }
    }

    // MARK: - Playlists Loading

    private func loadPlaylists() async {
        guard let appEnvironment,
              let instance = instanceForSource() else {
            return
        }

        do {
            let result = try await appEnvironment.contentService.channelPlaylists(
                id: channelID,
                instance: instance,
                continuation: nil
            )

            await MainActor.run {
                playlists = result.playlists
                playlistsContinuation = result.continuation
                playlistsLoaded = true
            }
        } catch {
            await MainActor.run {
                playlistsLoaded = true
            }
        }
    }

    private func loadMorePlaylists() async {
        guard let appEnvironment,
              let instance = instanceForSource(),
              let continuation = playlistsContinuation,
              !isLoadingMore else {
            return
        }

        isLoadingMore = true

        do {
            let result = try await appEnvironment.contentService.channelPlaylists(
                id: channelID,
                instance: instance,
                continuation: continuation
            )

            await MainActor.run {
                playlists.append(contentsOf: result.playlists)
                playlistsContinuation = result.continuation
                isLoadingMore = false
            }
        } catch {
            await MainActor.run {
                isLoadingMore = false
            }
        }
    }

    // MARK: - Shorts Loading

    private func loadShorts() async {
        guard let appEnvironment,
              let instance = instanceForSource() else {
            return
        }

        do {
            let result = try await appEnvironment.contentService.channelShorts(
                id: channelID,
                instance: instance,
                continuation: nil
            )

            await MainActor.run {
                shorts = result.videos
                shortsContinuation = result.continuation
                shortsLoaded = true
            }
        } catch {
            await MainActor.run {
                shortsLoaded = true
            }
        }
    }

    private func loadMoreShorts() async {
        guard let appEnvironment,
              let instance = instanceForSource(),
              let continuation = shortsContinuation,
              !isLoadingMore else {
            return
        }

        isLoadingMore = true

        do {
            let result = try await appEnvironment.contentService.channelShorts(
                id: channelID,
                instance: instance,
                continuation: continuation
            )

            await MainActor.run {
                // Filter out duplicates before appending
                let existingIDs = Set(shorts.map(\.id))
                let newShorts = result.videos.filter { !existingIDs.contains($0.id) }
                shorts.append(contentsOf: newShorts)
                // Stop pagination if all returned videos are duplicates
                shortsContinuation = newShorts.isEmpty ? nil : result.continuation
                isLoadingMore = false
            }
        } catch {
            await MainActor.run {
                isLoadingMore = false
            }
        }
    }

    // MARK: - Streams Loading

    private func loadStreams() async {
        guard let appEnvironment,
              let instance = instanceForSource() else {
            return
        }

        do {
            let result = try await appEnvironment.contentService.channelStreams(
                id: channelID,
                instance: instance,
                continuation: nil
            )

            await MainActor.run {
                streams = result.videos
                streamsContinuation = result.continuation
                streamsLoaded = true
            }
        } catch {
            await MainActor.run {
                streamsLoaded = true
            }
        }
    }

    private func loadMoreStreams() async {
        guard let appEnvironment,
              let instance = instanceForSource(),
              let continuation = streamsContinuation,
              !isLoadingMore else {
            return
        }

        isLoadingMore = true

        do {
            let result = try await appEnvironment.contentService.channelStreams(
                id: channelID,
                instance: instance,
                continuation: continuation
            )

            await MainActor.run {
                // Filter out duplicates before appending
                let existingIDs = Set(streams.map(\.id))
                let newStreams = result.videos.filter { !existingIDs.contains($0.id) }
                streams.append(contentsOf: newStreams)
                // Stop pagination if all returned videos are duplicates
                streamsContinuation = newStreams.isEmpty ? nil : result.continuation
                isLoadingMore = false
            }
        } catch {
            await MainActor.run {
                isLoadingMore = false
            }
        }
    }
}

// MARK: - Scroll Offset Tracking Modifier

/// Tracks scroll offset for header zoom/scale effect
private struct ChannelScrollOffsetModifier: ViewModifier {
    @Binding var scrollOffset: CGFloat
    var isPlayerExpanded: Bool

    func body(content: Content) -> some View {
        #if os(macOS)
        content
        #else
        content
            .onScrollGeometryChange(for: CGFloat.self) { geometry in
                geometry.contentOffset.y
            } action: { _, newValue in
                // Skip updates when player is expanded to avoid multiple updates per frame
                guard !isPlayerExpanded else { return }

                if scrollOffset != newValue {
                    scrollOffset = newValue
                }
            }
        #endif
    }
}

// MARK: - Background Extension Modifier

/// Applies backgroundExtensionEffect on iOS 26+ for Liquid Glass sidebar (iPad/Mac only)
private struct BackgroundExtensionModifier: ViewModifier {
    let useBackgroundExtension: Bool
    
    func body(content: Content) -> some View {
        if useBackgroundExtension {
            if #available(iOS 26, macOS 26, tvOS 26, *) {
                content.backgroundExtensionEffect()
            } else {
                content
            }
        } else {
            content
        }
    }
}

// MARK: - Preview

#Preview {
    NavigationStack {
        ChannelView(channelID: "UCxxxxxx", source: .global(provider: ContentSource.youtubeProvider))
    }
    .appEnvironment(.preview)
}
