//
//  MacOSPlayerControlsView.swift
//  Yattee
//
//  QuickTime-style player controls for macOS with hover-to-show behavior and keyboard shortcuts.
//

#if os(macOS)

import AppKit
import SwiftUI

/// QuickTime-style player controls overlay for macOS.
/// Shows condensed control bar on hover, hides when mouse leaves.
struct MacOSPlayerControlsView: View {
    @Environment(\.appEnvironment) private var appEnvironment
    @Bindable var playerState: PlayerState

    // MARK: - Callbacks

    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
    /// Collapses the expanded player (Esc in the inline overlay presentation).
    /// When nil, Esc outside fullscreen passes through to the responder chain.
    var onCollapse: (() -> Void)? = nil
    var onClose: (() -> Void)? = nil
    var onTogglePiP: (() -> Void)? = nil
    var onPlayNext: (() async -> Void)? = nil
    var onPlayPrevious: (() async -> Void)? = nil
    var onVolumeChanged: ((Float) -> Void)? = nil
    var onMuteToggled: (() -> Void)? = nil
    var onShowSettings: (() -> Void)? = nil
    var onShowQueue: (() -> Void)? = nil
    var onShowPlaylistSelector: (() -> Void)? = nil
    /// Tapping the avatar / title / author in the top bar toggles the video details panel.
    var onTitleTap: (() -> Void)? = nil
    /// Whether the floating video details panel is currently visible. When it opens,
    /// the controls hide immediately instead of waiting for the auto-hide timer.
    var isDetailsPanelVisible: Bool = false
    /// Change playback rate (used by the playback speed button).
    var onRateChanged: ((PlaybackRate) -> Void)? = nil

    // MARK: - State

    @State private var isHovering = false
    @State private var hideTimer: Timer?
    @State private var isInteracting = false
    @State private var showControls: Bool?
    @State private var keyboardMonitor: Any?
    /// Last pointer location from `.onContinuousHover`, used to tell real mouse
    /// movement apart from re-emitted hover events (e.g. after the view resizes
    /// when entering fullscreen).
    @State private var lastHoverLocation: CGPoint?
    /// Pointer rests on the top bar / bottom control bar — the idle timer must
    /// not hide the controls out from under it. Two flags because moving
    /// between the bars fires one bar's exit and the other's entry in
    /// unspecified order.
    @State private var isHoveringTopBar = false
    @State private var isHoveringBottomBar = false

    /// Window hosting these controls; keyboard shortcuts only apply to events
    /// destined for this window (not e.g. the Settings window).
    @State private var hostWindow: NSWindow?

    /// The active player controls layout (macOS preset). `nil` until loaded, so
    /// the bars don't flash a wrong default before the preset arrives.
    @State private var layout: PlayerControlsLayout?

    /// Extra top inset for the top bar so its content drops below the window's
    /// traffic-light buttons in the separate/floating window presentation, while
    /// keeping the avatar/title aligned to the leading edge.
    /// Stays 0 when there is no overlap (inline overlay, side panel, fullscreen).
    @State private var trafficLightInset: CGFloat = 0

    // MARK: - Control Bar Drag State

    /// Committed offset of the control bar from its default bottom-center
    /// position, as fractions of the container size so it survives window and
    /// aspect-ratio driven resizes. Mirrors SettingsManager; (0, 0) = docked.
    @State private var barOffsetFraction: CGSize = .zero
    /// Live drag translation in points; nil when not dragging.
    @State private var barDragTranslation: CGSize?
    /// Pixel offset captured at drag start so the drag composes with the committed offset.
    @State private var barDragBase: CGSize = .zero
    /// Measured size of the visible glass capsule (narrower than its 650pt layout frame).
    @State private var barSize = CGSize(width: 500, height: 90)
    /// Measured height of the top bar (including its gradient padding) so the
    /// dragged control bar can't overlap the top row of buttons.
    @State private var topBarHeight: CGFloat = 0

    private enum BarDrag {
        static let edgeMargin: CGFloat = 12
        static let snapDistance: CGFloat = 28
        static let bottomPadding: CGFloat = 20
    }

    // MARK: - Computed Properties

