use crate::array::{diamond_square, rand_grid, rand_sub_grid};
use crate::grid::Grid;
use crate::operation::{interpolate, scale};
use super::make_rng;
use rand::Rng;
#[cfg(feature = "parallel")]
use rayon::prelude::*;

/// Returns a spatially random NLM with values ranging [0, 1).
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `seed` - Optional RNG seed for reproducible results.
pub fn random(rows: usize, cols: usize, seed: Option<u64>) -> Grid {
    let mut rng = make_rng(seed);
    rand_grid(rows, cols, &mut rng)
}

/// Returns a random element nearest-neighbour NLM with values ranging [0, 1).
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n` - Number of labelled seed elements to place.
/// * `seed` - Optional RNG seed for reproducible results.
///
/// Implementation ported from NLMpy.
pub fn random_element(rows: usize, cols: usize, n: f64, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let mut rng = make_rng(seed);
    let mut grid = Grid::new(rows, cols);

    // Track the last successfully placed label to avoid an O(rows*cols) max() scan
    // every iteration (which would make the whole function O(n * rows * cols)).
    let mut last_label = 0.0f64;
    let mut i: f64 = 1.;
    while last_label < n && i < n {
        let row = rng.gen_range(0..rows);
        let col = rng.gen_range(0..cols);
        if grid[row][col] == 0. {
            grid[row][col] = i;
            last_label = i;
        }
        i += 1.;
    }

    interpolate(&mut grid);
    scale(&mut grid);

    grid
}

/// Returns a midpoint displacement NLM with values ranging [0, 1).
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `h` - Controls the spatial autocorrelation in element values.
/// * `seed` - Optional RNG seed for reproducible results.
///
/// Implementation ported from NLMpy.
pub fn midpoint_displacement(rows: usize, cols: usize, h: f64, seed: Option<u64>) -> Grid {
    let max_dim = rows.max(cols);
    if max_dim == 0 {
        return Grid::new(0, 0);
    }
    let n = ((max_dim - 1) as f64).log2().ceil() as u32;
    let dim = usize::pow(2, n) + 1;

    let mut rng = make_rng(seed);
    let mut surface = diamond_square(dim, h, &mut rng);
    surface = rand_sub_grid(surface, rows, cols, &mut rng);

    scale(&mut surface);
    surface
}

/// Returns a spatially correlated Gaussian random field NLM with values in [0, 1).
///
/// Generates white noise then applies a separable Gaussian blur, so nearby
/// cells are correlated over a distance roughly proportional to `sigma` cells.
/// `sigma` maps directly to ecological correlation length.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `sigma` - Standard deviation of the Gaussian kernel in cells.  Higher
///   values produce larger, smoother patches.
/// * `seed` - Optional RNG seed for reproducible results.
pub fn gaussian_field(rows: usize, cols: usize, sigma: f64, seed: Option<u64>) -> Grid {
    let mut rng = make_rng(seed);

    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let mut grid = rand_grid(rows, cols, &mut rng);

    if sigma <= 0.0 {
        scale(&mut grid);
        return grid;
    }

    // Build a 1-D normalised Gaussian kernel with radius = ceil(3σ).
    let radius = (3.0 * sigma).ceil() as usize;
    let kernel: Vec<f64> = {
        let size = 2 * radius + 1;
        let mut k = vec![0.0f64; size];
        let mut sum = 0.0f64;
        for i in 0..size {
            let x = i as f64 - radius as f64;
            k[i] = (-0.5 * (x / sigma).powi(2)).exp();
            sum += k[i];
        }
        k.iter_mut().for_each(|v| *v /= sum);
        k
    };

    // Horizontal pass — row-wise, cache-friendly.
    let mut row_conv = Grid::new(rows, cols);
    {
        let fill = |(i, out_row): (usize, &mut [f64])| {
            let src_row = &grid[i];
            for j in 0..cols {
                let mut val = 0.0f64;
                for (k, &w) in kernel.iter().enumerate() {
                    let jj = (j as i64 + k as i64 - radius as i64)
                        .clamp(0, cols as i64 - 1) as usize;
                    val += src_row[jj] * w;
                }
                out_row[j] = val;
            }
        };
        #[cfg(feature = "parallel")]
        row_conv.data.par_chunks_mut(cols).enumerate().for_each(fill);
        #[cfg(not(feature = "parallel"))]
        row_conv.data.chunks_mut(cols).enumerate().for_each(fill);
    }

    // Vertical pass.
    let mut result = Grid::new(rows, cols);
    {
        let fill = |(i, out_row): (usize, &mut [f64])| {
            for j in 0..cols {
                let mut val = 0.0f64;
                for (k, &w) in kernel.iter().enumerate() {
                    let ii = (i as i64 + k as i64 - radius as i64)
                        .clamp(0, rows as i64 - 1) as usize;
                    val += row_conv[ii][j] * w;
                }
                out_row[j] = val;
            }
        };
        #[cfg(feature = "parallel")]
        result.data.par_chunks_mut(cols).enumerate().for_each(fill);
        #[cfg(not(feature = "parallel"))]
        result.data.chunks_mut(cols).enumerate().for_each(fill);
    }

    scale(&mut result);
    result
}

/// Returns a random cluster NLM with values ranging [0, 1).
///
/// Applies `n` random fault-line cuts; each cut adds +1 to all cells on one
/// side and −1 on the other.  After all cuts the accumulated field is scaled
/// to [0, 1], producing spatially clustered landscapes with the linear
/// structural elements characteristic of geological fault patterns.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n` - Number of fault-line cuts (higher = finer-grained clustering).
/// * `seed` - Optional RNG seed for reproducible results.
pub fn random_cluster(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let mut rng = make_rng(seed);

    // Pre-generate all cuts so the per-cell inner loop has no mutable state.
    // Each cut: (px, py, sin θ, cos θ) where θ ∈ [0, π).
    let cuts: Vec<(f64, f64, f64, f64)> = (0..n)
        .map(|_| {
            let theta: f64 = rng.gen_range(0.0..std::f64::consts::PI);
            let px: f64 = rng.gen();
            let py: f64 = rng.gen();
            (px, py, theta.sin(), theta.cos())
        })
        .collect();

    let mut grid = Grid::new(rows, cols);
    let inv_rows = 1.0 / rows as f64;
    let inv_cols = 1.0 / cols as f64;

    let fill = |(k, v): (usize, &mut f64)| {
        let row = k / cols;
        let col = k % cols;
        let x = col as f64 * inv_cols;
        let y = row as f64 * inv_rows;
        *v = cuts
            .iter()
            .map(|&(px, py, sin_t, cos_t)| {
                if (x - px) * sin_t + (y - py) * cos_t > 0.0 { 1.0 } else { -1.0 }
            })
            .sum::<f64>();
    };
    #[cfg(feature = "parallel")]
    grid.data.par_iter_mut().enumerate().for_each(fill);
    #[cfg(not(feature = "parallel"))]
    grid.data.iter_mut().enumerate().for_each(fill);

    scale(&mut grid);
    grid
}

