//
//  ExpandedPlayerWindowManager.swift
//  Yattee
//
//  Manages expanded player window on macOS.
//  Uses a separate NSWindow for better control over presentation and floating behavior.
//

#if os(macOS)
import AppKit
import SwiftUI

/// Manages the expanded player window on macOS.
/// Supports both normal window and floating (always-on-top) modes.
@MainActor
final class ExpandedPlayerWindowManager: NSObject {
    static let shared = ExpandedPlayerWindowManager()

    private var playerWindow: NSWindow?
    private weak var appEnvironment: AppEnvironment?

    /// Main app window hosting the inline player overlay, the observers that
    /// mirror its fullscreen transitions into the coordinator flag (Esc, green
    /// button, menu, Mission Control all bypass the player's own button), the
    /// toolbar visibility to restore when the overlay ends (the toolbar is
    /// hidden while the overlay is up so it doesn't sit above the video), and
    /// whether the window was already fullscreen before the overlay began (so
    /// collapsing doesn't exit a fullscreen the user had independently).
    private weak var overlayParent: NSWindow?
    private var overlayFullScreenObservers: [NSObjectProtocol] = []
    private var overlayToolbarWasVisible: Bool?
    private var overlayWasFullScreenAtBegin = false

    /// Window hosting ContentView, registered by `MainContentWindowReader`.
    /// `beginInlineOverlay` must not guess via mainWindow/keyWindow: when the
    /// separate-window setting is toggled during playback, the key window is
    /// the Settings window (and the separate player window can be "main"), so
    /// guessing hides/restores the toolbar on the wrong window.
    private weak var mainContentWindow: NSWindow?

    /// Registers the window hosting the app's main content view.
    func registerMainContentWindow(_ window: NSWindow?) {
        guard let window else { return }
        mainContentWindow = window
    }

    /// Whether the window has performed its first size application since being
    /// shown. The first resize after each open snaps (no animation) so the player
    /// appears at its final fixed layout; later resizes (e.g. switching to a
    /// different-aspect video while the window stays open) animate normally.
    private var hasCompletedInitialSizing = false

    /// Aspect ratio received while the window was in native fullscreen. Applying
    /// `contentAspectRatio`/`setFrame` to a fullscreen window makes AppKit fit and
    /// center the content inside the fullscreen space (black bars), so the ratio
    /// is parked here and replayed in `windowDidExitFullScreen`.
    private var pendingFullScreenAspectRatio: Double?

    private var isPlayerWindowFullScreen: Bool {
        playerWindow?.styleMask.contains(.fullScreen) ?? false
    }

    // Configuration
    private static let minWidth: CGFloat = 640
    private static let minHeight: CGFloat = 360
    private static let maxScreenRatio: CGFloat = 0.7
    private static let targetVideoHeight: CGFloat = 720
    private static let defaultAspectRatio: Double = 16.0 / 9.0
    private static let frameAutosaveName = "ExpandedPlayerWindow"

    var isPresented: Bool {
        playerWindow != nil
    }

    /// The live player window managed by this instance (nil after hide()).
    /// A window that exists here but isn't visible is mid-presentation or
    /// hidden for PiP — both valid homes for the shared render view, unlike a
    /// stale ordered-out window that is no longer tracked. Used by
    /// MPVContainerNSView when picking a transfer target.
    var currentPlayerWindow: NSWindow? {
        playerWindow
    }

    private override init() {
        super.init()
    }

    // MARK: - Public API

