//
//  PlayerControlsView.swift
//  Yattee
//
//  Custom player controls overlay for iOS with play/pause, seek, and time display.
//

import SwiftUI

#if os(iOS)

// MARK: - Controls Theme Modifier

/// A view modifier that applies the controls theme color scheme.
private struct ControlsThemeModifier: ViewModifier {
    let theme: ControlsTheme
    @Environment(\.colorScheme) private var systemColorScheme

    func body(content: Content) -> some View {
        if let forcedScheme = theme.colorScheme {
            content.environment(\.colorScheme, forcedScheme)
        } else {
            content.environment(\.colorScheme, systemColorScheme)
        }
    }
}

struct PlayerControlsView: View {
    @Environment(\.appEnvironment) private var appEnvironment
    @Bindable var playerState: PlayerState
    let onPlayPause: () -> Void
    let onSeek: (TimeInterval) async -> Void
    let onSeekForward: (TimeInterval) async -> Void
    let onSeekBackward: (TimeInterval) async -> Void
    var onToggleFullscreen: (() -> Void)? = nil
    var isFullscreen: Bool = false
    var isWidescreenVideo: Bool = false
    var onClose: (() -> Void)? = nil
    var onTogglePiP: (() -> Void)? = nil
    var onToggleDebug: (() -> Void)? = nil
    var isWideScreenLayout: Bool = false
    var onTogglePanel: (() -> Void)? = nil
    var isPanelVisible: Bool = true
    var panelSide: FloatingPanelSide = .right
    var isPanelPinned: Bool = false
    /// Safe area padding from layout (when panel is pinned)
    var layoutLeadingSafeArea: CGFloat = 0
    var layoutTrailingSafeArea: CGFloat = 0
    var onToggleOrientationLock: (() -> Void)? = nil
    var isOrientationLocked: Bool = false
    var onToggleDetailsVisibility: (() -> Void)? = nil
    var onShowSettings: (() -> Void)? = nil
    /// Callback to play next video in queue
    var onPlayNext: (() async -> Void)? = nil
    /// Callback to play previous video in queue
    var onPlayPrevious: (() async -> Void)? = nil
    /// Callback to show queue management sheet
    var onShowQueue: (() -> Void)? = nil
    /// When true, forces controls to start hidden (user must tap to reveal)
    var forceInitialHidden: Bool = false

    /// Video area top offset from screen top (for positioning controls within video bounds)
    var videoAreaTop: CGFloat = 0
    /// Video area height (for positioning controls within video bounds, nil = use full geometry height)
    var videoAreaHeight: CGFloat? = nil
    /// Video fit height for slider sizing (stable during drag, unlike videoAreaHeight)
    var videoFitHeight: CGFloat? = nil

    /// Callback when volume changes (value 0.0-1.0)
    var onVolumeChanged: ((Float) -> Void)? = nil
    /// Callback when mute state toggles
    var onMuteToggled: (() -> Void)? = nil

    // MARK: - Video Context Properties (for share, playlist, captions, context menu)

    /// Currently playing video (for share, playlist, context menu)
    var currentVideo: Video? = nil
    /// Available captions for captions button
    var availableCaptions: [Caption] = []
    /// Currently selected caption
    var currentCaption: Caption? = nil
    /// Available streams for quality selector (when showing captions)
    var availableStreams: [Stream] = []
    /// Current video stream
    var currentStream: Stream? = nil
    /// Current audio stream
    var currentAudioStream: Stream? = nil
    /// Callback when playback rate changes
    var onRateChanged: ((PlaybackRate) -> Void)? = nil
    /// Callback when caption is selected
    var onCaptionSelected: ((Caption?) -> Void)? = nil
    /// Callback when stream is selected (for quality selector integration)
    var onStreamSelected: ((Stream, Stream?) -> Void)? = nil
    /// Embedded (in-container) tracks reported by mpv for the loaded file
    var embeddedAudioTracks: [MPVTrack] = []
    var embeddedSubtitleTracks: [MPVTrack] = []
    var currentEmbeddedAudioTrackID: Int? = nil
    var currentEmbeddedSubtitleTrackID: Int? = nil
    var embeddedVideoTrack: MPVTrack? = nil
    /// Callback when an embedded audio track is selected
    var onEmbeddedAudioTrackSelected: ((Int) -> Void)? = nil
    /// Callback when an embedded subtitle track is selected (nil = off)
    var onEmbeddedSubtitleTrackSelected: ((Int?) -> Void)? = nil

    /// Current panscan value (0.0 = fit, 1.0 = fill)
    var panscanValue: Double = 0.0
    /// Whether panscan change is currently allowed
    var isPanscanAllowed: Bool = false
    /// Callback to toggle panscan between 0 and 1
    var onTogglePanscan: (() -> Void)? = nil

    // MARK: - Sheet State

    @State private var showingPlaylistSheet = false
    @State private var showingCaptionsSheet = false
    @State private var showingVideoTrackSheet = false
    @State private var showingAudioTrackSheet = false
    @State private var showingChaptersSheet = false

    private var showPlayerAreaDebug: Bool {
        appEnvironment?.settingsManager.showPlayerAreaDebug ?? false
    }

    private var isDismissGestureActive: Bool {
        appEnvironment?.navigationCoordinator.isPlayerDismissGestureActive ?? false
    }

    @State private var isDragging = false
    @State private var dragProgress: Double = 0
    @State private var isPendingSeek = false
    /// Target progress for pending seek - used to detect when playerState.progress converges
    @State private var targetSeekProgress: Double = 0
    @State private var hideTimer: Timer?
    @State private var showControls: Bool?
    @State private var isAdjustingVolume = false
    @State private var isAdjustingBrightness = false
    /// Tracks brightness value for side slider (needed because UIScreen.main.brightness isn't observable)
    @State private var currentBrightness: Double = UIScreen.main.brightness
    @State private var playNextTapCount = 0
    /// Track safe area to force re-render when it changes
    @State private var currentSafeArea: UIEdgeInsets = .zero
    /// Active player controls layout from preset (passed from parent to avoid flashing during view recreation)
    var activeLayout: PlayerControlsLayout = .default
    /// Animation trigger for seek backward button
    @State private var seekBackwardTrigger = false
    /// Animation trigger for seek forward button
    @State private var seekForwardTrigger = false

    // MARK: - Gesture State

    /// Gesture action handler for seek accumulation
    private let gestureActionHandler = PlayerGestureActionHandler()
    /// Current tap gesture feedback to display
    @State private var currentTapFeedback: (action: TapGestureAction, position: TapZonePosition, accumulated: Int?)?
    /// Pending seek to execute when feedback completes (direction, accumulated seconds)
    @State private var pendingSeek: (isForward: Bool, seconds: Int)?

    // MARK: - Seek Gesture State

    /// Whether the seek gesture is currently active (shared with NavigationCoordinator for mutual exclusion with pinch gesture)
    private var isSeekGestureActive: Bool {
        get { appEnvironment?.navigationCoordinator.isSeekGestureActive ?? false }
        nonmutating set { appEnvironment?.navigationCoordinator.isSeekGestureActive = newValue }
    }
    /// Preview time during seek gesture
    @State private var seekGesturePreviewTime: TimeInterval = 0
    /// Current time when seek gesture started (for relative seeking)
    @State private var seekGestureStartTime: TimeInterval = 0
    /// Screen width for seek calculation (captured from geometry)
    @State private var screenWidth: CGFloat = 0
    /// Whether boundary haptic was already triggered during current gesture
    @State private var seekGestureBoundaryHapticTriggered = false