/// Returns a mosaic (discrete Voronoi) NLM with values ranging [0, 1).
///
/// Places `n` random seed cells each with a unique random float value, then
/// fills every remaining cell with the value of its nearest seed via BFS.
/// The result is a flat-coloured patch map — all cells within a patch share
/// the same value, unlike `random_element` where values grade between seeds.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n` - Number of Voronoi seed points to place.
/// * `seed` - Optional RNG seed for reproducible results.
pub fn mosaic(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let mut rng = make_rng(seed);
    let mut grid = Grid::new(rows, cols);

    for _ in 0..n {
        let row = rng.gen_range(0..rows);
        let col = rng.gen_range(0..cols);
        if grid[row][col] == 0.0 {
            // Use (0, 1) range but avoid 0.0 (reserved for "unlabelled")
            grid[row][col] = rng.gen::<f64>() * 0.999 + 0.001;
        }
    }

    // BFS fills every unlabelled cell with the nearest seed's value.
    // Values are already in (0.001, 1.0) — no scale() needed.
    interpolate(&mut grid);
    grid
}

/// Returns a rectangular cluster NLM with values ranging [0, 1).
///
/// Places `n` random axis-aligned rectangles and accumulates +1 per cell for
/// each overlapping rectangle. The result is scaled to [0, 1], producing
/// patch-like landscapes with rectilinear boundaries.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n` - Number of rectangles to place.
/// * `seed` - Optional RNG seed for reproducible results.
pub fn rectangular_cluster(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let mut rng = make_rng(seed);
    let mut grid = Grid::new(rows, cols);
    let max_side = (rows.max(cols) / 3).max(1);

    for _ in 0..n {
        let r0 = rng.gen_range(0..rows);
        let c0 = rng.gen_range(0..cols);
        let r1 = (r0 + rng.gen_range(1..=max_side)).min(rows);
        let c1 = (c0 + rng.gen_range(1..=max_side)).min(cols);
        for i in r0..r1 {
            for j in c0..c1 {
                grid[i][j] += 1.0;
            }
        }
    }

    scale(&mut grid);
    grid
}

/// Returns a binary percolation NLM with values in {0.0, 1.0}.
///
/// Each cell is independently set to 1.0 (habitat) with probability `p` and
/// 0.0 (matrix) with probability `1 - p`.  As `p` approaches the critical
/// percolation threshold (~0.593 for 4-connectivity) habitat clusters coalesce
/// and span the landscape, making this the canonical NLM for studying
/// percolation theory and habitat connectivity.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `p` - Probability that a cell is habitat (0.0–1.0).
/// * `seed` - Optional RNG seed for reproducible results.
///
/// Based on: Gardner et al. (1987). Neutral models for the analysis of
/// broad-scale landscape pattern. *Landscape Ecology* 1(1):19–28.
pub fn percolation(rows: usize, cols: usize, p: f64, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);
    let data = (0..rows * cols)
        .map(|_| if rng.gen::<f64>() < p { 1.0 } else { 0.0 })
        .collect();
    Grid { data, rows, cols }
}

/// Returns a binary space partitioning (BSP) NLM with values in [0, 1).
///
/// Recursively splits the grid into non-overlapping axis-aligned rectangles.
/// At each step the largest remaining rectangle is split along its longest
/// dimension at a random position. Once `n` rectangles exist each is assigned
/// a unique random float value, producing a hierarchically-nested rectilinear
/// partition.  Unlike `rectangular_cluster` (overlapping accumulation), BSP
/// produces a complete, non-overlapping partition of the grid — ideal for
/// modelling human-dominated agricultural or urban landscapes.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n` - Number of rectangles in the final partition.
/// * `seed` - Optional RNG seed for reproducible results.
///
/// Based on: Etherington, Morgan & O'Sullivan (2022). Binary space
/// partitioning generates hierarchical and rectilinear neutral landscape
/// models suitable for human-dominated landscapes. *Landscape Ecology*
/// 37:1761–1769.
pub fn binary_space_partitioning(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);
    let mut grid = Grid::new(rows, cols);

    // (row_start, col_start, row_end, col_end)
    let mut rects: Vec<(usize, usize, usize, usize)> = vec![(0, 0, rows, cols)];

    while rects.len() < n.max(1) {
        // Always split the largest rectangle for more uniform patch sizes.
        let idx = rects
            .iter()
            .enumerate()
            .max_by_key(|(_, &(r0, c0, r1, c1))| (r1 - r0) * (c1 - c0))
            .map(|(i, _)| i)
            .unwrap();

        let (r0, c0, r1, c1) = rects[idx];
        let height = r1 - r0;
        let width = c1 - c0;

        if height <= 1 && width <= 1 {
            break; // All patches are single cells; can't split further.
        }

        if height >= width && height > 1 {
            let split = r0 + rng.gen_range(1..height);
            rects[idx] = (r0, c0, split, c1);
            rects.push((split, c0, r1, c1));
        } else {
            let split = c0 + rng.gen_range(1..width);
            rects[idx] = (r0, c0, r1, split);
            rects.push((r0, split, r1, c1));
        }
    }

    // Assign a unique random float to every leaf rectangle.
    for (r0, c0, r1, c1) in rects {
        let val: f64 = rng.gen();
        for r in r0..r1 {
            for c in c0..c1 {
                grid[r][c] = val;
            }
        }
    }

    grid
}