    /// Shows the expanded player in a separate window.
    /// - Parameters:
    ///   - appEnvironment: The app environment for state and services
    ///   - animated: Whether to animate the window appearance
    func show(with appEnvironment: AppEnvironment, animated: Bool = true) {
        // If window already exists (hidden for PiP), restore it instead of creating new one
        if let existingWindow = playerWindow {
            // Already on screen (e.g. "Play Now" while playing) — just bring it
            // forward; resetting alphaValue here would make the window blink.
            if existingWindow.isVisible {
                LoggingService.shared.debug("ExpandedPlayerWindowManager: show() - window already visible, bringing to front", category: .player)
                existingWindow.makeKeyAndOrderFront(nil)
                return
            }
            LoggingService.shared.debug("ExpandedPlayerWindowManager: show() - restoring existing window (was hidden for PiP)", category: .player)
            if animated {
                existingWindow.alphaValue = 0
                existingWindow.makeKeyAndOrderFront(nil)
                NSAnimationContext.runAnimationGroup { context in
                    context.duration = 0.25
                    context.timingFunction = CAMediaTimingFunction(name: .easeOut)
                    existingWindow.animator().alphaValue = 1
                }
            } else {
                existingWindow.alphaValue = 1
                existingWindow.makeKeyAndOrderFront(nil)
            }
            // Any forced draw requested while the window was ordered out was
            // dropped (no drawable); the layer is pull-based, so repaint now
            // that the window is on screen — otherwise a paused video stays
            // black until the next MPV frame (never, while paused).
            (appEnvironment.playerService.currentBackend as? MPVBackend)?.resumeRendering()
            return
        }

        self.appEnvironment = appEnvironment

        // Mark expanding state for mini player coordination
        appEnvironment.navigationCoordinator.isPlayerExpanding = true

        // Whether the window should float above other windows (always on top).
        let floating = appEnvironment.settingsManager.macPlayerFloating

        // Host a lightweight two-phase root instead of ExpandedPlayerSheet directly.
        // AppKit won't composite the window to screen until the hosted SwiftUI view
        // finishes its first layout pass; ExpandedPlayerSheet is heavy (MPV setup,
        // full controls, layout math), so building it inline leaves the alpha 0→1
        // fade with nothing to draw and the window only pops in after the render.
        // ExpandedPlayerWindowRoot paints black + spinner immediately, then defers
        // building ExpandedPlayerSheet by one runloop so the window appears at once.
        let playerView = ExpandedPlayerWindowRoot()
            .appEnvironment(appEnvironment)

        // Open at the real video aspect ratio when it's already known (e.g.
        // expanding a video already playing in the mini bar) so the window appears
        // at its final size with no snap-jump. Falls back to 16:9 when the ratio
        // isn't decoded yet; the first-resize snap below removes any later grow.
        let knownAspect = appEnvironment.playerService.state.videoAspectRatio ?? 0
        let seedAspect = knownAspect > 0 ? knownAspect : Self.defaultAspectRatio

        // Create hosting controller
        let hostingController = NSHostingController(rootView: playerView)
        // Don't let the hosting controller drive the window size from the SwiftUI
        // content's fitting size. The lightweight loading root (just a spinner) has a
        // tiny ideal size, which would otherwise shrink the window and then grow it
        // when ExpandedPlayerSheet builds. Window sizing is owned by `initialSize`
        // below and `resizeToFitAspectRatio` once the real video ratio is known.
        hostingController.sizingOptions = []

        // Calculate initial window size at the seeded aspect ratio
        let initialSize = calculateInitialWindowSize(aspectRatio: seedAspect)

        // Create window with appropriate style
        let window = NSWindow(
            contentRect: NSRect(origin: .zero, size: initialSize),
            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
            backing: .buffered,
            defer: false
        )

        // Configure window appearance
        window.titlebarAppearsTransparent = true
        window.titleVisibility = .hidden
        window.isMovableByWindowBackground = true
        window.backgroundColor = NSColor.windowBackgroundColor
        window.contentViewController = hostingController

        // Lock manual resize to the video aspect ratio. Seeded with the real ratio
        // when known (else 16:9); updated as soon as the real ratio is known.
        Self.applyAspectRatioConstraint(seedAspect, to: window)

        // Force the intended size. Assigning `contentViewController` above resizes
        // the window to the hosting controller's fitting size — and the lightweight
        // loading root has a tiny fitting size, which produced a tiny window that
        // only grew once `resizeToFitAspectRatio` fired after streams loaded. Set
        // the content size back to `initialSize` so the window opens full-sized
        // during the loading phase too.
        window.setContentSize(initialSize)

        // Set up window delegate for close handling
        // Make ExpandedPlayerWindowManager itself the delegate to avoid lifecycle issues
        window.delegate = self

        // Configure window level based on the floating preference
        configureWindowLevel(window, floating: floating)

        // Center window on screen
        window.center()

        // With auto-resize off, the window must reopen exactly as the user last
        // left it — no fitting, no centering. Restore the persisted frame over
        // the computed default (no-op when nothing was saved yet, e.g. first
        // open). With auto-resize on, sizing is owned by resizeToFitAspectRatio.
        if !appEnvironment.settingsManager.playerSheetAutoResize {
            window.setFrameUsingName(Self.frameAutosaveName)
        }
        // Persist every later frame change (manual resize/move, auto-fit) so the
        // next open — with auto-resize off — can restore it.
        _ = window.setFrameAutosaveName(Self.frameAutosaveName)

        // Store reference
        self.playerWindow = window

        // Fresh window: the next resize is the initial sizing and must snap
        // (no animation) so the player appears at its final fixed layout.
        hasCompletedInitialSizing = false
        pendingFullScreenAspectRatio = nil

        // Show window
        if animated {
            window.alphaValue = 0
            window.makeKeyAndOrderFront(nil)
            NSAnimationContext.runAnimationGroup({ context in
                context.duration = 0.25
                context.timingFunction = CAMediaTimingFunction(name: .easeOut)
                window.animator().alphaValue = 1
            }, completionHandler: {
                Task { @MainActor in
                    appEnvironment.navigationCoordinator.isPlayerExpanding = false
                }
            })
        } else {
            window.makeKeyAndOrderFront(nil)
            appEnvironment.navigationCoordinator.isPlayerExpanding = false
        }
    }

