// Hosts grid ⇄ trust prompt ⇄ live stream. ContentView is the coordinator: it owns the session
// model, host store, and LAN discovery; switches between the home grid (HomeView) and the live
// session; and holds the connect logic (it reads the @AppStorage stream mode). The grid + cards
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
// their own files.
//
// Ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
// live-but-blurred stream, compared with the host's log; only for a host advertising pair=optional),
// the PIN pairing ceremony (verifies both sides at once), or — for a host that requires pairing —
// delegated approval ("Request Access": a plain identified connect the host parks until the operator
// approves this device in its console, no PIN). Once pinned, reconnects are silent and a changed
// host identity refuses to connect.

#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI

struct ContentView: View {
    @StateObject private var model = SessionModel()
    @StateObject private var store = HostStore()
    @StateObject private var discovery = HostDiscovery()
    @AppStorage(DefaultsKey.streamWidth) private var width = 1920
    @AppStorage(DefaultsKey.streamHeight) private var height = 1080
    @AppStorage(DefaultsKey.streamHz) private var hz = 60
    @AppStorage(DefaultsKey.compositor) private var compositor = 0
    @AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
    @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
    @AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
    @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
    @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
    @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
    @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
    @State private var showAddHost = false
    @State private var pairingTarget: StoredHost?
    /// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
    /// delegated approval ("Request Access") and the SPAKE2 PIN ceremony (rule 3b).
    @State private var approvalChoice: ApprovalRequest?
    /// A delegated-approval connect is in flight (host parks it until the operator approves):
    /// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
    @State private var awaitingApproval: ApprovalRequest?
    @State private var speedTestTarget: StoredHost?
    @State private var libraryTarget: StoredHost?
    #if !os(macOS)
    @State private var showSettings = false
    #endif

