import SwiftUI
import AppKit
import Observation
import NucleicCore

/// Whether the floating PiP monitor is on screen right now, published so the *docked* ``VMMonitorPanel``
/// can idle its capture loop while the PiP is showing the very same guest — one screen, one poller.
@MainActor
@Observable
final class VMMonitorPiPState {
    static let shared = VMMonitorPiPState()
    private(set) var visibleSessionID: SessionID?
    /// The background VM the user last hand-picked from the fanned-out stack (see ``VMMonitorPiPController``).
    /// A *transient bias*, not a hard pin: `RootView.watchAutoVMMonitor` feeds it to the PiP selector so
    /// the pick floats first while it's still a running background VM, and clears it once that VM is gone
    /// (or its chat is opened), at which point the auto-driver resumes.
    var userFeaturedSessionID: SessionID?

    fileprivate func setVisibleSession(_ sessionID: SessionID?) { visibleSessionID = sessionID }
    private init() {}
}

/// Drives the featured PiP card's *stack hint*: when other background VMs are available the edge of one
/// more card peeks out on the side the deck fans toward, and that peek hides while the deck is fanned
/// open. Reactive so toggling these never rebuilds the hosting view (which would restart the capture).
@MainActor
@Observable
final class VMMonitorPiPStackModel {
    /// There is more than one background VM, so hint a stack by peeking a card edge on the fan side.
    var hasOthers = false
    /// Which side the deck fans toward (and the peek shows on): `true` = trailing/right, `false` = leading/left.
    var fanRight = true
    /// The deck is currently fanned open — hide the resting peek while the real sibling cards are out.
    var fanned = false
    fileprivate init() {}
}

/// A periodic still-image cache for the PiP deck's **fanned** cards. The featured card renders a live
/// ``VMMonitorPanel`` (30 Hz); the fan cards — only ever glimpsed while the deck slides open — do not
/// need that, so they show a screen grab refreshed every few seconds instead. Capturing many live
/// monitors mid-slide is exactly what made the fan-out stutter; a handful of lazy grabs costs a tiny
/// fraction of that and keeps the slide cheap while still reading as the guest's screen.
///
/// ``VMMonitorPiPController`` feeds it the deck's current non-featured candidates. It captures each
/// guest's framebuffer host-side on a lazy interval — the same path ``VMMonitorPanel`` uses — and
/// republishes the images; cards observe it, so a refreshed grab updates them in place without rebuilding
/// the hosting view (which would restart nothing, since these cards have no capture loop to restart).
@MainActor
@Observable
final class VMMonitorPiPSnapshotStore {
    static let shared = VMMonitorPiPSnapshotStore()

    /// Latest still per session, keyed by session id. Republished as grabs land; fan cards read from here.
    private(set) var snapshots: [SessionID: NSImage] = [:]
    /// Overlay state captured alongside each still. Fan cards are intentionally not live monitors, so the
    /// cache must explicitly carry this state as well as pixels or they appear active regardless of the VM.
    private(set) var overlayStates: [SessionID: VMMonitorOverlayState] = [:]

    /// The sessions to keep stills for (the deck's non-featured candidates) and the store to capture with.
    private var sessions: [Session] = []
    private weak var store: AppStore?
    private var captureTask: Task<Void, Never>?

    /// How often each fanned candidate's still is refreshed. A few seconds: the fan cards are peripheral
    /// glances, not the focus, so this is plenty fresh while costing a tiny fraction of the featured card's
    /// 30 Hz capture — and, crucially, adds nothing to the main thread during the slide-out animation.
    private static let refreshInterval: Duration = .seconds(3)

    private init() {}

    /// Point the cache at the deck's current non-featured candidates. Starts the capture loop when there's
    /// anything to grab and stops it (dropping stale stills) when there isn't; restarts on a changed set so
    /// a promoted or removed card can't linger. Idempotent for an unchanged candidate set.
    func setCandidates(_ sessions: [Session], store: AppStore) {
        self.store = store
        let ids = sessions.map(\.id)
        let unchanged = ids == self.sessions.map(\.id)
        self.sessions = sessions
        // Drop stills for sessions no longer in the deck so a card can't flash a stale grab from before.
        snapshots = snapshots.filter { ids.contains($0.key) }
        overlayStates = overlayStates.filter { ids.contains($0.key) }
        guard !sessions.isEmpty else { clear(); return }
        if !unchanged || captureTask == nil { restart() }
    }

