import SwiftUI
import AppKit
import NucleicCore

/// A live view of the session's VM screen — a "monitor" hung off the chat so the user can watch
/// the GUI state of the guest the virtual-machine service booted for it (what the agent is doing in it,
/// a build running, a simulator, Setup Assistant, …). Works for either guest OS: a session runs at most
/// one macOS *or* Linux VM at a time, and the monitor tracks whichever is up.
///
/// It polls the VM's current framebuffer up to 30 times a second (and only while its window is on
/// screen), always host-side from the guest's
/// display driver (the `VZVirtualMachineView` framebuffer — virtio-gpu on Linux, the Mac paravirtual
/// display on macOS), never via in-guest `screencapture`, and renders the latest frame scaled to fit.
/// It is **read-only**: it shows the screen, it doesn't drive it. When no VM is running for the chat —
/// or the guest can't be captured — it shows a status placeholder rather than a stale image.
struct VMMonitorPanel: View {
    let session: Session?
    /// True for the copy hosted inside ``VMMonitorPiPController``'s floating window. The docked copy
    /// (false) idles its capture loop whenever that floating one is up, so the same guest is never
    /// captured twice.
    var isPictureInPicture = false
    @Environment(AppStore.self) private var store
    /// The panel kinds sharing this monitor's column or bottom row. A full Nash Viewer among them
    /// already shows every row the under-screen log would, so the monitor gives its space back to
    /// the screen rather than printing the same stream twice a few inches apart.
    @Environment(\.panelRegionKinds) private var regionKinds

    /// Full Glass mode (Settings → Appearance). When on, the docked monitor's black stage gives way to
    /// the detail pane's glass so the blank canvas around the letterboxed guest joins the single sheet of
    /// material the rest of the window wears, instead of reading as an opaque slab dropped into the glass.
    @AppStorage(UltraGlass.enabledKey) private var ultraGlass = false

    @State private var frame: NSImage?
    @State private var status: Status = .idle
    /// The guest is running a headless `mac_vm_exec` command with no computer-use action in flight — its
    /// screen is static but it's busy. Drives the translucent "Working in the background…" overlay so a
    /// frozen-looking frame doesn't read as a hang. Only meaningful while a live frame is showing.
    @State private var workingInBackground = false
    /// The nash environment of whichever guest this monitor is currently showing — `mac-vm:<name>` or
    /// `linux-vm:<name>`, the same identity `ClaudeCodeBackend` attributes that VM's drained shell
    /// events to. It is what scopes the under-screen ``NashViewerPanel`` to this guest's shell, so it
    /// tracks the running VM rather than the session (a session has a name reserved for both guests,
    /// but only the one that is actually up has a shell to show). Nil when no VM is running.
    @State private var vmEnvironment: NashEnvironment?
    /// The guest is suspended (frozen in RAM) via a `mac_vm_control`/`linux_vm_control` `suspend`. Its
    /// screen is a still grabbed the instant it paused; we lay a dark "Paused" overlay over it so
    /// the frozen frame reads as deliberately stopped, not hung. Only meaningful over a live frame.
    @State private var paused = false
    /// The VM is powered on, but no agent operation currently owns it. Its live screen remains visible
    /// under a dark "Waiting" overlay so an idle guest reads as available rather than abandoned or hung.
    @State private var waiting = false
    /// In-flight white flashes, one per click. This monitor is read-only, so a click that lands on the
    /// guest screen does nothing — flashing the whole screen tells the user they're tapping on glass and
    /// their input isn't reaching the VM. Each flash self-removes when its animation finishes.
    @State private var clickFlashes: [ClickFlash] = []
    /// Whether this monitor's hosting window is actually on screen. Fed by ``WindowVisibilityReader`` from
    /// the window's occlusion state; when false (minimized, fully covered, or on another Space) ``poll()``
    /// stands its capture loop down so an unseen monitor never spends the main thread.
    @State private var windowVisible = true
    /// Target time between the *start* of consecutive live-frame captures — a 15 Hz budget. 15 fps is
    /// plenty for a live monitor thumbnail and keeps the main thread far clearer than a 30/60 Hz spin:
    /// the one main-actor step left in a capture is `cacheDisplay`, a full software render of the VM
    /// view's layer tree, so halving the cadence halves that per-second main-thread cost. The downscale +
    /// JPEG encode (surface side) and the JPEG decode (here) both run off the main actor, so ``poll()``
    /// sleeps the *remainder* of this budget after a capture and the budget actually paces the loop
    /// instead of collapsing to zero sleep under a heavy main-thread encode.
    private static let liveFrameBudget: Duration = .nanoseconds(1_000_000_000 / 15)  // 15 Hz
    /// Cadence for a live-but-static screen: a guest doing headless `mac_vm_exec` work
    /// (``workingInBackground``) isn't being screen-driven, so its framebuffer barely changes. Poll it a
    /// few times a second — enough to keep the under-screen console tail current and catch the occasional
    /// on-screen update — rather than paying a full software render per live-budget tick for a frame
    /// that's the same as the last one.
    private static let backgroundWorkingFrameInterval: Duration = .milliseconds(250)  // ~4 Hz
    /// Refresh interval for a monitor with nothing new to show — a paused guest serves a frozen still and
    /// an empty/booting stage has no moving frame, so poll them lazily rather than at the live budget.
    private static let idleFrameInterval: Duration = .milliseconds(2000)
    /// How often a monitor whose window is off screen re-checks whether it's back on screen. It captures
    /// nothing while dormant (nobody can see it), so this is just the wake-up latency when the window
    /// returns — short enough to feel instant.
    private static let dormantPollInterval: Duration = .milliseconds(500)
    /// Preserve the log's previous useful minimum in short/wide panels, plus the height of the Nash
    /// Viewer's filter row so its first rows are still visible at the minimum; in taller panels it
    /// expands to consume all space left below the aspect-correct monitor.
    private static let shellLogMinimumHeight: CGFloat = 180