    /// Controls visibility - show when hovering, interacting, or paused
    private var shouldShowControls: Bool {
        // Ended/failed overlays take over the surface; controls stay hidden
        // regardless of hover or manual toggles.
        if playerState.playbackState == .ended || playerState.isFailed {
            return false
        }
        if let showControls {
            return showControls
        }
        // Show if hovering, interacting (scrubbing, adjusting volume), or paused
        if isHovering || isInteracting {
            return true
        }
        // Show when paused or loading
        return playerState.playbackState == .paused ||
               playerState.playbackState == .loading
    }

    /// Traffic-light visibility. Follows the controls, except on the
    /// ended/failed overlays: those hide the controls entirely (no hover
    /// brings them back), so the window buttons must stay up or the error /
    /// replay screen can't be closed.
    private var shouldShowTrafficLights: Bool {
        shouldShowControls || playerState.playbackState == .ended || playerState.isFailed
    }

    /// Whether the hosting window is in native fullscreen. The `isFullscreen`
    /// parameter covers the inline overlay flow, but its key-window half isn't
    /// reactive — check the tracked host window directly as well.
    private var isInNativeFullscreen: Bool {
        isFullscreen || hostWindow?.styleMask.contains(.fullScreen) == true
    }

    /// Yattee Server URL used by `ChannelAvatarView` for avatar fallback.
    private var yatteeServerURL: URL? {
        appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
    }

    // MARK: - Top Bar