/// Returns a neighbourhood clustering NLM with values ranging [0, 1).
///
/// Initialises a grid with `k` randomly assigned classes then repeatedly
/// applies a majority-vote rule: each cell adopts the most common class among
/// its 3×3 Moore neighbourhood. After `iterations` passes the discrete classes
/// are mapped to evenly spaced values in [0, 1), producing smooth organic
/// patch regions.
///
/// # Arguments
///
/// * `rows`       - Number of rows.
/// * `cols`       - Number of columns.
/// * `k`          - Number of distinct patch classes (≥ 2).
/// * `iterations` - Number of majority-vote passes (more = larger, smoother patches).
/// * `seed`       - Optional RNG seed for reproducible results.
pub fn neighbourhood_clustering(
    rows: usize,
    cols: usize,
    k: usize,
    iterations: usize,
    seed: Option<u64>,
) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let k = k.max(2);
    let mut rng = make_rng(seed);

    let mut classes: Vec<usize> = (0..rows * cols).map(|_| rng.gen_range(0..k)).collect();
    let mut next = vec![0usize; rows * cols];

    for _ in 0..iterations {
        let fill = |(idx, out): (usize, &mut usize)| {
            let i = idx / cols;
            let j = idx % cols;
            let mut counts = vec![0usize; k];
            for di in -1i64..=1 {
                let ni = (i as i64 + di).clamp(0, rows as i64 - 1) as usize;
                for dj in -1i64..=1 {
                    let nj = (j as i64 + dj).clamp(0, cols as i64 - 1) as usize;
                    counts[classes[ni * cols + nj]] += 1;
                }
            }
            *out = counts.iter().enumerate().max_by_key(|&(_, &c)| c).map(|(i, _)| i).unwrap_or(0);
        };
        #[cfg(feature = "parallel")]
        next.par_iter_mut().enumerate().for_each(fill);
        #[cfg(not(feature = "parallel"))]
        next.iter_mut().enumerate().for_each(fill);
        std::mem::swap(&mut classes, &mut next);
    }

    let inv = 1.0 / k as f64;
    let data: Vec<f64> = classes.iter().map(|&c| c as f64 * inv).collect();
    Grid { data, rows, cols }
}

/// Returns a cellular automaton NLM with binary values {0.0, 1.0}.
///
/// Initialises a random binary grid where each cell is alive with probability
/// `p`, then applies Conway-style birth/survival rules for `iterations` steps.
/// Out-of-bounds neighbours are treated as dead, producing natural cave walls
/// at grid edges. Typical cave settings: `p ≈ 0.45`, `birth ≥ 5`, `survival ≥ 4`.
///
/// # Arguments
///
/// * `rows`               - Number of rows.
/// * `cols`               - Number of columns.
/// * `p`                  - Initial probability of a cell being alive.
/// * `iterations`         - Number of CA rule applications.
/// * `birth_threshold`    - Minimum live neighbours for a dead cell to become alive.
/// * `survival_threshold` - Minimum live neighbours for a live cell to stay alive.
/// * `seed`               - Optional RNG seed for reproducible results.
pub fn cellular_automaton(
    rows: usize,
    cols: usize,
    p: f64,
    iterations: usize,
    birth_threshold: usize,
    survival_threshold: usize,
    seed: Option<u64>,
) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let mut rng = make_rng(seed);
    let mut state: Vec<bool> = (0..rows * cols).map(|_| rng.gen::<f64>() < p).collect();
    let mut next = vec![false; rows * cols];

    for _ in 0..iterations {
        let fill = |(idx, out): (usize, &mut bool)| {
            let i = idx / cols;
            let j = idx % cols;
            let mut count = 0usize;
            for di in -1i64..=1 {
                let ni = i as i64 + di;
                if ni < 0 || ni >= rows as i64 { continue; }
                let ni = ni as usize;
                for dj in -1i64..=1 {
                    if di == 0 && dj == 0 { continue; }
                    let nj = j as i64 + dj;
                    if nj < 0 || nj >= cols as i64 { continue; }
                    if state[ni * cols + nj as usize] { count += 1; }
                }
            }
            *out = if state[idx] { count >= survival_threshold } else { count >= birth_threshold };
        };
        #[cfg(feature = "parallel")]
        next.par_iter_mut().enumerate().for_each(fill);
        #[cfg(not(feature = "parallel"))]
        next.iter_mut().enumerate().for_each(fill);
        std::mem::swap(&mut state, &mut next);
    }

    let data: Vec<f64> = state.iter().map(|&b| if b { 1.0 } else { 0.0 }).collect();
    Grid { data, rows, cols }
}

/// Returns a diffusion-limited aggregation (DLA) NLM with binary values {0.0, 1.0}.
///
/// Seeds a single cluster at the grid centre then releases `n` particles one at
/// a time. Each particle spawns on a circle slightly outside the current cluster
/// radius and performs a 4-connected random walk until it touches the cluster,
/// at which point it sticks. Particles that wander too far are respawned.
/// Produces branching fractal tree-like structures.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n`    - Number of particles to release.
/// * `seed` - Optional RNG seed for reproducible results.
pub fn diffusion_limited_aggregation(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }

    let mut rng = make_rng(seed);
    let mut cluster = vec![false; rows * cols];

    let cr = rows / 2;
    let cc = cols / 2;
    cluster[cr * cols + cc] = true;
    let mut cluster_r: f64 = 1.0;

    let half = (rows.min(cols) as f64 / 2.0) - 1.0;

    for _ in 0..n {
        let spawn_r = (cluster_r + 5.0).min(half);
        let kill_r  = (spawn_r * 2.0).min(half);

        let angle = rng.gen::<f64>() * 2.0 * std::f64::consts::PI;
        let mut pr = (cr as f64 + spawn_r * angle.sin())
            .round().clamp(0.0, rows as f64 - 1.0) as usize;
        let mut pc = (cc as f64 + spawn_r * angle.cos())
            .round().clamp(0.0, cols as f64 - 1.0) as usize;

        if cluster[pr * cols + pc] {
            continue;
        }

        loop {
            // Stick if adjacent to cluster (4-connected).
            let stuck = [
                (pr.wrapping_sub(1), pc),
                (pr + 1, pc),
                (pr, pc.wrapping_sub(1)),
                (pr, pc + 1),
            ]
            .iter()
            .any(|&(nr, nc)| nr < rows && nc < cols && cluster[nr * cols + nc]);

            if stuck {
                cluster[pr * cols + pc] = true;
                let d = ((pr as f64 - cr as f64).powi(2) + (pc as f64 - cc as f64).powi(2)).sqrt();
                if d > cluster_r { cluster_r = d; }
                break;
            }

            // Respawn if the particle has wandered too far.
            let d = ((pr as f64 - cr as f64).powi(2) + (pc as f64 - cc as f64).powi(2)).sqrt();
            if d > kill_r {
                let angle = rng.gen::<f64>() * 2.0 * std::f64::consts::PI;
                pr = (cr as f64 + spawn_r * angle.sin())
                    .round().clamp(0.0, rows as f64 - 1.0) as usize;
                pc = (cc as f64 + spawn_r * angle.cos())
                    .round().clamp(0.0, cols as f64 - 1.0) as usize;
                continue;
            }

            // 4-connected random walk step.
            match rng.gen_range(0..4u8) {
                0 => { if pr > 0 { pr -= 1; } }
                1 => { if pr + 1 < rows { pr += 1; } }
                2 => { if pc > 0 { pc -= 1; } }
                _ => { if pc + 1 < cols { pc += 1; } }
            }
        }
    }

    let data: Vec<f64> = cluster.iter().map(|&c| if c { 1.0 } else { 0.0 }).collect();
    Grid { data, rows, cols }
}