    /// Controls visibility - only show when user interacts or explicitly pauses
    private var shouldShowControls: Bool {
        if let showControls {
            return showControls
        }
        // If forced hidden, require explicit tap to show
        if forceInitialHidden {
            return false
        }
        // Initial state: show if paused, hide otherwise (including during loading/buffering)
        return playerState.playbackState == .paused
    }

    var body: some View {
        GeometryReader { geometry in

            ZStack {
                // Gesture overlay (active when controls are hidden)
                gestureOverlayLayer(geometry: geometry)

                // Tap to show/hide controls (fallback when gestures disabled)
                if !gesturesEffectivelyEnabled {
                    Color.clear
                        .contentShape(Rectangle())
                        .onTapGesture {
                            toggleControlsVisibility()
                        }
                        .allowsHitTesting(playerState.playbackState != .ended && !playerState.isFailed)
                }

                // Controls overlay
                controlsOverlay
                    .frame(width: geometry.size.width, height: geometry.size.height)
                    .opacity(shouldShowControls ? 1 : 0)
                    .allowsHitTesting(shouldShowControls)
                    .modifier(ControlsThemeModifier(theme: activeLayout.globalSettings.theme))

                // Gesture feedback overlays
                gestureFeedbackLayer(geometry: geometry)
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
        .animation(.easeInOut(duration: 0.2), value: shouldShowControls)
        .onChange(of: playerState.playbackState) { oldState, newState in
            // When playback starts after being paused/loading, start hide timer if controls are showing
            if newState == .playing && shouldShowControls {
                startHideTimer()
            }
            // Hide controls when video ends (ended overlay takes over)
            if newState == .ended {
                showControls = false
                cancelHideTimer()
            }
            // Hide controls when video fails (failed overlay takes over)
            if case .failed = newState {
                showControls = false
                cancelHideTimer()
            }
        }
        .onChange(of: playerState.retryState.exhausted) { _, exhausted in
            // Hide controls when retries are exhausted (retry exhausted overlay takes over)
            if exhausted {
                showControls = false
                cancelHideTimer()
            }
        }
        .onChange(of: forceInitialHidden) { _, hidden in
            // Reset controls visibility when forced hidden (e.g., during fullscreen transition)
            if hidden {
                showControls = nil
                cancelHideTimer()
            }
        }
        .onChange(of: showControls) { _, newValue in
            // Sync local controls visibility to playerState for other views to observe
            playerState.controlsVisible = newValue ?? (playerState.playbackState == .paused)
        }
        .onAppear {
            // Update safe area on appear to ensure correct layout
            currentSafeArea = windowSafeArea
            // Sync initial controls visibility to playerState
            playerState.controlsVisible = shouldShowControls
        }
        .onGeometryChange(for: CGSize.self, of: { $0.size }) { _ in
            // Update safe area when geometry changes (rotation)
            currentSafeArea = windowSafeArea
        }
        .onChange(of: activeLayout.effectiveGesturesSettings.panscanGesture.snapToEnds, initial: true) { _, newValue in
            // Sync panscan snap setting to navigation coordinator (initial: true ensures sync on first render)
            appEnvironment?.navigationCoordinator.shouldSnapPanscan = newValue
        }
        .onReceive(NotificationCenter.default.publisher(for: UIScreen.brightnessDidChangeNotification)) { _ in
            currentBrightness = UIScreen.main.brightness
        }
        .sheet(isPresented: $showingPlaylistSheet) {
            if let video = currentVideo {
                PlaylistSelectorSheet(video: video)
            }
        }
        .sheet(isPresented: $showingCaptionsSheet) {
            captionsSheet
        }
        .sheet(isPresented: $showingVideoTrackSheet) {
            videoTrackSheet
        }
        .sheet(isPresented: $showingAudioTrackSheet) {
            audioTrackSheet
        }
        .sheet(isPresented: $showingChaptersSheet) {
            chaptersSheet
        }
    }

    // MARK: - Track Selector Sheets

    @ViewBuilder
    private var captionsSheet: some View {
        QualitySelectorView(
            streams: [],
            captions: availableCaptions,
            currentStream: nil,
            currentCaption: currentCaption,
            initialTab: .subtitles,
            showTabPicker: false,
            embeddedSubtitleTracks: embeddedSubtitleTracks,
            currentEmbeddedSubtitleTrackID: currentEmbeddedSubtitleTrackID,
            onStreamSelected: { _, _ in },
            onCaptionSelected: { caption in
                onCaptionSelected?(caption)
            },
            onEmbeddedSubtitleTrackSelected: { trackID in
                onEmbeddedSubtitleTrackSelected?(trackID)
            }
        )
    }

    @ViewBuilder
    private var videoTrackSheet: some View {
        QualitySelectorView(
            streams: availableStreams,
            captions: [],
            currentStream: currentStream,
            currentAudioStream: currentAudioStream,
            initialTab: .video,
            showTabPicker: false,
            embeddedVideoTrack: embeddedVideoTrack,
            onStreamSelected: { stream, audioStream in
                onStreamSelected?(stream, audioStream)
            },
            onCaptionSelected: { _ in }
        )
    }

    @ViewBuilder
    private var audioTrackSheet: some View {
        QualitySelectorView(
            streams: availableStreams,
            captions: [],
            currentStream: currentStream,
            currentAudioStream: currentAudioStream,
            initialTab: .audio,
            showTabPicker: false,
            embeddedAudioTracks: embeddedAudioTracks,
            currentEmbeddedAudioTrackID: currentEmbeddedAudioTrackID,
            onStreamSelected: { stream, audioStream in
                onStreamSelected?(stream, audioStream)
            },
            onCaptionSelected: { _ in },
            onEmbeddedAudioTrackSelected: { trackID in
                onEmbeddedAudioTrackSelected?(trackID)
            }
        )
    }

    @ViewBuilder
    private var chaptersSheet: some View {
        ChaptersView(
            chapters: playerState.chapters,
            currentTime: playerState.currentTime,
            storyboard: playerState.preferredStoryboard,
            onChapterTap: { chapter in
                await onSeek(chapter.startTime)
            }
        )
    }

    // MARK: - Controls Overlay

    /// Whether running on iPad
    private var isIPad: Bool {
        UIDevice.current.userInterfaceIdiom == .pad
    }

    /// Get safe area from window (works even when view ignores safe area)
    private var windowSafeArea: UIEdgeInsets {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .first?
            .windows
            .first?
            .safeAreaInsets ?? .zero
    }

    /// Whether to show in-app volume controls (only when volume mode is .mpv)
    private var showVolumeControls: Bool {
        GlobalLayoutSettings.cached.volumeMode == .mpv
    }

    /// Build the consolidated actions for the section renderer
    private var controlsActions: PlayerControlsActions {
        PlayerControlsActions(
            playerState: playerState,
            isWideScreenLayout: isWideScreenLayout,
            isFullscreen: isFullscreen,
            isWidescreenVideo: isWidescreenVideo,
            isOrientationLocked: isOrientationLocked,
            isPanelVisible: isPanelVisible,
            isPanelPinned: isPanelPinned,
            panelSide: panelSide,
            isIPad: isIPad,
            showVolumeControls: showVolumeControls,
            showDebugButton: onToggleDebug != nil,
            showCloseButton: onClose != nil,
            currentVideo: currentVideo,
            availableCaptions: availableCaptions,
            currentCaption: currentCaption,
            availableStreams: availableStreams,
            currentStream: currentStream,
            currentAudioStream: currentAudioStream,
            embeddedAudioTracks: embeddedAudioTracks,
            embeddedSubtitleTracks: embeddedSubtitleTracks,
            currentEmbeddedAudioTrackID: currentEmbeddedAudioTrackID,
            currentEmbeddedSubtitleTrackID: currentEmbeddedSubtitleTrackID,
            panscanValue: panscanValue,
            isPanscanAllowed: isPanscanAllowed,
            isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,
            isAudioModeEnabled: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
            yatteeServerURL: appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url,
            deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider,
            onClose: onClose,
            onToggleDebug: onToggleDebug,
            onTogglePiP: onTogglePiP,
            onToggleFullscreen: onToggleFullscreen,
            onToggleDetailsVisibility: onToggleDetailsVisibility,
            onToggleOrientationLock: onToggleOrientationLock,
            onTogglePanel: onTogglePanel,
            onTogglePanscan: onTogglePanscan,
            onToggleAutoPlayNext: {
                appEnvironment?.settingsManager.queueAutoPlayNext.toggle()
            },
            onToggleAudioMode: { [weak appEnvironment] in
                guard let appEnvironment else { return }
                let enabled = !appEnvironment.settingsManager.audioOnlyModeEnabled
                Task { await appEnvironment.playerService.setAudioMode(enabled) }
            },
            onShowSettings: onShowSettings,
            onPlayNext: onPlayNext,
            onPlayPrevious: onPlayPrevious,
            onPlayPause: onPlayPause,
            onSeekForward: { seconds in await onSeekForward(seconds) },
            onSeekBackward: { seconds in await onSeekBackward(seconds) },
            onVolumeChanged: onVolumeChanged,
            onMuteToggled: onMuteToggled,
            onCancelHideTimer: { [self] in cancelHideTimer() },
            onResetHideTimer: { [self] in resetHideTimer() },
            onSliderAdjustmentChanged: { isAdjusting in
                appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders = isAdjusting
            },
            onRateChanged: onRateChanged,
            onCaptionSelected: onCaptionSelected,
            onShowPlaylistSelector: { [self] in showingPlaylistSheet = true },
            onShowQueue: onShowQueue,
            onShowCaptionsSelector: { [self] in showingCaptionsSheet = true },
            onShowChaptersSelector: { [self] in showingChaptersSheet = true },
            onShowVideoTrackSelector: { [self] in showingVideoTrackSheet = true },
            onShowAudioTrackSelector: { [self] in showingAudioTrackSheet = true },
            onControlsLockToggled: { locked in
                playerState.isControlsLocked = locked
            }
        )
    }

    private var controlsOverlay: some View {
        ZStack {
            // Background fill - extends into safe areas (except during dismiss gesture)
            if isDismissGestureActive {
                Color.black.opacity(0.5)
                    .allowsHitTesting(false)
            } else {
                Color.black.opacity(0.5)
                    .ignoresSafeArea()
                    .allowsHitTesting(false)
            }

            // Controls layout - explicitly fill parent bounds
            controlsVStack
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }

    private var controlsVStack: some View {
        // Use currentSafeArea state to ensure view updates when safe area changes
        let safeArea = currentSafeArea
        let basePadding: CGFloat = 12

        // Vertical padding - always apply in widescreen (layout ignores safe area)
        let topPadding: CGFloat = 2 + (isWideScreenLayout ? safeArea.top + 8 : 0)

        // Bottom padding - add safe area when controls are at screen bottom in fullscreen modes
        // When floating panel is visible (isPanelVisible && !isPanelPinned),
        // bottomExtraPadding handles positioning, so no safe area needed
        // In landscape (widescreen), cap the safe area since home indicator needs less clearance
        let floatingPanelActive = isPanelVisible && !isPanelPinned
        let needsBottomSafeArea = (isWideScreenLayout || isFullscreen) && !floatingPanelActive
        let bottomSafeArea = isWideScreenLayout ? min(safeArea.bottom, 12) : safeArea.bottom
        let bottomPadding: CGFloat = 2 + (needsBottomSafeArea ? bottomSafeArea : 0)

        // Horizontal padding - depends on panel state
        // When pinned, use layout-provided safe areas which account for panel and Dynamic Island
        let leftPadding: CGFloat
        let rightPadding: CGFloat

        // When panel is pinned AND visible, the controls frame is already constrained by layout
        // so we only need base padding (no additional safe area padding needed)
        // When panel is hidden or not pinned, controls must handle safe areas themselves
        let frameAlreadySafeAreaAdjusted = isWideScreenLayout && isPanelPinned && isPanelVisible

        if isWideScreenLayout {
            if frameAlreadySafeAreaAdjusted {
                // Frame is already positioned/sized to avoid panel and Dynamic Island
                // Just use base padding for aesthetics
                leftPadding = basePadding
                rightPadding = basePadding
            } else {
                // Panel not visible or not pinned - controls handle all safe areas (symmetric with max)
                let safeAreaPadding = max(safeArea.left, safeArea.right) + 4
                leftPadding = basePadding + safeAreaPadding
                rightPadding = basePadding + safeAreaPadding
            }
        } else {
            leftPadding = basePadding
            rightPadding = basePadding
        }

        let safeAreaOffset = isWideScreenLayout ? (safeArea.top - safeArea.bottom) / 2 : 0

        let centerSettings = activeLayout.centerSettings
        let buttonBackground = activeLayout.globalSettings.buttonBackground
        let theme = activeLayout.globalSettings.theme

        return GeometryReader { geometry in
            // Detect compact vertical mode for wide aspect ratio videos with limited height
            let isCompactVertical = geometry.size.height < 200

            // Video area bounds - use provided values or default to full geometry
            let effectiveVideoTop = videoAreaTop
            let effectiveVideoHeight = videoAreaHeight ?? geometry.size.height
            let effectiveVideoBottom = effectiveVideoTop + effectiveVideoHeight

            // Calculate center offset for video area (to position center controls within video bounds)
            let videoCenterY = effectiveVideoTop + effectiveVideoHeight / 2
            let screenCenterY = geometry.size.height / 2
            let centerYOffset = videoCenterY - screenCenterY

            // Only apply safeAreaOffset when no explicit video area bounds are provided
            // (i.e., when videoAreaHeight is nil and we're using full geometry)
            let hasExplicitVideoArea = videoAreaHeight != nil
            let effectiveSafeAreaOffset = hasExplicitVideoArea ? 0 : safeAreaOffset

            // Extra padding at bottom to account for space below video area
            let bottomExtraPadding = geometry.size.height - effectiveVideoBottom

            ZStack {
                // Top bar - aligned to screen top (stays fixed regardless of video area)
                VStack {
                    topBar
                        .padding(.top, topPadding)
                        .padding(.leading, leftPadding)
                        .padding(.trailing, rightPadding)
                        .applyCornerAdaptationOffset()
                        .overlay {
                            if showPlayerAreaDebug {
                                Rectangle().stroke(Color.green, lineWidth: 1)
                            }
                        }
                    Spacer()
                }

                // Calculate horizontal space for center controls scaling
                let sliderWidth: CGFloat = 36
                let leftSliderOccupies = centerSettings.leftSlider != .disabled ? sliderWidth + leftPadding : 0
                let rightSliderOccupies = centerSettings.rightSlider != .disabled ? sliderWidth + rightPadding : 0
                let availableForCenter = geometry.size.width - leftSliderOccupies - rightSliderOccupies - 24 // 24pt margin

                let requiredWidth = centerControlsRequiredWidth(
                    compact: isCompactVertical,
                    hasBackground: buttonBackground.glassStyle != nil,
                    settings: centerSettings
                )
                let horizontalScale = requiredWidth > 0 ? min(1.0, max(0.6, availableForCenter / requiredWidth)) : 1.0

                // Center controls - centered within video area
                centerControls(compact: isCompactVertical, scale: horizontalScale)
                    .offset(y: centerYOffset - effectiveSafeAreaOffset)

                // Vertical side sliders - constrained to video area
                // Position using same offset as center controls for proper vertical alignment
                // Rendered BEFORE bottom bar so seek preview appears above sliders
                if !isCompactVertical {
                    // Calculate available height for sliders
                    // Use full video area height for sliders (extends into letterbox areas)
                    let sliderBasisHeight = min(effectiveVideoHeight, geometry.size.height)
                    let baseSliderAreaHeight = sliderBasisHeight - 48 - 100
                    // When videoFitHeight provided (panel/drag): use stable sizing within video
                    // When nil (fullscreen): target 250pt sliders when there's extra space
                    let isFullscreenWithSpace = videoFitHeight == nil && geometry.size.height > sliderBasisHeight * 1.5
                    let sliderAreaHeight: CGFloat = isFullscreenWithSpace
                        ? min(250, geometry.size.height - 200)
                        : max(80, baseSliderAreaHeight)

                    HStack {
                        // Left slider
                        if centerSettings.leftSlider != .disabled {
                            verticalSideSlider(
                                type: centerSettings.leftSlider,
                                isLeading: true,
                                buttonBackground: buttonBackground,
                                theme: theme
                            )
                            .padding(.leading, leftPadding)
                        }

                        Spacer()

                        // Right slider
                        if centerSettings.rightSlider != .disabled {
                            verticalSideSlider(
                                type: centerSettings.rightSlider,
                                isLeading: false,
                                buttonBackground: buttonBackground,
                                theme: theme
                            )
                            .padding(.trailing, rightPadding)
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .frame(height: sliderAreaHeight)
                    // Use same offset as center controls for proper vertical alignment
                    .offset(y: centerYOffset - effectiveSafeAreaOffset)
                }

                // Bottom bar - at screen bottom in fullscreen, at video area bottom when panel visible
                // Rendered after side sliders so seek preview appears above them
                VStack {
                    Spacer()
                    bottomBar
                        // Only apply bottomExtraPadding when floating panel is visible (to stay above panel)
                        // When panel is pinned (side panel), bottom bar stays at screen bottom
                        .padding(.bottom, bottomPadding + (isPanelVisible && !isPanelPinned ? bottomExtraPadding : 0))
                        .padding(.leading, leftPadding)
                        .padding(.trailing, rightPadding)
                        .overlay {
                            if showPlayerAreaDebug {
                                Rectangle().stroke(Color.blue, lineWidth: 1)
                            }
                        }
                }

                // DEBUG: Show internal padding values when setting enabled
                // Position at bottom-left to avoid overlap with yellow layout debug
                if showPlayerAreaDebug {
                    VStack(alignment: .leading, spacing: 2) {
                        Text(verbatim: "Controls (cyan):")
                            .fontWeight(.bold)
                        Text(verbatim: "winSA T:\(Int(safeArea.top)) B:\(Int(safeArea.bottom)) L:\(Int(safeArea.left)) R:\(Int(safeArea.right))")
                        Text(verbatim: "topPad: \(Int(topPadding)) btmPad: \(Int(bottomPadding)) wide: \(isWideScreenLayout ? "Y" : "N")")
                        Text(verbatim: "geom W:\(Int(geometry.size.width)) H:\(Int(geometry.size.height))")
                        Text(verbatim: "vidTop: \(Int(effectiveVideoTop)) vidH: \(Int(effectiveVideoHeight))")
                        Text(verbatim: "centerOff: \(Int(centerYOffset)) btmExtra: \(Int(bottomExtraPadding))")
                        Text(verbatim: "vis: \(isPanelVisible ? "Y" : "N") fitH: \(Int(videoFitHeight ?? -1)) effVH: \(Int(effectiveVideoHeight))")
                    }
                    .font(.system(size: 9, weight: .medium, design: .monospaced))
                    .foregroundStyle(.cyan)
                    .padding(4)
                    .background(.black.opacity(0.8))
                    .position(x: 120, y: geometry.size.height - 120)
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }

    // MARK: - Top Bar

    private var topBar: some View {
        ControlsSectionRenderer(
            section: activeLayout.topSection,
            actions: controlsActions,
            globalSettings: activeLayout.globalSettings,
            controlsVisible: shouldShowControls
        )
    }

    // MARK: - Center Controls

    private func centerControls(compact: Bool, scale: CGFloat = 1.0) -> some View {
        let settings = activeLayout.centerSettings
        let buttonBackground = activeLayout.globalSettings.buttonBackground
        let theme = activeLayout.globalSettings.theme
        let hasBackground = buttonBackground.glassStyle != nil

        // Base sizes - scale down in compact mode to prevent overlapping with top/bottom bars
        let baseSpacing: CGFloat
        let baseSeekButtonSize: CGFloat
        let basePlayButtonSize: CGFloat
        let baseSeekFontSize: CGFloat
        let basePlayFontSize: CGFloat

        if compact {
            baseSpacing = 16
            baseSeekButtonSize = 36
            basePlayButtonSize = 48
            baseSeekFontSize = 20
            basePlayFontSize = 32
        } else {
            // Normal sizes - slightly larger when backgrounds are enabled
            baseSpacing = hasBackground ? 24 : 16
            baseSeekButtonSize = hasBackground ? 64 : 56
            basePlayButtonSize = hasBackground ? 82 : 72
            baseSeekFontSize = 36
            basePlayFontSize = 56
        }

        // Apply horizontal scale factor
        let spacing = baseSpacing * scale
        let seekButtonSize = baseSeekButtonSize * scale
        let playButtonSize = basePlayButtonSize * scale
        let seekFontSize = baseSeekFontSize * scale
        let playFontSize = basePlayFontSize * scale

        return HStack(spacing: spacing) {
            // Skip backward - conditionally shown based on layout settings
            if settings.showSeekBackward {
                Button {
                    seekBackwardTrigger.toggle()
                    Task { await onSeekBackward(TimeInterval(settings.seekBackwardSeconds)) }
                    resetHideTimer()
                } label: {
                    centerButtonContent(
                        systemImage: settings.seekBackwardSystemImage,
                        fontSize: seekFontSize,
                        frameSize: seekButtonSize,
                        buttonBackground: buttonBackground,
                        theme: theme
                    )
                }
                .symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekBackwardTrigger)
                .disabled(isTransportDisabled || playerState.isControlsLocked)
                .opacity(playerState.isControlsLocked ? 0.5 : 1.0)
            }

            // Play/Pause - conditionally shown based on layout settings
            if settings.showPlayPause {
                if !isTransportDisabled && !playerState.isControlsLocked {
                    Button {
                        let wasPaused = playerState.playbackState == .paused
                        onPlayPause()
                        // Keep controls visible; start hide timer when resuming playback
                        showControls = true
                        if wasPaused {
                            resetHideTimer()
                        }
                    } label: {
                        centerButtonContent(
                            systemImage: playPauseIcon,
                            fontSize: playFontSize,
                            frameSize: playButtonSize,
                            buttonBackground: buttonBackground,
                            theme: theme
                        )
                    }
                    .accessibilityIdentifier("player.playPauseButton")
                    .accessibilityLabel("player.playPauseButton")
                } else if playerState.isControlsLocked {
                    // Show dimmed play/pause when locked
                    centerButtonContent(
                        systemImage: playPauseIcon,
                        fontSize: playFontSize,
                        frameSize: playButtonSize,
                        buttonBackground: buttonBackground,
                        theme: theme
                    )
                    .opacity(0.5)
                } else {
                    // Invisible spacer maintains layout stability when transport disabled
                    Color.clear
                        .frame(width: playButtonSize, height: playButtonSize)
                        .allowsHitTesting(false)
                }
            }

            // Skip forward - conditionally shown based on layout settings
            if settings.showSeekForward {
                Button {
                    seekForwardTrigger.toggle()
                    Task { await onSeekForward(TimeInterval(settings.seekForwardSeconds)) }
                    resetHideTimer()
                } label: {
                    centerButtonContent(
                        systemImage: settings.seekForwardSystemImage,
                        fontSize: seekFontSize,
                        frameSize: seekButtonSize,
                        buttonBackground: buttonBackground,
                        theme: theme
                    )
                }
                .symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekForwardTrigger)
                .disabled(isTransportDisabled || playerState.isControlsLocked)
                .opacity(playerState.isControlsLocked ? 0.5 : 1.0)
            }
        }
    }

    /// Calculates the required width for center controls based on current settings.
    private func centerControlsRequiredWidth(compact: Bool, hasBackground: Bool, settings: CenterSectionSettings) -> CGFloat {
        let spacing: CGFloat = compact ? 16 : (hasBackground ? 24 : 16)
        let seekSize: CGFloat = compact ? 36 : (hasBackground ? 64 : 56)
        let playSize: CGFloat = compact ? 48 : (hasBackground ? 82 : 72)

        var width: CGFloat = 0
        var buttonCount = 0
        if settings.showSeekBackward { width += seekSize; buttonCount += 1 }
        if settings.showPlayPause { width += playSize; buttonCount += 1 }
        if settings.showSeekForward { width += seekSize; buttonCount += 1 }
        if buttonCount > 1 { width += CGFloat(buttonCount - 1) * spacing }
        return width
    }

    /// Creates content for center control buttons with optional glass background.
    @ViewBuilder
    private func centerButtonContent(
        systemImage: String,
        fontSize: CGFloat,
        frameSize: CGFloat,
        buttonBackground: ButtonBackgroundStyle,
        theme: ControlsTheme
    ) -> some View {
        if let glassStyle = buttonBackground.glassStyle {
            Image(systemName: systemImage)
                .contentTransition(.symbolEffect(.replace, options: .speed(2)))
                .font(.system(size: fontSize))
                .foregroundStyle(.white)
                .frame(width: frameSize, height: frameSize)
                .glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
                .contentShape(Circle())
        } else {
            Image(systemName: systemImage)
                .contentTransition(.symbolEffect(.replace, options: .speed(2)))
                .font(.system(size: fontSize))
                .foregroundStyle(.white)
                .frame(width: frameSize, height: frameSize)
                .contentShape(Rectangle())
        }
    }

    /// Whether transport controls should be disabled (during loading/buffering or buffer not ready)
    private var isTransportDisabled: Bool {
        playerState.isTransportDisabled
    }

    private var playPauseIcon: String {
        switch playerState.playbackState {
        case .playing:
            return "pause.fill"
        default:
            return "play.fill"
        }
    }

    // MARK: - Bottom Bar

    private var bottomBar: some View {
        VStack(spacing: 4) {
            // Progress bar (stays hardcoded - not customizable)
            progressBar

            // Dynamic button row from layout configuration
            ControlsSectionRenderer(
                section: activeLayout.bottomSection,
                actions: controlsActions,
                globalSettings: activeLayout.globalSettings,
                controlsVisible: shouldShowControls
            )
        }
    }

    private var progressBar: some View {
        GeometryReader { geometry in
            ZStack(alignment: .bottomLeading) {
                if playerState.isLive {
                    // For live streams, show simple red indicator (no scrubbing)
                    Rectangle()
                        .fill(.red.opacity(0.6))
                        .frame(height: 4)
                        .clipShape(Capsule())
                } else {
                    // Regular VOD progress bar with chapter segments
                    SegmentedProgressBar(
                        chapters: activeLayout.progressBarSettings.showChapters ? playerState.chapters : [],
                        duration: playerState.duration,
                        currentTime: (isDragging || isPendingSeek) ? dragProgress * playerState.duration : playerState.currentTime,
                        bufferedTime: playerState.bufferedTime,
                        height: 4,
                        gapWidth: 2,
                        playedColor: activeLayout.progressBarSettings.playedColor.color,
                        bufferedColor: .white.opacity(0.5),
                        backgroundColor: .white.opacity(0.3),
                        sponsorSegments: playerState.sponsorSegments,
                        sponsorBlockSettings: activeLayout.progressBarSettings.sponsorBlockSettings
                    )
                    .clipShape(Capsule())

                    // Scrubber handle - only visible while dragging, zooms in/out
                    Circle()
                        .fill(activeLayout.progressBarSettings.playedColor.color)
                        .frame(width: 20, height: 20)
                        .scaleEffect(isDragging ? 1.0 : 0.0)
                        .opacity(isDragging ? 1 : 0)
                        .offset(x: geometry.size.width * dragProgress - 10, y: 8)
                        .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDragging)
                        .allowsHitTesting(false)
                }
            }
            .frame(height: 20)
            .contentShape(Rectangle())
            // Only allow interaction for non-live streams and when controls are not locked
            .allowsHitTesting(!playerState.isLive && !playerState.isControlsLocked)
            .opacity(playerState.isControlsLocked ? 0.5 : 1.0)
            // Seek preview as overlay - doesn't affect layout
            .overlay {
                if !playerState.isLive, let storyboard = playerState.preferredStoryboard {
                    seekPreviewOverlay(storyboard: storyboard, geometry: geometry)
                } else if !playerState.isLive, isDragging {
                    seekTimePreviewOverlay(geometry: geometry)
                }
            }
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { value in
                        guard !playerState.isLive, !playerState.isControlsLocked else { return }
                        isDragging = true
                        let progress = max(0, min(1, value.location.x / geometry.size.width))
                        dragProgress = progress
                        cancelHideTimer()
                    }
                    .onEnded { value in
                        guard !playerState.isLive, !playerState.isControlsLocked else { return }
                        let progress = max(0, min(1, value.location.x / geometry.size.width))
                        dragProgress = progress
                        targetSeekProgress = progress
                        isDragging = false
                        isPendingSeek = true
                        let seekTime = progress * playerState.duration
                        Task { await onSeek(seekTime) }
                        resetHideTimer()
                    }
            )
            .accessibilityIdentifier("player.progressBar")
            .background {
                GeometryReader { geo in
                    Color.clear
                        .onAppear {
                            appEnvironment?.navigationCoordinator.progressBarFrame = geo.frame(in: .global)
                        }
                        .onChange(of: geo.frame(in: .global)) { _, newFrame in
                            appEnvironment?.navigationCoordinator.progressBarFrame = newFrame
                        }
                }
            }
            .onChange(of: playerState.progress) { _, newProgress in
                // Clear isPendingSeek when playerState.progress converges to target (within 2%)
                if isPendingSeek && abs(newProgress - targetSeekProgress) < 0.02 {
                    isPendingSeek = false
                }
            }
        }
        .frame(height: 20)
    }

    @ViewBuilder
    private func seekPreviewOverlay(storyboard: Storyboard, geometry: GeometryProxy) -> some View {
        if isDragging {
            let seekTime = dragProgress * playerState.duration
            // Use fixed display dimensions (160x90) for positioning, not storyboard resolution
            let previewWidth: CGFloat = 160 + 16 // thumbnail width + padding
            let previewHeight: CGFloat = 90
            let halfWidth = previewWidth / 2
            let xPosition = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * dragProgress))
            let yPosition = -previewHeight / 2 - 12

            SeekPreviewView(
                storyboard: storyboard,
                seekTime: seekTime,
                storyboardService: StoryboardService.shared,
                buttonBackground: activeLayout.globalSettings.buttonBackground,
                theme: activeLayout.globalSettings.theme
            )
            .position(x: xPosition, y: yPosition)
            .transition(.opacity.combined(with: .scale(scale: 0.9)))
            .animation(.easeInOut(duration: 0.15), value: isDragging)

            // Chapter capsule follows storyboard x but clamps to screen edges
            if let chapter = chapterForSeekTime(seekTime) {
                let capsuleY = yPosition - previewHeight / 2 - 6 - 12
                ChapterCapsuleView(
                    title: chapter.title,
                    buttonBackground: activeLayout.globalSettings.buttonBackground
                )
                .positioned(xTarget: xPosition, availableWidth: geometry.size.width)
                .position(x: geometry.size.width / 2, y: capsuleY)
                .transition(.opacity.combined(with: .scale(scale: 0.9)))
                .animation(.easeInOut(duration: 0.15), value: isDragging)
            }
        }
    }

    @ViewBuilder
    private func seekTimePreviewOverlay(geometry: GeometryProxy) -> some View {
        let seekTime = dragProgress * playerState.duration
        let previewWidth: CGFloat = 80
        let halfWidth = previewWidth / 2
        let xPosition = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * dragProgress))
        let yPosition: CGFloat = -20

        SeekTimePreviewView(
            seekTime: seekTime,
            buttonBackground: activeLayout.globalSettings.buttonBackground,
            theme: activeLayout.globalSettings.theme
        )
        .position(x: xPosition, y: yPosition)
        .transition(.opacity.combined(with: .scale(scale: 0.9)))
        .animation(.easeInOut(duration: 0.15), value: isDragging)

        if let chapter = chapterForSeekTime(seekTime) {
            let capsuleY = yPosition - 24
            ChapterCapsuleView(
                title: chapter.title,
                buttonBackground: activeLayout.globalSettings.buttonBackground
            )
            .positioned(xTarget: xPosition, availableWidth: geometry.size.width)
            .position(x: geometry.size.width / 2, y: capsuleY)
            .transition(.opacity.combined(with: .scale(scale: 0.9)))
            .animation(.easeInOut(duration: 0.15), value: isDragging)
        }
    }

