//! Frame-feedback + bloom post stack, built only from nannou's own validated
//! Draw + offscreen renderer (no hand-written render pipelines).
//!
//! Per frame, at a fixed internal resolution (super-sampled for cheap AA):
//!   1. the sigil is rendered into `scene`;
//!   2. a composite pass writes `acc_next = fade(acc_prev) + ADD(scene)` — the
//!      previous accumulator, dimmed toward the background by drawing a
//!      translucent rect over it (nannou textures can't be tinted, so decay is
//!      done this way), with the fresh scene added on top. A slight zoom on the
//!      fed-back copy makes trails bloom outward instead of just smearing.
//!   3. the bin presents the accumulator to the window (downsampled → AA) and
//!      can capture it for the offline video.
//!
//! Chromatic aberration is done at draw time in the bin (per-channel offset
//! passes) because nannou's texture primitive ignores vertex colour.

use nannou::draw::{Renderer, RendererBuilder};
use nannou::prelude::*;
use nannou::wgpu;

/// Internal render resolution (square; super-sampled vs. the 960² design).
pub const RES: u32 = 1440;
// sRGB8 so a frame can be read straight back to a PNG without HDR/f16
// conversion. Blending on an sRGB target is done in linear space by the GPU,
// so additive bloom still behaves.
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;

/// Plain additive blend (`dst + src`). nannou's `blend::ADD` is
/// `src·src + dst·dst`, which is *not* what we want for HDR accumulation.
pub const ADDITIVE: wgpu::BlendComponent = wgpu::BlendComponent {
    src_factor: wgpu::BlendFactor::One,
    dst_factor: wgpu::BlendFactor::One,
    operation: wgpu::BlendOperation::Add,
};

/// Fully-transparent clear so render-to-texture starts each pass blank
/// (nannou loads existing contents when a Draw has no background).
fn clear() -> nannou::color::Srgba<f32> {
    srgba(0.0, 0.0, 0.0, 0.0)
}

pub struct Post {
    renderer: Renderer,
    scene: wgpu::Texture,
    acc: [wgpu::Texture; 2],
    cur: usize,
    primed: bool, // false until the accumulator holds real (non-garbage) data
}

fn make_tex(device: &wgpu::Device) -> wgpu::Texture {
    wgpu::TextureBuilder::new()
        .size([RES, RES])
        .format(FMT)
        .usage(
            wgpu::TextureUsages::RENDER_ATTACHMENT
                | wgpu::TextureUsages::TEXTURE_BINDING
                | wgpu::TextureUsages::COPY_SRC,
        )
        .build(device)
}

impl Post {
    pub fn res() -> f32 {
        RES as f32
    }

    pub fn new(device: &wgpu::Device) -> Self {
        let renderer = RendererBuilder::new().build(device, [RES, RES], 1.0, 1, FMT);
        Post {
            renderer,
            scene: make_tex(device),
            acc: [make_tex(device), make_tex(device)],
            cur: 0,
            primed: false,
        }
    }