/// Returns a Gray-Scott reaction-diffusion NLM. Values in [0, 1).
///
/// Two virtual chemicals (A and B) diffuse and react across the grid.
/// Different `feed`/`kill` parameter combinations produce spots, stripes,
/// labyrinths, and other Turing-pattern morphologies.
///
/// # Arguments
///
/// * `rows`       - Number of rows.
/// * `cols`       - Number of columns.
/// * `iterations` - Number of simulation steps (more = more developed patterns).
/// * `feed`       - Feed rate for chemical A. Controls pattern type.
/// * `kill`       - Kill rate for chemical B. Controls pattern type.
/// * `seed`       - Optional RNG seed for reproducible initial conditions.
pub fn reaction_diffusion(
    rows: usize,
    cols: usize,
    iterations: usize,
    feed: f64,
    kill: f64,
    seed: Option<u64>,
) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);

    const DA: f64 = 0.2;
    const DB: f64 = 0.1;

    let mut a = vec![1.0f64; rows * cols];
    let mut b = vec![0.0f64; rows * cols];

    // Seed random 3×3 squares of B (with periodic wrap)
    let n_seeds = (((rows * cols) as f64 * 0.01).ceil() as usize).max(1);
    for _ in 0..n_seeds {
        let r = rng.gen_range(0..rows);
        let c = rng.gen_range(0..cols);
        for dr in -1i64..=1 {
            for dc in -1i64..=1 {
                let nr = ((r as i64 + dr).rem_euclid(rows as i64)) as usize;
                let nc = ((c as i64 + dc).rem_euclid(cols as i64)) as usize;
                b[nr * cols + nc] = 1.0;
                a[nr * cols + nc] = 0.0;
            }
        }
    }

    let mut na = vec![0.0f64; rows * cols];
    let mut nb = vec![0.0f64; rows * cols];

    for _ in 0..iterations {
        let iter_fn = |(idx, (out_a, out_b)): (usize, (&mut f64, &mut f64))| {
            let i = idx / cols;
            let j = idx % cols;
            let ip = if i + 1 < rows { i + 1 } else { 0 };
            let im = if i > 0 { i - 1 } else { rows - 1 };
            let jp = if j + 1 < cols { j + 1 } else { 0 };
            let jm = if j > 0 { j - 1 } else { cols - 1 };

            let av = a[idx];
            let bv = b[idx];
            let lap_a = a[im * cols + j] + a[ip * cols + j]
                      + a[i * cols + jm] + a[i * cols + jp]
                      - 4.0 * av;
            let lap_b = b[im * cols + j] + b[ip * cols + j]
                      + b[i * cols + jm] + b[i * cols + jp]
                      - 4.0 * bv;

            let reaction = av * bv * bv;
            *out_a = (av + DA * lap_a - reaction + feed * (1.0 - av)).clamp(0.0, 1.0);
            *out_b = (bv + DB * lap_b + reaction - (kill + feed) * bv).clamp(0.0, 1.0);
        };

        #[cfg(feature = "parallel")]
        na.par_iter_mut().zip(nb.par_iter_mut()).enumerate().for_each(iter_fn);
        #[cfg(not(feature = "parallel"))]
        na.iter_mut().zip(nb.iter_mut()).enumerate().for_each(iter_fn);

        std::mem::swap(&mut a, &mut na);
        std::mem::swap(&mut b, &mut nb);
    }

    let mut grid = Grid { data: b, rows, cols };
    scale(&mut grid);
    grid
}

/// Returns an Eden growth model NLM. Binary values {0.0, 1.0}.
///
/// Grows a compact cluster from the grid centre by repeatedly selecting a
/// random cell on the current cluster boundary and adding it. Produces
/// irregular blob shapes with fractal perimeters.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n`    - Number of cells to add to the cluster (beyond the initial seed).
/// * `seed` - Optional RNG seed for reproducible results.
pub fn eden_growth(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);

    let mut cluster = vec![false; rows * cols];
    let cr = rows / 2;
    let cc = cols / 2;
    cluster[cr * cols + cc] = true;

    let dirs: [(i64, i64); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
    let mut perimeter: Vec<(usize, usize)> = Vec::new();
    for (dr, dc) in dirs {
        let nr = cr as i64 + dr;
        let nc = cc as i64 + dc;
        if nr >= 0 && nr < rows as i64 && nc >= 0 && nc < cols as i64 {
            perimeter.push((nr as usize, nc as usize));
        }
    }

    let n_cells = n.min(rows * cols - 1);
    for _ in 0..n_cells {
        if perimeter.is_empty() { break; }
        let idx = rng.gen_range(0..perimeter.len());
        let (r, c) = perimeter.swap_remove(idx);
        if cluster[r * cols + c] { continue; }
        cluster[r * cols + c] = true;
        for (dr, dc) in dirs {
            let nr = r as i64 + dr;
            let nc = c as i64 + dc;
            if nr >= 0 && nr < rows as i64 && nc >= 0 && nc < cols as i64 {
                let nr = nr as usize;
                let nc = nc as usize;
                if !cluster[nr * cols + nc] {
                    perimeter.push((nr, nc));
                }
            }
        }
    }

    let data: Vec<f64> = cluster.iter().map(|&c| if c { 1.0 } else { 0.0 }).collect();
    Grid { data, rows, cols }
}