    /// Hides and cleans up the player window.
    /// - Parameters:
    ///   - animated: Whether to animate the dismissal
    ///   - completion: Called after the window is hidden
    func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
        guard let window = playerWindow else {
            // No window means inline overlay mode (or the window is already
            // gone). The overlay's container may unmount without handing the
            // shared render view to the mini capsule in the same pass - watch
            // for the teardown to finish and re-home the view.
            MPVContainerNSView.scheduleSharedViewAdoptionRetry()
            completion?()
            return
        }

        // Mark collapsing state for mini player coordination
        appEnvironment?.navigationCoordinator.isPlayerCollapsing = true

        // Check if PiP is active - if so, just hide the window without destroying content
        // The AVSampleBufferDisplayLayer needs to stay alive while PiP is active
        let mpvBackend = appEnvironment?.playerService.currentBackend as? MPVBackend
        let isPiPActive = mpvBackend?.isPiPActive ?? false

        LoggingService.shared.debug("ExpandedPlayerWindowManager: hide() called, isPiPActive=\(isPiPActive)", category: .player)

        // Capture navigationCoordinator before closures to avoid Swift 6 concurrency warnings
        let navigationCoordinator = appEnvironment?.navigationCoordinator

        if isPiPActive {
            // PiP is active - just hide the window, keep content alive
            // Don't clear playerWindow reference so we can restore it later
            let hideWindow: @Sendable () -> Void = {
                Task { @MainActor in
                    navigationCoordinator?.isPlayerCollapsing = false
                    window.orderOut(nil)
                    completion?()
                }
            }

            exitFullScreenIfNeeded(window) {
                Task { @MainActor in
                    if animated {
                        NSAnimationContext.runAnimationGroup({ context in
                            context.duration = 0.2
                            context.timingFunction = CAMediaTimingFunction(name: .easeIn)
                            window.animator().alphaValue = 0
                        }, completionHandler: hideWindow)
                    } else {
                        hideWindow()
                    }
                }
            }
        } else {
            // PiP is not active - fully clean up the window
            // Clear reference immediately to prevent re-entry
            playerWindow = nil

            let cleanup: @Sendable () -> Void = {
                Task { @MainActor in
                    navigationCoordinator?.isPlayerCollapsing = false
                    window.delegate = nil
                    // Don't set contentViewController to nil or call close() - just order out
                    // This lets SwiftUI views deallocate naturally rather than being forcibly torn down
                    window.orderOut(nil)
                    // The mini capsule's claim on the shared render view was
                    // declined while this window was still visible during the
                    // fade-out, and nothing retries after orderOut - hand the
                    // view over now instead of leaving the capsule black until
                    // the render watchdog recovers it.
                    if MPVContainerNSView.recoverSharedPlayerViewIfNeeded() {
                        mpvBackend?.resumeRendering()
                    }
                    completion?()
                }
            }

            exitFullScreenIfNeeded(window) {
                Task { @MainActor in
                    if animated {
                        NSAnimationContext.runAnimationGroup({ context in
                            context.duration = 0.2
                            context.timingFunction = CAMediaTimingFunction(name: .easeIn)
                            window.animator().alphaValue = 0
                        }, completionHandler: cleanup)
                    } else {
                        cleanup()
                    }
                }
            }
        }
    }

    /// Runs `completion` once `window` is out of native fullscreen. Ordering
    /// out a fullscreen window skips the exit transition and strands its (now
    /// empty, black) fullscreen space on screen, so every dismissal path must
    /// leave fullscreen before hiding the window.
    private func exitFullScreenIfNeeded(_ window: NSWindow, completion: @escaping @Sendable () -> Void) {
        guard window.styleMask.contains(.fullScreen) else {
            completion()
            return
        }

        let waiter = FullScreenExitWaiter()
        waiter.observer = NotificationCenter.default.addObserver(
            forName: NSWindow.didExitFullScreenNotification,
            object: window,
            queue: .main
        ) { _ in
            waiter.finish(completion)
        }
        // Fallback: if AppKit refuses the exit (e.g. mid-transition), don't
        // leave the window stranded on screen forever.
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            waiter.finish(completion)
        }
        window.toggleFullScreen(nil)
    }

    /// Updates the window level based on floating preference.
    /// Call this when the user changes the player mode setting.
    func updateWindowLevel(floating: Bool) {
        guard let window = playerWindow else { return }
        // Don't touch level/collectionBehavior mid-fullscreen; the setting is
        // re-read in windowDidExitFullScreen.
        guard !window.styleMask.contains(.fullScreen) else { return }
        configureWindowLevel(window, floating: floating)
    }

    /// Toggles native fullscreen on the player window.
    func toggleFullScreen() {
        guard let window = playerWindow else {
            // Inline overlay presentation: the player already fills the main
            // window, so fullscreen is just the window's native toggle. The
            // fullscreen observers keep the coordinator flag in sync.
            overlayParent?.toggleFullScreen(nil)
            return
        }
        // A floating (pinned) window carries .fullScreenAuxiliary, which AppKit
        // refuses to make a primary fullscreen window. Switch to the primary
        // config for the transition; windowDidExitFullScreen restores floating.
        if !window.styleMask.contains(.fullScreen) {
            configureWindowLevel(window, floating: false)
        }
        window.toggleFullScreen(nil)
    }

    // MARK: - Inline Overlay Presentation

    /// Called when the inline player overlay mounts in the main window
    /// (separate-window mode off, player expanded). Hides the main window's
    /// toolbar for the overlay's lifetime and starts mirroring the window's
    /// fullscreen state into `isMacInlinePlayerFullScreen`.
    func beginInlineOverlay() {
        guard overlayParent == nil else { return }
        guard let parent = mainContentWindow ?? NSApp.mainWindow ?? NSApp.keyWindow else {
            LoggingService.shared.debug(
                "ExpandedPlayerWindowManager: beginInlineOverlay found no main window",
                category: .player
            )
            return
        }

        LoggingService.shared.debug("ExpandedPlayerWindowManager: beginning inline overlay", category: .player)
        overlayParent = parent
        overlayToolbarWasVisible = parent.toolbar?.isVisible
        parent.toolbar?.isVisible = false
        overlayWasFullScreenAtBegin = parent.styleMask.contains(.fullScreen)

        let coordinator = AppEnvironment.shared.navigationCoordinator
        coordinator.isMacInlinePlayerFullScreen = overlayWasFullScreenAtBegin
        observeOverlayFullScreenTransitions(of: parent)

        // The overlay's render container may mount while the mini capsule is
        // still tearing down, leaving the shared render view parked with no
        // later lifecycle event to re-trigger adoption — retry until it lands.
        MPVContainerNSView.scheduleSharedViewAdoptionRetry()
    }

    /// Called when the inline player overlay unmounts (collapse, close, or
    /// switch to separate-window mode). Restores the main window's toolbar and,
    /// if fullscreen was entered for the video, exits it.
    func endInlineOverlay() {
        guard let parent = overlayParent else { return }

        LoggingService.shared.debug("ExpandedPlayerWindowManager: ending inline overlay", category: .player)
        for observer in overlayFullScreenObservers {
            NotificationCenter.default.removeObserver(observer)
        }
        overlayFullScreenObservers = []

        // Leave the window fullscreen if the user was fullscreen before the
        // overlay began; only exit a fullscreen entered for the video.
        if parent.styleMask.contains(.fullScreen), !overlayWasFullScreenAtBegin {
            parent.toggleFullScreen(nil)
        }
        if let wasVisible = overlayToolbarWasVisible {
            parent.toolbar?.isVisible = wasVisible
        }

        AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = false
        overlayToolbarWasVisible = nil
        overlayWasFullScreenAtBegin = false
        overlayParent = nil
    }

    /// Mirrors the parent window's fullscreen transitions into the coordinator
    /// flag so the player controls stay in sync with exits the player's button
    /// doesn't see (Esc, hover-revealed green button, Window menu, Mission
    /// Control).
    private func observeOverlayFullScreenTransitions(of parent: NSWindow) {
        for observer in overlayFullScreenObservers {
            NotificationCenter.default.removeObserver(observer)
        }
        let center = NotificationCenter.default
        overlayFullScreenObservers = [
            center.addObserver(
                forName: NSWindow.didEnterFullScreenNotification,
                object: parent,
                queue: .main
            ) { _ in
                Task { @MainActor in
                    AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = true
                }
            },
            center.addObserver(
                forName: NSWindow.didExitFullScreenNotification,
                object: parent,
                queue: .main
            ) { _ in
                Task { @MainActor in
                    AppEnvironment.shared.navigationCoordinator.isMacInlinePlayerFullScreen = false
                }
            },
        ]
    }

    /// Restores a window that was hidden for PiP mode.
    /// Call this when returning from PiP to show the player window again.
    func restoreFromPiP(animated: Bool = true) {
        guard let window = playerWindow else {
            LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP - no window to restore", category: .player)
            return
        }

        LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP called", category: .player)

        if animated {
            window.alphaValue = 0
            window.makeKeyAndOrderFront(nil)
            NSAnimationContext.runAnimationGroup { context in
                context.duration = 0.25
                context.timingFunction = CAMediaTimingFunction(name: .easeOut)
                window.animator().alphaValue = 1
            }
        } else {
            window.alphaValue = 1
            window.makeKeyAndOrderFront(nil)
        }
    }

    /// Cleans up a window that was hidden for PiP when PiP ends without restoring.
    /// Call this when PiP is closed via the X button (not restore).
    func cleanupAfterPiP() {
        guard let window = playerWindow else { return }

        LoggingService.shared.debug("ExpandedPlayerWindowManager: cleanupAfterPiP called", category: .player)

        // Clear our reference immediately to prevent further use
        playerWindow = nil
        window.delegate = nil

        // Don't forcefully destroy the contentViewController or close the window immediately.
        // This causes crashes because AVKit's PiP implementation adds internal views to the
        // window hierarchy (via NSHostingController), and forcefully tearing down the view
        // hierarchy while AVKit still has references causes use-after-free crashes.
        //
        // Instead, just order the window out and let it deallocate naturally when all
        // references are released.
        window.orderOut(nil)

        LoggingService.shared.debug("ExpandedPlayerWindowManager: window ordered out", category: .player)
    }

    /// Resizes the player window to fit the given video aspect ratio.
    /// - Parameters:
    ///   - aspectRatio: Video width / height ratio
    ///   - animated: Whether to animate the resize
    func resizeToFitAspectRatio(_ aspectRatio: Double, animated: Bool = true) {
        guard let window = playerWindow else { return }
        guard aspectRatio > 0 else { return }

        // In native fullscreen the video must keep the whole screen; constraining
        // or reframing the window here would letterbox the content inside the
        // fullscreen space. Park the ratio and replay it on fullscreen exit.
        if isPlayerWindowFullScreen {
            pendingFullScreenAspectRatio = aspectRatio
            LoggingService.shared.debug(
                "resizeToFitAspectRatio deferred while fullscreen aspect=\(aspectRatio)",
                category: .player
            )
            return
        }

        // Always update the aspect-ratio lock, even if we end up not resizing here.
        Self.applyAspectRatioConstraint(aspectRatio, to: window)

        // Get screen bounds
        guard let screen = window.screen ?? NSScreen.main else { return }
        let screenFrame = screen.visibleFrame

        // Calculate target size
        let targetSize = calculateWindowSize(for: aspectRatio, screenFrame: screenFrame)

        // Calculate new frame centered on current position
        let currentFrame = window.frame
        let newOrigin = NSPoint(
            x: currentFrame.midX - targetSize.width / 2,
            y: currentFrame.midY - targetSize.height / 2
        )
        let newFrame = NSRect(origin: newOrigin, size: targetSize)

        // Ensure frame stays on screen
        let adjustedFrame = constrainToScreen(newFrame, screen: screen)

        // The first resize after each open snaps regardless of the requested
        // animation, so the player appears at its final fixed layout instead of
        // animating (the video/controls track the window's live size). Later
        // resizes — e.g. switching to a different-aspect video while the window
        // stays open — honor `animated`.
        let effectiveAnimated = animated && hasCompletedInitialSizing
        hasCompletedInitialSizing = true

        LoggingService.shared.debug(
            "resizeToFitAspectRatio aspect=\(aspectRatio) requested=\(animated) effective=\(effectiveAnimated) from=\(window.frame.size) to=\(adjustedFrame.size)",
            category: .player
        )

        // Apply the new frame
        window.setFrame(adjustedFrame, display: true, animate: effectiveAnimated)
    }

    /// Locks the window's resize behavior to the given aspect ratio without
    /// changing the current frame. Use this when the auto-resize setting is
    /// disabled but we still want manual resizing to be ratio-locked.
    func lockAspectRatio(_ aspectRatio: Double) {
        guard let window = playerWindow else { return }
        guard aspectRatio > 0 else { return }
        if isPlayerWindowFullScreen {
            pendingFullScreenAspectRatio = aspectRatio
            return
        }
        Self.applyAspectRatioConstraint(aspectRatio, to: window)
    }

    // MARK: - Private Helpers

    /// Sets `contentAspectRatio` and a ratio-consistent minimum content size on
    /// the window so that interactive resize couples width and height
    /// proportionally and can't shrink below a usable minimum.
    static func applyAspectRatioConstraint(_ aspectRatio: Double, to window: NSWindow) {
        guard aspectRatio > 0 else { return }

        // contentAspectRatio is expressed as a ratio; using (aspectRatio, 1) keeps it exact.
        window.contentAspectRatio = NSSize(width: aspectRatio, height: 1)

        // Derive a minimum size that lies on the same ratio so the lower bound
        // doesn't force the window off-ratio (which would re-introduce bars).
        // Anchor on minHeight and scale width by aspect.
        let derivedMinWidth = max(Self.minHeight * CGFloat(aspectRatio), 320)
        let minContentSize = NSSize(width: derivedMinWidth, height: Self.minHeight)

        // Best-effort minimum. NSHostingController resets these to zero at runtime,
        // so the real resize floor is enforced in the window delegate's
        // `windowWillResize(_:to:)`; these are kept for any code path that reads
        // the window's stored minimum before the reset happens.
        window.contentMinSize = minContentSize
        window.minSize = minContentSize
    }

    private func configureWindowLevel(_ window: NSWindow, floating: Bool) {
        if floating {
            window.level = .floating
            window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
        } else {
            window.level = .normal
            window.collectionBehavior = [.managed, .fullScreenPrimary]
        }
    }

    /// Initial window size for a given aspect ratio. Delegates to the shared
    /// `fittedPlayerSize` so the opening size and the later aspect-fitted size use
    /// identical math (no mismatch → no grow when the two are compared).
    private func calculateInitialWindowSize(aspectRatio: Double) -> NSSize {
        let ratio = aspectRatio > 0 ? aspectRatio : Self.defaultAspectRatio
        guard let screen = NSScreen.main else {
            return Self.fittedPlayerSize(
                for: ratio,
                screenFrame: NSRect(x: 0, y: 0, width: 1280, height: 720)
            )
        }
        return Self.fittedPlayerSize(for: ratio, screenFrame: screen.visibleFrame)
    }

    private func calculateWindowSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
        Self.fittedPlayerSize(for: aspectRatio, screenFrame: screenFrame)
    }

    /// Sizing math for the standalone-window path.
    /// Anchors on `targetVideoHeight`, derives width from the aspect ratio, then
    /// clamps to `maxScreenRatio` of the screen and the minimum size.
    static func fittedPlayerSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
        let maxWidth = screenFrame.width * maxScreenRatio
        let maxHeight = screenFrame.height * maxScreenRatio

        var width: CGFloat
        var height: CGFloat

        // Start with target video height and calculate width from aspect ratio
        height = targetVideoHeight
        width = height * aspectRatio

        // Scale down if too wide for screen
        if width > maxWidth {
            width = maxWidth
            height = width / aspectRatio
        }

        // Scale down if too tall for screen
        if height > maxHeight {
            height = maxHeight
            width = height * aspectRatio
        }

        // Apply minimum constraints
        width = max(width, minWidth)
        height = max(height, minHeight)

        return NSSize(width: width, height: height)
    }

    private func constrainToScreen(_ frame: NSRect, screen: NSScreen) -> NSRect {
        let screenFrame = screen.visibleFrame
        var adjustedFrame = frame

        // Ensure width/height don't exceed screen
        adjustedFrame.size.width = min(adjustedFrame.size.width, screenFrame.width)
        adjustedFrame.size.height = min(adjustedFrame.size.height, screenFrame.height)

        // Adjust origin to keep on screen
        if adjustedFrame.minX < screenFrame.minX {
            adjustedFrame.origin.x = screenFrame.minX
        }
        if adjustedFrame.maxX > screenFrame.maxX {
            adjustedFrame.origin.x = screenFrame.maxX - adjustedFrame.width
        }
        if adjustedFrame.minY < screenFrame.minY {
            adjustedFrame.origin.y = screenFrame.minY
        }
        if adjustedFrame.maxY > screenFrame.maxY {
            adjustedFrame.origin.y = screenFrame.maxY - adjustedFrame.height
        }

        return adjustedFrame
    }
}

