//! Skia on the presenter's device: `DirectContext` over the shared handles, a ring of
//! two offscreen render-target surfaces (the presenter runs one frame in flight and may
//! still be sampling the previous image while we render the next), damage-driven
//! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame).
//!
//! Two personas over one `Overlay`: the CONSOLE (the full shell — home, library,
//! settings, pairing — always dirty, the aurora animates) and the stream chrome (stats
//! OSD, capture hint, the auto-fading start banner).

use crate::model::{ConsoleBus, ConsoleShared, HostRow};
use crate::screens::Screen;
use crate::shell::{ConsoleOptions, Shell};
use crate::theme::{match_first_family, Fonts};
use anyhow::{anyhow, Context as _, Result};
use ash::vk as avk;
use ash::vk::Handle as _;
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
use pf_presenter::overlay::{
    FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase, SharedDevice,
};
use skia_safe::gpu::vk as skvk;
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface};
use std::time::Instant;

/// Skia's GPU resource budget — poster art plus a few screen layers; 64 MB fits
/// Deck-class shared memory.
const RESOURCE_CACHE_BYTES: usize = 64 << 20;

/// How long the start-of-stream banner lingers (fading through the tail).
const BANNER_S: f64 = 6.0;
const BANNER_FADE_S: f64 = 0.6;

/// One offscreen target: the Skia surface + the raw Vulkan handles the presenter
/// samples. The image is Skia-owned (freed with the surface); the view is ours.
struct Slot {
    surface: Surface,
    image: avk::Image,
    view: avk::ImageView,
    width: u32,
    height: u32,
}

/// What the current ring slot has drawn — re-render only when this changes.
#[derive(PartialEq, Clone, Default)]
struct Drawn {
    width: u32,
    height: u32,
    stats: Option<String>,
    hint: Option<String>,
    /// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
    banner_step: u8,
    /// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
    /// mid-stream resize is in flight forces the per-frame redraw the spinner needs; `0`
    /// when no resize is showing (so a still stream stays damage-free).
    resize_step: u16,
}

/// Where the console starts (the session binary's `--browse` forms).
pub enum ConsoleEntry {
    /// The host list (bare `--browse`).
    Home,
    /// Home with this host's library already pushed (`--browse host` — the Decky
    /// per-host launch; B backs out to Home).
    Library(HostRow),
}

/// The binary's ends of the console: models to write, commands to serve.
pub struct ConsoleHandles {
    pub console: ConsoleShared,
    pub library: crate::library::LibraryShared,
    pub bus: ConsoleBus,
}

pub struct SkiaOverlay {
    /// Set by `init`; `None` until then (and after an init failure the run loop drops
    /// the whole overlay, so mid-session these are always `Some`).
    gpu: Option<Gpu>,
    slots: [Option<Slot>; 2],
    /// Which slot the LAST returned frame lives in — the next render takes the other.
    current: usize,
    drawn: Drawn,
    /// The stats OSD's monospace face (system-resolved; the console uses Geist).
    font: Option<Font>,
    fonts: Option<Fonts>,
    /// The console shell (`--browse`) — `None` for a plain `--connect` session.
    shell: Option<Shell>,
    /// When the current stream started presenting — drives the start banner.
    streaming_since: Option<Instant>,
    /// The banner's words (set per stream from the active-pad state).
    banner_text: Option<String>,
    /// When the current mid-stream resize scrim began showing — drives its spinner phase.
    /// `None` = no resize in flight (`FrameCtx::resizing` was false last frame).
    resizing_since: Option<Instant>,
}

struct Gpu {
    device: ash::Device,
    queue_family_index: u32,
    context: DirectContext,
    /// The device's shared queue lock (see `SharedDevice::queue_lock`): Skia submits
    /// on the presenter's graphics queue, which FFmpeg's decode prep also uses from
    /// the pump thread — every `flush*`/`submit` below holds this.
    queue_lock: std::sync::Arc<pf_client_core::video::QueueLock>,
    // Keep the loader library + instance dispatch alive as long as the DirectContext
    // (its baked fn pointers live inside libvulkan).
    _entry: ash::Entry,
    _instance: ash::Instance,
}