/// Returns an invasion percolation NLM. Binary values {0.0, 1.0}.
///
/// Each cell is assigned a random weight. Growing from the centre, the algorithm
/// always invades the boundary cell with the lowest weight first, producing
/// dendritic, river-like fractal cluster patterns.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n`    - Number of cells to invade (beyond the initial seed).
/// * `seed` - Optional RNG seed for reproducible results.
pub fn invasion_percolation(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    use std::collections::BinaryHeap;
    use std::cmp::Reverse;

    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);

    let weights: Vec<f64> = (0..rows * cols).map(|_| rng.gen::<f64>()).collect();
    let mut invaded = vec![false; rows * cols];
    let cr = rows / 2;
    let cc = cols / 2;
    invaded[cr * cols + cc] = true;

    let dirs: [(i64, i64); 4] = [(-1, 0), (1, 0), (0, -1), (0, 1)];
    // Min-heap keyed on weight bits (IEEE 754 positive floats sort correctly as u64)
    let mut heap: BinaryHeap<Reverse<(u64, usize, usize)>> = BinaryHeap::new();
    for (dr, dc) in dirs {
        let nr = cr as i64 + dr;
        let nc = cc as i64 + dc;
        if nr >= 0 && nr < rows as i64 && nc >= 0 && nc < cols as i64 {
            let nr = nr as usize;
            let nc = nc as usize;
            heap.push(Reverse((weights[nr * cols + nc].to_bits(), nr, nc)));
        }
    }

    let n_cells = n.min(rows * cols - 1);
    for _ in 0..n_cells {
        if heap.is_empty() { break; }
        let Reverse((_, r, c)) = heap.pop().unwrap();
        if invaded[r * cols + c] { continue; }
        invaded[r * cols + c] = true;
        for (dr, dc) in dirs {
            let nr = r as i64 + dr;
            let nc = c as i64 + dc;
            if nr >= 0 && nr < rows as i64 && nc >= 0 && nc < cols as i64 {
                let nr = nr as usize;
                let nc = nc as usize;
                if !invaded[nr * cols + nc] {
                    heap.push(Reverse((weights[nr * cols + nc].to_bits(), nr, nc)));
                }
            }
        }
    }

    let data: Vec<f64> = invaded.iter().map(|&b| if b { 1.0 } else { 0.0 }).collect();
    Grid { data, rows, cols }
}

/// Returns a Gaussian blobs NLM with values in [0, 1).
///
/// Places `n` Gaussian kernels at random positions and accumulates their
/// contributions. Produces soft, overlapping circular patches resembling
/// habitat fragments or dispersal kernels.
///
/// # Arguments
///
/// * `rows`  - Number of rows.
/// * `cols`  - Number of columns.
/// * `n`     - Number of Gaussian blobs to place.
/// * `sigma` - Standard deviation (radius) of each blob in cells.
/// * `seed`  - Optional RNG seed for reproducible results.
pub fn gaussian_blobs(rows: usize, cols: usize, n: usize, sigma: f64, seed: Option<u64>) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);

    let centers: Vec<(f64, f64)> = (0..n)
        .map(|_| (rng.gen::<f64>() * rows as f64, rng.gen::<f64>() * cols as f64))
        .collect();
    let inv2s2 = 1.0 / (2.0 * sigma * sigma);

    let fill = |(idx, v): (usize, &mut f64)| {
        let i = idx / cols;
        let j = idx % cols;
        *v = centers.iter().map(|&(cr, cc)| {
            let dr = i as f64 - cr;
            let dc = j as f64 - cc;
            (-(dr * dr + dc * dc) * inv2s2).exp()
        }).sum::<f64>();
    };

    let mut data = vec![0.0f64; rows * cols];
    #[cfg(feature = "parallel")]
    data.par_iter_mut().enumerate().for_each(fill);
    #[cfg(not(feature = "parallel"))]
    data.iter_mut().enumerate().for_each(fill);

    let mut grid = Grid { data, rows, cols };
    scale(&mut grid);
    grid
}

/// Returns an Ising model NLM. Binary values {0.0, 1.0}.
///
/// Simulates a 2D ferromagnetic Ising model using Glauber dynamics. Spins start
/// random and relax under neighbourhood energy rules. The inverse temperature
/// `beta` controls cluster size: values near 0 produce random binary noise;
/// values near and above the critical point (~0.44) produce large clusters.
///
/// # Arguments
///
/// * `rows`       - Number of rows.
/// * `cols`       - Number of columns.
/// * `beta`       - Inverse temperature. Higher = larger, more ordered clusters.
/// * `iterations` - Number of full-grid update sweeps.
/// * `seed`       - Optional RNG seed for reproducible results.
pub fn ising_model(
    rows: usize,
    cols: usize,
    beta: f64,
    iterations: usize,
    seed: Option<u64>,
) -> Grid {
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);

    let mut spins: Vec<i8> = (0..rows * cols)
        .map(|_| if rng.gen::<bool>() { 1i8 } else { -1i8 })
        .collect();

    for _ in 0..iterations {
        // One sweep = rows*cols random single-spin updates (Glauber dynamics)
        for _ in 0..rows * cols {
            let idx = rng.gen_range(0..rows * cols);
            let i = idx / cols;
            let j = idx % cols;

            let s = spins[idx] as i32;
            let sum = spins[if i > 0 { (i - 1) * cols + j } else { (rows - 1) * cols + j }] as i32
                    + spins[if i + 1 < rows { (i + 1) * cols + j } else { j }] as i32
                    + spins[if j > 0 { i * cols + j - 1 } else { i * cols + cols - 1 }] as i32
                    + spins[if j + 1 < cols { i * cols + j + 1 } else { i * cols }] as i32;

            // Glauber flip probability: P(flip) = 1 / (1 + exp(β * ΔE)), ΔE = 2 * s * Σ
            let delta_e = 2.0 * s as f64 * sum as f64;
            if rng.gen::<f64>() < 1.0 / (1.0 + (beta * delta_e).exp()) {
                spins[idx] = -spins[idx];
            }
        }
    }

    let data: Vec<f64> = spins.iter().map(|&s| if s > 0 { 1.0 } else { 0.0 }).collect();
    Grid { data, rows, cols }
}