    /// The latest still for a session, or `nil` while its first grab is still in flight.
    func image(for id: SessionID) -> NSImage? { snapshots[id] }

    /// The paused/background-working/waiting treatment belonging to the latest candidate-state pass.
    func overlayState(for id: SessionID) -> VMMonitorOverlayState? { overlayStates[id] }

    /// Stop capturing and drop every still — the deck is gone (PiP hidden/closed or down to one VM).
    func clear() {
        captureTask?.cancel()
        captureTask = nil
        sessions = []
        if !snapshots.isEmpty { snapshots = [:] }
        if !overlayStates.isEmpty { overlayStates = [:] }
    }

    private func restart() {
        captureTask?.cancel()
        captureTask = Task { @MainActor [weak self] in
            while let self, !Task.isCancelled {
                await self.captureAll()
                try? await Task.sleep(for: Self.refreshInterval)
            }
        }
    }

    /// One pass: capture each candidate whose guest is running (matching either the macOS or Linux sibling,
    /// as ``VMMonitorPanel`` does), decode off the main actor, and publish. Keeps the previous still through
    /// a single failed grab — a transient miss shouldn't blank a card; a candidate with no running guest is
    /// dropped so its card falls back to the "off" stage rather than a frozen image of a stopped VM.
    private func captureAll() async {
        guard let store else { return }
        let running = await store.runningMacVMs()
        for session in sessions {
            let names = [MacVMManager.vmName(for: session.id), MacVMManager.linuxVMName(for: session.id)]
            guard let entry = running.first(where: { names.contains($0.name) }) else {
                snapshots[session.id] = nil
                overlayStates[session.id] = nil
                continue
            }
            if let data = await store.captureMacVMScreenData(name: entry.name),
               let image = await Self.decode(data) {
                snapshots[session.id] = image
            }
            if entry.paused {
                overlayStates[session.id] = .paused
            } else if await store.macVMWorkingInBackground(name: entry.name) {
                overlayStates[session.id] = .workingInBackground
            } else if await store.macVMIsWaiting(name: entry.name) {
                overlayStates[session.id] = .waiting
            } else {
                overlayStates[session.id] = nil
            }
        }
    }

    /// Decode a JPEG grab into an `NSImage` off the main actor (a full-frame decode is too heavy for the
    /// main thread), handed back in an `UncheckedSendableBox` as ``VMMonitorPanel/decode(_:)`` does.
    private static func decode(_ data: Data) async -> NSImage? {
        await Task.detached(priority: .utility) {
            UncheckedSendableBox(value: NSImage(data: data))
        }.value.value
    }
}

/// **Picture-in-Picture VM monitor.** A small always-on-top window that shows a background chat's live VM
/// screen so the user can keep watching that guest while working in — and over — any other window, even
/// another app. It hosts the same read-only ``VMMonitorPanel`` the in-chat monitor uses, so like every
/// other VM interface surface it is **look-only**: it merely polls and renders the guest framebuffer
/// (no `VZVirtualMachineView`, no key window, no synthesized HID), and therefore **never captures the
/// host's mouse or keyboard**. The floating window itself is a non-activating panel that can't become
/// key/main, so clicking or dragging it never steals focus or routes input at the guest.
///
/// **Stacking + fan-out.** When more than one background session has a running VM, the featured card
/// hints a *deck*: the edge of one more card peeks out on the side there is room to fan toward. Hovering
/// slides the deck open horizontally — a sibling PiP window per other background VM, each **sliding out
/// from behind** the card in front of it (see ``fanOut(store:)``), coming to rest in a non-overlapping
/// row straight off to the side (never into a corner). The fan cards render a **still refreshed every few
/// seconds** (``VMMonitorPiPSnapshotStore``) rather than a live 30 Hz monitor, so sliding the deck open
/// stays smooth; only the featured card is live. Clicking a fanned card promotes that VM to the
/// featured "top" window (``select(_:store:)``) — a transient pick the auto-driver keeps until the VM
/// stops or its chat is opened. Hover uses an `.activeAlways` `NSTrackingArea` (see ``HoverTrackingView``)
/// rather than SwiftUI `.onHover`, because the PiP floats over *other* apps while Nucleic is in the
/// background, where SwiftUI hover (active-app only) would never fire.
///
/// Driven by `RootView.watchAutoVMMonitor`: while the "Float VM monitors in Picture in Picture" setting
/// is on (default) and a background chat has a VM running, the PiP follows the conversation — appearing
/// for that chat's guest and retracting when there is none. It also stands down whenever the docked
/// monitor is already showing that guest in the foreground; the docked panel in turn idles its capture
/// loop while the PiP is up, so only ever one of the two polls the featured guest.
///
/// The window is deliberately chrome-less — no title bar, no border — so the guest's screen is the whole
/// of it. A manual close sticks until the user switches chats (or the VM goes away), so the driver
/// doesn't fight the red button.
@MainActor
final class VMMonitorPiPController: NSObject, NSWindowDelegate {
    static let shared = VMMonitorPiPController()