    /// What the monitor is currently showing / waiting on. Drives the placeholder when there's no frame.
    private enum Status: Equatable {
        case idle          // no chat open, or the host can't run macOS guests at all
        case notRunning    // no VM is booted for this session
        case booting       // VM is up but has no IP / hasn't produced a frame yet
        case noSignal      // VM is running but capture came back empty (unprovisioned guest / VZ bug)
        case live          // showing a frame
        case handedToPiP   // the floating PiP is showing this guest; this docked copy stopped capturing
    }

    /// The VM name(s) to track: the open chat's per-session guests — both the macOS (`nucleic-mac-<short>…`)
    /// and Linux (`nucleic-lin-<short>…`) siblings, since a session may run either. Empty with no open chat.
    private var candidateVMNames: [String] {
        guard let session else { return [] }
        return [MacVMManager.vmName(for: session.id), MacVMManager.linuxVMName(for: session.id)]
    }

    /// The nash environment a running guest's shell events are filed under. Told apart by name rather
    /// than by asking the manager for the guest's OS: the two names are what the session reserved, and
    /// `ClaudeCodeBackend` builds the environment from the same pair — so this is the identity the log
    /// actually holds, not a re-derivation of it.
    private static func nashEnvironment(forVM name: String, session: Session?) -> NashEnvironment {
        guard let session, name == MacVMManager.linuxVMName(for: session.id) else {
            return .macVM(name: name)
        }
        return .linuxVM(name: name)
    }

    var body: some View {
        // Stack this guest's shell log under its screen, so what the VM is *doing* is legible even when
        // its screen isn't moving. Only the docked monitor splits like this — the PiP window is sized to
        // the guest's exact aspect and has nowhere to put a log.
        Group {
            if showShellLog {
                GeometryReader { geometry in
                    VStack(spacing: 0) {
                        // Keep the screen at its real aspect instead of letting its black stage greedily
                        // consume the panel's vertical space. The log below then receives every point
                        // left in the panel, which is especially useful in a tall side-column monitor.
                        monitorStage
                            .frame(height: min(
                                max(
                                    0,
                                    geometry.size.height - Self.shellLogMinimumHeight
                                ),
                                geometry.size.width / monitorAspectRatio
                            ))
                        Divider()
                        shellLog
                    }
                }
            } else {
                monitorStage
            }
        }
        .animation(.easeInOut(duration: 0.2), value: showShellLog)
        // Track whether the hosting window is on screen, so `poll()` can stand down when it isn't.
        .background(WindowVisibilityReader(isVisible: $windowVisible))
        // Restart polling when the open chat changes (a different chat means a different VM).
        .task(id: session?.id) { await poll() }
    }