    private func chapterForSeekTime(_ seekTime: TimeInterval) -> VideoChapter? {
        playerState.chapters.last { $0.startTime <= seekTime }
    }

    // MARK: - Timer Management

    private func toggleControlsVisibility() {
        showControls = !shouldShowControls
        if shouldShowControls {
            startHideTimer()
        }
    }

    private func startHideTimer() {
        cancelHideTimer()
        hideTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
            Task { @MainActor in
                if playerState.playbackState == .playing {
                    showControls = false
                }
            }
        }
    }

    private func resetHideTimer() {
        startHideTimer()
    }

    private func cancelHideTimer() {
        hideTimer?.invalidate()
        hideTimer = nil
    }

    // MARK: - Gesture Handling

    /// Whether gestures are effectively enabled (master toggle + controls hidden).
    private var gesturesEffectivelyEnabled: Bool {
        let settings = activeLayout.effectiveGesturesSettings
        return settings.hasActiveGestures
    }

    /// Whether gestures should currently respond (controls hidden, not in failed/ended state).
    private var gesturesActive: Bool {
        // Disable gestures when controls are locked
        if playerState.isControlsLocked {
            return false
        }
        return gesturesEffectivelyEnabled &&
            !shouldShowControls &&
            playerState.playbackState != .ended &&
            !playerState.isFailed
    }

    @ViewBuilder
    private func gestureOverlayLayer(geometry: GeometryProxy) -> some View {
        let settings = activeLayout.effectiveGesturesSettings

        // Only render gesture overlay during normal playback
        // When video ends or fails, don't intercept taps - let ended/failed overlays handle them
        if gesturesEffectivelyEnabled && playerState.playbackState != .ended && !playerState.isFailed {
            PlayerGestureOverlay(
                settings: settings,
                isActive: gesturesActive,
                isSeekable: !playerState.isLive,
                onTapAction: { action, position in
                    handleTapGestureAction(action, position: position)
                },
                onSingleTap: {
                    toggleControlsVisibility()
                },
                onSeekGestureStarted: {
                    handleSeekGestureStarted(screenWidth: geometry.size.width)
                },
                onSeekGestureChanged: { horizontalDelta in
                    handleSeekGestureChanged(horizontalDelta: horizontalDelta)
                },
                onSeekGestureEnded: { horizontalDelta in
                    handleSeekGestureEnded(horizontalDelta: horizontalDelta)
                },
                isPinchGestureActive: { [weak appEnvironment] in
                    appEnvironment?.navigationCoordinator.isPinchGestureActive ?? false
                },
                isPanelDragging: { [weak appEnvironment] in
                    appEnvironment?.navigationCoordinator.isPanelDragging ?? false
                }
            )
        }
    }

    @ViewBuilder
    private func gestureFeedbackLayer(geometry: GeometryProxy) -> some View {
        ZStack {
            // Tap feedback
            if let feedback = currentTapFeedback {
                TapGestureFeedbackView(
                    action: feedback.action,
                    accumulatedSeconds: feedback.accumulated,
                    onComplete: {
                        currentTapFeedback = nil
                        executePendingSeek()
                    }
                )
                // Stable identity based on action type + position (not accumulated value)
                // This prevents view recreation when accumulated seconds changes
                .id("\(feedback.action.actionType.rawValue)-\(feedback.position.rawValue)")
            }

            // Seek gesture preview (top-aligned)
            GeometryReader { gestureGeometry in
                VStack {
                    GestureSeekPreviewView(
                        storyboard: playerState.preferredStoryboard,
                        seekTime: seekGesturePreviewTime,
                        duration: playerState.duration,
                        storyboardService: StoryboardService.shared,
                        buttonBackground: activeLayout.globalSettings.buttonBackground,
                        theme: activeLayout.globalSettings.theme,
                        chapters: playerState.chapters,
                        isActive: isSeekGestureActive,
                        availableWidth: gestureGeometry.size.width
                    )
                    .padding(.top, 16)
                    Spacer()
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
            .safeAreaPadding(.top)
        }
        // Allow taps to pass through feedback visuals to gesture recognizer below
        .allowsHitTesting(false)
    }

    private func handleTapGestureAction(_ action: TapGestureAction, position: TapZonePosition) {
        Task {
            // Update player state for clamping calculations
            await gestureActionHandler.updatePlayerState(
                currentTime: playerState.currentTime,
                duration: playerState.duration
            )

            // Check if switching seek direction or performing non-seek action - cancel pending seek
            switch action {
            case .seekForward:
                // Switching from backward to forward - cancel (option B)
                if pendingSeek?.isForward == false {
                    pendingSeek = nil
                    currentTapFeedback = nil
                    await gestureActionHandler.cancelAccumulation()
                }
            case .seekBackward:
                // Switching from forward to backward - cancel (option B)
                if pendingSeek?.isForward == true {
                    pendingSeek = nil
                    currentTapFeedback = nil
                    await gestureActionHandler.cancelAccumulation()
                }
            default:
                // Non-seek action cancels any pending seek
                if pendingSeek != nil {
                    pendingSeek = nil
                    currentTapFeedback = nil
                    await gestureActionHandler.cancelAccumulation()
                }
            }

            let result = await gestureActionHandler.handleTapAction(action, position: position)

            // Show feedback
            await MainActor.run {
                currentTapFeedback = (action, position, result.accumulatedSeconds)
            }

            // Handle the action
            switch action {
            case .togglePlayPause:
                // Execute immediately
                onPlayPause()

            case .seekForward(let seconds):
                // Defer execution - update pending seek
                let seekSeconds = result.accumulatedSeconds ?? seconds
                await MainActor.run {
                    pendingSeek = (isForward: true, seconds: seekSeconds)
                }

            case .seekBackward(let seconds):
                // Defer execution - update pending seek
                let seekSeconds = result.accumulatedSeconds ?? seconds
                await MainActor.run {
                    pendingSeek = (isForward: false, seconds: seekSeconds)
                }

            case .toggleFullscreen:
                // Execute immediately, using the same decision logic as the fullscreen button
                controlsActions.performFullscreenTap()

            case .togglePiP:
                // Execute immediately
                onTogglePiP?()

            case .playNext:
                // Execute immediately
                await onPlayNext?()

            case .playPrevious:
                // Not implemented yet - would need onPlayPrevious callback
                break

            case .cyclePlaybackSpeed:
                // Execute immediately
                let currentSpeed = playerState.rate.rawValue
                let nextSpeed = await gestureActionHandler.nextPlaybackSpeed(currentSpeed: currentSpeed)
                if let newRate = PlaybackRate(rawValue: nextSpeed) {
                    onRateChanged?(newRate)
                }

            case .toggleMute:
                // Execute immediately
                onMuteToggled?()
            }
        }
    }

    /// Executes the pending seek action when feedback completes.
    private func executePendingSeek() {
        guard let seek = pendingSeek else { return }
        pendingSeek = nil

        // Don't seek if accumulated time is 0 (already at boundary)
        guard seek.seconds > 0 else { return }

        Task {
            if seek.isForward {
                await onSeekForward(TimeInterval(seek.seconds))
            } else {
                await onSeekBackward(TimeInterval(seek.seconds))
            }

            // Reset accumulation in handler
            await gestureActionHandler.cancelAccumulation()
        }
    }

    // MARK: - Seek Gesture Handlers

    /// Called when horizontal seek gesture is recognized (after activation threshold).
    private func handleSeekGestureStarted(screenWidth: CGFloat) {
        // Capture initial state
        seekGestureStartTime = playerState.currentTime
        seekGesturePreviewTime = playerState.currentTime
        self.screenWidth = screenWidth
        seekGestureBoundaryHapticTriggered = false
        isSeekGestureActive = true

        // Trigger activation haptic
        appEnvironment?.settingsManager.triggerHapticFeedback(for: .seekGestureActivation)
    }

    /// Called during seek gesture with cumulative horizontal delta.
    private func handleSeekGestureChanged(horizontalDelta: CGFloat) {
        guard isSeekGestureActive, screenWidth > 0 else { return }

        let settings = activeLayout.effectiveGesturesSettings.seekGesture

        // Calculate raw delta for continuous preview (even if below minimum threshold)
        let rawDelta = Double(horizontalDelta / screenWidth) * settings.sensitivity.baseSecondsPerScreenWidth *
            SeekGestureCalculator.calculateDurationMultiplier(videoDuration: playerState.duration)

        // Clamp to boundaries
        let clampResult = SeekGestureCalculator.clampSeekTime(
            currentTime: seekGestureStartTime,
            seekDelta: rawDelta,
            duration: playerState.duration
        )

        // Update preview time
        seekGesturePreviewTime = clampResult.seekTime

        // Trigger boundary haptic if needed (only once per boundary hit)
        if clampResult.hitBoundary && !seekGestureBoundaryHapticTriggered {
            appEnvironment?.settingsManager.triggerHapticFeedback(for: .seekGestureBoundary)
            seekGestureBoundaryHapticTriggered = true
        } else if !clampResult.hitBoundary {
            // Reset if moved away from boundary
            seekGestureBoundaryHapticTriggered = false
        }
    }

    /// Called when seek gesture ends with final horizontal delta.
    private func handleSeekGestureEnded(horizontalDelta: CGFloat) {
        guard isSeekGestureActive else { return }

        let settings = activeLayout.effectiveGesturesSettings.seekGesture

        // Calculate final seek delta
        let seekDelta = SeekGestureCalculator.calculateSeekDelta(
            dragDistance: horizontalDelta,
            screenWidth: screenWidth,
            videoDuration: playerState.duration,
            sensitivity: settings.sensitivity
        )

        // Reset state
        isSeekGestureActive = false

        // Only seek if delta meets minimum threshold
        guard let delta = seekDelta else { return }

        // Clamp final seek time
        let clampResult = SeekGestureCalculator.clampSeekTime(
            currentTime: seekGestureStartTime,
            seekDelta: delta,
            duration: playerState.duration
        )

        // Execute the seek
        Task {
            await onSeek(clampResult.seekTime)
        }
    }

    // MARK: - Vertical Side Sliders

    /// Renders a vertical slider on the left or right edge of the player.
    @ViewBuilder
    private func verticalSideSlider(
        type: SideSliderType,
        isLeading: Bool,
        buttonBackground: ButtonBackgroundStyle,
        theme: ControlsTheme
    ) -> some View {
        if type != .disabled {
            VStack(spacing: 4) {
                // Tappable icon at top with expanded tap area
                Button {
                    handleSliderIconTap(type: type)
                } label: {
                    sliderIcon(type: type)
                        .font(.system(size: 14, weight: .medium))
                        .foregroundStyle(type == .volume && playerState.isMuted ? .red : .white)
                        .frame(width: 28, height: 28)
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)

                // Vertical slider
                verticalSliderControl(type: type)
            }
            .padding(.top, 4)
            .padding(.bottom, 8)
            .padding(.horizontal, 4)
            .frame(width: 36)
            .modifier(SideSliderBackgroundModifier(buttonBackground: buttonBackground, theme: theme))
            .opacity(playerState.isControlsLocked ? 0.5 : 1.0)
            .allowsHitTesting(!playerState.isControlsLocked)
        }
    }

    /// Returns the appropriate icon for the slider using variable value SF Symbols.
    @ViewBuilder
    private func sliderIcon(type: SideSliderType) -> some View {
        switch type {
        case .volume:
            if playerState.isMuted {
                Image(systemName: "speaker.slash.fill")
            } else {
                Image(systemName: "speaker.wave.3.fill", variableValue: Double(playerState.volume))
            }
        case .brightness:
            Image(systemName: "sun.max.fill", variableValue: currentBrightness)
        case .disabled:
            EmptyView()
        }
    }

    /// Handles tap on the slider icon.
    private func handleSliderIconTap(type: SideSliderType) {
        switch type {
        case .volume:
            // Toggle mute
            onMuteToggled?()
            resetHideTimer()

        case .brightness:
            // Cycle to next brightness preset
            cycleBrightnessPreset()
            resetHideTimer()

        case .disabled:
            break
        }
    }

    /// Cycles brightness to the next preset value (0, 25, 50, 75, 100%).
    /// Always goes to the next higher preset from current position, wrapping from 100 to 0.
    private func cycleBrightnessPreset() {
        let presets: [Double] = [0.0, 0.25, 0.5, 0.75, 1.0]
        let current = UIScreen.main.brightness

        // Find next higher preset (with small tolerance for floating point)
        let newValue: Double
        if let next = presets.first(where: { $0 > current + 0.01 }) {
            newValue = next
        } else {
            // At or above 100%, wrap to 0
            newValue = 0.0
        }

        UIScreen.main.brightness = newValue
        currentBrightness = newValue
    }

    /// Creates the appropriate vertical slider control based on type.
    @ViewBuilder
    private func verticalSliderControl(type: SideSliderType) -> some View {
        switch type {
        case .volume:
            VerticalSlider(
                value: Binding(
                    get: { Double(playerState.volume) },
                    set: { newValue in
                        // Auto-unmute when dragging volume slider
                        if playerState.isMuted {
                            onMuteToggled?()
                        }
                        playerState.volume = Float(newValue)
                        onVolumeChanged?(Float(newValue))
                    }
                ),
                onEditingChanged: { editing in
                    // Disable sheet dismiss gesture while adjusting slider
                    appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders = editing
                    if editing {
                        cancelHideTimer()
                    } else {
                        resetHideTimer()
                    }
                }
            )
        case .brightness:
            VerticalSlider(
                value: Binding(
                    get: { currentBrightness },
                    set: { newValue in
                        UIScreen.main.brightness = newValue
                        currentBrightness = newValue
                    }
                ),
                onEditingChanged: { editing in
                    // Disable sheet dismiss gesture while adjusting slider
                    appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders = editing
                    if editing {
                        cancelHideTimer()
                    } else {
                        resetHideTimer()
                    }
                }
            )
        case .disabled:
            EmptyView()
        }
    }
}

// MARK: - Vertical Slider Component

/// A vertical slider control for volume/brightness adjustment.
private struct VerticalSlider: View {
    @Binding var value: Double
    var range: ClosedRange<Double> = 0...1
    var onEditingChanged: ((Bool) -> Void)?

    @State private var isDragging = false
    /// Local value tracked during drag for immediate visual feedback
    @State private var dragValue: Double?

    /// The display value - uses local drag value during drag, otherwise the binding
    private var displayValue: Double {
        dragValue ?? value
    }

    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: .bottom) {
                // Track background
                Capsule()
                    .fill(.white.opacity(0.3))

                // Fill
                Capsule()
                    .fill(.white)
                    .frame(height: geometry.size.height * normalizedValue)
            }
            .frame(width: 4)
            .frame(maxWidth: .infinity)
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { gesture in
                        if !isDragging {
                            isDragging = true
                            dragValue = value
                            onEditingChanged?(true)
                        }
                        // Invert Y since 0 is at top
                        let newValue = 1.0 - (gesture.location.y / geometry.size.height)
                        let clampedValue = min(max(newValue, range.lowerBound), range.upperBound)
                        dragValue = clampedValue
                        value = clampedValue
                    }
                    .onEnded { _ in
                        isDragging = false
                        dragValue = nil
                        onEditingChanged?(false)
                    }
            )
        }
        .frame(width: 32)
        .frame(minHeight: 40, maxHeight: 180)
    }

    private var normalizedValue: CGFloat {
        let rangeSize = range.upperBound - range.lowerBound
        guard rangeSize > 0 else { return 0 }
        return (displayValue - range.lowerBound) / rangeSize
    }
}