    private var panel: NSPanel?
    /// The session the featured window is currently rendering, so content is rebuilt only when it changes.
    private var shownSession: SessionID?
    /// The session whose PiP the user explicitly closed — kept closed until they switch chats (or its
    /// VM disappears), so the 2-second auto-driver doesn't immediately reopen it under them.
    private var dismissedSession: SessionID?

    /// The ordered background sessions with running guests (featured first), as handed in by RootView.
    /// The featured window shows `candidates.first`; hovering fans the rest.
    private var candidates: [Session] = []
    /// The transient fan-out windows, one per non-featured candidate, keyed by session. Non-empty only
    /// while the deck is fanned open (on hover); torn down on collapse. Each renders a cached still from
    /// ``snapshotStore`` (not a live capture loop), so building and sliding them stays cheap.
    private var fanPanels: [SessionID: NSPanel] = [:]
    /// The fanned cards in slot order (nearest first), so the deck can be re-laid-out in formation when
    /// the featured window is dragged.
    private var fanOrder: [SessionID] = []
    private var fanShown = false
    /// Reactive stack hint (peek edge / fan side / fanned) shared into the featured card's SwiftUI content.
    private let stackModel = VMMonitorPiPStackModel()
    /// Periodic still-image cache backing the *fanned* cards (see ``VMMonitorPiPSnapshotStore``). The
    /// featured card stays live at 30 Hz; the deck's siblings render these lazily-refreshed stills, so
    /// sliding many cards open no longer spins up a live capture loop per card mid-animation.
    private let snapshotStore = VMMonitorPiPSnapshotStore.shared
    /// The `AppStore` from the most recent `show`, so a hover (which originates in a tracking-area
    /// callback, not a RootView call) can build fan cards without threading the store through.
    private var currentStore: AppStore?

    /// Which windows the cursor is currently over — the featured one keys on `"featured"`, each fan card
    /// on its session's raw id. The fan stays open while this is non-empty and collapses (after a short
    /// grace) once it drains, so crossing the gaps between cards doesn't dismiss it.
    private var hoveredTokens: Set<String> = []
    private static let featuredToken = "featured"
    /// Debounces collapse so moving the cursor between the featured window and a fan card — briefly over
    /// neither — doesn't retract the fan.
    private var collapseTask: Task<Void, Never>?
    private static let collapseGrace: Duration = .milliseconds(250)

    /// Gap between adjacent fanned cards so they never overlap (they slide clear of one another).
    private static let fanGap: CGFloat = 12
    // Brisk slides. The cards move origin-only (no resize) and are animated with `display: false` and
    // no window shadow, so the deck glides instead of stuttering the way shadowed transparent windows do.
    private static let fanOutDuration: TimeInterval = 0.19
    private static let fanInDuration: TimeInterval = 0.15

    /// Width of the tab that extends off the featured card's fan side when other VMs are available — the
    /// edge of the next card, sticking out *beside* the monitor rather than eating into it. The featured
    /// window is grown by this on the fan side so the monitor keeps its full size (see `syncFeaturedFrame`).
    private static let tabWidth: CGFloat = 16
    /// Whether the featured window currently includes the tab strip, and on which side (`true` = trailing),
    /// so the window can be grown/shrunk and the card region computed against the real geometry.
    private var tabShown = false
    private var tabSide = true

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