/// Returns a Levy flight NLM with values ranging [0, 1).
///
/// Simulates a Levy flight: a random walk where step lengths follow a
/// power-law (heavy-tailed) distribution. Starting from a random cell, the
/// walker takes `n` steps on a toroidal grid, incrementing the visit count at
/// each landing cell. The normalised density map produces clustered hotspots
/// with occasional long-range jumps, modelling dispersal or foraging patterns.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n`    - Number of flight steps.
/// * `seed` - Optional RNG seed for reproducible results.
pub fn levy_flight(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    use std::f64::consts::PI;
    if rows == 0 || cols == 0 {
        return Grid::new(0, 0);
    }
    let mut rng = make_rng(seed);
    let mut grid = Grid::new(rows, cols);

    let mut row = rng.gen_range(0..rows);
    let mut col = rng.gen_range(0..cols);
    grid.data[row * cols + col] += 1.0;

    let alpha = 1.5_f64;
    let max_step = (rows + cols) as f64;
    for _ in 0..n {
        // Power-law step length via inverse CDF: step ~ U^(-1/alpha).
        let u = rng.gen::<f64>().max(1e-10);
        let step = u.powf(-1.0 / alpha).min(max_step);
        let angle = rng.gen::<f64>() * 2.0 * PI;
        let dr = (step * angle.sin()).round() as i64;
        let dc = (step * angle.cos()).round() as i64;
        // Toroidal wrap so the walker never leaves the grid.
        row = ((row as i64 + dr).rem_euclid(rows as i64)) as usize;
        col = ((col as i64 + dc).rem_euclid(cols as i64)) as usize;
        grid.data[row * cols + col] += 1.0;
    }

    scale(&mut grid);
    grid
}

/// Returns a hydraulic erosion NLM with values ranging [0, 1).
///
/// Generates a random initial heightmap, then simulates `n` water droplets
/// flowing downhill. Each droplet carries sediment, eroding steeper terrain
/// and depositing on flatter areas. The result resembles naturally worn
/// terrain with drainage channels, alluvial fans, and rounded ridges.
///
/// # Arguments
///
/// * `rows` - Number of rows.
/// * `cols` - Number of columns.
/// * `n`    - Number of erosion droplets to simulate.
/// * `seed` - Optional RNG seed for reproducible results.
pub fn hydraulic_erosion(rows: usize, cols: usize, n: usize, seed: Option<u64>) -> Grid {
    if rows < 2 || cols < 2 {
        return Grid::new(rows, cols);
    }
    let mut rng = make_rng(seed);
    let mut grid = rand_grid(rows, cols, &mut rng);

    let inertia = 0.3_f64;
    let capacity_factor = 8.0_f64;
    let erosion_rate = 0.1_f64;
    let deposition_rate = 0.1_f64;
    let evaporation_rate = 0.02_f64;
    let min_slope = 0.01_f64;

    for _ in 0..n {
        let mut x = rng.gen::<f64>() * (cols - 1) as f64;
        let mut y = rng.gen::<f64>() * (rows - 1) as f64;
        let mut vx = 0.0_f64;
        let mut vy = 0.0_f64;
        let mut water = 1.0_f64;
        let mut sediment = 0.0_f64;

        for _ in 0..30 {
            let ix = x as usize;
            let iy = y as usize;
            if ix + 1 >= cols || iy + 1 >= rows {
                break;
            }

            let fx = x - ix as f64;
            let fy = y - iy as f64;
            let h00 = grid.data[iy * cols + ix];
            let h10 = grid.data[iy * cols + ix + 1];
            let h01 = grid.data[(iy + 1) * cols + ix];
            let h11 = grid.data[(iy + 1) * cols + ix + 1];
            let h = h00 * (1.0 - fx) * (1.0 - fy)
                + h10 * fx * (1.0 - fy)
                + h01 * (1.0 - fx) * fy
                + h11 * fx * fy;
            // Bilinear gradient
            let gx = (h10 - h00) * (1.0 - fy) + (h11 - h01) * fy;
            let gy = (h01 - h00) * (1.0 - fx) + (h11 - h10) * fx;

            vx = vx * inertia - gx * (1.0 - inertia);
            vy = vy * inertia - gy * (1.0 - inertia);
            let speed = (vx * vx + vy * vy).sqrt();
            if speed < 1e-6 {
                break;
            }

            let new_x = (x + vx / speed).clamp(0.0, (cols - 1) as f64 - 1e-6);
            let new_y = (y + vy / speed).clamp(0.0, (rows - 1) as f64 - 1e-6);
            let ix2 = new_x as usize;
            let iy2 = new_y as usize;
            let fx2 = new_x - ix2 as f64;
            let fy2 = new_y - iy2 as f64;
            let h_new = grid.data[iy2 * cols + ix2] * (1.0 - fx2) * (1.0 - fy2)
                + grid.data[iy2 * cols + (ix2 + 1).min(cols - 1)] * fx2 * (1.0 - fy2)
                + grid.data[(iy2 + 1).min(rows - 1) * cols + ix2] * (1.0 - fx2) * fy2
                + grid.data[(iy2 + 1).min(rows - 1) * cols + (ix2 + 1).min(cols - 1)]
                    * fx2
                    * fy2;

            let dh = h_new - h;
            let capacity = ((-dh).max(min_slope) * speed * water * capacity_factor).max(0.0);
            let (deposit, erode) = if sediment > capacity {
                ((sediment - capacity) * deposition_rate, 0.0)
            } else {
                (0.0, ((capacity - sediment) * erosion_rate).min(0.1))
            };
            let delta = deposit - erode;
            grid.data[iy * cols + ix] += delta * (1.0 - fx) * (1.0 - fy);
            grid.data[iy * cols + ix + 1] += delta * fx * (1.0 - fy);
            grid.data[(iy + 1) * cols + ix] += delta * (1.0 - fx) * fy;
            grid.data[(iy + 1) * cols + ix + 1] += delta * fx * fy;

            sediment += erode - deposit;
            x = new_x;
            y = new_y;
            water *= 1.0 - evaporation_rate;
            if water < 0.01 {
                break;
            }
        }
    }

    scale(&mut grid);
    grid
}