    /// Whether to show the under-screen shell log: the docked monitor only (the PiP is sized to the
    /// guest's aspect and has nowhere to put it), no Nash Viewer already open beside it, a guest
    /// actually running, and something to show — either nash has already reported from this guest, or
    /// it's working headless and about to.
    ///
    /// Deliberately *not* gated on ``workingInBackground`` alone, the way the old `mac_vm_exec` console
    /// was: nash's spool is drained on an interval as well as at each exec's return (docs/NASH.md §6.2),
    /// so the rows for a long command keep landing after it finishes — and a log that retracted the
    /// instant the guest went idle would take them away exactly when they became readable.
    private var showShellLog: Bool {
        guard !isPictureInPicture, !regionKinds.contains(.nashViewer),
            let session, let vmEnvironment
        else { return false }
        return workingInBackground
            || store.nashEvents.environments(for: session.id).contains(vmEnvironment)
    }

    /// The live framebuffer's aspect, with the VM's standard 16:10 display as the pre-frame fallback.
    private var monitorAspectRatio: CGFloat {
        guard let frame, frame.size.height > 0 else { return 16.0 / 10.0 }
        return frame.size.width / frame.size.height
    }

    /// The monitor proper — the black stage with the letterboxed guest frame, overlays, and click flashes.
    private var monitorStage: some View {
        ZStack {
            // A black stage, like a physical monitor: the guest screen sits letterboxed in it, and the
            // placeholder reads as an "off" display when nothing's live. The floating PiP omits this
            // behind a live frame so its edges are the guest image itself: the window is exactly the
            // guest's 16:10, so any sub-point gap in the aspect-fit would otherwise leak a thin black
            // outline around all four sides. Dropping the backdrop lets that hairline fall through to
            // the transparent window instead — an entirely frameless PiP.
            if !(isPictureInPicture && frame != nil) {
                // In Full Glass mode the docked stage drops to `.clear` so the detail pane's glass (painted
                // once at the root and read through the clear inner `DetailSurface` behind this panel) shows
                // through the blank canvas around the guest, letterbox bars and empty "off" stage alike. The
                // PiP stays black even here: it's a standalone transparent window with no glass behind it, so
                // clearing it would punch a hole to the desktop rather than reveal the app's material.
                if ultraGlass && !isPictureInPicture {
                    Color.clear
                } else {
                    Color.black
                }
            }
            if let frame {
                // Frame + overlays share one aspect-fitted stage so any overlay covers only the letterboxed
                // display output, not the black bars around it.
                ZStack {
                    Image(nsImage: frame)
                        .resizable()
                        .interpolation(.medium)
                    // A suspended guest shows the still it froze on; a headless-busy guest shows a live-but-
                    // static screen. Either way the frame doesn't move, so label why: "Paused" (deliberately
                    // stopped) takes precedence over "Working in the background…" (busy over SSH),
                    // which takes precedence over "Waiting" (powered on but unused).
                    if paused { pausedOverlay }
                    else if workingInBackground { backgroundWorkingOverlay }
                    else if waiting { waitingOverlay }
                }
                .aspectRatio(frame.size.width / frame.size.height, contentMode: .fit)
            } else {
                placeholder
            }
            // White flash over the whole monitor on each click, layered above the frame. Purely visual
            // (hit-testing off) so they never swallow a subsequent click.
            ForEach(clickFlashes) { flash in
                ClickFlashView { removeClickFlash(flash.id) }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .animation(.easeInOut(duration: 0.25), value: workingInBackground)
        .animation(.easeInOut(duration: 0.25), value: paused)
        .animation(.easeInOut(duration: 0.25), value: waiting)
        // The monitor is look-only: a click never reaches the guest. Register taps ourselves purely to
        // flash the screen white, telling the user they're tapping on glass and their input isn't landing.
        // Only while a live frame is up — a placeholder ("no VM running") has nothing to mistake for an
        // interactive screen.
        .contentShape(Rectangle())
        .onTapGesture {
            guard frame != nil else { return }
            clickFlashes.append(ClickFlash())
        }
    }

    /// The guest's shell, under the guest's screen: a ``NashViewerPanel`` pinned to this VM's nash
    /// environment, so the rows are exactly what nash saw inside *this* guest.
    ///
    /// It replaces the `mac_vm_exec` output tail this panel used to mirror here. That console could only
    /// show the stdout/stderr of commands the agent ran through the exec tool, as flat text; nash's own
    /// gates see every command the guest runs — including the ones a script or a GUI action spawns, which
    /// no tool call announces — plus the data crossing each pipe, redirect, and `$(…)`, one click away
    /// (docs/NASH.md §5). The viewer's filters and search come with it, so a busy guest is narrowable
    /// instead of a wall of scrolling text.
    private var shellLog: some View {
        NashViewerPanel(session: session, pinnedEnvironment: vmEnvironment)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .transition(.move(edge: .bottom).combined(with: .opacity))
    }

    private func removeClickFlash(_ id: UUID) {
        clickFlashes.removeAll { $0.id == id }
    }

    private var placeholder: some View {
        VStack(spacing: 10) {
            Image(systemName: placeholderIcon)
                .font(.system(size: 34)).foregroundStyle(.tertiary)
            Text(placeholderMessage)
                .font(.callout).foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
                .fixedSize(horizontal: false, vertical: true)
            if status == .booting {
                ProgressView().controlSize(.small)
            }
        }
        .padding(24)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    /// A dark layer over the live frame telling the user the guest is busy even though its screen isn't
    /// moving — shown while a `mac_vm_exec` command runs without any computer-use action driving the UI.
    /// It deliberately matches the paused overlay so both inactive-looking monitor states read alike.
    private var backgroundWorkingOverlay: some View {
        VMMonitorStateOverlay(state: .workingInBackground)
    }

    /// The "Paused" state over a suspended guest's frozen still: a much-darker-gray, almost-black wash to
    /// mute the frame (it's stopped, not live) plus a white pause glyph and the word "Paused". The frame
    /// underneath is the screenshot captured the instant the VM was suspended.
    private var pausedOverlay: some View {
        VMMonitorStateOverlay(state: .paused)
    }

    /// A powered-on guest with no agent operation in flight. Matches the Paused treatment so both
    /// inactive states are immediately legible, while the hourglass distinguishes available-and-idle
    /// from deliberately frozen.
    private var waitingOverlay: some View {
        VMMonitorStateOverlay(state: .waiting)
    }

    /// Poll the VM's state + screen while the panel is on screen. Keeps the last good frame across a
    /// single failed capture (a transient SSH hiccup shouldn't blank the monitor); only a stopped VM
    /// clears it.
    private func poll() async {
        frame = nil
        workingInBackground = false
        vmEnvironment = nil
        paused = false
        waiting = false
        let candidates = candidateVMNames
        guard !candidates.isEmpty, store.macVMSupported else {
            status = .idle
            return
        }
        // A freshly-built floating PiP card starts out "warming up" rather than "no VM", so promoting a
        // fan card (which rebuilds this view) never flashes the "No VM is running" placeholder before its
        // first capture lands. The docked monitor keeps its existing idle behavior.
        if isPictureInPicture { status = .booting }
        // Consecutive ticks where the guest wasn't in the running list. One miss on a PiP card is usually
        // a rebuild/role-swap race rather than a stopped VM, so it's ridden out; two in a row is real.
        var notFoundStreak = 0
        while !Task.isCancelled {
            // Stand down while the hosting window isn't on screen (minimized, fully covered, or on another
            // Space): nobody can see this monitor, so don't spend the main actor capturing for it. Keep the
            // last good frame so it's there instantly when the window returns; re-check on a short interval.
            if !windowVisible {
                try? await Task.sleep(for: Self.dormantPollInterval)
                continue
            }
            // The floating PiP is showing this same guest, and the two capture over the identical (and
            // not-free) screenshot path. Drop the frame and stand down until it retracts.
            if !isPictureInPicture, let session, VMMonitorPiPState.shared.visibleSessionID == session.id {
                frame = nil
                workingInBackground = false
                vmEnvironment = nil
                paused = false
                waiting = false
                status = .handedToPiP
                try? await Task.sleep(for: .milliseconds(500))
                continue
            }
            // Time the capture work so a live frame can pace to the live budget (sleep only the leftover
            // after the tick), while an idle/paused monitor just backs off a fixed interval.
            let tickStart = ContinuousClock.now
            let running = await store.runningMacVMs()
            // Match whichever of the session's guests (macOS or Linux) is actually up, and capture it
            // by its real name — the two never run at once for one session.
            if let entry = running.first(where: { candidates.contains($0.name) }) {
                notFoundStreak = 0
                // Scope the under-screen log to the guest that's actually up. Assign only on a change so
                // an unchanged environment doesn't invalidate the log's view every capture tick.
                let environment = Self.nashEnvironment(forVM: entry.name, session: session)
                if vmEnvironment != environment { vmEnvironment = environment }
                if let data = await store.captureMacVMScreenData(name: entry.name),
                    let image = await Self.decode(data) {
                    frame = image
                    status = .live
                } else if frame == nil {
                    // No frame yet: distinguish "still booting" (agent not up) from "up but not
                    // capturable". Keyed on the real readiness flag, not the NAT IP — a vsock-ready
                    // guest often has no IP, and treating that as "still booting" wedged this overlay.
                    status = entry.booting ? .booting : .noSignal
                }
                // else: keep showing the last good frame despite this capture miss.
                // A suspended guest serves the still it froze on: label it "Paused" and don't ask about
                // background work (its CPU is stopped). Otherwise, over a live frame, track the agent's
                // headless SSH work. Recompute each tick so both flags follow the VM's real state.
                paused = frame != nil && entry.paused
                waiting = frame != nil && !paused
                    ? await store.macVMIsWaiting(name: entry.name)
                    : false
                workingInBackground = frame != nil && !paused
                    ? await store.macVMWorkingInBackground(name: entry.name)
                    : false
            } else {
                notFoundStreak += 1
                // Hold the last frame through a single miss on a PiP card (a rebuild/role-swap race);
                // a docked monitor, or a second miss in a row, blanks to "No VM" as before.
                if notFoundStreak >= 2 || !isPictureInPicture {
                    frame = nil
                    status = .notRunning
                    workingInBackground = false
                    vmEnvironment = nil
                    paused = false
                    waiting = false
                }
            }
            // A live, moving frame paces to the live budget: sleep only the slice of the frame the tick
            // didn't already eat (a slow encode leaves none, so it just runs as fast as it can). A guest
            // working headless has a static screen, so poll it on the slower background cadence rather
            // than software-rendering an unchanging frame at the live rate. Anything with nothing new to
            // show — a paused guest's frozen still, an empty/booting stage — backs off the idle interval.
            if status == .live && !paused && !workingInBackground {
                let remaining = Self.liveFrameBudget - (ContinuousClock.now - tickStart)
                if remaining > .zero { try? await Task.sleep(for: remaining) }
            } else if status == .live && !paused {
                try? await Task.sleep(for: Self.backgroundWorkingFrameInterval)
            } else {
                try? await Task.sleep(for: Self.idleFrameInterval)
            }
        }
    }

    /// Decode a JPEG frame into an `NSImage` **off the main actor** — a full-frame JPEG decode is far too
    /// heavy to run on the thread that also handles scrolling and keystrokes. The result is handed back
    /// across the actor boundary in an `UncheckedSendableBox` (`NSImage` isn't `Sendable`); it's freshly
    /// created and touched nowhere off-main after this, so the hand-off is safe.
    private static func decode(_ data: Data) async -> NSImage? {
        await Task.detached(priority: .userInitiated) {
            UncheckedSendableBox(value: NSImage(data: data))
        }.value.value
    }

    private var placeholderIcon: String {
        switch status {
        case .idle, .notRunning, .noSignal: "power"
        case .booting: "hourglass"
        case .live: "display"
        case .handedToPiP: "pip"
        }
    }

    private var placeholderMessage: String {
        switch status {
        case .idle, .notRunning, .noSignal:
            "No VM is running for this chat. It appears here once the agent uses one."
        case .booting:
            "The VM is starting up…"
        case .live:
            ""
        case .handedToPiP:
            "This VM is showing in the floating Picture in Picture window."
        }
    }
}

/// The presentation state laid over a VM framebuffer. Shared by the live monitor and the PiP deck's
/// lazily refreshed fan cards so an inactive card communicates the same state when it is promoted.
enum VMMonitorOverlayState: Equatable {
    case paused
    case workingInBackground
    case waiting
}

/// The shared full-frame treatment for a paused, headless-busy, or idle guest. Keeping this in one view
/// prevents the optimized snapshot cards from drifting visually from the featured live monitor.
struct VMMonitorStateOverlay: View {
    let state: VMMonitorOverlayState

    var body: some View {
        ZStack {
            Color(white: 0.08).opacity(state == .waiting ? 0.62 : 0.72)
            VStack(spacing: 10) {
                Image(systemName: icon)
                    .font(.system(size: 40, weight: .semibold))
                Text(label)
                    .font(.title3.weight(.semibold))
            }
            .foregroundStyle(.white)
            .padding(.horizontal, 20)
            .padding(.vertical, 14)
            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
            .environment(\.colorScheme, .dark)
        }
        .transition(.opacity)
        .allowsHitTesting(false)
    }

    private var icon: String {
        switch state {
        case .paused: "pause.circle.fill"
        case .workingInBackground: "apple.terminal.circle.fill"
        case .waiting: "hourglass.circle.fill"
        }
    }

    private var label: String {
        switch state {
        case .paused: "Paused"
        case .workingInBackground: "Working in the background…"
        case .waiting: "Waiting…"
        }
    }
}

/// One click's worth of white-flash feedback on the read-only monitor. Identifiable so several rapid
/// clicks each get their own independent flash.
private struct ClickFlash: Identifiable {
    let id = UUID()
}

/// A white flash across the whole monitor — the feedback that tells the user the monitor is look-only
/// (their click landed on glass, not on the guest). Self-drives its animation on appear and calls
/// `onFinished` once it's faded so the panel can drop it from the list.
private struct ClickFlashView: View {
    let onFinished: () -> Void
    @State private var faded = false

    var body: some View {
        Color.white
            .opacity(faded ? 0 : 0.3)
            .allowsHitTesting(false)  // pure feedback — never intercept the next click
            .onAppear { withAnimation(.easeOut(duration: 0.35)) { faded = true } }
            .task {
                // Outlive the 0.35 s animation, then ask the panel to remove this flash.
                try? await Task.sleep(for: .milliseconds(400))
                onFinished()
            }
    }
}

/// Reports whether the monitor's hosting window is actually on screen, so the capture loop can stand
/// down when nobody can see it. Reads the window's `occlusionState`: AppKit clears `.visible` when the
/// window is minimized, fully covered by other windows, or on another Space — exactly the cases where a
/// 30 Hz framebuffer capture would be pure wasted main-thread work. Works for both the docked monitor
/// (in the main window) and the PiP/fan cards (each in its own floating panel).
private struct WindowVisibilityReader: NSViewRepresentable {
    @Binding var isVisible: Bool

    func makeNSView(context: Context) -> TrackerView {
        let view = TrackerView()
        view.onChange = { visible in if isVisible != visible { isVisible = visible } }
        return view
    }

    func updateNSView(_ nsView: TrackerView, context: Context) {}

    /// Observes its own window's occlusion state and reports `.visible` changes. Re-subscribes whenever it
    /// moves to a new window (the PiP reuses one panel across sessions, but this is cheap and robust).
    final class TrackerView: NSView {
        var onChange: ((Bool) -> Void)?
        // Set/read on the main actor and once in the nonisolated `deinit` to unregister; the token is a
        // non-Sendable value the deinit must touch, so mark it `nonisolated(unsafe)` (matches the
        // observer tokens in `MacVMComputerSurface`).
        private nonisolated(unsafe) var observer: NSObjectProtocol?

        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            if let observer { NotificationCenter.default.removeObserver(observer); self.observer = nil }
            guard let window else { notify(true); return }  // not in a window yet: assume visible
            notify(window.occlusionState.contains(.visible))
            observer = NotificationCenter.default.addObserver(
                forName: NSWindow.didChangeOcclusionStateNotification, object: window, queue: .main
            ) { [weak self] _ in
                MainActor.assumeIsolated {
                    guard let self, let w = self.window else { return }
                    self.notify(w.occlusionState.contains(.visible))
                }
            }
        }

        /// Report a visibility change off the current call stack: `viewDidMoveToWindow` can fire inside a
        /// SwiftUI update pass, and writing `@State` synchronously there trips the "modifying state during
        /// update" warning. A main-actor `Task` hop lands the write on the next turn — cheap and rare.
        private func notify(_ visible: Bool) {
            Task { @MainActor [weak self] in self?.onChange?(visible) }
        }

        deinit { if let observer { NotificationCenter.default.removeObserver(observer) } }
    }
}