    /// Top row rendered from the active preset's top section (title/author,
    /// keep-on-top pin, close by default).
    private func topBar(layout: PlayerControlsLayout) -> some View {
        MacOSControlsSectionRenderer(
            section: layout.topSection,
            actions: controlsActions(layout: layout),
            globalSettings: layout.globalSettings,
            context: .overlay
        )
        .padding(.horizontal, 20)
        .padding(.top, 16 + trafficLightInset)
        .padding(.bottom, 24)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            topBarScrim
                // Extend the scrim past the bar's bottom edge so the fade has
                // more room and doesn't end right under the title text.
                .padding(.bottom, -24)
                .allowsHitTesting(false)
        )
        .background(
            TrafficLightInsetReader(inset: $trafficLightInset)
                .allowsHitTesting(false)
        )
    }

    /// Alpha ramp shared by both scrim variants: multi-stop eased fade so the
    /// scrim has no visible hard edge where it ends.
    private func scrimGradient(maxOpacity: Double) -> LinearGradient {
        LinearGradient(
            stops: [
                .init(color: .black.opacity(maxOpacity), location: 0),
                .init(color: .black.opacity(maxOpacity * 0.8), location: 0.35),
                .init(color: .black.opacity(maxOpacity * 0.45), location: 0.65),
                .init(color: .black.opacity(maxOpacity * 0.15), location: 0.85),
                .init(color: .clear, location: 1),
            ],
            startPoint: .top, endPoint: .bottom
        )
    }

    /// Background behind the top bar keeping the title/author legible over the
    /// video. A gradient-masked Liquid Glass strip was tried on macOS 26 but
    /// rendered poorly over the video layer, so the dimming gradient is used
    /// on all versions.
    private var topBarScrim: some View {
        scrimGradient(maxOpacity: 0.75)
    }

    // MARK: - Actions

    /// Consolidated actions for the section renderers.
    private func controlsActions(layout: PlayerControlsLayout) -> PlayerControlsActions {
        PlayerControlsActions(
            playerState: playerState,
            isWideScreenLayout: true,
            isFullscreen: isFullscreen,
            isWidescreenVideo: true,
            isPanelVisible: isDetailsPanelVisible,
            panelSide: .right,
            showVolumeControls: layout.globalSettings.volumeMode == .mpv,
            showDebugButton: true,
            showCloseButton: onClose != nil,
            currentVideo: playerState.currentVideo,
            availableCaptions: [],
            currentCaption: nil,
            availableStreams: [],
            currentStream: nil,
            currentAudioStream: nil,
            isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,
            isAudioModeEnabled: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
            yatteeServerURL: yatteeServerURL,
            deArrowBrandingProvider: appEnvironment?.deArrowBrandingProvider,
            onClose: onClose,
            onToggleDebug: { [self] in
                withAnimation(.easeInOut(duration: 0.2)) {
                    playerState.showDebugOverlay.toggle()
                }
            },
            onTogglePiP: onTogglePiP,
            onToggleFullscreen: onToggleFullscreen,
            onToggleDetailsVisibility: onTitleTap,
            onToggleAutoPlayNext: { [weak appEnvironment] in
                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: { [self] in
                let wasPaused = playerState.playbackState == .paused
                onPlayPause()
                showControls = true
                if wasPaused {
                    resetHideTimer()
                }
            },
            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: { [self] adjusting in
                isInteracting = adjusting
                if adjusting {
                    cancelHideTimer()
                } else {
                    resetHideTimer()
                }
            },
            onRateChanged: onRateChanged,
            onShowPlaylistSelector: onShowPlaylistSelector,
            onShowQueue: onShowQueue,
            onControlsLockToggled: { [self] locked in
                playerState.isControlsLocked = locked
            }
        )
    }

    // MARK: - Body

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // Invisible layer for tap/click to toggle controls
                Color.clear
                    .contentShape(Rectangle())
                    .onTapGesture {
                        toggleControlsVisibility()
                    }
                    .allowsHitTesting(playerState.playbackState != .ended && !playerState.isFailed)

                // Top bar (from preset top section) + control bar at bottom center
                if let layout {
                    VStack(spacing: 0) {
                        topBar(layout: layout)
                            .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { topBarHeight = $0 }
                            .onHover { hovering in
                                isHoveringTopBar = hovering
                                if !hovering { startHideTimer() }
                            }

                        Spacer()

                        MacOSControlBar(
                            playerState: playerState,
                            section: layout.bottomSection,
                            globalSettings: layout.globalSettings,
                            actions: controlsActions(layout: layout),
                            onSeek: onSeek,
                            showChapters: layout.progressBarSettings.showChapters,
                            sponsorSegments: playerState.sponsorSegments,
                            sponsorBlockSettings: layout.progressBarSettings.sponsorBlockSettings,
                            playedColor: layout.progressBarSettings.playedColor.color,
                            onInteractionStarted: {
                                isInteracting = true
                                cancelHideTimer()
                            },
                            onInteractionEnded: {
                                isInteracting = false
                                resetHideTimer()
                            }
                        )
                        .onGeometryChange(for: CGSize.self) { $0.size } action: { barSize = $0 }
                        .onHover { hovering in
                            isHoveringBottomBar = hovering
                            if !hovering { startHideTimer() }
                        }
                        // The player window is movable by background; without this,
                        // AppKit claims drags on the glass capsule as window moves
                        // and the reposition gesture never fires.
                        .background(WindowDragBlockingView())
                        .frame(width: 650)
                        .padding(.bottom, BarDrag.bottomPadding)
                        .offset(barOffset(in: geometry.size))
                        .gesture(barDragGesture(in: geometry.size))
                        .animation(
                            .spring(response: 0.3, dampingFraction: 0.75),
                            value: barOffset(in: geometry.size) == .zero
                        )
                    }
                    .opacity(shouldShowControls ? 1 : 0)
                    .allowsHitTesting(shouldShowControls)
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
        .background(
            HostWindowReader(window: $hostWindow)
                .allowsHitTesting(false)
        )
        .animation(.easeInOut(duration: 0.2), value: shouldShowControls)
        .onContinuousHover { phase in
            switch phase {
            case .active(let location):
                isHovering = true
                // Only genuine movement counts: the first event after a gap
                // (pointer entry, fullscreen resize re-emission) is a baseline,
                // so entering fullscreen doesn't itself pop the controls up.
                let moved = lastHoverLocation.map {
                    hypot($0.x - location.x, $0.y - location.y) > 1
                } ?? false
                lastHoverLocation = location
                guard moved else { break }
                // Mouse movement clears a hide override (left by the idle
                // timer or a manual toggle) so controls show again. Not while
                // the details panel is open — that state keeps controls
                // hidden on purpose.
                if showControls == false && !isDetailsPanelVisible {
                    showControls = nil
                }
                resetHideTimer()
            case .ended:
                isHovering = false
                lastHoverLocation = nil
                startHideTimer()
            }
        }
        .onChange(of: isFullscreen) { _, _ in
            // The fullscreen transition resizes the view and re-emits hover
            // with jumped coordinates — rebaseline so that doesn't read as
            // mouse movement.
            lastHoverLocation = nil
        }
        .onChange(of: isDetailsPanelVisible) { _, isVisible in
            if isVisible {
                // Hide controls immediately when the details panel opens.
                showControls = false
                cancelHideTimer()
            } else {
                // Restore default hover/paused-driven visibility when it closes.
                showControls = nil
            }
        }
        .onChange(of: shouldShowControls) { _, visible in
            // Hidden bars stop hit-testing, so their `onHover(false)` may
            // never arrive — clear the flags or they'd stay stuck true and
            // block every future idle-hide.
            if !visible {
                isHoveringTopBar = false
                isHoveringBottomBar = false
            }
        }
        .onChange(of: shouldShowTrafficLights) { _, visible in
            setTrafficLightsVisible(visible)
        }
        .onChange(of: hostWindow) { oldWindow, _ in
            // Restore the old window's chrome when re-parented (sheet <->
            // floating window), then sync the new window to the current state.
            if let oldWindow { applyTrafficLightAlpha(1, to: oldWindow, animated: false) }
            setTrafficLightsVisible(shouldShowTrafficLights, animated: false)
        }
        .onReceive(NotificationCenter.default.publisher(for: NSWindow.willEnterFullScreenNotification)) { note in
            // In fullscreen the system owns titlebar visibility (hover at the
            // top edge reveals it) — hand the buttons back at full alpha.
            if let window = note.object as? NSWindow, window === hostWindow {
                applyTrafficLightAlpha(1, to: window, animated: false)
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { note in
            if let window = note.object as? NSWindow, window === hostWindow {
                setTrafficLightsVisible(shouldShowTrafficLights, animated: false)
                // Pointer hiding is a fullscreen-only behavior; make sure it's
                // back even before the next mouse move.
                NSCursor.setHiddenUntilMouseMoves(false)
            }
        }
        .onChange(of: playerState.playbackState) { oldState, newState in
            // When playback starts, start hide timer
            if newState == .playing && shouldShowControls {
                startHideTimer()
            }
            // A new load resets any manual show/hide override (e.g. hidden
            // after ended/failed) so controls are visible while loading.
            if newState == .loading {
                showControls = nil
            }
            // Ended/failed hide via shouldShowControls; just stop the timer.
            if newState == .ended || playerState.isFailed {
                cancelHideTimer()
            }
        }
        .onAppear {
            setupKeyboardMonitor()
            if let settings = appEnvironment?.settingsManager {
                barOffsetFraction = CGSize(
                    width: settings.macControlsBarOffsetX,
                    height: settings.macControlsBarOffsetY
                )
            }
        }
        .onDisappear {
            removeKeyboardMonitor()
            // The controls unmount while the window stays up (PiP, debug
            // overlay) — don't leave the window without its close button.
            if let hostWindow { applyTrafficLightAlpha(1, to: hostWindow, animated: false) }
        }
        .task {
            await loadLayout()
        }
        .onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in
            Task { await loadLayout() }
        }
        .onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in
            Task { await loadLayout() }
        }
        // The ended/failed overlays own the surface — their replay/retry/next
        // buttons live in the layer BELOW this view. The inner layers already
        // opt out of hit-testing in those states, but on macOS 15 the hover
        // tracking on this view makes its full frame participate in click
        // hit-testing anyway, silently swallowing every click aimed at those
        // buttons. Drop the whole view out of hit-testing while they're up.
        .allowsHitTesting(playerState.playbackState != .ended && !playerState.isFailed)
    }

    // MARK: - Control Bar Drag

    /// Display offset of the control bar for the current container size:
    /// live drag translation when dragging, otherwise the committed fraction,
    /// clamped so the capsule stays visible, with magnetic snap to default.
    private func barOffset(in container: CGSize) -> CGSize {
        let raw: CGSize
        if let drag = barDragTranslation {
            raw = CGSize(
                width: barDragBase.width + drag.width,
                height: barDragBase.height + drag.height
            )
        } else {
            raw = CGSize(
                width: barOffsetFraction.width * container.width,
                height: barOffsetFraction.height * container.height
            )
        }
        let clamped = clampBarOffset(raw, in: container)
        if hypot(clamped.width, clamped.height) < BarDrag.snapDistance {
            return .zero
        }
        return clamped
    }

    /// Clamps an offset so the visible capsule (measured `barSize`, not the
    /// wider layout frame) keeps at least `edgeMargin` from every container edge
    /// and never overlaps the top bar's button row.
    private func clampBarOffset(_ offset: CGSize, in container: CGSize) -> CGSize {
        let maxDX = max(0, (container.width - barSize.width) / 2 - BarDrag.edgeMargin)
        let topInset = max(topBarHeight, BarDrag.edgeMargin)
        let travelUp = max(0, container.height - BarDrag.bottomPadding - barSize.height - topInset)
        let travelDown = max(0, BarDrag.bottomPadding - BarDrag.edgeMargin)
        return CGSize(
            width: min(max(offset.width, -maxDX), maxDX),
            height: min(max(offset.height, -travelUp), travelDown)
        )
    }

    private func barDragGesture(in container: CGSize) -> some Gesture {
        DragGesture(minimumDistance: 4, coordinateSpace: .global)
            .onChanged { value in
                guard !playerState.isControlsLocked else { return }
                if barDragTranslation == nil {
                    barDragBase = barOffset(in: container)
                    isInteracting = true
                    cancelHideTimer()
                }
                barDragTranslation = value.translation
            }
            .onEnded { value in
                guard barDragTranslation != nil else { return }
                let final = clampBarOffset(
                    CGSize(
                        width: barDragBase.width + value.translation.width,
                        height: barDragBase.height + value.translation.height
                    ),
                    in: container
                )
                let docked = hypot(final.width, final.height) < BarDrag.snapDistance
                let committed = docked ? .zero : final
                barOffsetFraction = CGSize(
                    width: committed.width / max(1, container.width),
                    height: committed.height / max(1, container.height)
                )
                barDragTranslation = nil
                isInteracting = false
                resetHideTimer()
                if let settings = appEnvironment?.settingsManager {
                    settings.macControlsBarOffsetX = barOffsetFraction.width
                    settings.macControlsBarOffsetY = barOffsetFraction.height
                }
            }
    }

    // MARK: - Traffic Lights

    /// Fades the hosting window's traffic-light buttons together with the
    /// controls overlay, QuickTime-style. Only applies to the dedicated
    /// separate player window — the inline sheet's parent window keeps its own
    /// chrome — and never in fullscreen, where the system owns the titlebar.
    private func setTrafficLightsVisible(_ visible: Bool, animated: Bool = true) {
        guard let window = hostWindow,
              window === ExpandedPlayerWindowManager.shared.currentPlayerWindow,
              !window.styleMask.contains(.fullScreen) else { return }
        applyTrafficLightAlpha(visible ? 1 : 0, to: window, animated: animated)
    }

    private func applyTrafficLightAlpha(_ alpha: CGFloat, to window: NSWindow, animated: Bool) {
        let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
        let views = buttons.compactMap { window.standardWindowButton($0) }
        guard !views.isEmpty else { return }
        if animated {
            NSAnimationContext.runAnimationGroup { context in
                context.duration = 0.2
                context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
                views.forEach { $0.animator().alphaValue = alpha }
            }
        } else {
            views.forEach { $0.alphaValue = alpha }
        }
    }

    // MARK: - Layout Loading

    private func loadLayout() async {
        guard let service = appEnvironment?.playerControlsLayoutService else { return }
        layout = await service.activeLayout()
    }

    // MARK: - Keyboard Shortcuts

    private func setupKeyboardMonitor() {
        keyboardMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] event in
            // Only handle keys destined for the window hosting the player;
            // pass through events for other windows (e.g. Settings).
            guard let window = event.window, window === hostWindow else { return event }

            // Let text fields receive typing (the field editor is an NSText).
            if window.firstResponder is NSText { return event }

            // When controls are locked, ignore playback keys (space/arrows/mute) and
            // only keep fullscreen (F) and exit-fullscreen (Esc) working.
            if playerState.isControlsLocked {
                switch event.keyCode {
                case 3: // F
                    onToggleFullscreen?()
                    return nil
                case 53: // Escape
                    if isFullscreen {
                        onToggleFullscreen?()
                        return nil
                    }
                    return event
                default:
                    return event // Pass through space/arrows/M while locked
                }
            }

            // Let ⌘-based key equivalents reach the menu bar (Playback menu shortcuts
            // like ⌘←/⌘⇧← seek and ⌘⌥← previous video) instead of consuming them here.
            if event.modifierFlags.contains(.command) { return event }

            let isShiftHeld = event.modifierFlags.contains(.shift)

            // Handle keyboard shortcuts
            switch event.keyCode {
            case 49: // Space
                onPlayPause()
                return nil // Consume event

            case 123: // Left arrow
                let seconds = TimeInterval(
                    isShiftHeld
                        ? layout?.centerSettings.secondarySeekBackwardSeconds ?? 30
                        : layout?.centerSettings.seekBackwardSeconds ?? 10
                )
                Task { await onSeekBackward(seconds) }
                return nil

            case 124: // Right arrow
                let seconds = TimeInterval(
                    isShiftHeld
                        ? layout?.centerSettings.secondarySeekForwardSeconds ?? 30
                        : layout?.centerSettings.seekForwardSeconds ?? 10
                )
                Task { await onSeekForward(seconds) }
                return nil

            case 126: // Up arrow
                // In system volume mode MPV volume is pinned at 1.0; don't fight it.
                guard layout?.globalSettings.volumeMode != .system else { return event }
                let newVolume = min(1.0, playerState.volume + 0.1)
                playerState.volume = newVolume
                onVolumeChanged?(newVolume)
                return nil

            case 125: // Down arrow
                guard layout?.globalSettings.volumeMode != .system else { return event }
                let newVolume = max(0, playerState.volume - 0.1)
                playerState.volume = newVolume
                onVolumeChanged?(newVolume)
                return nil

            case 46: // M key
                guard layout?.globalSettings.volumeMode != .system else { return event }
                onMuteToggled?()
                return nil

            case 3: // F key
                onToggleFullscreen?()
                return nil

            case 53: // Escape
                if isFullscreen {
                    onToggleFullscreen?()
                    return nil
                }
                if let onCollapse {
                    // Inline overlay presentation: Esc collapses to the mini bar
                    // (the sheet's default Esc-dismiss is gone with the sheet).
                    onCollapse()
                    return nil
                }
                return event // Pass through if not fullscreen

            default:
                return event // Pass through unhandled keys
            }
        }
    }

    private func removeKeyboardMonitor() {
        if let monitor = keyboardMonitor {
            NSEvent.removeMonitor(monitor)
            keyboardMonitor = nil
        }
    }

    // MARK: - Timer Management

    private func toggleControlsVisibility() {
        showControls = !shouldShowControls
        if shouldShowControls {
            startHideTimer()
        } else if isInNativeFullscreen {
            NSCursor.setHiddenUntilMouseMoves(true)
        }
    }

    private func startHideTimer() {
        cancelHideTimer()
        hideTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
            Task { @MainActor in
                // Idle-based hide: the pointer resting over the video doesn't
                // keep controls up (it never leaves the view in fullscreen) —
                // only resting on one of the bars does.
                if playerState.playbackState == .playing && !isInteracting
                    && !isHoveringTopBar && !isHoveringBottomBar {
                    showControls = false
                    if isInNativeFullscreen {
                        NSCursor.setHiddenUntilMouseMoves(true)
                    }
                }
            }
        }
    }

    private func resetHideTimer() {
        startHideTimer()
    }

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

