//
//  TVPlayerView.swift
//  Yattee
//
//  Main tvOS player container with custom controls and Apple TV remote support.
//

#if os(tvOS)
import NukeUI
import SwiftUI

/// Focus targets for tvOS player controls navigation.
enum TVPlayerFocusTarget: Hashable {
    case background  // For capturing events when controls hidden
    case progressBar
    case settingsButton
    case infoButton
    case commentsButton
    case debugButton
    case playPrevious
    case playPauseButton
    case playNext
    case closeButton
    case queueButton
    case errorDetails
    case errorRetry
    case errorPlayNext
    case errorClose
}

/// Main tvOS fullscreen player view.
struct TVPlayerView: View {
    @Environment(\.appEnvironment) private var appEnvironment
    @Environment(\.dismiss) private var dismiss
    @Environment(\.scenePhase) private var scenePhase

    // MARK: - State

    /// Whether controls overlay is visible.
    @State private var controlsVisible = true

    /// Timer for auto-hiding controls.
    @State private var controlsHideTimer: Timer?

    /// Whether the details panel is shown.
    @State private var isDetailsPanelVisible = false

    /// Initial tab for the details panel when opened.
    @State private var detailsPanelInitialTab: TVDetailsTab = .info

    /// Whether user is scrubbing the progress bar.
    @State private var isScrubbing = false

    /// Whether playback was active when the current scrub session began;
    /// used to decide whether to resume on scrub end.
    @State private var wasPlayingBeforeScrub = false

    /// Whether the quality sheet is shown.
    @State private var showingQualitySheet = false

    /// Whether the queue sheet is shown.
    @State private var showingQueueSheet = false

    /// Whether the error details sheet is shown.
    @State private var showingErrorSheet = false

    /// Whether the debug overlay is shown.
    @State private var isDebugOverlayVisible = false

    /// Debug statistics from MPV.
    @State private var debugStats: MPVDebugStats = .init()

    /// Timer for updating debug stats.
    @State private var debugUpdateTimer: Timer?

    /// Current focus target for D-pad navigation.
    @FocusState private var focusedControl: TVPlayerFocusTarget?

    /// Whether the autoplay countdown is visible.
    @State private var showAutoplayCountdown = false

    /// Current countdown value (5, 4, 3, 2, 1).
    @State private var autoplayCountdown = 5

    /// Timer for the countdown.
    @State private var autoplayTimer: Timer?

    /// Current tap-seek feedback to display.
    @State private var currentTapFeedback: (action: TapGestureAction, position: TapZonePosition, accumulated: Int?)?

    /// Pending seek to execute when feedback completes.
    @State private var pendingSeek: (isForward: Bool, seconds: Int)?

    /// Pending target time for arrow-key seeks while the progress bar is
    /// focused (controls visible). Mirrored onto the scrubber so the handle
    /// moves without any extra overlay.
    @State private var scrubberRemoteSeekTime: TimeInterval?

    /// Most recent accumulated seek amount for the focused-bar flow; applied
    /// on debounced commit.
    @State private var scrubberRemoteSeek: (isForward: Bool, seconds: Int)?

    /// Debounce task that commits the focused-bar arrow-key seek 1s after the
    /// last press.
    @State private var scrubberRemoteSeekTask: Task<Void, Never>?

    /// Bumped to signal `TVPlayerProgressBar` to cancel an in-progress scrub
    /// without performing the seek (used when Menu is pressed during scrub).
    @State private var cancelScrubTrigger: UUID?

    // MARK: - Computed Properties

    private var playerService: PlayerService? {
        appEnvironment?.playerService
    }

    private var playerState: PlayerState? {
        playerService?.state
    }

    // MARK: - Body

    var body: some View {
        mpvPlayerContent
            .ignoresSafeArea()
            .playerToastOverlay()
            .fullScreenCover(isPresented: $showingErrorSheet) {
                ZStack {
                    Rectangle()
                        .fill(.ultraThinMaterial)
                        .ignoresSafeArea()
                    ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error")
                        .frame(maxWidth: 1200, maxHeight: 700)
                        .padding(.horizontal, 200)
                        .padding(.vertical, 80)
                }
            }
    }

    // MARK: - Quality Panel Content