// MARK: - Corner Adaptation Offset (iPadOS 26+ Stage Manager)

extension View {
    /// Applies corner adaptation offset for iPadOS 26+ Stage Manager window controls.
    /// This prevents the video info bar from colliding with traffic lights when the window is resized.
    @ViewBuilder
    func applyCornerAdaptationOffset() -> some View {
        if #available(iOS 26, *) {
            self.containerCornerOffset(.leading, sizeToFit: true)
        } else {
            self
        }
    }
}

// MARK: - Side Slider Background Modifier

/// Applies glass background to side sliders when enabled.
private struct SideSliderBackgroundModifier: ViewModifier {
    let buttonBackground: ButtonBackgroundStyle
    let theme: ControlsTheme

    func body(content: Content) -> some View {
        if let glassStyle = buttonBackground.glassStyle {
            content
                .glassBackground(
                    glassStyle,
                    in: .capsule,
                    fallback: .ultraThinMaterial,
                    colorScheme: theme.colorScheme
                )
        } else {
            // No background when "None" is selected
            content
        }
    }
}

// MARK: - Preview

#Preview {
    ZStack {
        Color.black

        PlayerControlsView(
            playerState: PlayerState(),
            onPlayPause: {},
            onSeek: { _ in },
            onSeekForward: { _ in },
            onSeekBackward: { _ in }
        )
    }
    .aspectRatio(16/9, contentMode: .fit)
}

#Preview("Slider") {
    @Previewable @State var value: Double = 50
    
    ZStack {
        Color.black
        VerticalSlider(value: $value, range: 0...100)
            .border(Color.red, width: 3)
    }
}

#endif