    /// Render `scene_draw` through the feedback chain. `bg` is the field
    /// colour the trails decay toward, `fade` the per-frame decay (0 = endless
    /// trails, 1 = none), `zoom` the feedback bloom expansion (~1.004).
    /// Returns the texture to present this frame.
    pub fn render(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        scene_draw: &Draw,
        bg: [f32; 3],
        fade: f32,
        zoom: f32,
    ) -> &wgpu::Texture {
        let prev = self.cur;
        let next = 1 - self.cur;
        let s = RES as f32;

        // Pass 1: sigil -> scene texture (cleared transparent first).
        scene_draw.background().color(clear());
        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("sigil-post"),
        });
        self.renderer
            .render_to_texture(device, &mut enc, scene_draw, &self.scene);

        // Pass 2: composite -> acc[next]. Every step is a convex over-blend so
        // the buffer can never exceed 1.0 (no additive runaway to white):
        //   clear
        //   -> previous accumulator, slightly zoomed   (trail, opaque)
        //   -> bg rect at alpha=fade                    (decays trail toward bg)
        //   -> fresh scene composited over              (new figure on trails)
        // Trail length ~ 1/fade frames; bloom comes from the zoom spread plus
        // the per-stroke faux-glow halos, not from unbounded accumulation.
        let comp = Draw::new();
        if self.primed {
            comp.background().color(clear());
            comp.texture(&self.acc[prev]).w_h(s * zoom, s * zoom);
            comp.rect()
                .w_h(s, s)
                .color(srgba(bg[0], bg[1], bg[2], fade.clamp(0.0, 1.0)));
        } else {
            // First frame: no valid history yet — start from the bg field
            // instead of the texture's uninitialised garbage.
            comp.background().color(srgba(bg[0], bg[1], bg[2], 1.0));
            self.primed = true;
        }
        comp.texture(&self.scene).w_h(s, s);
        self.renderer
            .render_to_texture(device, &mut enc, &comp, &self.acc[next]);

        queue.submit(Some(enc.finish()));
        self.cur = next;
        &self.acc[self.cur]
    }

    /// Most recent accumulator (for the no-feedback bypass / direct present).
    pub fn current(&self) -> &wgpu::Texture {
        &self.acc[self.cur]
    }

    /// Synchronously read the current accumulator back to the CPU as tightly
    /// packed RGBA8. The per-frame source for both the PNG path and the
    /// streaming-to-ffmpeg render path. See [`read_texture_rgba`].
    pub fn capture_raw(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
    ) -> anyhow::Result<Vec<u8>> {
        read_texture_rgba(device, queue, &self.acc[self.cur])
    }

    /// Read the current accumulator back and write it as a PNG (manual `P`
    /// screenshot). Thin wrapper over [`Self::capture_raw`].
    pub fn capture_png(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        path: &std::path::Path,
    ) -> anyhow::Result<()> {
        let pixels = self.capture_raw(device, queue)?;
        let img = nannou::image::RgbaImage::from_raw(RES, RES, pixels)
            .ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?;
        img.save(path)?;
        Ok(())
    }

    /// Render `scene_draw` straight into the accumulator (feedback bypass).
    pub fn render_direct(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        scene_draw: &Draw,
    ) -> &wgpu::Texture {
        let next = 1 - self.cur;
        scene_draw.background().color(clear());
        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("sigil-direct"),
        });
        self.renderer
            .render_to_texture(device, &mut enc, scene_draw, &self.acc[next]);
        queue.submit(Some(enc.finish()));
        self.cur = next;
        &self.acc[self.cur]
    }
}

/// Synchronously read a `RES`×`RES` `Rgba8UnormSrgb` texture (must carry
/// `COPY_SRC`) back to the CPU as tightly packed RGBA8 (`RES*RES*4` bytes, no
/// row padding). Uses an explicit `device.poll(Wait)` so the buffer map always
/// resolves — unlike nannou's async `capture_frame`, which leaks/cancels its
/// map callbacks when the app loop tears the device down. Shared by [`Post`]
/// and the `breakcore` raymarch target so the leak-safe path lives once.
pub fn read_texture_rgba(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    tex: &wgpu::Texture,
) -> anyhow::Result<Vec<u8>> {
    let (w, h) = (RES, RES);
    let unpadded = w * 4; // Rgba8
    let align = 256u32;
    let padded = unpadded.div_ceil(align) * align;

    let buf = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("sigil-readback"),
        size: (padded as u64) * (h as u64),
        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
        mapped_at_creation: false,
    });
    let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("sigil-readback"),
    });
    enc.copy_texture_to_buffer(
        wgpu::ImageCopyTexture {
            texture: &**tex, // nannou Texture -> raw wgpu::Texture
            mip_level: 0,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::ImageCopyBuffer {
            buffer: &buf,
            layout: wgpu::ImageDataLayout {
                offset: 0,
                bytes_per_row: Some(padded),
                rows_per_image: Some(h),
            },
        },
        wgpu::Extent3d {
            width: w,
            height: h,
            depth_or_array_layers: 1,
        },
    );
    queue.submit(Some(enc.finish()));

    let slice = buf.slice(..);
    let (tx, rx) = std::sync::mpsc::channel();
    slice.map_async(wgpu::MapMode::Read, move |r| {
        let _ = tx.send(r);
    });
    device.poll(wgpu::Maintain::Wait);
    rx.recv()
        .map_err(|_| anyhow::anyhow!("map channel closed"))?
        .map_err(|e| anyhow::anyhow!("buffer map failed: {e:?}"))?;

    let data = slice.get_mapped_range();
    let mut pixels = Vec::with_capacity((unpadded * h) as usize);
    for row in 0..h {
        let s = (row * padded) as usize;
        pixels.extend_from_slice(&data[s..s + unpadded as usize]);
    }
    drop(data);
    buf.unmap();
    Ok(pixels)
}