    /// Builds the quality/settings panel for the right-half overlay. The view
    /// itself supplies its own glass backdrop (matches `TVDetailsPanel`).
    @ViewBuilder
    private var qualityPanelContent: some View {
        if let playerService {
            let supportedFormats = playerService.currentBackendType.supportedFormats

            QualitySelectorView(
                streams: playerService.availableStreams.filter { stream in
                    let format = StreamFormat.detect(from: stream)
                    // DASH is never offered for manual selection
                    if format == .dash {
                        return false
                    }
                    return supportedFormats.contains(format)
                },
                captions: playerService.availableCaptions,
                currentStream: playerState?.currentStream,
                currentAudioStream: playerState?.currentAudioStream,
                currentCaption: playerService.currentCaption,
                isLoading: playerState?.playbackState == .loading,
                currentDownload: playerService.currentDownload,
                isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
                localCaptionURL: playerService.currentDownload.flatMap { download in
                    guard let path = download.localCaptionPath else { return nil }
                    return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
                },
                currentRate: playerState?.rate ?? .x1,
                isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
                embeddedAudioTracks: playerService.embeddedAudioTracks,
                embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
                currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
                currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
                embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
                onStreamSelected: { stream, audioStream in
                    switchToStream(stream, audioStream: audioStream)
                },
                onCaptionSelected: { caption in
                    playerService.loadCaption(caption)
                },
                onLoadOnlineStreams: {
                    Task {
                        await playerService.loadOnlineStreams()
                    }
                },
                onSwitchToOnlineStream: { stream, audioStream in
                    Task {
                        await playerService.switchToOnlineStream(stream, audioStream: audioStream)
                    }
                },
                onEmbeddedAudioTrackSelected: { trackID in
                    playerService.selectEmbeddedAudioTrack(trackID)
                },
                onEmbeddedSubtitleTrackSelected: { trackID in
                    playerService.selectEmbeddedSubtitleTrack(trackID)
                },
                onRateChanged: { rate in
                    playerState?.rate = rate
                    playerService.currentBackend?.rate = Float(rate.rawValue)
                },
                onAudioModeToggled: { enabled in
                    Task {
                        await playerService.setAudioMode(enabled)
                    }
                },
                onDismiss: { hideQualitySheet() }
            )
        }
    }

    // MARK: - MPV Content