// MARK: - Window Drag Blocking View

/// Blocks `isMovableByWindowBackground` window-dragging within its bounds so
/// drags on the control bar reach the SwiftUI reposition gesture instead of
/// moving the player window. Doesn't handle any events itself — unhandled
/// mouse events continue up the responder chain to the hosting view.
private struct WindowDragBlockingView: NSViewRepresentable {
    final class BlockingNSView: NSView {
        override var mouseDownCanMoveWindow: Bool { false }
    }

    func makeNSView(context: Context) -> BlockingNSView { BlockingNSView() }
    func updateNSView(_ nsView: BlockingNSView, context: Context) {}
}

// MARK: - Host Window Reader

/// Reports the `NSWindow` hosting this view so the keyboard monitor can ignore
/// events destined for other windows. Tracks re-parenting (mini bar <-> sheet
/// <-> floating window) via `viewDidMoveToWindow`.
private struct HostWindowReader: NSViewRepresentable {
    @Binding var window: NSWindow?

    func makeNSView(context: Context) -> ReaderView {
        let view = ReaderView()
        view.onWindowChange = { newWindow in
            if window !== newWindow { window = newWindow }
        }
        return view
    }

    func updateNSView(_ nsView: ReaderView, context: Context) {
        nsView.onWindowChange = { newWindow in
            if window !== newWindow { window = newWindow }
        }
    }