    /// Show (or update) the floating PiP. `session` is the featured guest; `candidates` is the ordered
    /// background sessions (featured first) that the deck fans out to. Idempotent: re-showing the same
    /// featured chat just refreshes the stack hint and brings it forward. Respects a manual close.
    func show(session: Session, candidates: [Session], store: AppStore) {
        currentStore = store
        self.candidates = candidates
        // Honor the user's hand-picked "top" card over RootView's computed `session` while that pick is
        // still a live candidate: RootView's cached featured lags a click by up to a poll, and an
        // event-driven re-sync in that window would otherwise revert the featured window — rebuilding its
        // content (and restarting the capture) so both cards flash the "No VM" placeholder.
        let featured = Self.resolveFeatured(session: session, candidates: candidates)
        // The user closed the PiP for exactly this chat's VM — honor that until the chat changes.
        if dismissedSession == featured.id { return }
        dismissedSession = nil

        if let panel {
            if shownSession != featured.id {
                panel.contentView = featuredContainer(for: featured, store: store)
                shownSession = featured.id
            }
            updateStackHint()
            syncSnapshotCandidates()
            if !panel.isVisible { panel.orderFrontRegardless() }
            VMMonitorPiPState.shared.setVisibleSession(featured.id)
            return
        }

        let panel = makePanel(aspectLocked: false)
        panel.delegate = self
        panel.contentView = featuredContainer(for: featured, store: store)
        Self.positionTopTrailing(panel)
        panel.orderFrontRegardless()
        self.panel = panel
        self.shownSession = featured.id
        updateStackHint()
        syncSnapshotCandidates()
        VMMonitorPiPState.shared.setVisibleSession(featured.id)
    }

    /// Point the still-image cache at the deck's current *non-featured* candidates, so their fan cards
    /// have a fresh grab ready the instant the deck slides open. The featured guest is excluded — its
    /// card is the live ``VMMonitorPanel`` and needs no still.
    private func syncSnapshotCandidates() {
        guard let store = currentStore else { return }
        snapshotStore.setCandidates(candidates.filter { $0.id != shownSession }, store: store)
    }

    /// Honor the user's hand-picked "top" card over RootView's computed `session` while that pick is still
    /// among the live candidates; otherwise take RootView's choice. Keeps the featured window stable
    /// through the brief window where RootView's cached selection trails a fan-card click.
    private static func resolveFeatured(session: Session, candidates: [Session]) -> Session {
        if let pick = VMMonitorPiPState.shared.userFeaturedSessionID,
           let match = candidates.first(where: { $0.id == pick }) {
            return match
        }
        return session
    }

    /// Retract the PiP (no background chat has a VM, the docked monitor took over, or the setting was
    /// turned off). Keeps the panel instance for reuse and clears any manual-dismissal so a returning VM
    /// shows again.
    func hide() {
        collapseFan(animated: false)
        snapshotStore.clear()
        panel?.orderOut(nil)
        shownSession = nil
        dismissedSession = nil
        VMMonitorPiPState.shared.setVisibleSession(nil)
    }

    /// The ⌘W close sticks for the current chat's VM until the user switches chats.
    func windowWillClose(_ notification: Notification) {
        collapseFan(animated: false)
        snapshotStore.clear()
        dismissedSession = shownSession
        VMMonitorPiPState.shared.setVisibleSession(nil)
    }

    /// Follow the featured window as the user drags it: keep the whole fanned deck in formation, or (when
    /// not fanned) just re-aim the resting peek toward whichever side now has room.
    func windowDidMove(_ notification: Notification) {
        if fanShown { repositionFan() } else { updateStackHint() }
    }

    // MARK: - Stack hint

    /// Refresh the resting stack hint from the current candidates and window position: show the tab (and
    /// the deck it fans into) only when there are other VMs, on whichever side has room, and grow/shrink
    /// the featured window so the tab is an extension beside the monitor rather than a margin inside it.
    private func updateStackHint() {
        stackModel.hasOthers = candidates.count > 1
        // Re-aim the fan side only while the deck is closed — flipping it mid-fan would jump the cards to
        // the other side and re-shuffle the tab under them.
        if panel != nil, !fanShown {
            stackModel.fanRight = Self.fansRight(for: cardFrame(), screen: panel?.screen)
        }
        syncFeaturedFrame()
    }

    /// The on-screen rect of just the monitor card — the featured window minus the tab strip it grows on
    /// the fan side. Fan cards are sized and positioned against this (not the wider window), so the deck
    /// lines up with the monitor and not its tab.
    private func cardFrame() -> NSRect {
        guard let panel else { return .zero }
        let frame = panel.frame
        guard tabShown else { return frame }
        let x = tabSide ? frame.minX : frame.minX + Self.tabWidth  // tabSide (== fanRight) ⇒ card on the left
        return NSRect(x: x, y: frame.minY, width: frame.width - Self.tabWidth, height: frame.height)
    }