/// One-shot gate shared by the fullscreen-exit notification and its timeout
/// fallback: whichever fires first runs the completion, the other is a no-op.
/// Both fire on the main thread.
private final class FullScreenExitWaiter: @unchecked Sendable {
    var observer: NSObjectProtocol?
    private var completed = false

    func finish(_ completion: @Sendable () -> Void) {
        guard !completed else { return }
        completed = true
        if let observer {
            NotificationCenter.default.removeObserver(observer)
            self.observer = nil
        }
        completion()
    }
}

// MARK: - NSWindowDelegate

extension ExpandedPlayerWindowManager: NSWindowDelegate {
    /// Clamp interactive resize to a usable minimum size.
    ///
    /// Once `contentAspectRatio` is set, AppKit's aspect-ratio resize handler
    /// takes over and stops enforcing `minSize`/`contentMinSize` during live
    /// drags; NSHostingController also zeroes those stored minimums at runtime.
    /// So the floor is computed here from constants and applied to the proposed
    /// (already aspect-correct) frame, growing any under-sized proposal back up
    /// while preserving its ratio.
    nonisolated func windowWillResize(_: NSWindow, to frameSize: NSSize) -> NSSize {
        MainActor.assumeIsolated {
            // Enforce the floor from constants here rather than from
            // `window.minSize`/`contentMinSize`: NSHostingController resets those
            // to zero at runtime (even with `sizingOptions = []`), so the window's
            // stored minimum can't be trusted. AppKit already applies
            // `contentAspectRatio` to `frameSize`, so we only need to grow an
            // under-sized proposal back up to the minimum while preserving its
            // (already correct) aspect ratio.
            let aspect = frameSize.height > 0
                ? frameSize.width / frameSize.height
                : CGFloat(Self.defaultAspectRatio)

            var height = max(frameSize.height, Self.minHeight)
            var width = height * aspect

            // Guard the width floor too (for narrow/portrait videos where the
            // height minimum alone would leave the window too thin), then rederive
            // height so the result stays on the proposed aspect ratio.
            let minWidthFloor = max(Self.minHeight * aspect, 320)
            if width < minWidthFloor {
                width = minWidthFloor
                height = aspect > 0 ? width / aspect : height
            }

            return NSSize(width: width, height: height)
        }
    }