impl SkiaOverlay {
    #[allow(clippy::new_without_default)]
    pub fn new() -> SkiaOverlay {
        SkiaOverlay {
            gpu: None,
            slots: [None, None],
            current: 0,
            drawn: Drawn::default(),
            font: None,
            fonts: None,
            shell: None,
            streaming_since: None,
            banner_text: None,
            resizing_since: None,
        }
    }

    /// The `--browse` overlay: the full console shell between streams, stream chrome
    /// during them. Returns the binary's handles (models + command bus).
    pub fn console(
        opts: ConsoleOptions,
        entry: ConsoleEntry,
    ) -> Result<(SkiaOverlay, ConsoleHandles)> {
        let console = ConsoleShared::default();
        let library = crate::library::LibraryShared::default();
        let bus = ConsoleBus::default();
        let stack = match entry {
            ConsoleEntry::Home => vec![Screen::Home(crate::screens::home::HomeScreen::new())],
            ConsoleEntry::Library(host) => vec![
                Screen::Home(crate::screens::home::HomeScreen::new()),
                Screen::Library(crate::screens::library::LibraryScreen::new(&host)),
            ],
        };
        let shell = Shell::new(console.clone(), library.clone(), bus.clone(), opts, stack)?;
        let mut o = SkiaOverlay::new();
        o.shell = Some(shell);
        Ok((
            o,
            ConsoleHandles {
                console,
                library,
                bus,
            },
        ))
    }

    fn console_visible(&self) -> bool {
        self.shell.as_ref().is_some_and(|s| !s.in_stream)
    }
}

impl Drop for SkiaOverlay {
    /// The run loop quiesces the queue before dropping us; releasing the views + Skia
    /// surfaces (which free their VkImages) is then safe. Field order drops the slots
    /// before the DirectContext.
    fn drop(&mut self) {
        if let Some(gpu) = &mut self.gpu {
            for slot in self.slots.iter_mut().flat_map(Option::take) {
                unsafe { gpu.device.destroy_image_view(slot.view, None) };
                drop(slot.surface);
            }
            let _q = gpu.queue_lock.guard(); // queue external sync vs FFmpeg's pump
            gpu.context.flush_and_submit();
        }
    }
}

impl Overlay for SkiaOverlay {
    fn init(&mut self, shared: &SharedDevice) -> Result<()> {
        // Skia resolves its Vulkan entry points through us: instance-scoped names via
        // the loader, device-scoped via the device — the exact same dispatch ash uses.
        // Resolution completes inside `make_vulkan` (the DirectContext bakes its fn
        // table); the closure and its clones end with `init`.
        let entry = shared.entry.clone();
        let instance = shared.instance.clone();
        let get_proc = move |of: skvk::GetProcOf| -> *const std::ffi::c_void {
            unsafe {
                match of {
                    skvk::GetProcOf::Instance(raw_instance, name) => entry
                        .get_instance_proc_addr(avk::Instance::from_raw(raw_instance as _), name)
                        .map_or(std::ptr::null(), |f| f as *const std::ffi::c_void),
                    skvk::GetProcOf::Device(raw_device, name) => {
                        (instance.fp_v1_0().get_device_proc_addr)(
                            avk::Device::from_raw(raw_device as _),
                            name,
                        )
                        .map_or(std::ptr::null(), |f| f as *const std::ffi::c_void)
                    }
                }
            }
        };
        let backend = unsafe {
            skvk::BackendContext::new(
                shared.instance.handle().as_raw() as _,
                shared.physical_device.as_raw() as _,
                shared.device.handle().as_raw() as _,
                (
                    shared.queue.as_raw() as _,
                    shared.queue_family_index as usize,
                ),
                &get_proc,
            )
        };
        let mut context = gpu::direct_contexts::make_vulkan(&backend, None)
            .ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?;
        context.set_resource_cache_limit(RESOURCE_CACHE_BYTES);

        let typeface = match_first_family(
            &FontMgr::new(),
            &["monospace", "Consolas", "Cascadia Mono", "Courier New"],
            skia_safe::FontStyle::normal(),
        )
        .context("no monospace typeface (fontconfig alias or system family)")?;
        self.font = Some(Font::new(typeface, 14.0));
        self.fonts = Some(crate::theme::build_fonts()?);

        self.gpu = Some(Gpu {
            device: shared.device.clone(),
            queue_family_index: shared.queue_family_index,
            context,
            queue_lock: shared.queue_lock.clone(),
            _entry: shared.entry.clone(),
            _instance: shared.instance.clone(),
        });
        tracing::info!("Skia console UI on the presenter's device");
        Ok(())
    }