    var body: some View {
        Group {
            // The stream view's structural identity MUST be stable across the
            // awaiting-trust → streaming transition: recreating it restarts the pump,
            // which has then already missed the opening IDR (infinite GOP — no other
            // keyframe ever comes) and decodes nothing. So: one branch per connection,
            // trust prompt as an overlay.
            if model.connection != nil {
                sessionView
            } else {
                home
            }
        }
        .onAppear {
            seedDefaultModeIfNeeded()
            autoConnectIfAsked()
        }
        .onChange(of: model.phase) { _, phase in
            switch phase {
            case .streaming:
                // A session actually started — remember it on the card ("Connected … ago"
                // plus the accent ring on the most recent host).
                guard let host = model.activeHost else { break }
                // Delegated approval just succeeded: the operator let this device in, so pin the
                // host's observed fingerprint and remember it as paired — future connects are then
                // silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
                let approvedFingerprint = awaitingApproval?.host.id == host.id
                    ? model.connection?.hostFingerprint : nil
                if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
                // Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
                // its @Published from inside .onChange (a view-update callback) trips SwiftUI's
                // "Publishing changes from within view updates". A one-tick delay is imperceptible.
                let store = store
                DispatchQueue.main.async {
                    store.markConnected(host.id)
                    if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
                }
            case .idle:
                // The delegated-approval connect failed, timed out, or was cancelled — drop the
                // wait prompt (SessionModel surfaces any error via `errorMessage`).
                if awaitingApproval != nil { awaitingApproval = nil }
            default:
                break
            }
        }
        .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
        // Expose the session to the Scene-level Stream menu (Disconnect ⌘D works even when
        // the HUD is hidden). tvOS has no such menu.
        #if !os(tvOS)
        .focusedSceneValue(\.sessionFocus, SessionFocus(
            isStreaming: model.connection != nil,
            disconnect: { model.disconnect() }))
        #endif
        #if os(macOS)
        // Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
        // windowed on the host list — so the picker isn't forced fullscreen. Opt-out in Settings.
        .background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil))
        #endif
        // On the outer Group so the sheet survives the trust-prompt → home transition
        // (the "Pair with PIN instead" path disconnects first — the host's accept loop
        // is sequential, a pairing connection would queue behind the live session).
        #if !os(tvOS)
        .sheet(item: $pairingTarget) { host in
            PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
        }
        .sheet(item: $speedTestTarget) { host in
            SpeedTestSheet(host: host)
        }
        .sheet(item: $libraryTarget) { host in
            NavigationStack {
                LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
            }
        }
        #endif
        // Fresh pair=required / unknown host: offer the two ways in. An action sheet (not an
        // alert) so it never collides with the wait alert below. "Request Access" is the no-PIN
        // delegated-approval path; "Pair with PIN…" runs the SPAKE2 ceremony. The follow-on
        // presentation is deferred a tick so this dialog is fully dismissed first.
        .confirmationDialog(
            "Pairing required",
            isPresented: Binding(
                get: { approvalChoice != nil },
                set: { if !$0 { approvalChoice = nil } }),
            titleVisibility: .visible,
            presenting: approvalChoice
        ) { req in
            Button("Request Access") {
                DispatchQueue.main.async { requestAccess(req) }
            }
            Button("Pair with PIN…") {
                DispatchQueue.main.async { pairingTarget = req.host }
            }
            Button("Cancel", role: .cancel) {}
        } message: { req in
            Text("\(req.host.displayName) requires pairing. Request access and approve this "
                + "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or "
                + "pair with the 4-digit PIN it can display.")
        }
        // The delegated-approval wait: the host holds the connection open until the operator
        // approves it. Cancel returns the UI at once; the in-flight connect is left to time out
        // and its late result is discarded by SessionModel's connect guard (disconnect resets the
        // phase/host it checks).
        .alert(
            "Waiting for approval",
            isPresented: Binding(
                get: { awaitingApproval != nil },
                set: { if !$0 { awaitingApproval = nil } }),
            presenting: awaitingApproval
        ) { _ in
            Button("Cancel", role: .cancel) { model.disconnect() }
        } message: { req in
            Text("Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s web "
                + "console (port 3000 → Pairing). This device connects automatically once you "
                + "approve it — no need to reconnect.")
        }
    }

    private var home: some View {
        #if os(macOS)
        HomeView(
            store: store, model: model, discovery: discovery,
            showAddHost: $showAddHost, pairingTarget: $pairingTarget,
            speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
            connect: { connect($0) }, connectDiscovered: connectDiscovered,
            onPaired: handlePaired, onLaunchTitle: launchTitle)
        #else
        HomeView(
            store: store, model: model, discovery: discovery,
            showAddHost: $showAddHost, pairingTarget: $pairingTarget,
            speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
            showSettings: $showSettings,
            connect: { connect($0) }, connectDiscovered: connectDiscovered,
            onPaired: handlePaired, onLaunchTitle: launchTitle)
        #endif
    }

    // MARK: - Session

    private var sessionView: some View {
        let pendingFingerprint: Data? = {
            if case .awaitingTrust(let fp) = model.phase { return fp }
            return nil
        }()
        return ZStack {
            stream(captureEnabled: pendingFingerprint == nil)
                .blur(radius: pendingFingerprint != nil ? 32 : 0)
                .overlay {
                    if pendingFingerprint != nil {
                        Color.black.opacity(0.45)
                    }
                }
            if let fp = pendingFingerprint {
                TrustCardView(
                    fingerprint: fp,
                    hostName: model.activeHost?.displayName ?? "host",
                    onCancel: { model.rejectTrust() },
                    onTrust: {
                        if let fp = model.confirmTrust(), let host = model.activeHost {
                            store.pin(host.id, fingerprint: fp)
                        }
                    },
                    onPairInstead: {
                        let host = model.activeHost
                        model.rejectTrust()
                        pairingTarget = host
                    })
            }
        }
        #if os(macOS)
        .frame(minWidth: 640, minHeight: 360)
        .background(Color.black)
        // Fill the whole display in fullscreen, INCLUDING behind the camera housing (notch).
        // Without this the stream is laid out in the safe area below the notch, so an
        // aspect-fit video at the display's native mode scales down and leaves black borders.
        // A fullscreen video behind the notch (a thin top-center strip occluded) is the
        // expected behavior — same edge-to-edge intent as the iOS/tvOS branches below. Inert
        // in windowed mode (no notch safe-area inset on a titled window).
        .ignoresSafeArea()
        #elseif os(iOS)
        // Streaming is immersive: edge-to-edge under the status bar and home
        // indicator, both hidden for the session (they return with the hosts grid).
        .background(Color.black)
        .ignoresSafeArea()
        .statusBarHidden(true)
        .persistentSystemOverlays(.hidden)
        #else
        .background(Color.black)
        .ignoresSafeArea()
        // Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
        // disconnect control during play, the controller's buttons flow to the host instead of
        // driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
        // host as Start — the Siri Remote is the intended disconnect path.
        .onExitCommand { model.disconnect() }
        #endif
    }

    private func stream(captureEnabled: Bool) -> some View {
        let placement = HUDPlacement(rawValue: hudPlacement) ?? .topTrailing
        return Group {
            if let conn = model.connection {
                StreamView(
                    connection: conn,
                    captureEnabled: captureEnabled,
                    onCaptureChange: { [weak model] captured in
                        model?.mouseCaptured = captured
                    },
                    onFrame: { [meter = model.meter, latency = model.latency, offset = conn.clockOffsetNs] au in
                        meter.note(byteCount: au.data.count)
                        latency.record(ptsNs: au.ptsNs, offsetNs: offset)
                    },
                    onSessionEnd: { [weak model] in
                        Task { @MainActor in model?.sessionEnded() }
                    },
                    presentMeter: model.presentLatency
                )
                .overlay(alignment: placement.alignment) {
                    if captureEnabled && hudEnabled {
                        StreamHUDView(model: model, connection: conn, placement: placement)
                    }
                }
                #if os(iOS)
                // Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
                // is hidden, keep a minimal always-reachable exit in a corner. It rides a
                // material disc (like the HUD) so the glyph stays legible over a bright frame
                // — this is the sole touch disconnect path when stats are off.
                .overlay(alignment: .topLeading) {
                    if captureEnabled && !hudEnabled {
                        Button { model.disconnect() } label: {
                            Image(systemName: "xmark")
                                .font(.headline.weight(.semibold))
                                .frame(width: 36, height: 36)
                                // Sole touch exit when the HUD is off — a floating glass disc
                                // over the frame (26+, material fallback). interactive: the disc
                                // IS the tap target, so the glass reacts to press.
                                .glassBackground(Circle(), interactive: true)
                                // Match the hit region to the visible disc so every tap also
                                // triggers the interactive-glass press highlight.
                                .contentShape(Circle())
                        }
                        .buttonStyle(.plain)
                        .padding(12)
                        .accessibilityLabel("Disconnect")
                    }
                }
                #endif
            }
        }
    }

    // MARK: - Connect

    private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
        // A pinned host connects on its stored fingerprint; an unpinned host may only TOFU when
        // the host's LIVE advert says `pair=optional` (rule 3a). When the caller doesn't already
        // know the policy (a saved-card tap / manual entry), resolve it from the current mDNS set:
        // an unpinned host with no matching `pair=optional` advert routes to the approval choice
        // (request access / pair with PIN) instead of silently entering the trust prompt (rules
        // 3b + 4). A pinned host ignores all of this.
        if host.pinnedSHA256 == nil {
            let tofuOK = allowTofu ?? discovery.hosts.contains {
                host.matches($0) && $0.allowsTofu
            }
            if !tofuOK {
                // pair=required / unknown policy / manual entry (rule 3b): never a silent
                // connect — offer no-PIN delegated approval or the PIN ceremony.
                approvalChoice = ApprovalRequest(
                    host: host, advertisedFingerprint: advertisedFingerprint(for: host))
                return
            }
        }
        startSession(host, launchID: launchID, allowTofu: host.pinnedSHA256 == nil)
    }

    /// Resolve the @AppStorage stream mode + input prefs and hand off to the session model. The
    /// gamepad-type setting resolves NOW (Automatic → match the active physical controller): the
    /// host's virtual pad backend is fixed per session. `requestAccess` opens the no-PIN
    /// delegated-approval connect (host parks it until the operator approves).
    private func startSession(
        _ host: StoredHost, launchID: String? = nil,
        allowTofu: Bool, requestAccess: Bool = false
    ) {
        model.connect(
            to: host,
            width: UInt32(clamping: width), height: UInt32(clamping: height),
            hz: UInt32(clamping: hz),
            compositor: PunktfunkConnection.Compositor(
                rawValue: UInt32(clamping: compositor)) ?? .auto,
            gamepad: GamepadManager.shared.resolveType(
                setting: PunktfunkConnection.GamepadType(
                    rawValue: UInt32(clamping: gamepadType)) ?? .auto),
            bitrateKbps: UInt32(clamping: bitrateKbps),
            audioChannels: UInt8(clamping: audioChannels),
            hdrEnabled: hdrEnabled,
            launchID: launchID,
            allowTofu: allowTofu,
            requestAccess: requestAccess)
    }

    /// The no-PIN delegated-approval flow: open an identified connect the host parks until the
    /// operator approves it in the console, showing the cancelable "Waiting for approval" prompt
    /// meanwhile. On success the SAME connection is admitted (no reconnect) and the host is pinned
    /// as paired (see the `.streaming` branch of `onChange`).
    private func requestAccess(_ req: ApprovalRequest) {
        guard !model.isBusy else { return }
        awaitingApproval = req
        // Pin the advertised certificate for a discovered host (impostor defence during the long
        // wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
        var host = req.host
        host.pinnedSHA256 = req.advertisedFingerprint
        startSession(host, allowTofu: false, requestAccess: true)
    }

    /// Picked a title in the (experimental) library: dismiss the browser and start a session that
    /// asks the host to launch it.
    private func launchTitle(_ host: StoredHost, _ id: String) {
        libraryTarget = nil
        connect(host, launchID: id)
    }

    /// Tap a discovered host: save it (so the session has a stored identity and the trust pin
    /// persists), then connect or pair per the host's advertised policy. The host is the policy
    /// authority — TOFU is offered ONLY when it explicitly advertised `pair=optional` (rule 3a);
    /// a `pair=required` host, or one with no/unknown `pair` field, gets the approval choice
    /// (request access / pair with PIN) (rule 3b). (A pinned discovered host connects silently
    /// inside `connect`.)
    private func connectDiscovered(_ d: DiscoveredHost) {
        guard !model.isBusy else { return }
        let host = StoredHost(name: d.name, address: d.host, port: d.port)
        store.add(host)
        if d.allowsTofu {
            connect(host, allowTofu: true)
        } else {
            // pair=required / unknown policy (rule 3b): offer no-PIN delegated approval or PIN.
            approvalChoice = ApprovalRequest(
                host: host, advertisedFingerprint: pinFingerprint(d.fingerprintHex))
        }
    }

    /// Pairing ceremony succeeded — pin the host and connect. The guard backstops a stale
    /// ceremony surfacing after dismissal (PairSheet also self-discards those).
    private func handlePaired(_ host: StoredHost, fingerprint: Data) {
        guard pairingTarget?.id == host.id else { return }
        store.pin(host.id, fingerprint: fingerprint)
        var pinned = host
        pinned.pinnedSHA256 = fingerprint
        connect(pinned)
    }

    /// The certificate fingerprint a live mDNS advert carries for this saved host (advisory — see
    /// `HostDiscovery`), to pin during a delegated-approval wait. nil if the host isn't currently
    /// advertising or advertised no/invalid `fp`.
    private func advertisedFingerprint(for host: StoredHost) -> Data? {
        pinFingerprint(discovery.hosts.first { host.matches($0) }?.fingerprintHex)
    }

    /// Parse an advertised cert fingerprint (lowercase hex) into the 32-byte pin the connect
    /// expects; nil unless it's exactly a 32-byte (SHA-256) value, so a malformed advert falls
    /// back to trust-on-first-use rather than failing the connect closed.
    private func pinFingerprint(_ hex: String?) -> Data? {
        guard let hex, let data = Data(hexString: hex), data.count == 32 else { return nil }
        return data
    }

    /// How the host lists this device in its approval prompt (matches PairSheet's client name).
    private var localDeviceName: String {
        #if os(macOS)
        Host.current().localizedName ?? "Mac"
        #else
        UIDevice.current.name
        #endif
    }

    // MARK: - First-run + dev hooks

    /// First run on iOS: default the stream mode to this device's native screen so the
    /// video fills the display instead of letterboxing 1920×1080 onto a 4:3 iPad. (The
    /// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps
    /// 1080p — a desktop window is not the screen.)
    private func seedDefaultModeIfNeeded() {
        #if !os(macOS)
        let defaults = UserDefaults.standard
        guard defaults.object(forKey: DefaultsKey.streamWidth) == nil else { return }
        let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
        defaults.set(Int(max(bounds.width, bounds.height)), forKey: DefaultsKey.streamWidth)
        defaults.set(Int(min(bounds.width, bounds.height)), forKey: DefaultsKey.streamHeight)
        defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: DefaultsKey.streamHz)
        #endif
    }

    /// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
    /// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
    /// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope|… overrides the
    /// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
    /// pad type (same names as the host env knobs). (IPv4/hostname only.)
    private func autoConnectIfAsked() {
        guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
              !target.isEmpty, model.phase == .idle
        else { return }
        let parts = target.split(separator: ":")
        var host = StoredHost(name: "", address: String(parts[0]))
        if parts.count == 2, let p = UInt16(parts[1]) { host.port = p }
        if let mode = ProcessInfo.processInfo.environment["PUNKTFUNK_MODE"] {
            let dims = mode.split(separator: "x").compactMap { Int($0) }
            if dims.count == 3 {
                width = dims[0]
                height = dims[1]
                hz = dims[2]
            }
        }
        var pref = PunktfunkConnection.Compositor(
            rawValue: UInt32(clamping: compositor)) ?? .auto
        if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"],
           let c = PunktfunkConnection.Compositor(name: name) {
            pref = c
        }
        var pad = GamepadManager.shared.resolveType(
            setting: PunktfunkConnection.GamepadType(
                rawValue: UInt32(clamping: gamepadType)) ?? .auto)
        if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
           let g = PunktfunkConnection.GamepadType(name: name) {
            pad = g
        }
        var bitrate = UInt32(clamping: bitrateKbps)
        if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
           let v = UInt32(kbps) {
            bitrate = v
        }
        model.connect(
            to: host,
            width: UInt32(clamping: width), height: UInt32(clamping: height),
            hz: UInt32(clamping: hz),
            compositor: pref,
            gamepad: pad,
            bitrateKbps: bitrate,
            audioChannels: UInt8(clamping: audioChannels),
            hdrEnabled: hdrEnabled,
            autoTrust: true)
    }
}