    /// Custom MPV player view with custom controls.
    @ViewBuilder
    private var mpvPlayerContent: some View {
        ZStack {
            // Background - always focusable to capture remote events
            backgroundLayer

            // Video layer
            videoLayer

            // Controls overlay — always in tree, toggled via opacity so the fade-out
            // isn't skipped by tvOS's focus engine forcibly tearing down a focused
            // subview when the conditional flips to false. Focus is redirected to
            // the background Button after each hide so the remote touch area still
            // triggers showControls() instead of hitting a disabled hidden control.
            TVPlayerControlsView(
                playerState: playerState,
                playerService: playerService,
                focusedControl: $focusedControl,
                onShowSettings: { showQualitySheet() },
                onShowQueue: { showQueueSheet() },
                onShowDetails: { showDetailsPanel(tab: .info) },
                onShowComments: { showDetailsPanel(tab: .comments) },
                onShowDebug: { showDebugOverlay() },
                onClose: { closeVideo() },
                onTogglePlayPause: { handlePlayPause() },
                onScrubbingChanged: { scrubbing in
                    isScrubbing = scrubbing
                    if scrubbing {
                        stopControlsTimer()
                        wasPlayingBeforeScrub = playerService?.state.playbackState == .playing
                        if wasPlayingBeforeScrub {
                            playerService?.pause()
                        }
                    } else {
                        startControlsTimer()
                        if wasPlayingBeforeScrub {
                            playerService?.resume()
                        }
                        wasPlayingBeforeScrub = false
                    }
                },
                remoteSeekTime: scrubberRemoteSeekTime,
                onRemoteSeek: { forward in
                    triggerScrubberRemoteSeek(forward: forward)
                },
                cancelScrubTrigger: cancelScrubTrigger
            )
            .opacity(shouldShowControls ? 1 : 0)
            .allowsHitTesting(shouldShowControls)
            .disabled(!shouldShowControls)
            .animation(.easeInOut(duration: 0.25), value: shouldShowControls)

            // Right-side details panel (covers ~50% of screen)
            if isDetailsPanelVisible {
                GeometryReader { geo in
                    HStack(spacing: 0) {
                        Spacer(minLength: 0)
                        TVDetailsPanel(
                            video: playerState?.currentVideo,
                            initialTab: detailsPanelInitialTab,
                            onDismiss: { hideDetailsPanel() }
                        )
                        .frame(width: geo.size.width / 2)
                    }
                }
                .transition(.move(edge: .trailing).combined(with: .opacity))
            }

            // Right-side quality / settings panel (covers ~50% of screen)
            if showingQualitySheet {
                GeometryReader { geo in
                    HStack(spacing: 0) {
                        Spacer(minLength: 0)
                        qualityPanelContent
                            .frame(width: geo.size.width / 2)
                            // Ignore tvOS title-safe area on the trailing edge
                            // so panel content lines up symmetrically with the
                            // mid-screen leading edge instead of being inset
                            // from the physical right edge.
                            .ignoresSafeArea(.container, edges: .horizontal)
                            .focusSection()
                    }
                }
                .transition(.move(edge: .trailing).combined(with: .opacity))
            }

            // Right-side queue panel (covers ~50% of screen)
            if showingQueueSheet {
                GeometryReader { geo in
                    HStack(spacing: 0) {
                        Spacer(minLength: 0)
                        QueueManagementSheet(onDismiss: { hideQueueSheet() })
                            .frame(width: geo.size.width / 2)
                            .ignoresSafeArea(.container, edges: .horizontal)
                            .focusSection()
                    }
                }
                .transition(.move(edge: .trailing).combined(with: .opacity))
            }

            // Debug overlay
            if isDebugOverlayVisible {
                MPVDebugOverlay(
                    stats: debugStats,
                    isVisible: $isDebugOverlayVisible,
                    isLandscape: true
                )
                .transition(.opacity.combined(with: .scale(scale: 0.95)))
            }

            // Arrow-key seek feedback (when controls are hidden)
            if let feedback = currentTapFeedback {
                TapGestureFeedbackView(
                    action: feedback.action,
                    accumulatedSeconds: feedback.accumulated,
                    onComplete: {
                        currentTapFeedback = nil
                        executePendingSeek()
                    }
                )
                .id("\(feedback.action.actionType.rawValue)-\(feedback.position.rawValue)")
                .allowsHitTesting(false)
            }

            // Press-and-hold continuous seek (UIPress-based; routes to the
            // same accumulating seek functions as the discrete .onMoveCommand
            // path).
            TVRemoteHoldSeekOverlay(
                isActive: !isDetailsPanelVisible
                    && !isDebugOverlayVisible
                    && !showingQualitySheet
                    && !showingQueueSheet
                    && !isScrubbing
            ) { forward, step in
                if controlsVisible, !isScrubbing {
                    // Controls visible — mirror the discrete focused-bar
                    // path. We do NOT gate on focusedControl == .progressBar
                    // because tvOS focus may briefly drop while the window
                    // recognizer captures the press; the user's intent is
                    // clearly to scrub the visible bar.
                    triggerScrubberRemoteSeek(forward: forward, stepSeconds: step)
                } else {
                    // Controls hidden — accumulating overlay seek.
                    triggerRemoteSeek(forward: forward, stepSeconds: step)
                }
            }
            .allowsHitTesting(false)

            // Autoplay countdown overlay
            if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
                TVAutoplayCountdownView(
                    countdown: autoplayCountdown,
                    nextVideo: nextVideo,
                    onPlayNext: { playNextInQueue() },
                    onCancel: { cancelAutoplay() }
                )
                .transition(.opacity.combined(with: .scale(scale: 0.95)))
            }

            // Playback failure overlay
            if playerState?.isFailed == true {
                failedOverlay
                    .transition(.opacity)
            } else if playerState?.retryState.exhausted == true {
                retryExhaustedOverlay
                    .transition(.opacity)
            }
        }
        .animation(.easeInOut(duration: 0.25), value: playerState?.isFailed)
        .animation(.easeInOut(duration: 0.25), value: playerState?.retryState.exhausted)
        .onAppear {
            startControlsTimer()
            focusedControl = .progressBar
        }
        .onDisappear {
            stopControlsTimer()
            stopDebugUpdates()
            stopAutoplayCountdown()
            scrubberRemoteSeekTask?.cancel()
            scrubberRemoteSeekTask = nil
            scrubberRemoteSeek = nil
            scrubberRemoteSeekTime = nil
        }
        // Remote event handling - these work globally
        .onPlayPauseCommand {
            handlePlayPause()
        }
        .onExitCommand {
            handleMenuButton()
        }
        // Track focus changes to show controls when navigating
        .onChange(of: focusedControl) { oldValue, newValue in
            handleFocusChange(from: oldValue, to: newValue)
        }
        // Start auto-hide timer when playback starts, handle video ended
        .onChange(of: playerState?.playbackState) { _, newState in
            if newState == .playing && controlsVisible && !isScrubbing {
                startControlsTimer()
            } else if newState == .ended {
                handleVideoEnded()
            } else if case .failed = newState {
                handleVideoFailed()
            }
        }
        .onChange(of: playerState?.retryState.exhausted) { _, exhausted in
            if exhausted == true {
                handleVideoFailed()
            }
        }
        // Dismiss countdown if video changes during countdown (e.g., from remote control)
        .onChange(of: playerState?.currentVideo?.id) { _, _ in
            if showAutoplayCountdown {
                stopAutoplayCountdown()
                showControls()
            }
        }
        // When app returns to foreground (e.g. after auto-pause from background),
        // surface the controls so the user can immediately resume or navigate.
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .active && oldPhase != .active {
                showControls()
            }
        }
    }

    // MARK: - Failure Overlays

    /// Whether either failure overlay is currently visible.
    private var isFailureOverlayVisible: Bool {
        playerState?.isFailed == true || playerState?.retryState.exhausted == true
    }

    @ViewBuilder
    private var failedOverlay: some View {
        ZStack {
            Color.black.opacity(0.55)
                .ignoresSafeArea()

            VStack(spacing: 36) {
                VStack(spacing: 20) {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .font(.system(size: 64, weight: .semibold))
                        .foregroundStyle(.yellow)
                    if let message = playerState?.errorMessage, !message.isEmpty {
                        Text(message)
                            .font(.system(size: 24))
                            .foregroundStyle(.white.opacity(0.85))
                            .multilineTextAlignment(.center)
                            .lineLimit(3)
                            .frame(maxWidth: 1000)
                    }
                }
                .padding(.horizontal, 60)
                .padding(.vertical, 36)
                .glassBackground(.regular, in: .rect(cornerRadius: 28), fallback: .ultraThinMaterial)

                HStack(spacing: 32) {
                    failureButton(
                        title: String(localized: "player.error.button"),
                        systemImage: "info.circle",
                        focus: .errorDetails,
                        action: { showingErrorSheet = true }
                    )

                    failureButton(
                        title: String(localized: "player.error.retry"),
                        systemImage: "arrow.clockwise",
                        focus: .errorRetry,
                        action: { retryPlayback() }
                    )

                    if playerState?.nextQueuedVideo != nil {
                        failureButton(
                            title: String(localized: "player.autoplay.playNext"),
                            systemImage: "forward.fill",
                            focus: .errorPlayNext,
                            action: { playNextInQueue() }
                        )
                    } else {
                        failureButton(
                            title: String(localized: "player.close"),
                            systemImage: "xmark",
                            focus: .errorClose,
                            action: { closeVideo() }
                        )
                    }
                }
                .focusSection()
            }
        }
    }

    @ViewBuilder
    private var retryExhaustedOverlay: some View {
        ZStack {
            Color.black.opacity(0.55)
                .ignoresSafeArea()

            VStack(spacing: 36) {
                VStack(spacing: 12) {
                    Image(systemName: "arrow.clockwise.circle")
                        .font(.system(size: 56, weight: .semibold))
                        .foregroundStyle(.white.opacity(0.85))
                    Text(String(localized: "player.retry.button"))
                        .font(.system(size: 32, weight: .semibold))
                        .foregroundStyle(.white)
                }

                HStack(spacing: 32) {
                    failureButton(
                        title: String(localized: "player.error.retry"),
                        systemImage: "arrow.clockwise",
                        focus: .errorRetry,
                        action: { retryPlayback() }
                    )

                    failureButton(
                        title: String(localized: "player.close"),
                        systemImage: "xmark",
                        focus: .errorClose,
                        action: { closeVideo() }
                    )
                }
                .focusSection()
            }
        }
    }

    @ViewBuilder
    private func failureButton(
        title: String,
        systemImage: String,
        focus: TVPlayerFocusTarget,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            Label(title, systemImage: systemImage)
                .font(.system(size: 22, weight: .semibold))
                .foregroundStyle(.white)
                .frame(width: 320, height: 80)
        }
        .buttonStyle(TVFailureButtonStyle())
        .focused($focusedControl, equals: focus)
    }

    // MARK: - Failure Actions

    /// Restart playback of the current video from scratch.
    private func retryPlayback() {
        guard let playerService, let video = playerState?.currentVideo else { return }
        Task {
            await playerService.play(video: video)
        }
    }

    /// Called when playback enters the failed state or retries are exhausted.
    private func handleVideoFailed() {
        stopControlsTimer()
        stopAutoplayCountdown()
        if showingQualitySheet {
            hideQualitySheet()
        }
        if showingQueueSheet {
            hideQueueSheet()
        }
        if isDetailsPanelVisible {
            hideDetailsPanel()
        }
        withAnimation(.easeOut(duration: 0.25)) {
            controlsVisible = false
        }
        // Defer focus assignment so the overlay is in the tree before the focus
        // engine evaluates it.
        Task { @MainActor in
            try? await Task.sleep(for: .milliseconds(50))
            focusedControl = .errorRetry
        }
    }

    // MARK: - Background Layer

    @ViewBuilder
    private var backgroundLayer: some View {
        if !controlsVisible
            && !isDetailsPanelVisible
            && !isDebugOverlayVisible
            && !isFailureOverlayVisible
            && !showingQualitySheet
            && !showingQueueSheet {
            // When controls hidden, use a Button to capture both click and swipe
            Button {
                showControls()
            } label: {
                Color.black
                    .ignoresSafeArea()
            }
            .buttonStyle(TVBackgroundButtonStyle())
            .focused($focusedControl, equals: .background)
            .onMoveCommand { direction in
                handleBackgroundMoveCommand(direction)
            }
        } else {
            // When controls visible, just a plain background
            Color.black
                .ignoresSafeArea()
        }
    }

    // MARK: - Video Layer

    @ViewBuilder
    private var videoLayer: some View {
        let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
        let hasBackend = playerService?.currentBackend is MPVBackend

        ZStack {
            if let playerService,
               let backend = playerService.currentBackend as? MPVBackend,
               let playerState {
                MPVRenderViewRepresentable(
                    backend: backend,
                    playerState: playerState
                )
                .ignoresSafeArea()
                .allowsHitTesting(false)
                // Keep the render view alive but hidden for audio-only
                // playback so toggling audio mode doesn't tear down GL state
                .opacity(isAudioOnly ? 0 : 1)
            }

            // Thumbnail for audio-only playback and the pre-backend loading state
            if isAudioOnly || !hasBackend,
               let video = playerState?.currentVideo,
               !video.thumbnailURLsByQuality.isEmpty {
                FallbackLazyImage(urls: video.thumbnailURLsByQuality) { state in
                    if let image = state.image {
                        image
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                    } else {
                        Color.black
                    }
                }
                .allowsHitTesting(false)
            }
        }
    }

    // MARK: - Focus Handling

    private func handleFocusChange(from oldValue: TVPlayerFocusTarget?, to newValue: TVPlayerFocusTarget?) {
        // If focus moved to a control, ensure controls are visible
        if let newValue, newValue != .background {
            if !controlsVisible {
                showControls()
            }
            startControlsTimer()
        }
    }

    // MARK: - Derived State

    /// Whether the primary controls overlay should be visible right now.
    private var shouldShowControls: Bool {
        controlsVisible
            && !isDetailsPanelVisible
            && !isDebugOverlayVisible
            && !isFailureOverlayVisible
            && !showingQualitySheet
            && !showingQueueSheet
    }

    // MARK: - Controls Timer

    private func startControlsTimer() {
        stopControlsTimer()

        // Don't auto-hide if paused
        guard playerState?.playbackState == .playing else { return }

        controlsHideTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
            Task { @MainActor in
                withAnimation(.easeOut(duration: 0.25)) {
                    controlsVisible = false
                }
                // After controlsVisible flips, the backgroundLayer Button is in the
                // tree and focusable — move focus to it so the remote touch area
                // calls showControls() instead of a hidden/disabled control.
                focusedControl = .background
            }
        }
    }

    private func stopControlsTimer() {
        controlsHideTimer?.invalidate()
        controlsHideTimer = nil
    }

    private func showControls() {
        withAnimation(.easeIn(duration: 0.2)) {
            controlsVisible = true
        }
        if focusedControl == .background || focusedControl == nil {
            focusedControl = .progressBar
        }
        startControlsTimer()
    }

    // MARK: - Details Panel

    private func showDetailsPanel(tab: TVDetailsTab = .info) {
        stopControlsTimer()
        detailsPanelInitialTab = tab
        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
            isDetailsPanelVisible = true
            controlsVisible = false
        }
    }

    // MARK: - Quality Sheet

    private func showQualitySheet() {
        stopControlsTimer()
        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
            showingQualitySheet = true
            controlsVisible = false
        }
    }

    private func hideQualitySheet() {
        withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
            showingQualitySheet = false
        }
        showControls()
    }

    private func showQueueSheet() {
        stopControlsTimer()
        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
            showingQueueSheet = true
            controlsVisible = false
        }
    }

    private func hideQueueSheet() {
        withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
            showingQueueSheet = false
        }
        showControls()
    }

    private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
        Task {
            await playerService?.selectStreamManually(stream, audioStream: audioStream)
        }
    }

    private func hideDetailsPanel() {
        withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
            isDetailsPanelVisible = false
        }
        showControls()
    }

    // MARK: - Debug Overlay

    private func showDebugOverlay() {
        stopControlsTimer()
        startDebugUpdates()
        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
            isDebugOverlayVisible = true
            controlsVisible = false
        }
    }

    private func hideDebugOverlay() {
        stopDebugUpdates()
        withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
            isDebugOverlayVisible = false
        }
        showControls()
    }

    private func startDebugUpdates() {
        stopDebugUpdates()
        guard let backend = playerService?.currentBackend as? MPVBackend else { return }

        // Update immediately
        debugStats = backend.getDebugStats()

        // Then update every second
        debugUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            Task { @MainActor in
                guard let backend = self.playerService?.currentBackend as? MPVBackend else { return }
                self.debugStats = backend.getDebugStats()
            }
        }
    }

    private func stopDebugUpdates() {
        debugUpdateTimer?.invalidate()
        debugUpdateTimer = nil
    }

    // MARK: - Remote Event Handlers

    private func handlePlayPause() {
        // Cancel countdown if visible
        if showAutoplayCountdown {
            stopAutoplayCountdown()
            showControls()
            return
        }

        // Show controls if hidden (but not if any overlay is visible), then toggle playback
        if !controlsVisible
            && !isDetailsPanelVisible
            && !isDebugOverlayVisible
            && !showingQualitySheet
            && !showingQueueSheet {
            showControls()
        }

        playerService?.togglePlayPause()

        // Reset timer when interacting (only if controls are visible)
        if !isDebugOverlayVisible {
            if playerState?.playbackState == .playing {
                startControlsTimer()
            } else {
                stopControlsTimer()
            }
        }
    }

    /// Handles D-pad / arrow presses while controls are hidden.
    /// Left/right trigger an accumulating seek with on-screen feedback; up/down reveal controls.
    private func handleBackgroundMoveCommand(_ direction: MoveCommandDirection) {
        switch direction {
        case .left:
            triggerRemoteSeek(forward: false)
        case .right:
            triggerRemoteSeek(forward: true)
        case .up, .down:
            showControls()
        @unknown default:
            showControls()
        }
    }

    /// Triggers a seek action from the remote, accumulating a signed net
    /// offset across rapid presses. A reverse press within the active window
    /// subtracts from the pending offset instead of restarting from the
    /// current playback time (e.g. right, right, left from 30s → +10s → 40s,
    /// not −10s → 20s).
    private func triggerRemoteSeek(forward: Bool, stepSeconds: Int = 10) {
        let currentTime = playerState?.currentTime ?? 0
        let duration = playerState?.duration ?? 0

        // Current signed offset from any in-flight accumulation.
        let currentNet: Int = pendingSeek.map { $0.isForward ? $0.seconds : -$0.seconds } ?? 0
        let step = forward ? stepSeconds : -stepSeconds
        let rawNet = currentNet + step

        // Clamp to the available seekable range in either direction.
        let maxForward = Int(max(0, duration - currentTime))
        let maxBackward = Int(max(0, currentTime))
        let clampedNet = min(max(rawNet, -maxBackward), maxForward)

        let netMagnitude = abs(clampedNet)
        let netIsForward = clampedNet >= 0

        let action: TapGestureAction = netIsForward
            ? .seekForward(seconds: stepSeconds)
            : .seekBackward(seconds: stepSeconds)
        let position: TapZonePosition = netIsForward ? .right : .left

        currentTapFeedback = (action, position, netMagnitude)
        pendingSeek = (isForward: netIsForward, seconds: netMagnitude)
    }

    /// Accumulating arrow-key seek for when the progress bar is focused and
    /// controls are visible. Suppresses the circular feedback overlay — the
    /// visible scrubber shows the pending target instead — and uses the same
    /// signed net-offset accumulation as the hidden-controls flow.
    private func triggerScrubberRemoteSeek(forward: Bool, stepSeconds: Int = 10) {
        let currentTime = playerState?.currentTime ?? 0
        let duration = playerState?.duration ?? 0

        // Keep controls on-screen while the user is arrow-seeking.
        stopControlsTimer()

        // Current signed offset from any in-flight accumulation.
        let currentNet: Int = scrubberRemoteSeek.map { $0.isForward ? $0.seconds : -$0.seconds } ?? 0
        let step = forward ? stepSeconds : -stepSeconds
        let rawNet = currentNet + step

        let maxForward = Int(max(0, duration - currentTime))
        let maxBackward = Int(max(0, currentTime))
        let clampedNet = min(max(rawNet, -maxBackward), maxForward)

        let netMagnitude = abs(clampedNet)
        let netIsForward = clampedNet >= 0

        // When the seek is clamped at the edge of the seekable range,
        // successive ticks would write the same values; skip the @State
        // assignments to avoid spurious SwiftUI invalidations.
        if scrubberRemoteSeek?.isForward != netIsForward
            || scrubberRemoteSeek?.seconds != netMagnitude
        {
            scrubberRemoteSeek = (isForward: netIsForward, seconds: netMagnitude)
        }
        let newSeekTime = currentTime + TimeInterval(clampedNet)
        if scrubberRemoteSeekTime != newSeekTime {
            scrubberRemoteSeekTime = newSeekTime
        }

        scrubberRemoteSeekTask?.cancel()
        scrubberRemoteSeekTask = Task { @MainActor in
            try? await Task.sleep(for: .milliseconds(1000))
            guard !Task.isCancelled else { return }
            commitScrubberRemoteSeek()
        }
    }

    /// Commits the debounced accumulating arrow-key seek for the focused bar.
    private func commitScrubberRemoteSeek() {
        guard let seek = scrubberRemoteSeek else { return }
        scrubberRemoteSeek = nil
        scrubberRemoteSeekTime = nil
        scrubberRemoteSeekTask = nil

        if seek.seconds > 0 {
            if seek.isForward {
                playerService?.seekForward(by: TimeInterval(seek.seconds))
            } else {
                playerService?.seekBackward(by: TimeInterval(seek.seconds))
            }
        }

        // Resume the auto-hide timer now that the user is done seeking.
        startControlsTimer()
    }

    /// Commits the pending seek when the feedback overlay finishes its dismiss animation.
    private func executePendingSeek() {
        guard let seek = pendingSeek else { return }
        pendingSeek = nil
        guard seek.seconds > 0 else { return }

        if seek.isForward {
            playerService?.seekForward(by: TimeInterval(seek.seconds))
        } else {
            playerService?.seekBackward(by: TimeInterval(seek.seconds))
        }
    }

    private func handleMenuButton() {
        if showingErrorSheet {
            // Top priority: close the error details sheet
            showingErrorSheet = false
        } else if isFailureOverlayVisible {
            // While the failure overlay is up, Menu closes the video so the
            // user isn't stranded with no working remote affordance.
            closeVideo()
        } else if showAutoplayCountdown {
            // First priority: cancel countdown
            cancelAutoplay()
        } else if isDebugOverlayVisible {
            // Second: hide debug overlay
            hideDebugOverlay()
        } else if showingQualitySheet {
            // Third: hide quality / settings panel
            hideQualitySheet()
        } else if showingQueueSheet {
            // Fourth: hide queue panel
            hideQueueSheet()
        } else if isDetailsPanelVisible {
            // Fifth: hide details panel
            hideDetailsPanel()
        } else if isScrubbing {
            // Fourth: cancel scrub without seeking, then hide controls. The
            // subsequent focus-loss path sees cleared scrub state and no-ops.
            cancelScrubTrigger = UUID()
            hideControls()
        } else if controlsVisible {
            // Fifth: hide controls
            hideControls()
        } else if appEnvironment?.settingsManager.tvOSMenuButtonClosesVideo == true {
            // Sixth (Menu-closes mode): fully close the video like the xmark button
            closeVideo()
        } else {
            // Sixth: dismiss player (controls already hidden)
            dismissPlayer()
        }
    }

    private func hideControls() {
        stopControlsTimer()
        withAnimation(.easeOut(duration: 0.25)) {
            controlsVisible = false
        }
        // Focus is redirected outside withAnimation to keep the fade-out animation
        // from being skipped, and to keep the remote touch area pointing at the
        // background Button's showControls() action rather than a hidden control.
        focusedControl = .background
    }

    private func closeVideo() {
        playerState?.isClosingVideo = true
        appEnvironment?.queueManager.clearQueue()
        playerService?.stop()
        appEnvironment?.navigationCoordinator.isPlayerExpanded = false
        dismiss()
    }

    private func dismissPlayer() {
        // Collapse the player but keep it alive so audio continues in the background
        // and the "Now Playing" sidebar entry can restore the session. Matches the
        // iOS/macOS ExpandedPlayerWindow dismissal path, which also does not stop()
        // on dismiss.
        appEnvironment?.navigationCoordinator.isPlayerExpanded = false
        dismiss()
    }

    // MARK: - Autoplay Countdown

    private func handleVideoEnded() {
        // Hide controls immediately
        stopControlsTimer()
        withAnimation(.easeOut(duration: 0.25)) {
            controlsVisible = false
        }

        // Repeat one: PlayerService restarts the video itself - no countdown, no replay controls
        if playerState?.queueMode == .repeatOne {
            return
        }

        // Check if autoplay is enabled and there's a next video
        let autoPlayEnabled = appEnvironment?.settingsManager.queueAutoPlayNext ?? true
        let hasNextVideo = playerState?.hasNext ?? false

        if autoPlayEnabled && hasNextVideo {
            startAutoplayCountdown()
        } else {
            // No next video or autoplay disabled - show controls with replay option
            showControls()
        }
    }

    private func startAutoplayCountdown() {
        stopAutoplayCountdown()

        // Get countdown duration from settings (default: 5 seconds, range: 1-15)
        let countdownDuration = appEnvironment?.settingsManager.queueAutoPlayCountdown ?? 5
        autoplayCountdown = countdownDuration

        withAnimation(.easeIn(duration: 0.3)) {
            showAutoplayCountdown = true
        }

        // Start countdown timer
        autoplayTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            Task { @MainActor in
                if self.autoplayCountdown > 1 {
                    self.autoplayCountdown -= 1
                } else {
                    // Countdown finished - play next video
                    self.stopAutoplayCountdown()
                    self.playNextInQueue()
                }
            }
        }
    }

    private func stopAutoplayCountdown() {
        autoplayTimer?.invalidate()
        autoplayTimer = nil

        withAnimation(.easeOut(duration: 0.2)) {
            showAutoplayCountdown = false
        }
    }

    private func playNextInQueue() {
        stopAutoplayCountdown()

        Task {
            await playerService?.playNext()
        }
    }

    private func cancelAutoplay() {
        stopAutoplayCountdown()

        // Show controls so user can replay or manually navigate
        showControls()
    }
}

// MARK: - Background Button Style

/// Invisible button style for the background - no visual feedback, just captures input.
struct TVBackgroundButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
    }
}

/// Glass-backed button style used by the playback failure overlay.
/// Scales on focus and brightens the glass material to indicate selection.
struct TVFailureButtonStyle: ButtonStyle {
    @Environment(\.isFocused) private var isFocused

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .glassBackground(
                isFocused ? .tinted(.white.opacity(0.25)) : .regular,
                in: .capsule,
                fallback: isFocused ? .ultraThickMaterial : .ultraThinMaterial
            )
            .scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.08 : 1.0))
            .animation(.easeInOut(duration: 0.15), value: isFocused)
            .animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
    }
}

#endif