    fn handle_event(&mut self, event: &sdl3::event::Event) -> bool {
        // Keyboard + text into the console while it's on screen — never for
        // chord-modified keys (those stay the run loop's), never during a stream.
        if !self.console_visible() {
            return false;
        }
        let Some(shell) = &mut self.shell else {
            return false;
        };
        match event {
            sdl3::event::Event::KeyDown {
                scancode: Some(sc),
                keymod,
                repeat,
                ..
            } => {
                use sdl3::keyboard::Mod;
                if keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) {
                    return false;
                }
                shell.key(*sc, *repeat)
            }
            sdl3::event::Event::TextInput { text, .. } => {
                shell.text_input(text);
                true
            }
            _ => false,
        }
    }

    fn handle_menu(&mut self, event: MenuEvent) -> Option<MenuPulse> {
        if self.console_visible() {
            self.shell.as_mut().and_then(|s| s.handle_menu(event))
        } else {
            None
        }
    }

    fn take_action(&mut self) -> Option<OverlayAction> {
        self.shell.as_mut().and_then(|s| s.take_action())
    }

    fn text_input_active(&self) -> bool {
        self.console_visible() && self.shell.as_ref().is_some_and(Shell::editing)
    }

    fn session_phase(&mut self, phase: SessionPhase) {
        let Some(shell) = &mut self.shell else { return };
        match phase {
            SessionPhase::Connecting => {} // the shell raised the Launch; already showing
            SessionPhase::Streaming => {
                shell.session_streaming();
                self.streaming_since = Some(Instant::now());
            }
            SessionPhase::Failed(msg) => shell.session_failed(msg),
            SessionPhase::Ended(reason) => {
                shell.session_ended(reason);
                self.streaming_since = None;
            }
        }
    }

    fn frame(&mut self, ctx: &FrameCtx) -> Result<Option<OverlayFrame>> {
        // The console: full-screen, opaque, and always dirty (the aurora animates
        // every frame — the GPU port's whole point).
        if self.console_visible() {
            let next = 1 - self.current;
            self.ensure_slot(next, ctx.width, ctx.height)?;
            let Self {
                gpu,
                slots,
                shell,
                fonts,
                ..
            } = self;
            let gpu = gpu.as_mut().expect("init ran");
            let slot = slots[next].as_mut().expect("just ensured");
            let shell = shell.as_mut().expect("console_visible");
            let fonts = fonts.as_ref().expect("init ran");
            shell.render(
                slot.surface.canvas(),
                ctx.width,
                ctx.height,
                fonts,
                ctx.pad,
                ctx.pad_pref,
                ctx.pads,
            );
            {
                // Queue external sync vs FFmpeg's pump-thread submits (same queue).
                let _q = gpu.queue_lock.guard();
                gpu.context.flush_surface_with_texture_state(
                    &mut slot.surface,
                    &gpu::FlushInfo::default(),
                    Some(&skvk::mutable_texture_states::new_vulkan(
                        skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
                        gpu.queue_family_index,
                    )),
                );
                gpu.context.submit(None);
            }
            self.current = next;
            self.drawn = Drawn::default(); // stream chrome re-renders when it returns
            let slot = self.slots[next].as_ref().expect("just rendered");
            return Ok(Some(OverlayFrame {
                image: slot.image,
                view: slot.view,
                width: slot.width,
                height: slot.height,
            }));
        }

        // --- Stream chrome: stats OSD + capture hint + start banner + resize scrim -----
        let banner_alpha = self.banner_alpha(ctx);
        let banner_step = (banner_alpha * 32.0).round() as u8;
        let resize_phase = self.resize_phase(ctx);
        // 120 steps/s: every ~16 ms frame lands on a fresh step, so the spinner keeps
        // spinning through the damage gate; `+ 1` keeps an active resize's step nonzero
        // even on its first frame (phase 0) so the guard below doesn't skip it.
        let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1);
        if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 {
            self.drawn = Drawn::default(); // forget content so re-show re-renders
            return Ok(None);
        }
        let want = Drawn {
            width: ctx.width,
            height: ctx.height,
            stats: ctx.stats.map(str::to_owned),
            hint: ctx.hint.map(str::to_owned),
            banner_step,
            resize_step,
        };
        if want == self.drawn {
            // Unchanged — hand the presenter the already-rendered image.
            return Ok(self.slots[self.current].as_ref().map(|s| OverlayFrame {
                image: s.image,
                view: s.view,
                width: s.width,
                height: s.height,
            }));
        }

        // Render into the OTHER slot — the presenter may still be sampling the current
        // one (one frame in flight; the ring of two is exactly deep enough).
        let next = 1 - self.current;
        self.ensure_slot(next, ctx.width, ctx.height)?;
        let gpu = self.gpu.as_mut().expect("init ran");
        let slot = self.slots[next].as_mut().expect("just ensured");

        let canvas = slot.surface.canvas();
        canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
        let font = self.font.as_ref().expect("init ran");
        // The resize scrim sits UNDER the OSD/hint so those stay legible over it.
        if let Some(phase) = resize_phase {
            draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
        }
        if let Some(stats) = &want.stats {
            draw_osd_panel(canvas, font, stats, 12.0, 12.0);
        }
        if let Some(hint) = &want.hint {
            draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0);
        } else if banner_step > 0 {
            // The start banner: the leave/stats shortcuts, fading out on its own —
            // discoverable without the stats overlay, gone before it annoys.
            if let Some(text) = &self.banner_text {
                draw_hint_pill(
                    canvas,
                    font,
                    text,
                    ctx.width,
                    ctx.height,
                    banner_alpha as f32,
                );
            }
        }

        // Flush on the shared queue, ending in SHADER_READ_ONLY on our family — the
        // layout the presenter's composite samples (its own barrier covers visibility).
        // Lock: queue external sync vs FFmpeg's pump-thread submits (same queue).
        {
            let _q = gpu.queue_lock.guard();
            gpu.context.flush_surface_with_texture_state(
                &mut slot.surface,
                &gpu::FlushInfo::default(),
                Some(&skvk::mutable_texture_states::new_vulkan(
                    skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
                    gpu.queue_family_index,
                )),
            );
            gpu.context.submit(None);
        }

        self.current = next;
        self.drawn = want;
        let slot = self.slots[next].as_ref().expect("just rendered");
        Ok(Some(OverlayFrame {
            image: slot.image,
            view: slot.view,
            width: slot.width,
            height: slot.height,
        }))
    }
}