#if os(macOS)
/// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in
/// the view tree; on each `active` change it captures the window and toggles fullscreen only when
/// the current state differs (so it never fights a toggle already in flight, and never touches a
/// window the user fullscreened manually unless `active` says otherwise).
private struct FullscreenController: NSViewRepresentable {
    let active: Bool

    func makeNSView(context: Context) -> NSView { NSView() }

    func updateNSView(_ view: NSView, context: Context) {
        let want = active
        DispatchQueue.main.async {
            guard let window = view.window else { return }
            let isFull = window.styleMask.contains(.fullScreen)
            if want != isFull { window.toggleFullScreen(nil) }
        }
    }
}
#endif

/// A fresh `pair=required`/unknown host pending a trust decision: drives both the "request access
/// vs. pair with PIN" choice and the subsequent approval wait. `advertisedFingerprint` is the
/// discovered host's advertised cert (nil for a manually-typed host → trust-on-first-use).
private struct ApprovalRequest {
    let host: StoredHost
    let advertisedFingerprint: Data?
}

private extension Data {
    /// Parse an even-length hex string into bytes; nil on any non-hex character or odd length.
    /// Used to turn an mDNS-advertised cert fingerprint into a connect pin.
    init?(hexString: String) {
        let chars = Array(hexString)
        guard chars.count.isMultiple(of: 2) else { return nil }
        var bytes = [UInt8]()
        bytes.reserveCapacity(chars.count / 2)
        var i = 0
        while i < chars.count {
            guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else {
                return nil
            }
            bytes.append(UInt8(hi << 4 | lo))
            i += 2
        }
        self = Data(bytes)
    }
}