    final class ReaderView: NSView {
        var onWindowChange: ((NSWindow?) -> Void)?

        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            let window = window
            DispatchQueue.main.async { [weak self] in
                self?.onWindowChange?(window)
            }
        }
    }
}

// MARK: - Traffic Light Inset Reader

/// Reports how far the top bar's leading content must be pushed to clear the
/// hosting window's traffic-light buttons (close/minimize/zoom).
///
/// Returns 0 whenever the buttons don't actually overlap this view — e.g. in
/// fullscreen (buttons hidden), or when the controls render inside the inline
/// sheet / side panel where the top bar sits below the window's titlebar.
private struct TrafficLightInsetReader: NSViewRepresentable {
    @Binding var inset: CGFloat

    func makeNSView(context: Context) -> ReaderView {
        let view = ReaderView()
        view.onUpdate = { newInset in
            if inset != newInset { inset = newInset }
        }
        return view
    }

    func updateNSView(_ nsView: ReaderView, context: Context) {
        nsView.onUpdate = { newInset in
            if inset != newInset { inset = newInset }
        }
        DispatchQueue.main.async { nsView.recompute() }
    }

    final class ReaderView: NSView {
        var onUpdate: ((CGFloat) -> Void)?
        private var observers: [NSObjectProtocol] = []

        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            removeObservers()
            postsFrameChangedNotifications = true