impl SkiaOverlay {
    /// The start banner's current alpha (1 → 0 across the fade tail), refreshing its
    /// words while fully visible so a pad hot-plug updates the leave hint.
    fn banner_alpha(&mut self, ctx: &FrameCtx) -> f64 {
        let Some(since) = self.streaming_since else {
            self.banner_text = None;
            return 0.0;
        };
        let age = since.elapsed().as_secs_f64();
        if age >= BANNER_S {
            self.streaming_since = None;
            self.banner_text = None;
            return 0.0;
        }
        self.banner_text = Some(if ctx.pad.is_some() {
            "Hold L1 + R1 + Start + Select to leave · Ctrl+Alt+Shift+S stats".to_string()
        } else {
            "Ctrl+Alt+Shift+Q releases input · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"
                .to_string()
        });
        ((BANNER_S - age) / BANNER_FADE_S).min(1.0)
    }

    /// The mid-stream-resize spinner's phase (elapsed seconds since the scrim came up), or
    /// `None` when no resize is in flight. Latches the start on the first `resizing` frame
    /// and clears it the moment the run loop drops the flag (the target frame landed or the
    /// switch timed out), so the next resize starts its spinner from zero.
    fn resize_phase(&mut self, ctx: &FrameCtx) -> Option<f64> {
        if !ctx.resizing {
            self.resizing_since = None;
            return None;
        }
        Some(
            self.resizing_since
                .get_or_insert_with(Instant::now)
                .elapsed()
                .as_secs_f64(),
        )
    }

