//! The WinUI 3 (windows-reactor) application shell — host list, settings, PIN/TOFU pairing, and
//! the stream page (a `SwapChainPanel` bound to the D3D11 composition swapchain in
//! [`crate::present`], driven by reactor's per-frame `on_rendering`).
//!
//! Declarative React-like model: a single root component routes on a `Screen` value held in
//! `use_async_state` so background threads (discovery, the session pump) can drive navigation.
//! The present + decoded-frame handoff crosses to the UI thread through a `Mutex` side-channel
//! and thread-locals (the windows-reactor SwapChainPanel sample's pattern), since the per-frame
//! present must not go through state/rerender.
//!
//! The chrome follows the windows-reactor gallery's look: Mica backdrop, a centred max-width
//! column, theme brushes (`ThemeRef`), and rounded `border` cards.

use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService;
use crate::present::Presenter;
use crate::session::{self, SessionEvent, SessionParams, Stats};
use crate::trust::{self, KnownHost, KnownHosts, Settings};
use crate::video::{DecodedFrame, DecoderPref};
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use windows_reactor::*;

const RESOLUTIONS: &[(u32, u32)] = &[
    (0, 0),
    (1280, 720),
    (1920, 1080),
    (2560, 1440),
    (3840, 2160),
];
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
/// Decode backend presets: `(stored value, display label)`.
const DECODERS: &[(&str, &str)] = &[
    ("auto", "Automatic (GPU, fall back to CPU)"),
    ("hardware", "Hardware (GPU / D3D11VA)"),
    ("software", "Software (CPU)"),
];
/// Bitrate presets in Mb/s; `0` = host default.
const BITRATES_MBPS: &[u32] = &[0, 10, 20, 30, 50, 80, 150];
/// Audio channel presets: `(channel count, display label)`. The host clamps to what it can
/// capture; the resolved count drives the decoder + WASAPI render layout.
const AUDIO_CHANNELS: &[(u8, &str)] = &[(2, "Stereo"), (6, "5.1 Surround"), (8, "7.1 Surround")];

/// punktfunk's own license (MIT OR Apache-2.0), shown on the Licenses screen.
const APP_LICENSE: &str = concat!(
    include_str!("../../../LICENSE-MIT"),
    "\n\n================================ Apache-2.0 ================================\n\n",
    include_str!("../../../LICENSE-APACHE"),
);
/// Third-party software notices for the linked Rust crates (generated by
/// scripts/gen-third-party-notices.sh; the MSIX also ships this under licenses/).
const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt");

#[derive(Clone, PartialEq)]
enum Screen {
    Hosts,
    Connecting,
    /// The no-PIN "request access" wait: an identified connect is in flight, parked by the host
    /// until the operator approves this device in its console. Cancelable.
    RequestAccess,
    Stream,
    Settings,
    /// Open-source / third-party license notices (reached from Settings).
    Licenses,
    Pair,
}

/// The host we're about to connect to / pair with (carried into the Pair screen).
#[derive(Clone, Default)]
struct Target {
    name: String,
    addr: String,
    port: u16,
    fp_hex: Option<String>,
    pair_optional: bool,
}

/// Stable app services handed to the page components as props. Each routed screen that uses
/// hooks (`hosts_page`/`pair_page`/`stream_page`) is mounted as its own `component(...)`, so
/// its hooks live in an isolated slot list — calling them on the shared parent `cx` would
/// change the hook order whenever the screen changes (reactor's Rules-of-Hooks guard aborts).
///
/// `Svc` compares equal by `ctx` identity (it never meaningfully changes across renders), so a
/// page whose props are just `Svc` re-renders only via its own state hooks, never spuriously
/// from the parent.
#[derive(Clone)]
struct Svc {
    ctx: Arc<AppCtx>,
    set_screen: AsyncSetState<Screen>,
    set_status: AsyncSetState<String>,
}

impl PartialEq for Svc {
    fn eq(&self, other: &Self) -> bool {
        Arc::ptr_eq(&self.ctx, &other.ctx)
    }
}

/// Props for the hosts page: the services plus the changing discovery/status data that must
/// drive its re-render (compared by value, so a new host list or error refreshes the page).
#[derive(Clone)]
struct HostsProps {
    svc: Svc,
    hosts: Vec<DiscoveredHost>,
    status: String,
}

impl PartialEq for HostsProps {
    fn eq(&self, other: &Self) -> bool {
        self.svc == other.svc && self.hosts == other.hosts && self.status == other.status
    }
}

/// Props for the stream page: the services plus the live stats that drive the HUD overlay
/// (compared by value, so each new sample re-renders the overlay).
#[derive(Clone)]
struct StreamProps {
    svc: Svc,
    stats: Stats,
}

impl PartialEq for StreamProps {
    fn eq(&self, other: &Self) -> bool {
        self.svc == other.svc && self.stats == other.stats
    }
}

/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver.
struct PresentCtx {
    presenter: Presenter,
    frames: async_channel::Receiver<DecodedFrame>,
}

thread_local! {
    static PRESENT: RefCell<Option<PresentCtx>> = const { RefCell::new(None) };
    static PENDING_FRAMES: RefCell<Option<async_channel::Receiver<DecodedFrame>>> =
        const { RefCell::new(None) };
}

/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
#[derive(Default)]
struct Shared {
    handoff: Mutex<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
    target: Mutex<Target>,
    /// Latest stream stats, written by the session's event loop and mirrored into reactor state
    /// by the stream page's HUD poll thread to drive the overlay.
    stats: Mutex<Stats>,
    /// Cancel flag for the in-flight "request access" connect. A FRESH flag is installed per
    /// request: the waiting screen's Cancel button reads it back from here and sets it, and that
    /// request's event loop (which captured the same `Arc` at spawn) then tears down silently when
    /// the parked connect finally resolves. `None` outside a request-access flow.
    cancel: Mutex<Option<Arc<AtomicBool>>>,
}