/// Returns a Poisson disk sampling NLM. Binary values {0.0, 1.0}.
///
/// Uses Bridson's algorithm to place points such that no two are closer than
/// `min_dist`. Cells at sampling locations are set to 1.0; all others 0.0.
/// The resulting pattern has regular, inhibition-driven spacing, modelling
/// processes such as territorial behaviour or tree canopy competition.
///
/// # Arguments
///
/// * `rows`     - Number of rows.
/// * `cols`     - Number of columns.
/// * `min_dist` - Minimum distance in cells between any two sample points.
/// * `seed`     - Optional RNG seed for reproducible results.
pub fn poisson_disk(rows: usize, cols: usize, min_dist: f64, seed: Option<u64>) -> Grid {
    use std::f64::consts::PI;
    let mut grid = Grid::new(rows, cols);
    if rows == 0 || cols == 0 || min_dist <= 0.0 {
        return grid;
    }
    let mut rng = make_rng(seed);

    // Background acceleration grid; cell size = min_dist / sqrt(2).
    let cell = (min_dist / 2.0_f64.sqrt()).max(1.0);
    let gcols = (cols as f64 / cell).ceil() as usize + 1;
    let grows = (rows as f64 / cell).ceil() as usize + 1;
    // -1 = empty; otherwise stores the index into `samples`.
    let mut spatial: Vec<i32> = vec![-1i32; grows * gcols];
    let mut samples: Vec<(f64, f64)> = Vec::new();
    let mut active: Vec<usize> = Vec::new();

    // Seed the algorithm with one random point.
    let r0 = rng.gen::<f64>() * rows as f64;
    let c0 = rng.gen::<f64>() * cols as f64;
    let gi = (r0 / cell) as usize;
    let gj = (c0 / cell) as usize;
    spatial[gi * gcols + gj] = 0;
    samples.push((r0, c0));
    active.push(0);

    while !active.is_empty() {
        let pick = rng.gen_range(0..active.len());
        let (pr, pc) = samples[active[pick]];
        let mut found = false;
        for _ in 0..30 {
            // Sample in annulus [min_dist, 2 * min_dist].
            let r = min_dist * (1.0 + rng.gen::<f64>());
            let angle = rng.gen::<f64>() * 2.0 * PI;
            let nr = pr + r * angle.sin();
            let nc = pc + r * angle.cos();
            if nr < 0.0 || nr >= rows as f64 || nc < 0.0 || nc >= cols as f64 {
                continue;
            }
            let gi = (nr / cell) as usize;
            let gj = (nc / cell) as usize;
            let mut ok = true;
            'check: for di in 0..5usize {
                for dj in 0..5usize {
                    let ni = gi.wrapping_add(di).wrapping_sub(2);
                    let nj = gj.wrapping_add(dj).wrapping_sub(2);
                    if ni >= grows || nj >= gcols {
                        continue;
                    }
                    let s = spatial[ni * gcols + nj];
                    if s >= 0 {
                        let (sr, sc) = samples[s as usize];
                        let dr = nr - sr;
                        let dc = nc - sc;
                        if dr * dr + dc * dc < min_dist * min_dist {
                            ok = false;
                            break 'check;
                        }
                    }
                }
            }
            if ok {
                let new_idx = samples.len();
                spatial[gi * gcols + gj] = new_idx as i32;
                samples.push((nr, nc));
                active.push(new_idx);
                found = true;
                break;
            }
        }
        if !found {
            active.swap_remove(pick);
        }
    }

    for (r, c) in &samples {
        let ri = r.round() as usize;
        let ci = c.round() as usize;
        if ri < rows && ci < cols {
            grid.data[ri * cols + ci] = 1.0;
        }
    }

    grid
}