    nonisolated func windowShouldClose(_ sender: NSWindow) -> Bool {
        // Handle close ourselves to avoid deallocation race conditions
        MainActor.assumeIsolated {
            // Clear reference first so hide() becomes a no-op
            playerWindow = nil

            // Clear queue so closing the window fully ends the session,
            // matching the close button behavior
            appEnvironment?.queueManager.clearQueue()

            // Stop player BEFORE cleaning up window to avoid crash
            // The player must be stopped while views still exist to ensure
            // proper cleanup of render resources
            appEnvironment?.playerService.stop()

            // Update navigation state
            // Set collapsing first so mini player shows video immediately
            let navigationCoordinator = appEnvironment?.navigationCoordinator
            navigationCoordinator?.isPlayerCollapsing = true
            navigationCoordinator?.isPlayerExpanded = false

            // Clean up window, leaving native fullscreen first (orderOut on a
            // fullscreen window strands its black fullscreen space on screen)
            exitFullScreenIfNeeded(sender) {
                Task { @MainActor in
                    sender.delegate = nil
                    sender.contentViewController = nil
                    sender.orderOut(nil)

                    // Unlike hide(), this path has no animation completion to reset the
                    // flag. Left stuck true, the mini capsule mounts its video container
                    // forever and hijacks the shared render view on the next expand
                    // (player window stays black while the capsule renders the video).
                    navigationCoordinator?.isPlayerCollapsing = false
                }
            }
        }
        // Return false - we've already hidden the window with orderOut
        return false
    }