pub struct AppCtx {
    identity: (String, String),
    settings: Mutex<Settings>,
    gamepad: GamepadService,
    shared: Arc<Shared>,
}

pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_reactor::Result<()> {
    let ctx = Arc::new(AppCtx {
        identity,
        settings: Mutex::new(Settings::load()),
        gamepad,
        shared: Arc::new(Shared::default()),
    });
    App::new()
        .title("Punktfunk")
        .inner_size(1000.0, 720.0)
        .backdrop(Backdrop::Mica)
        .render(move |cx| root(cx, &ctx))
}

// --- shared styling -----------------------------------------------------------------------

fn uniform(v: f64) -> Thickness {
    Thickness::uniform(v)
}

fn edges(left: f64, top: f64, right: f64, bottom: f64) -> Thickness {
    Thickness {
        left,
        top,
        right,
        bottom,
    }
}

/// A rounded, bordered surface in the theme's card colours.
fn card(child: impl Into<Element>) -> Border {
    border(child.into())
        .background(ThemeRef::CardBackground)
        .border_brush(ThemeRef::CardStroke)
        .border_thickness(uniform(1.0))
        .corner_radius(8.0)
        .padding(uniform(16.0))
}

/// A small all-caps section label above a group of cards.
fn section(label: &str) -> Element {
    text_block(label)
        .font_size(12.0)
        .semibold()
        .foreground(ThemeRef::SecondaryText)
        .margin(edges(2.0, 10.0, 0.0, 0.0))
        .into()
}

/// Wrap a screen's children in a scrollable, centred, max-width column.
fn page(children: Vec<Element>) -> Element {
    let col = vstack(children)
        .spacing(10.0)
        .max_width(640.0)
        .horizontal_alignment(HorizontalAlignment::Center)
        .margin(edges(24.0, 24.0, 24.0, 40.0));
    scroll_view(col).into()
}

/// A rounded square "monogram" for a host, the first letter on an accent fill — a clean leading
/// visual that avoids depending on an icon font being installed.
fn avatar(name: &str) -> Border {
    let initial = name
        .chars()
        .find(|c| c.is_alphanumeric())
        .map(|c| c.to_uppercase().to_string())
        .unwrap_or_else(|| "?".into());
    border(
        text_block(initial)
            .font_size(17.0)
            .semibold()
            .foreground(ThemeRef::AccentText)
            .horizontal_alignment(HorizontalAlignment::Center)
            .vertical_alignment(VerticalAlignment::Center),
    )
    .background(ThemeRef::Accent)
    .corner_radius(10.0)
    .width(40.0)
    .height(40.0)
}

/// Pill chip colour intent.
#[derive(Clone, Copy)]
enum Pill {
    Accent,
    Good,
    Neutral,
}

/// A small rounded status chip (paired/PIN/HDR/etc.).
fn pill(text: &str, kind: Pill) -> Border {
    let (bg, fg) = match kind {
        Pill::Accent => (ThemeRef::Accent, ThemeRef::AccentText),
        Pill::Good => (ThemeRef::SystemSuccessBackground, ThemeRef::SystemSuccess),
        Pill::Neutral => (ThemeRef::SubtleFill, ThemeRef::SecondaryText),
    };
    border(text_block(text).font_size(11.0).semibold().foreground(fg))
        .background(bg)
        .corner_radius(10.0)
        .padding(edges(9.0, 3.0, 9.0, 3.0))
}

/// A clickable host row: monogram + name/address + status pill + chevron.
fn host_card(name: &str, sub: &str, badge: &str, on_tap: impl Fn() + 'static) -> Element {
    let kind = match badge {
        "Paired" => Pill::Good,
        "Open" => Pill::Neutral,
        _ => Pill::Accent, // Trusted / PIN
    };
    card(
        grid((
            avatar(name)
                .grid_column(0)
                .vertical_alignment(VerticalAlignment::Center),
            vstack((
                text_block(name).font_size(15.0).semibold(),
                text_block(sub)
                    .font_size(12.0)
                    .foreground(ThemeRef::SecondaryText),
            ))
            .spacing(2.0)
            .grid_column(1)
            .vertical_alignment(VerticalAlignment::Center)
            .margin(edges(12.0, 0.0, 0.0, 0.0)),
            pill(badge, kind)
                .grid_column(2)
                .vertical_alignment(VerticalAlignment::Center)
                .margin(edges(0.0, 0.0, 10.0, 0.0)),
            text_block("\u{203A}")
                .font_size(18.0)
                .foreground(ThemeRef::SecondaryText)
                .grid_column(3)
                .vertical_alignment(VerticalAlignment::Center),
        ))
        .columns([
            GridLength::Auto,
            GridLength::Star(1.0),
            GridLength::Auto,
            GridLength::Auto,
        ]),
    )
    .on_tapped(on_tap)
    .into()
}

// --- screens ------------------------------------------------------------------------------

fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
    let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
    let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
    let (status, set_status) = cx.use_async_state(String::new());
    let (stats, set_stats) = cx.use_async_state(Stats::default());

    // Continuous LAN discovery (spawned once).
    cx.use_effect((), {
        let set_hosts = set_hosts.clone();
        move || {
            let rx = discovery::browse();
            std::thread::spawn(move || {
                let mut acc: Vec<DiscoveredHost> = Vec::new();
                while let Ok(h) = rx.recv_blocking() {
                    if let Some(e) = acc.iter_mut().find(|e| e.key == h.key) {
                        *e = h;
                    } else {
                        acc.push(h);
                    }
                    set_hosts.call(acc.clone());
                }
            });
        }
    });

    // HUD stats: the session event loop writes `shared.stats`; this poll thread mirrors it into
    // root state so the stream page gets it as a *prop*. (A child component's own async-state
    // update is pruned when its props are unchanged — only a prop change re-renders it, exactly
    // like discovery → hosts above.)
    cx.use_effect((), {
        let shared = ctx.shared.clone();
        let set_stats = set_stats.clone();
        move || {
            std::thread::Builder::new()
                .name("pf-hud".into())
                .spawn(move || {
                    let mut last = Stats::default();
                    loop {
                        std::thread::sleep(std::time::Duration::from_millis(400));
                        let s = *shared.stats.lock().unwrap();
                        if s != last {
                            last = s;
                            set_stats.call(s);
                        }
                    }
                })
                .ok();
        }
    });

    // Each hook-using screen is mounted as its own component so its hooks are isolated from
    // root's (root's own hooks above stay a stable prefix regardless of which screen renders).
    let svc = Svc {
        ctx: ctx.clone(),
        set_screen: set_screen.clone(),
        set_status: set_status.clone(),
    };
    match screen {
        Screen::Hosts => component(hosts_page, HostsProps { svc, hosts, status }),
        Screen::Connecting => {
            let target_name = ctx.shared.target.lock().unwrap().name.clone();
            let headline = if target_name.is_empty() {
                "Connecting\u{2026}".to_string()
            } else {
                format!("Connecting to {target_name}\u{2026}")
            };
            vstack((
                ProgressRing::indeterminate()
                    .width(48.0)
                    .height(48.0)
                    .horizontal_alignment(HorizontalAlignment::Center),
                text_block(headline)
                    .font_size(18.0)
                    .semibold()
                    .horizontal_alignment(HorizontalAlignment::Center),
                text_block(if status.is_empty() {
                    "Negotiating the session and creating the virtual display\u{2026}".to_string()
                } else {
                    status.clone()
                })
                .foreground(ThemeRef::SecondaryText)
                .horizontal_alignment(HorizontalAlignment::Center),
            ))
            .spacing(16.0)
            .horizontal_alignment(HorizontalAlignment::Center)
            .vertical_alignment(VerticalAlignment::Center)
            .into()
        }
        // request_access_page (like settings_page/Connecting) uses no hooks, so calling it inline
        // is sound — it only wires a Cancel button to the shared cancel flag + navigation.
        Screen::RequestAccess => request_access_page(ctx, &set_screen),
        // settings_page uses no hooks (it never touches `cx`), so calling it inline is sound.
        Screen::Settings => settings_page(ctx, &set_screen),
        // licenses_page is a static text screen (no hooks), so inline is sound.
        Screen::Licenses => licenses_page(&set_screen),
        Screen::Pair => component(pair_page, svc),
        Screen::Stream => component(stream_page, StreamProps { svc, stats }),
    }
}

fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
    let ctx = &props.svc.ctx;
    let hosts = props.hosts.as_slice();
    let status = props.status.as_str();
    let set_screen = &props.svc.set_screen;
    let set_status = &props.svc.set_status;
    let (manual, set_manual) = cx.use_state(String::new());
    let known = KnownHosts::load();

    let mut body: Vec<Element> = Vec::new();

    // Header: title block + Settings button.
    body.push(
        grid((
            vstack((
                text_block("Punktfunk").font_size(30.0).bold(),
                text_block("Stream from a host on your network.")
                    .foreground(ThemeRef::SecondaryText),
            ))
            .spacing(2.0)
            .grid_column(0)
            .vertical_alignment(VerticalAlignment::Center),
            button("Settings")
                .icon(SymbolGlyph::Setting)
                .on_click({
                    let ss = set_screen.clone();
                    move || ss.call(Screen::Settings)
                })
                .grid_column(1)
                .vertical_alignment(VerticalAlignment::Center),
        ))
        .columns([GridLength::Star(1.0), GridLength::Auto])
        .margin(edges(0.0, 0.0, 0.0, 6.0))
        .into(),
    );

    if !status.is_empty() {
        body.push(
            InfoBar::new("Couldn't connect")
                .message(status.to_string())
                .error()
                .is_closable(false)
                .into(),
        );
    }

    // Saved (trusted/paired) hosts.
    if !known.hosts.is_empty() {
        body.push(section("SAVED HOSTS"));
        for k in &known.hosts {
            let target = Target {
                name: k.name.clone(),
                addr: k.addr.clone(),
                port: k.port,
                fp_hex: Some(k.fp_hex.clone()),
                pair_optional: false,
            };
            let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
            body.push(host_card(
                &k.name,
                &format!("{}:{}", k.addr, k.port),
                if k.paired { "Paired" } else { "Trusted" },
                move || initiate(&ctx2, target.clone(), &ss, &st),
            ));
        }
    }

    // Discovered hosts.
    body.push(section("ON YOUR NETWORK"));
    if hosts.is_empty() {
        body.push(
            card(
                hstack((
                    ProgressRing::indeterminate().width(18.0).height(18.0),
                    text_block("Searching the LAN\u{2026}").foreground(ThemeRef::SecondaryText),
                ))
                .spacing(12.0),
            )
            .into(),
        );
    } else {
        for h in hosts {
            let target = Target {
                name: h.name.clone(),
                addr: h.addr.clone(),
                port: h.port,
                fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
                pair_optional: h.pair == "optional",
            };
            let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
            let badge = if h.pair == "required" { "PIN" } else { "Open" };
            body.push(host_card(
                &h.name,
                &format!("{}:{}", h.addr, h.port),
                badge,
                move || initiate(&ctx2, target.clone(), &ss, &st),
            ));
        }
    }

    // Manual connection.
    body.push(section("CONNECT MANUALLY"));
    let connect_manual = {
        let (ctx2, ss, st, text) = (
            ctx.clone(),
            set_screen.clone(),
            set_status.clone(),
            manual.clone(),
        );
        move || {
            let text = text.trim();
            if text.is_empty() {
                return;
            }
            let (addr, port) = match text.rsplit_once(':') {
                Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
                None => (text.to_string(), 9777),
            };
            initiate(
                &ctx2,
                Target {
                    name: addr.clone(),
                    addr,
                    port,
                    fp_hex: None,
                    pair_optional: false,
                },
                &ss,
                &st,
            );
        }
    };
    body.push(
        card(
            grid((
                text_box(manual)
                    .placeholder("host or host:port")
                    .on_changed(move |s| set_manual.call(s))
                    .grid_column(0)
                    .vertical_alignment(VerticalAlignment::Center),
                button("Connect")
                    .accent()
                    .icon(SymbolGlyph::Forward)
                    .on_click(connect_manual)
                    .grid_column(1)
                    .margin(edges(8.0, 0.0, 0.0, 0.0)),
            ))
            .columns([GridLength::Star(1.0), GridLength::Auto]),
        )
        .into(),
    );

    page(body)
}