    /// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
    fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
        if self.slots[i]
            .as_ref()
            .is_some_and(|s| s.width == width && s.height == height)
        {
            return Ok(());
        }
        let gpu = self.gpu.as_mut().expect("init ran");
        if let Some(old) = self.slots[i].take() {
            // Any in-flight sampling of THIS slot ended two presents ago (the ring
            // alternates and the presenter waits its fence before each record).
            unsafe { gpu.device.destroy_image_view(old.view, None) };
        }
        let info =
            skia_safe::ImageInfo::new_n32_premul((width.max(1) as i32, height.max(1) as i32), None);
        let mut surface = gpu::surfaces::render_target(
            &mut gpu.context,
            gpu::Budgeted::Yes,
            &info,
            None,
            SurfaceOrigin::TopLeft,
            None,
            false,
            None,
        )
        .context("Skia render-target surface")?;
        let texture = gpu::surfaces::get_backend_texture(
            &mut surface,
            skia_safe::surface::BackendHandleAccess::FlushRead,
        )
        .context("surface backend texture")?;
        let image_info = texture
            .vulkan_image_info()
            .context("backend texture is not Vulkan")?;
        let image = avk::Image::from_raw(*image_info.image() as u64);
        let view = unsafe {
            gpu.device.create_image_view(
                &avk::ImageViewCreateInfo::default()
                    .image(image)
                    .view_type(avk::ImageViewType::TYPE_2D)
                    .format(avk::Format::from_raw(image_info.format as i32))
                    .subresource_range(
                        avk::ImageSubresourceRange::default()
                            .aspect_mask(avk::ImageAspectFlags::COLOR)
                            .level_count(1)
                            .layer_count(1),
                    ),
                None,
            )
        }
        .context("overlay image view")?;
        self.slots[i] = Some(Slot {
            surface,
            image,
            view,
            width,
            height,
        });
        Ok(())
    }
}

/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's
/// look, minus the toolkit).
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
    let (_, metrics) = font.metrics();
    let line_h = metrics.descent - metrics.ascent + metrics.leading;
    let lines: Vec<&str> = text.lines().collect();
    let widest = lines
        .iter()
        .map(|l| font.measure_str(l, None).0)
        .fold(0.0f32, f32::max);
    let (pad_x, pad_y) = (10.0, 8.0);
    let panel = Rect::from_xywh(
        x,
        y,
        widest + 2.0 * pad_x,
        line_h * lines.len() as f32 + 2.0 * pad_y,
    );
    canvas.draw_rrect(
        RRect::new_rect_xy(panel, 8.0, 8.0),
        &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
    );
    let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
    for (i, line) in lines.iter().enumerate() {
        canvas.draw_str(
            line,
            Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32),
            font,
            &text_paint,
        );
    }
}

/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and
/// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder
/// rebuild reads as a deliberate pause rather than the stream stretching to the changed
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
/// hides the stretched in-between frame instead (same intent, one draw).
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
    let (wf, hf) = (width as f32, height as f32);
    canvas.draw_rect(
        Rect::from_wh(wf, hf),
        &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
    );
    // Spinner slightly above center; the label sits below it.
    let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0);
    let r = (f64::from(width.min(height)) * 0.045).clamp(16.0, 44.0);
    crate::theme::spinner(canvas, cx, cy - r, r, phase);
    let (_, metrics) = font.metrics();
    let label = "Resizing\u{2026}";
    let tw = font.measure_str(label, None).0;
    canvas.draw_str(
        label,
        Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent),
        font,
        &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
    );
}

/// The capture hint / start banner: a centered pill near the bottom edge.
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
    let (_, metrics) = font.metrics();
    let line_h = metrics.descent - metrics.ascent;
    let text_w = font.measure_str(text, None).0;
    let (pad_x, pad_y) = (14.0, 8.0);
    let w = text_w + 2.0 * pad_x;
    let h = line_h + 2.0 * pad_y;
    let x = (width as f32 - w) / 2.0;
    let y = height as f32 - h - 24.0;
    canvas.draw_rrect(
        RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
        &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
    );
    canvas.draw_str(
        text,
        Point::new(x + pad_x, y + pad_y - metrics.ascent),
        font,
        &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha), None),
    );
}