            guard let window else {
                onUpdate?(0)
                return
            }

            let center = NotificationCenter.default
            let names: [NSNotification.Name] = [
                NSWindow.didEnterFullScreenNotification,
                NSWindow.didExitFullScreenNotification,
                NSWindow.didResizeNotification,
                NSWindow.didBecomeKeyNotification
            ]
            for name in names {
                observers.append(center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
                    self?.recompute()
                })
            }
            observers.append(center.addObserver(forName: NSView.frameDidChangeNotification, object: self, queue: .main) { [weak self] _ in
                self?.recompute()
            })

            recompute()
        }

        func recompute() {
            guard let window,
                  !window.styleMask.contains(.fullScreen) else {
                onUpdate?(0)
                return
            }

            let buttons = [
                window.standardWindowButton(.closeButton),
                window.standardWindowButton(.miniaturizeButton),
                window.standardWindowButton(.zoomButton)
            ].compactMap { $0 }.filter { !$0.isHidden && $0.superview != nil }

            guard !buttons.isEmpty else {
                onUpdate?(0)
                return
            }

            // Union of the traffic lights and this view, both in window base coords.
            let buttonsRect = buttons
                .map { $0.convert($0.bounds, to: nil) }
                .reduce(NSRect.null) { $0.union($1) }
            let selfRect = convert(bounds, to: nil)

            // Only inset when the buttons actually sit over this view.
            guard selfRect.intersects(buttonsRect) else {
                onUpdate?(0)
                return
            }

            // Window base coords have a bottom-left origin (Y grows upward), so the
            // buttons' bottom edge is `buttonsRect.minY` and this view's top edge is
            // `selfRect.maxY`. Drop the content from its top edge down to just below
            // the buttons, keeping it aligned to the leading edge.
            let overlap = selfRect.maxY - buttonsRect.minY
            onUpdate?(max(0, overlap + 8))
        }

        private func removeObservers() {
            for observer in observers {
                NotificationCenter.default.removeObserver(observer)
            }
            observers.removeAll()
        }

        deinit {
            removeObservers()
        }
    }
}

// MARK: - Preview

#Preview {
    ZStack {
        Color.black

        MacOSPlayerControlsView(
            playerState: PlayerState(),
            onPlayPause: {},
            onSeek: { _ in },
            onSeekForward: { _ in },
            onSeekBackward: { _ in }
        )
    }
    .aspectRatio(16/9, contentMode: .fit)
    .frame(width: 800, height: 450)
}

#endif