    /// Grow or shrink the featured window so it carries the tab strip on the fan side (and only then),
    /// keeping the monitor card's on-screen region fixed. Skipped while fanned — resizing the window mid-
    /// fan would break the deck's formation; the tab reappears when the fan collapses.
    private func syncFeaturedFrame() {
        guard let panel, !fanShown else { return }
        let wantTab = stackModel.hasOthers
        let wantSide = stackModel.fanRight
        guard wantTab != tabShown || (wantTab && wantSide != tabSide) else { return }
        var frame = panel.frame
        if tabShown {  // strip the current tab back to the bare card frame
            frame.size.width -= Self.tabWidth
            if !tabSide { frame.origin.x += Self.tabWidth }
        }
        if wantTab {   // grow the tab on the wanted side, holding the card region in place
            frame.size.width += Self.tabWidth
            if !wantSide { frame.origin.x -= Self.tabWidth }
        }
        tabShown = wantTab
        tabSide = wantSide
        panel.setFrame(frame, display: true)
    }

    // MARK: - Fan-out

    /// Fan the deck open: a sibling PiP window per non-featured background VM. Each starts stacked exactly
    /// behind the featured card (and ordered below it) and **slides out from behind** it — and from behind
    /// the card ahead of it — into a non-overlapping row straight off to the side. Cheap to build and tear
    /// down: each card renders a cached still (``snapshotStore``), not a live capture, so no per-card poll
    /// spins up mid-slide — the windows exist only while hovering.
    private func fanOut(store: AppStore) {
        guard let panel, candidates.count > 1 else { return }
        let others = candidates.filter { $0.id != shownSession }
        guard !others.isEmpty else { return }
        collapseFan(animated: false)  // clear any stragglers before rebuilding
        // Everything is laid out against the monitor card region, not the tab-widened window, so the deck
        // starts flush behind the monitor and steps by exact card widths.
        let card = cardFrame()
        let right = stackModel.fanRight
        stackModel.fanned = true
        let slots = Self.fanSlots(around: card, screen: panel.screen, count: others.count, right: right)

        // Slide each card out from *behind* the one ahead of it: card 0 sits just under the featured
        // window, card 1 just under card 0, and so on, all starting fully occluded at the monitor region.
        var below = panel.windowNumber
        var animations: [(NSPanel, NSRect)] = []
        for (index, session) in others.enumerated() {
            let sibling = makePanel(shadow: false)  // shadowless: shadowed transparent windows stutter mid-slide
            sibling.contentView = fanContainer(for: session, store: store)
            sibling.setFrame(card, display: false)
            sibling.order(.below, relativeTo: below)
            below = sibling.windowNumber
            fanPanels[session.id] = sibling
            fanOrder.append(session.id)
            animations.append((sibling, index < slots.count ? slots[index] : card))
        }
        fanShown = true

        // Origin-only moves with `display: false` — the content is already rendered, so the window just
        // glides rather than re-rasterizing every frame.
        NSAnimationContext.runAnimationGroup { context in
            context.duration = Self.fanOutDuration
            context.timingFunction = CAMediaTimingFunction(name: .easeOut)
            for (card, slot) in animations { card.animator().setFrame(slot, display: false) }
        }
    }

    /// Re-lay the fanned deck around the featured window's current frame, in formation and without
    /// animation, so dragging the featured card carries the whole stack with it.
    private func repositionFan() {
        guard fanShown, let panel, !fanOrder.isEmpty else { return }
        let slots = Self.fanSlots(
            around: cardFrame(), screen: panel.screen, count: fanOrder.count, right: stackModel.fanRight)
        for (index, id) in fanOrder.enumerated() where index < slots.count {
            fanPanels[id]?.setFrame(slots[index], display: false)
        }
    }