/// The trust gate (mirrors the GTK client's `initiate_connect`): pinned fingerprint → silent
/// connect; known address → stored pin; advertised `pair=optional` → TOFU; otherwise → PIN
/// pairing.
fn initiate(
    ctx: &Arc<AppCtx>,
    target: Target,
    set_screen: &AsyncSetState<Screen>,
    set_status: &AsyncSetState<String>,
) {
    let known = KnownHosts::load();
    let pin = target
        .fp_hex
        .as_ref()
        .and_then(|fp| known.find_by_fp(fp).map(|_| fp.clone()))
        .or_else(|| {
            known
                .find_by_addr(&target.addr, target.port)
                .map(|k| k.fp_hex.clone())
        })
        .and_then(|fp| trust::parse_hex32(&fp));

    if let Some(pin) = pin {
        connect(ctx, &target, Some(pin), set_screen, set_status);
    } else if target.pair_optional {
        connect(ctx, &target, None, set_screen, set_status); // TOFU
    } else {
        *ctx.shared.target.lock().unwrap() = target;
        set_screen.call(Screen::Pair);
    }
}

/// Tunables that differ between the normal connect and the no-PIN "request access" flow.
/// `Default` is the normal connect: short handshake budget, persist *unpaired* on TOFU, and the
/// plain "Connecting" screen.
struct ConnectOpts {
    /// Handshake budget. Request-access uses a long one because the host PARKS the connection
    /// until the operator clicks Approve in its console (see the host's `PENDING_APPROVAL_WAIT`).
    connect_timeout: Duration,
    /// Persist the host as *paired* on a successful connect. Set for request-access, where the
    /// operator's approval IS the pairing, so future connects are silent (rule 1). Normal TOFU
    /// persists the host *unpaired* (pinned, but not PIN/approval-verified).
    persist_paired: bool,
    /// Show the cancelable "waiting for approval" screen instead of "Connecting" (request-access).
    awaiting_approval: bool,
    /// Set by the waiting screen's Cancel button. `NativeClient::connect` is blocking with no
    /// abort, so Cancel returns the UI immediately and leaves the parked connect to resolve/time
    /// out; this request's event loop then sees the flag and tears down silently (drops the
    /// connector → closes the connection) without touching a screen a new session may already own.
    cancel: Option<Arc<AtomicBool>>,
}

impl Default for ConnectOpts {
    fn default() -> Self {
        Self {
            connect_timeout: Duration::from_secs(15),
            persist_paired: false,
            awaiting_approval: false,
            cancel: None,
        }
    }
}

fn connect(
    ctx: &Arc<AppCtx>,
    target: &Target,
    pin: Option<[u8; 32]>,
    set_screen: &AsyncSetState<Screen>,
    set_status: &AsyncSetState<String>,
) {
    connect_with(
        ctx,
        target,
        pin,
        set_screen,
        set_status,
        ConnectOpts::default(),
    );
}