#[cfg(test)]
mod tests {
    use super::*;
    use super::super::{nan_count, zero_to_one_count};
    use rstest::rstest;

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(2, 1)]
    #[case(3, 2)]
    #[case(4, 3)]
    #[case(5, 5)]
    #[case(10, 10)]
    #[case(100, 100)]
    #[case(500, 1000)]
    #[case(1000, 500)]
    #[case(1000, 1000)]
    #[case(2000, 2000)]
    fn test_random(#[case] rows: usize, #[case] cols: usize) {
        let grid = random(rows, cols, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_random_seeded_determinism() {
        let a = random(50, 50, Some(42));
        let b = random(50, 50, Some(42));
        assert_eq!(a.data, b.data);
        let c = random(50, 50, Some(99));
        assert_ne!(a.data, c.data);
    }

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(2, 1)]
    #[case(3, 2)]
    #[case(4, 3)]
    #[case(5, 5)]
    #[case(10, 10)]
    #[case(100, 100)]
    #[case(500, 1000)]
    #[case(1000, 500)]
    #[case(1000, 1000)]
    #[case(2000, 2000)]
    fn test_random_element(#[case] rows: usize, #[case] cols: usize) {
        let grid = random_element(rows, cols, 900., None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(2, 1)]
    #[case(3, 2)]
    #[case(4, 3)]
    #[case(5, 5)]
    #[case(10, 10)]
    #[case(100, 100)]
    #[case(500, 1000)]
    #[case(1000, 500)]
    #[case(1000, 1000)]
    #[case(2000, 2000)]
    fn test_midpoint_displacement(#[case] rows: usize, #[case] cols: usize) {
        let grid = midpoint_displacement(rows, cols, 1., None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_midpoint_displacement_seeded_determinism() {
        let a = midpoint_displacement(100, 100, 1.0, Some(42));
        let b = midpoint_displacement(100, 100, 1.0, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── gaussian_field ────────────────────────────────────────────────────────

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_gaussian_field(#[case] rows: usize, #[case] cols: usize) {
        let grid = gaussian_field(rows, cols, 5.0, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_gaussian_field_seeded_determinism() {
        let a = gaussian_field(50, 50, 5.0, Some(42));
        let b = gaussian_field(50, 50, 5.0, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── random_cluster ────────────────────────────────────────────────────────

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    #[case(500, 500)]
    fn test_random_cluster(#[case] rows: usize, #[case] cols: usize) {
        let grid = random_cluster(rows, cols, 200, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_random_cluster_seeded_determinism() {
        let a = random_cluster(50, 50, 200, Some(42));
        let b = random_cluster(50, 50, 200, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── mosaic ────────────────────────────────────────────────────────────────

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_mosaic(#[case] rows: usize, #[case] cols: usize) {
        let grid = mosaic(rows, cols, 50, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_mosaic_seeded_determinism() {
        let a = mosaic(50, 50, 50, Some(42));
        let b = mosaic(50, 50, 50, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── rectangular_cluster ───────────────────────────────────────────────────

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_rectangular_cluster(#[case] rows: usize, #[case] cols: usize) {
        let grid = rectangular_cluster(rows, cols, 50, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_rectangular_cluster_seeded_determinism() {
        let a = rectangular_cluster(50, 50, 50, Some(42));
        let b = rectangular_cluster(50, 50, 50, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── percolation ───────────────────────────────────────────────────────────

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_percolation(#[case] rows: usize, #[case] cols: usize) {
        let grid = percolation(rows, cols, 0.5, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        for &v in grid.iter() {
            assert!(v == 0.0 || v == 1.0, "unexpected value {v}");
        }
    }

    #[test]
    fn test_percolation_all_habitat() {
        let grid = percolation(20, 20, 1.0, Some(1));
        assert!(grid.iter().all(|&v| v == 1.0));
    }

    #[test]
    fn test_percolation_no_habitat() {
        let grid = percolation(20, 20, 0.0, Some(1));
        assert!(grid.iter().all(|&v| v == 0.0));
    }

    #[test]
    fn test_percolation_seeded_determinism() {
        let a = percolation(50, 50, 0.5, Some(42));
        let b = percolation(50, 50, 0.5, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── binary_space_partitioning ─────────────────────────────────────────────

    #[rstest]
    #[case(0, 0)]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_binary_space_partitioning(#[case] rows: usize, #[case] cols: usize) {
        let grid = binary_space_partitioning(rows, cols, 20, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_binary_space_partitioning_seeded_determinism() {
        let a = binary_space_partitioning(50, 50, 20, Some(42));
        let b = binary_space_partitioning(50, 50, 20, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── neighbourhood_clustering ──────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_neighbourhood_clustering(#[case] rows: usize, #[case] cols: usize) {
        let grid = neighbourhood_clustering(rows, cols, 5, 10, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_neighbourhood_clustering_seeded_determinism() {
        let a = neighbourhood_clustering(50, 50, 5, 10, Some(42));
        let b = neighbourhood_clustering(50, 50, 5, 10, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── cellular_automaton ────────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_cellular_automaton(#[case] rows: usize, #[case] cols: usize) {
        let grid = cellular_automaton(rows, cols, 0.45, 5, 5, 4, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        // Binary output: every value must be 0.0 or 1.0
        assert!(grid.data.iter().all(|&v| v == 0.0 || v == 1.0));
    }

    #[test]
    fn test_cellular_automaton_seeded_determinism() {
        let a = cellular_automaton(50, 50, 0.45, 5, 5, 4, Some(42));
        let b = cellular_automaton(50, 50, 0.45, 5, 5, 4, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── diffusion_limited_aggregation ─────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(50, 50)]
    fn test_diffusion_limited_aggregation(#[case] rows: usize, #[case] cols: usize) {
        let grid = diffusion_limited_aggregation(rows, cols, 50, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert!(grid.data.iter().all(|&v| v == 0.0 || v == 1.0));
    }

    #[test]
    fn test_diffusion_limited_aggregation_seeded_determinism() {
        let a = diffusion_limited_aggregation(50, 50, 200, Some(42));
        let b = diffusion_limited_aggregation(50, 50, 200, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── reaction_diffusion ────────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(50, 50)]
    fn test_reaction_diffusion(#[case] rows: usize, #[case] cols: usize) {
        let grid = reaction_diffusion(rows, cols, 100, 0.055, 0.062, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_reaction_diffusion_seeded_determinism() {
        let a = reaction_diffusion(30, 30, 100, 0.055, 0.062, Some(42));
        let b = reaction_diffusion(30, 30, 100, 0.055, 0.062, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── eden_growth ───────────────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(50, 50)]
    fn test_eden_growth(#[case] rows: usize, #[case] cols: usize) {
        let grid = eden_growth(rows, cols, 50, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert!(grid.data.iter().all(|&v| v == 0.0 || v == 1.0));
    }

    #[test]
    fn test_eden_growth_seeded_determinism() {
        let a = eden_growth(50, 50, 200, Some(42));
        let b = eden_growth(50, 50, 200, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── invasion_percolation ──────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(50, 50)]
    fn test_invasion_percolation(#[case] rows: usize, #[case] cols: usize) {
        let grid = invasion_percolation(rows, cols, 50, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert!(grid.data.iter().all(|&v| v == 0.0 || v == 1.0));
    }

    #[test]
    fn test_invasion_percolation_seeded_determinism() {
        let a = invasion_percolation(50, 50, 200, Some(42));
        let b = invasion_percolation(50, 50, 200, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── gaussian_blobs ────────────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_gaussian_blobs(#[case] rows: usize, #[case] cols: usize) {
        let grid = gaussian_blobs(rows, cols, 10, 5.0, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_gaussian_blobs_seeded_determinism() {
        let a = gaussian_blobs(50, 50, 10, 5.0, Some(42));
        let b = gaussian_blobs(50, 50, 10, 5.0, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── ising_model ───────────────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(50, 50)]
    fn test_ising_model(#[case] rows: usize, #[case] cols: usize) {
        let grid = ising_model(rows, cols, 0.4, 100, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert!(grid.data.iter().all(|&v| v == 0.0 || v == 1.0));
    }

    #[test]
    fn test_ising_model_seeded_determinism() {
        let a = ising_model(50, 50, 0.4, 100, Some(42));
        let b = ising_model(50, 50, 0.4, 100, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── levy_flight ───────────────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_levy_flight(#[case] rows: usize, #[case] cols: usize) {
        let grid = levy_flight(rows, cols, 100, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_levy_flight_seeded_determinism() {
        let a = levy_flight(50, 50, 100, Some(42));
        let b = levy_flight(50, 50, 100, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── hydraulic_erosion ─────────────────────────────────────────────────────

    #[rstest]
    #[case(2, 2)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_hydraulic_erosion(#[case] rows: usize, #[case] cols: usize) {
        let grid = hydraulic_erosion(rows, cols, 50, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert_eq!(zero_to_one_count(&grid), rows * cols);
    }

    #[test]
    fn test_hydraulic_erosion_seeded_determinism() {
        let a = hydraulic_erosion(50, 50, 50, Some(42));
        let b = hydraulic_erosion(50, 50, 50, Some(42));
        assert_eq!(a.data, b.data);
    }

    // ── poisson_disk ──────────────────────────────────────────────────────────

    #[rstest]
    #[case(1, 1)]
    #[case(10, 10)]
    #[case(100, 100)]
    fn test_poisson_disk(#[case] rows: usize, #[case] cols: usize) {
        let grid = poisson_disk(rows, cols, 5.0, None);
        assert_eq!(grid.rows, rows);
        assert_eq!(grid.cols, cols);
        assert_eq!(nan_count(&grid), 0);
        assert!(grid.data.iter().all(|&v| v == 0.0 || v == 1.0));
    }

    #[test]
    fn test_poisson_disk_seeded_determinism() {
        let a = poisson_disk(50, 50, 5.0, Some(42));
        let b = poisson_disk(50, 50, 5.0, Some(42));
        assert_eq!(a.data, b.data);
    }
}