    /// Retract every fanned card (sliding them back behind the featured window when `animated`) and drop
    /// their hover tokens — the featured window's is kept if the cursor is still over it. Cancels the
    /// pending collapse timer and un-hides the resting peek.
    private func collapseFan(animated: Bool) {
        collapseTask?.cancel()
        collapseTask = nil
        let cards = Array(fanPanels.values)
        fanPanels.removeAll()
        fanOrder.removeAll()
        fanShown = false
        stackModel.fanned = false
        hoveredTokens = hoveredTokens.intersection([Self.featuredToken])
        guard !cards.isEmpty else { return }
        guard animated, panel != nil else {
            for card in cards { card.orderOut(nil) }
            return
        }
        let home = cardFrame()  // slide back behind the monitor region, not the tab-widened window
        NSAnimationContext.runAnimationGroup({ context in
            context.duration = Self.fanInDuration
            context.timingFunction = CAMediaTimingFunction(name: .easeIn)
            for card in cards { card.animator().setFrame(home, display: false) }
        }, completionHandler: {
            // AppKit fires this on the main thread once the slide-back finishes; assert that isolation
            // so ordering the (main-actor) windows out doesn't trip Swift 6 concurrency checking.
            MainActor.assumeIsolated { for card in cards { card.orderOut(nil) } }
        })
    }

    /// Promote a fanned card to the featured "top" window. Records the pick as a transient bias
    /// (`userFeaturedSessionID`) that RootView keeps until the VM stops or its chat opens, optimistically
    /// swaps the featured window's content so the click feels instant, and collapses the fan.
    private func select(_ session: Session, store: AppStore) {
        VMMonitorPiPState.shared.userFeaturedSessionID = session.id
        if let panel, shownSession != session.id {
            panel.contentView = featuredContainer(for: session, store: store)
            shownSession = session.id
            updateStackHint()
            VMMonitorPiPState.shared.setVisibleSession(session.id)
        }
        // The promoted card is now the live featured monitor and its former sibling(s) may have shifted:
        // re-aim the still cache at the new non-featured set so a stale grab can't linger on a fan card.
        syncSnapshotCandidates()
        collapseFan(animated: false)
    }

    // MARK: - Hover coordination

    private func hoverEnter(_ token: String) {
        hoveredTokens.insert(token)
        collapseTask?.cancel()
        collapseTask = nil
        if !fanShown, let store = currentStore { fanOut(store: store) }
    }

    private func hoverExit(_ token: String) {
        hoveredTokens.remove(token)
        guard hoveredTokens.isEmpty else { return }
        collapseTask?.cancel()
        collapseTask = Task { @MainActor [weak self] in
            try? await Task.sleep(for: Self.collapseGrace)
            guard let self, !Task.isCancelled, self.hoveredTokens.isEmpty else { return }
            self.collapseFan(animated: true)
        }
    }

    // MARK: - Geometry

    /// Which side the deck fans toward: whichever has more room between the featured window and the edge
    /// of its screen, so the cards slide into open space rather than off-screen. Defaults to the right.
    private static func fansRight(for featured: NSRect, screen: NSScreen?) -> Bool {
        guard let visible = (screen ?? NSScreen.main)?.visibleFrame else { return true }
        let roomRight = visible.maxX - featured.maxX
        let roomLeft = featured.minX - visible.minX
        return roomRight >= roomLeft
    }

    /// Frames for `count` fan cards laid out **straight to the side** in a single row — same height/`y` as
    /// the featured window, stepped by a full card width plus a gap so they never overlap. Each is clamped
    /// inside the visible frame. Computed from the live featured frame, so it follows a dragged window.
    static func fanSlots(around featured: NSRect, screen: NSScreen?, count: Int, right: Bool) -> [NSRect] {
        let size = featured.size
        let visible = (screen ?? NSScreen.main)?.visibleFrame
        let direction: CGFloat = right ? 1 : -1
        let step = size.width + fanGap
        return (1...max(count, 1)).prefix(count).map { i in
            var x = featured.origin.x + direction * step * CGFloat(i)
            if let visible { x = min(max(x, visible.minX), visible.maxX - size.width) }
            return NSRect(x: x, y: featured.origin.y, width: size.width, height: size.height)
        }
    }

    /// Park a freshly-created PiP in the top-trailing corner of the main screen — the conventional
    /// PiP home, out of the way of most window content.
    private static func positionTopTrailing(_ panel: NSPanel) {
        guard let screen = NSScreen.main else { panel.center(); return }
        let visible = screen.visibleFrame
        let size = panel.frame.size
        let margin: CGFloat = 16
        let origin = NSPoint(
            x: visible.maxX - size.width - margin,
            y: visible.maxY - size.height - margin)
        panel.setFrameOrigin(origin)
    }

    // MARK: - Window + content construction