fn connect_with(
    ctx: &Arc<AppCtx>,
    target: &Target,
    pin: Option<[u8; 32]>,
    set_screen: &AsyncSetState<Screen>,
    set_status: &AsyncSetState<String>,
    opts: ConnectOpts,
) {
    let s = ctx.settings.lock().unwrap().clone();
    let mode = if s.width != 0 && s.refresh_hz != 0 {
        Mode {
            width: s.width,
            height: s.height,
            refresh_hz: s.refresh_hz,
        }
    } else {
        Mode {
            width: 1920,
            height: 1080,
            refresh_hz: 60,
        }
    };
    let gamepad_pref = match GamepadPref::from_name(&s.gamepad) {
        Some(GamepadPref::Auto) | None => ctx.gamepad.auto_pref(),
        Some(explicit) => explicit,
    };
    let handle = session::start(SessionParams {
        host: target.addr.clone(),
        port: target.port,
        mode,
        compositor: CompositorPref::Auto,
        gamepad: gamepad_pref,
        bitrate_kbps: s.bitrate_kbps,
        audio_channels: s.audio_channels,
        mic_enabled: s.mic_enabled,
        hdr_enabled: s.hdr_enabled,
        decoder: DecoderPref::from_name(&s.decoder),
        pin,
        identity: ctx.identity.clone(),
        connect_timeout: opts.connect_timeout,
    });
    set_status.call(String::new());
    set_screen.call(if opts.awaiting_approval {
        Screen::RequestAccess
    } else {
        Screen::Connecting
    });

    let tofu = pin.is_none();
    let persist_paired = opts.persist_paired;
    let cancel = opts.cancel;
    let (shared, gamepad) = (ctx.shared.clone(), ctx.gamepad.clone());
    let (ss, st) = (set_screen.clone(), set_status.clone());
    let target = target.clone();
    std::thread::spawn(move || loop {
        let event = match handle.events.recv_blocking() {
            Ok(e) => e,
            Err(_) => {
                gamepad.detach();
                ss.call(Screen::Hosts);
                break;
            }
        };
        // A cancelled request-access connect that resolved late (the host approved or the park
        // timed out after the user walked away): tear down silently. Cancel already returned the
        // UI to the host list; dropping `event` (and with it any connector) closes the connection
        // without popping a stream or a stray error over the screen a new session may own.
        if cancel.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) {
            break;
        }
        match event {
            SessionEvent::Connected {
                connector,
                fingerprint,
                ..
            } => {
                if persist_paired || tofu {
                    // Request-access: the operator approved this device, so record the host as a
                    // trusted PAIRED host — future connects are then silent (rule 1), exactly like
                    // after a PIN ceremony. A plain TOFU connect persists it *unpaired* (pinned).
                    let mut k = KnownHosts::load();
                    k.upsert(KnownHost {
                        name: target.name.clone(),
                        addr: target.addr.clone(),
                        port: target.port,
                        fp_hex: trust::hex(&fingerprint),
                        paired: persist_paired,
                    });
                    let _ = k.save();
                }
                gamepad.attach(connector.clone());
                *shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
                *shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
                ss.call(Screen::Stream);
            }
            SessionEvent::Failed {
                msg,
                trust_rejected,
            } => {
                st.call(msg);
                gamepad.detach();
                if trust_rejected {
                    // Pinned-fingerprint mismatch / pairing required → re-pair via the PIN screen.
                    *shared.target.lock().unwrap() = target.clone();
                    ss.call(Screen::Pair);
                } else {
                    ss.call(Screen::Hosts);
                }
                break;
            }
            SessionEvent::Ended(err) => {
                st.call(err.unwrap_or_else(|| "Session ended".into()));
                gamepad.detach();
                ss.call(Screen::Hosts);
                break;
            }
            SessionEvent::Stats(s) => *shared.stats.lock().unwrap() = s,
        }
    });
}

/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
/// operator approves this device in its console (or web UI), showing a cancelable "waiting"
/// screen meanwhile. On approval the SAME connection is admitted (no reconnect) and the host is
/// saved as paired, so later connects are silent.
fn request_access(
    ctx: &Arc<AppCtx>,
    target: &Target,
    set_screen: &AsyncSetState<Screen>,
    set_status: &AsyncSetState<String>,
) {
    // Pin the advertised certificate for a discovered host (defence against a host impostor while
    // we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
    let pin = target.fp_hex.as_deref().and_then(trust::parse_hex32);
    // A fresh cancel flag per request, installed where the waiting screen's Cancel button can read
    // it back; this request's event loop captures the same `Arc` (via ConnectOpts) below.
    let cancel = Arc::new(AtomicBool::new(false));
    *ctx.shared.cancel.lock().unwrap() = Some(cancel.clone());
    connect_with(
        ctx,
        target,
        pin,
        set_screen,
        set_status,
        ConnectOpts {
            // Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
            // approval still lands on this connection rather than timing the client out first.
            connect_timeout: Duration::from_secs(185),
            persist_paired: true,
            awaiting_approval: true,
            cancel: Some(cancel),
        },
    );
}

/// The cancelable "waiting for approval" screen (request-access flow): a spinner + guidance while
/// the identified connect sits parked on the host, plus a Cancel that returns to the host list and
/// trips the shared cancel flag so the parked connect tears down silently if it resolves after the
/// user has walked away. Mirrors the inline `Connecting` screen; uses no hooks.
fn request_access_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
    let target_name = ctx.shared.target.lock().unwrap().name.clone();
    let headline = if target_name.is_empty() {
        "Waiting for approval\u{2026}".to_string()
    } else {
        format!("Waiting for {target_name} to approve\u{2026}")
    };
    let cancel_btn = {
        let (ctx, ss) = (ctx.clone(), set_screen.clone());
        button("Cancel")
            .icon(SymbolGlyph::Cancel)
            .on_click(move || {
                // Return the UI immediately; the parked connect is blocking with no abort, so trip
                // the flag this request's event loop captured — it then tears down silently when
                // the connect finally resolves (see ConnectOpts::cancel).
                if let Some(c) = ctx.shared.cancel.lock().unwrap().as_ref() {
                    c.store(true, Ordering::SeqCst);
                }
                ss.call(Screen::Hosts);
            })
            .horizontal_alignment(HorizontalAlignment::Center)
    };
    vstack((
        ProgressRing::indeterminate()
            .width(48.0)
            .height(48.0)
            .horizontal_alignment(HorizontalAlignment::Center),
        text_block(headline)
            .font_size(18.0)
            .semibold()
            .horizontal_alignment(HorizontalAlignment::Center),
        text_block(
            "Approve this device in the host's console or web UI \u{2014} it connects automatically \
             once you approve it. No PIN needed.",
        )
        .foreground(ThemeRef::SecondaryText)
        .horizontal_alignment(HorizontalAlignment::Center),
        cancel_btn,
    ))
    .spacing(16.0)
    .horizontal_alignment(HorizontalAlignment::Center)
    .vertical_alignment(VerticalAlignment::Center)
    .into()
}

fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
    let ctx = &props.ctx;
    let set_screen = &props.set_screen;
    let set_status = &props.set_status;
    let (code, set_code) = cx.use_state(String::new());
    let target = ctx.shared.target.lock().unwrap().clone();

    let pair_btn = {
        let (ctx2, ss, st, code2, target2) = (
            ctx.clone(),
            set_screen.clone(),
            set_status.clone(),
            code.clone(),
            target.clone(),
        );
        button("Pair & Connect")
            .accent()
            .icon(SymbolGlyph::Accept)
            .on_click(move || {
                let pin = code2.trim().to_string();
                let (ctx3, ss, st, target3) =
                    (ctx2.clone(), ss.clone(), st.clone(), target2.clone());
                std::thread::spawn(move || {
                    let name =
                        std::env::var("COMPUTERNAME").unwrap_or_else(|_| "windows-client".into());
                    match NativeClient::pair(
                        &target3.addr,
                        target3.port,
                        (&ctx3.identity.0, &ctx3.identity.1),
                        &pin,
                        &name,
                        std::time::Duration::from_secs(90),
                    ) {
                        Ok(fp) => {
                            let mut k = KnownHosts::load();
                            k.upsert(KnownHost {
                                name: target3.name.clone(),
                                addr: target3.addr.clone(),
                                port: target3.port,
                                fp_hex: trust::hex(&fp),
                                paired: true,
                            });
                            let _ = k.save();
                            connect(&ctx3, &target3, Some(fp), &ss, &st);
                        }
                        Err(e) => {
                            st.call(format!("Pairing failed: {e:?} (wrong PIN, or not armed?)"));
                            ss.call(Screen::Hosts);
                        }
                    }
                });
            })
    };
    let cancel_btn = {
        let ss = set_screen.clone();
        button("Cancel")
            .icon(SymbolGlyph::Cancel)
            .on_click(move || ss.call(Screen::Hosts))
    };
    // The no-PIN alternative offered alongside the PIN ceremony: open an identified connect that
    // the host parks until the operator approves this device in its console (delegated approval).
    let request_btn = {
        let (ctx2, ss, st, target2) = (
            ctx.clone(),
            set_screen.clone(),
            set_status.clone(),
            target.clone(),
        );
        button("Request access without a PIN")
            .icon(SymbolGlyph::Send)
            .on_click(move || request_access(&ctx2, &target2, &ss, &st))
            .horizontal_alignment(HorizontalAlignment::Stretch)
    };

    let content = card(vstack((
        grid((
            avatar(&target.name)
                .grid_column(0)
                .vertical_alignment(VerticalAlignment::Center),
            vstack((
                text_block(format!("Pair with {}", target.name))
                    .font_size(20.0)
                    .semibold(),
                text_block(format!("{}:{}", target.addr, target.port))
                    .font_size(12.0)
                    .foreground(ThemeRef::SecondaryText),
            ))
            .spacing(2.0)
            .grid_column(1)
            .vertical_alignment(VerticalAlignment::Center)
            .margin(edges(12.0, 0.0, 0.0, 0.0)),
        ))
        .columns([GridLength::Auto, GridLength::Star(1.0)]),
        InfoBar::new("Arm pairing on the host")
            .message(
                "On the host's console or web console, start pairing — it shows a 4-digit PIN. \
                 Enter it below within 90 seconds.",
            )
            .informational()
            .is_closable(false),
        text_box(code)
            .placeholder("PIN")
            .font_size(28.0)
            .on_changed(move |s| set_code.call(s)),
        hstack((pair_btn, cancel_btn)).spacing(8.0),
        text_block(
            "Don\u{2019}t have a PIN? Request access instead and approve this device on the host \
             (its console or web UI) \u{2014} no PIN needed.",
        )
        .font_size(12.0)
        .foreground(ThemeRef::SecondaryText),
        request_btn,
    ))
    .spacing(16.0))
    .max_width(480.0)
    .horizontal_alignment(HorizontalAlignment::Center)
    .margin(edges(0.0, 60.0, 0.0, 0.0));

    page(vec![content.into()])
}

fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen>) -> Element {
    let s = ctx.settings.lock().unwrap().clone();
    let res_i = RESOLUTIONS
        .iter()
        .position(|&(w, h)| w == s.width && h == s.height)
        .unwrap_or(0) as i32;
    let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0) as i32;

    let res_names: Vec<String> = RESOLUTIONS
        .iter()
        .map(|&(w, h)| {
            if w == 0 {
                "Native display".into()
            } else {
                format!("{w} \u{00D7} {h}")
            }
        })
        .collect();
    let hz_names: Vec<String> = REFRESH
        .iter()
        .map(|&r| {
            if r == 0 {
                "Native".into()
            } else {
                format!("{r} Hz")
            }
        })
        .collect();

    let res_combo = {
        let ctx = ctx.clone();
        ComboBox::new(res_names)
            .header("Resolution")
            .selected_index(res_i)
            .on_selection_changed(move |i: i32| {
                let (w, h) = RESOLUTIONS[(i.max(0) as usize).min(RESOLUTIONS.len() - 1)];
                let mut s = ctx.settings.lock().unwrap();
                (s.width, s.height) = (w, h);
                s.save();
            })
    };
    let hz_combo = {
        let ctx = ctx.clone();
        ComboBox::new(hz_names)
            .header("Refresh rate")
            .selected_index(hz_i)
            .on_selection_changed(move |i: i32| {
                let mut s = ctx.settings.lock().unwrap();
                s.refresh_hz = REFRESH[(i.max(0) as usize).min(REFRESH.len() - 1)];
                s.save();
            })
    };
    let dec_i = DECODERS
        .iter()
        .position(|&(v, _)| v == s.decoder)
        .unwrap_or(0) as i32;
    let dec_names: Vec<String> = DECODERS.iter().map(|&(_, l)| l.to_string()).collect();
    let decoder_combo = {
        let ctx = ctx.clone();
        ComboBox::new(dec_names)
            .header("Video decoder")
            .selected_index(dec_i)
            .on_selection_changed(move |i: i32| {
                let (v, _) = DECODERS[(i.max(0) as usize).min(DECODERS.len() - 1)];
                let mut s = ctx.settings.lock().unwrap();
                s.decoder = v.to_string();
                s.save();
            })
    };

    let br_i = BITRATES_MBPS
        .iter()
        .position(|&m| m * 1000 == s.bitrate_kbps)
        .unwrap_or(0) as i32;
    let br_names: Vec<String> = BITRATES_MBPS
        .iter()
        .map(|&m| {
            if m == 0 {
                "Automatic".into()
            } else {
                format!("{m} Mb/s")
            }
        })
        .collect();
    let bitrate_combo = {
        let ctx = ctx.clone();
        ComboBox::new(br_names)
            .header("Bitrate")
            .selected_index(br_i)
            .on_selection_changed(move |i: i32| {
                let m = BITRATES_MBPS[(i.max(0) as usize).min(BITRATES_MBPS.len() - 1)];
                let mut s = ctx.settings.lock().unwrap();
                s.bitrate_kbps = m * 1000;
                s.save();
            })
    };

    let hdr_toggle = {
        let ctx = ctx.clone();
        ToggleSwitch::new(s.hdr_enabled)
            .header("HDR (10-bit, BT.2020 PQ)")
            .on_content("On")
            .off_content("Off")
            .on_changed(move |on: bool| {
                let mut s = ctx.settings.lock().unwrap();
                s.hdr_enabled = on;
                s.save();
            })
    };
    let mic_toggle = {
        let ctx = ctx.clone();
        ToggleSwitch::new(s.mic_enabled)
            .header("Stream microphone to the host")
            .on_content("On")
            .off_content("Off")
            .on_changed(move |on: bool| {
                let mut s = ctx.settings.lock().unwrap();
                s.mic_enabled = on;
                s.save();
            })
    };
    let ac_i = AUDIO_CHANNELS
        .iter()
        .position(|&(v, _)| v == s.audio_channels)
        .unwrap_or(0) as i32;
    let ac_names: Vec<String> = AUDIO_CHANNELS.iter().map(|&(_, l)| l.to_string()).collect();
    let channels_combo = {
        let ctx = ctx.clone();
        ComboBox::new(ac_names)
            .header("Audio channels")
            .selected_index(ac_i)
            .on_selection_changed(move |i: i32| {
                let (v, _) = AUDIO_CHANNELS[(i.max(0) as usize).min(AUDIO_CHANNELS.len() - 1)];
                let mut s = ctx.settings.lock().unwrap();
                s.audio_channels = v;
                s.save();
            })
    };

    let header = grid((
        text_block("Settings")
            .font_size(30.0)
            .bold()
            .grid_column(0)
            .vertical_alignment(VerticalAlignment::Center),
        button("Back")
            .accent()
            .icon(SymbolGlyph::Back)
            .on_click({
                let ss = set_screen.clone();
                move || ss.call(Screen::Hosts)
            })
            .grid_column(1)
            .vertical_alignment(VerticalAlignment::Center),
    ))
    .columns([GridLength::Star(1.0), GridLength::Auto])
    .margin(edges(0.0, 0.0, 0.0, 6.0));

    let stream_card = card(
        vstack((
            text_block("Display").font_size(15.0).semibold(),
            text_block("The host creates a virtual display at exactly this mode.")
                .font_size(12.0)
                .foreground(ThemeRef::SecondaryText),
            res_combo,
            hz_combo,
        ))
        .spacing(10.0),
    );

    let video_card = card(
        vstack((
            text_block("Video").font_size(15.0).semibold(),
            text_block(
                "Hardware decode (D3D11VA) is zero-copy and far lighter than software — keep it on \
                 Automatic unless debugging.",
            )
            .font_size(12.0)
            .foreground(ThemeRef::SecondaryText),
            decoder_combo,
            bitrate_combo,
            hdr_toggle,
        ))
        .spacing(10.0),
    );

    let audio_card = card(
        vstack((
            text_block("Audio").font_size(15.0).semibold(),
            text_block("Request stereo or surround — the host downmixes if its output has fewer.")
                .font_size(12.0)
                .foreground(ThemeRef::SecondaryText),
            channels_combo,
            mic_toggle,
        ))
        .spacing(10.0),
    );

    let licenses_button = {
        let ss = set_screen.clone();
        button("Third-party licenses").on_click(move || ss.call(Screen::Licenses))
    };
    let about_card = card(
        vstack((
            text_block("About").font_size(15.0).semibold(),
            text_block("punktfunk is licensed under MIT OR Apache-2.0.")
                .font_size(12.0)
                .foreground(ThemeRef::SecondaryText),
            licenses_button,
        ))
        .spacing(10.0),
    );

    page(vec![
        header.into(),
        section("DISPLAY"),
        stream_card.into(),
        section("VIDEO"),
        video_card.into(),
        section("AUDIO"),
        audio_card.into(),
        section("ABOUT"),
        about_card.into(),
    ])
}