    nonisolated func windowDidExitFullScreen(_ notification: Notification) {
        MainActor.assumeIsolated {
            guard let window = playerWindow else { return }
            // Restore the pinned (floating) config that toggleFullScreen
            // dropped so the window could enter primary fullscreen.
            let floating = appEnvironment?.settingsManager.macPlayerFloating ?? false
            configureWindowLevel(window, floating: floating)

            // Replay the aspect ratio that arrived while fullscreen (queue
            // advanced to a different-aspect video): restore the manual-resize
            // ratio lock, and auto-fit the window if that setting is on —
            // exactly what the aspect-change handler would have done windowed.
            if let ratio = pendingFullScreenAspectRatio {
                pendingFullScreenAspectRatio = nil
                Self.applyAspectRatioConstraint(ratio, to: window)
                if appEnvironment?.settingsManager.playerSheetAutoResize == true {
                    resizeToFitAspectRatio(ratio, animated: false)
                }
            }
        }
    }
}

// MARK: - Two-Phase Window Root

/// Root view hosted in the expanded player window.
///
/// Shows a cheap black background + spinner on the first frame so the window
/// composites and fades in immediately, then defers building the heavy
/// `ExpandedPlayerSheet` by one runloop — after the window is already on screen.
/// This is the macOS equivalent of the instant loading feedback iOS shows while
/// its player window renders, and removes the perceptible "dead gap" between the
/// click and the window appearing.
private struct ExpandedPlayerWindowRoot: View {
    @State private var showFullPlayer = false

    var body: some View {
        ZStack {
            // Fills the window and matches the player's black background so the
            // swap to ExpandedPlayerSheet is seamless.
            Color.black

            if showFullPlayer {
                ExpandedPlayerSheet()
            } else {
                ProgressView()
                    .controlSize(.large)
                    .tint(.white)
            }
        }
        .ignoresSafeArea()
        .onAppear {
            // Defer so the cheap branch composites first, then build the real
            // player while the window is already visible.
            DispatchQueue.main.async { showFullPlayer = true }
        }
    }
}

// MARK: - Main Content Window Reader

/// Invisible background view that registers ContentView's hosting window with
/// `ExpandedPlayerWindowManager`, so the inline overlay always targets the real
/// main content window (mainWindow/keyWindow point elsewhere when e.g. the
/// Settings window has focus).
struct MainContentWindowReader: NSViewRepresentable {
    func makeNSView(context _: Context) -> ReaderView {
        ReaderView()
    }

    func updateNSView(_: ReaderView, context _: Context) {}

    final class ReaderView: NSView {
        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            guard let window else { return }
            Task { @MainActor in
                ExpandedPlayerWindowManager.shared.registerMainContentWindow(window)
            }
        }
    }
}

#endif