    /// A chrome-less, non-activating floating panel: no title bar and no border, so the window *is* the
    /// guest screen. `.nonactivatingPanel` keeps a click from activating the app, and ``NonKeyPanel``
    /// refuses key/main outright — belt-and-suspenders against ever capturing input. Locked to the guest's
    /// 16:10 aspect (content letterboxes anyway). Shared by the featured window and every fan card.
    private func makePanel(shadow: Bool = true, aspectLocked: Bool = true) -> NSPanel {
        let panel = NonKeyPanel(
            contentRect: NSRect(x: 0, y: 0, width: 320, height: 200),
            styleMask: [.borderless, .resizable, .nonactivatingPanel],
            backing: .buffered, defer: false)
        panel.isReleasedWhenClosed = false
        // No implicit AppKit window fades — the deck's motion is driven explicitly, and a stray system
        // animation on top of it is exactly the kind of thing that reads as jank.
        panel.animationBehavior = .none
        // Nothing behind the content to show through: the rounded corners are clipped by the SwiftUI
        // content, so the window itself must not paint its own square backdrop behind them. Fan cards
        // skip the shadow (see `fanOut`): compositing many shadowed transparent windows mid-slide stutters.
        panel.isOpaque = false
        panel.backgroundColor = .clear
        panel.hasShadow = shadow
        // Float above every other window — and every other app: a raised level, visible across Spaces
        // and over full-screen apps, and staying put when Nucleic isn't frontmost (that's the point of
        // a PiP).
        panel.level = .floating
        panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
        panel.hidesOnDeactivate = false
        panel.isFloatingPanel = true
        panel.isMovableByWindowBackground = true
        // Fan cards lock to the guest's 16:10 aspect on resize; the featured window doesn't, because it
        // grows a fixed-width tab strip on one side and so isn't a clean 16:10 while the tab is out.
        if aspectLocked { panel.aspectRatio = NSSize(width: 16, height: 10) }
        return panel
    }

    /// The featured window's content: the live monitor, wrapped in the reactive stack hint (a peeking
    /// card edge when other background VMs exist), draggable anywhere, and hover-tracked so entering it
    /// fans the deck.
    private func featuredContainer(for session: Session, store: AppStore) -> NSView {
        // The monitor is its own rounded card so the tab can be a *separate* rounded card beside it; the
        // whole window is not clipped as one shape (that would merge the tab into the monitor's outline).
        let monitor = VMMonitorPanel(session: session, isPictureInPicture: true)
            .environment(store)
            .frame(minWidth: 240, minHeight: 150)
            .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
        let root = AnyView(
            VMMonitorPiPStackHint(model: stackModel, tabWidth: Self.tabWidth) { monitor }
                // With the title bar gone there's no handle to move the window by, and
                // `isMovableByWindowBackground` never fires because the hosting view swallows the drag.
                // This gives the whole monitor back as the drag handle.
                .gesture(WindowDragGesture()))
        return hostContainer(root: root, token: Self.featuredToken)
    }

    /// A fanned card's content: the periodic *still* for one background VM (from ``snapshotStore``) rather
    /// than a live ``VMMonitorPanel`` — a card that is only ever glimpsed while the deck slides needs no
    /// 30 Hz capture loop of its own, and running one per card mid-animation is what made the fan-out
    /// stutter. Carries the same transparent tap-catcher that promotes it to the featured (live) card and
    /// the same hover tracking that keeps the fan open.
    private func fanContainer(for session: Session, store: AppStore) -> NSView {
        let root = AnyView(
            VMMonitorPiPSnapshotCard(store: snapshotStore, sessionID: session.id)
                .frame(minWidth: 240, minHeight: 150)
                .overlay(
                    Color.clear
                        .contentShape(Rectangle())
                        .onTapGesture { [weak self] in self?.select(session, store: store) })
                .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)))
        return hostContainer(root: root, token: session.id.rawValue)
    }

    /// Host a SwiftUI `root` in a hover-tracking container sized to fill the panel. The container's
    /// `.activeAlways` tracking area (see ``HoverTrackingView``) reports enter/exit even when Nucleic
    /// isn't the frontmost app — essential for a PiP that lives over other apps.
    private func hostContainer(root: some View, token: String) -> NSView {
        let hosting = NSHostingView(rootView: root)
        hosting.layer?.masksToBounds = true
        hosting.translatesAutoresizingMaskIntoConstraints = false
        let container = HoverTrackingView()
        container.addSubview(hosting)
        NSLayoutConstraint.activate([
            hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor),
            hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor),
            hosting.topAnchor.constraint(equalTo: container.topAnchor),
            hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor),
        ])
        container.onEnter = { [weak self] in self?.hoverEnter(token) }
        container.onExit = { [weak self] in self?.hoverExit(token) }
        return container
    }
}