/// Static screen: the app's own license + the third-party software notices (reached from Settings).
fn licenses_page(set_screen: &AsyncSetState<Screen>) -> Element {
    let header = grid((
        text_block("Third-party licenses")
            .font_size(30.0)
            .bold()
            .grid_column(0)
            .vertical_alignment(VerticalAlignment::Center),
        button("Back")
            .accent()
            .icon(SymbolGlyph::Back)
            .on_click({
                let ss = set_screen.clone();
                move || ss.call(Screen::Settings)
            })
            .grid_column(1)
            .vertical_alignment(VerticalAlignment::Center),
    ))
    .columns([GridLength::Star(1.0), GridLength::Auto])
    .margin(edges(0.0, 0.0, 0.0, 6.0));

    let app_card = card(
        vstack((
            text_block("punktfunk").font_size(15.0).semibold(),
            text_block("Licensed under MIT OR Apache-2.0, at your option.")
                .font_size(12.0)
                .foreground(ThemeRef::SecondaryText),
            text_block(APP_LICENSE)
                .font_size(11.0)
                .foreground(ThemeRef::SecondaryText),
        ))
        .spacing(8.0),
    );

    let natives_card = card(
        vstack((
            text_block("Bundled components").font_size(15.0).semibold(),
            text_block(
                "FFmpeg is bundled under the LGPL v2.1+ (dynamically linked, replaceable DLLs); its \
                 license and notice ship in the installed licenses\\ folder. SDL 3 (Zlib) and the \
                 Windows App SDK (Microsoft) are also linked.",
            )
            .font_size(12.0)
            .foreground(ThemeRef::SecondaryText),
        ))
        .spacing(8.0),
    );

    let notices_card = card(
        vstack((
            text_block("Rust crates").font_size(15.0).semibold(),
            text_block(THIRD_PARTY_NOTICES)
                .font_size(11.0)
                .foreground(ThemeRef::SecondaryText),
        ))
        .spacing(8.0),
    );

    page(vec![
        header.into(),
        section("PUNKTFUNK"),
        app_card.into(),
        section("BUNDLED"),
        natives_card.into(),
        section("OPEN SOURCE"),
        notices_card.into(),
    ])
}

// --- stream page --------------------------------------------------------------------------

fn present_newest(ctx: &mut PresentCtx) {
    // Apply the latest source HDR mastering metadata (from the session pump's 0xCE drain) before
    // presenting — a cheap no-op in the presenter when unchanged.
    if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() {
        ctx.presenter.set_hdr_metadata(meta);
    }
    // Drain to the newest decoded frame (drop any backlog) and hand it to the presenter by value —
    // the GPU zero-copy path retains the decoder surface across re-presents, so ownership matters.
    let mut newest = None;
    while let Ok(f) = ctx.frames.try_recv() {
        newest = Some(f);
    }
    ctx.presenter.present(newest);
}

fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
    let ctx = &props.svc.ctx;
    // Take the connector + frames handoff once on mount; keep the connector alive (and for input)
    // in a use_ref, stash frames for `on_ready`, install the input hooks (and remove on unmount).
    let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
    cx.use_effect_with_cleanup((), {
        let shared = ctx.shared.clone();
        let connector_ref = connector_ref.clone();
        move || {
            if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
                let mode = connector.mode();
                connector_ref.set(Some(connector.clone()));
                PENDING_FRAMES.with(|c| *c.borrow_mut() = Some(frames));
                crate::input::install(connector, mode);
            }
            Some(crate::input::uninstall)
        }
    });

    let rendering = cx.use_ref::<Option<Rendering>>(None);
    cx.use_effect((), {
        let rendering = rendering.clone();
        move || {
            if let Ok(r) = on_rendering(move || {
                PRESENT.with(|cell| {
                    if let Some(ctx) = cell.borrow_mut().as_mut() {
                        present_newest(ctx);
                    }
                });
            }) {
                rendering.set(Some(r));
            }
        }
    });

    let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
    grid((
        swap_chain_panel()
            .on_ready(|panel| match Presenter::new(1280, 720) {
                Ok(p) => {
                    if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
                        tracing::error!(error = %e, "set_swap_chain");
                    }
                    if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) {
                        PRESENT.with(|cell| {
                            *cell.borrow_mut() = Some(PresentCtx {
                                presenter: p,
                                frames,
                            });
                        });
                        tracing::info!("stream presenter bound to SwapChainPanel");
                    }
                }
                Err(e) => tracing::error!(error = %e, "create presenter"),
            })
            .on_resize(|w, h| {
                PRESENT.with(|cell| {
                    if let Some(ctx) = cell.borrow_mut().as_mut() {
                        ctx.presenter.resize(w as u32, h as u32);
                    }
                });
            }),
        hud_overlay(&props.stats, mode),
    ))
    .into()
}

/// A small chip for the dark HUD: coloured text on a translucent dark fill.
fn hud_chip(text: &str, color: Color) -> Border {
    border(
        text_block(text)
            .font_size(11.0)
            .semibold()
            .foreground(color),
    )
    .background(Color::rgb(38, 38, 38))
    .corner_radius(8.0)
    .padding(edges(8.0, 2.0, 8.0, 2.0))
}

/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode
/// path · HDR), the fps/throughput/latency line, and the release-cursor hint. Layered over the
/// `SwapChainPanel` in the same grid cell.
fn hud_overlay(stats: &Stats, mode: Option<Mode>) -> Element {
    let res = mode
        .map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
        .unwrap_or_else(|| "\u{2014}".into());
    let mut chips: Vec<Element> = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()];
    chips.push(if stats.hardware {
        hud_chip("GPU decode", Color::rgb(120, 220, 150)).into()
    } else {
        hud_chip("CPU decode", Color::rgb(240, 190, 90)).into()
    });
    if stats.hdr {
        chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into());
    }
    let line = format!(
        "{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms",
        stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms
    );
    border(
        vstack((
            hstack(chips).spacing(6.0),
            text_block(line)
                .font_size(11.0)
                .foreground(Color::rgb(210, 210, 210)),
            text_block("Ctrl+Alt+Shift+Q releases the mouse")
                .font_size(11.0)
                .foreground(Color::rgb(150, 150, 150)),
        ))
        .spacing(6.0),
    )
    .background(Color::rgb(0, 0, 0))
    .corner_radius(10.0)
    .padding(uniform(10.0))
    .opacity(0.82)
    .horizontal_alignment(HorizontalAlignment::Right)
    .vertical_alignment(VerticalAlignment::Top)
    .margin(uniform(12.0))
    .into()
}