/// A panel that refuses to become key or main, so it can never route the host's keyboard/mouse at the
/// guest — the read-only PiP monitor only ever displays a captured frame. Its close/resize/drag controls
/// still work (those are mouse-driven and need no key status).
private final class NonKeyPanel: NSPanel {
    override var canBecomeKey: Bool { false }
    override var canBecomeMain: Bool { false }
}

/// A content view that reports cursor enter/exit for the whole panel via an **`.activeAlways`** tracking
/// area. That option is the crux: the PiP floats over *other* apps while Nucleic is in the background,
/// where SwiftUI's `.onHover` (which tracks only in the active app) would never fire — so the hover
/// fan-out would be dead exactly when it's most useful. `.inVisibleRect` keeps the area glued to the
/// bounds across resizes without recomputing the rect.
private final class HoverTrackingView: NSView {
    var onEnter: (() -> Void)?
    var onExit: (() -> Void)?
    private var trackingArea: NSTrackingArea?

    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        if let trackingArea { removeTrackingArea(trackingArea) }
        let area = NSTrackingArea(
            rect: bounds,
            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
            owner: self, userInfo: nil)
        addTrackingArea(area)
        trackingArea = area
    }

    override func mouseEntered(with event: NSEvent) { onEnter?() }
    override func mouseExited(with event: NSEvent) { onExit?() }
}

/// A fanned card's content: the periodic still for one background VM held by ``VMMonitorPiPSnapshotStore``,
/// rendered as a plain image with no capture loop of its own — so the deck can slide many of these open
/// without the per-card 30 Hz work that made the fan-out stutter. Reads its still through the `@Observable`
/// store, so a refreshed grab re-renders it in place. Falls back to the same black "off monitor" stage
/// while the first grab is still in flight, matching a fresh ``VMMonitorPanel``.
private struct VMMonitorPiPSnapshotCard: View {
    let store: VMMonitorPiPSnapshotStore
    let sessionID: SessionID

    var body: some View {
        ZStack {
            if let image = store.image(for: sessionID) {
                ZStack {
                    Image(nsImage: image)
                        .resizable()
                        .interpolation(.medium)
                    if let state = store.overlayState(for: sessionID) {
                        VMMonitorStateOverlay(state: state)
                    }
                }
                .aspectRatio(image.size.width / image.size.height, contentMode: .fit)
            } else {
                Color.black
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

/// The featured PiP card's *stack hint*: when other background VMs are available (`model.hasOthers`) the
/// featured window is grown by `tabWidth` on the fan side (see ``VMMonitorPiPController/syncFeaturedFrame()``)
/// and the monitor is pinned to the far side at full size, so the edge of one more card sticks out **beside**
/// the monitor — an extension on the side of the card, not a margin carved out of it. The tab is the edge
/// of the next card (a dark rounded card behind the monitor, shifted toward the fan side); it hides while
/// the deck is fanned open, when the real sibling windows are out.
private struct VMMonitorPiPStackHint<Content: View>: View {
    let model: VMMonitorPiPStackModel
    let tabWidth: CGFloat
    @ViewBuilder let content: () -> Content

    var body: some View {
        let showTab = model.hasOthers            // window carries the tab strip whenever others exist
        let showCard = showTab && !model.fanned  // the peeking card fills it only while the deck is closed
        ZStack {
            if showCard {
                // The next card, tucked behind the monitor and shifted toward the fan side so exactly
                // `tabWidth` of its rounded edge sticks out past the monitor. A dark gray (not the
                // monitor's black) plus a hairline border reads as a *separate* card behind.
                RoundedRectangle(cornerRadius: 10, style: .continuous)
                    .fill(Color(white: 0.17))
                    .overlay(
                        RoundedRectangle(cornerRadius: 10, style: .continuous)
                            .strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
                    .padding(model.fanRight ? .leading : .trailing, tabWidth)
                    .transition(.opacity)
            }
            // Pin the full-size monitor to the side away from the tab; the padding places it in the card
            // region, it does not shrink it (the window grew by `tabWidth` to make room for the tab).
            content()
                .padding(model.fanRight ? .trailing : .leading, showTab ? tabWidth : 0)
        }
        .animation(.easeOut(duration: 0.18), value: showCard)
    }
}
