"""Independent snapshot of pygfx core filters and presets (2026-09-16).

Images and color tuples use OpenCV BGR order. No CLI or web server.
"""

import cv2
import numpy as np


def box_blur(img: np.ndarray, kernel_sz: int = 3) -> np.ndarray:
    kernel = np.ones((kernel_sz, kernel_sz), np.float32) / kernel_sz**2
    return cv2.filter2D(img, -1, kernel)


def to_websafe(img: np.ndarray) -> np.ndarray:
    levels = [0, 51, 102, 153, 204, 255]
    lut = np.zeros(256, np.uint8)
    for i in range(256):
        lut[i] = min(levels, key=lambda x: abs(x - i))

    return cv2.LUT(img, lut)


BAYER2 = np.array([[0, 2], [3, 1]]) / 4
BAYER4 = np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]) / 16


def _gray(img: np.ndarray) -> np.ndarray:
    return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img


def ordered_dither(img: np.ndarray, M: np.ndarray = BAYER4) -> np.ndarray:
    """Threshold against a tiled Bayer matrix. Returns a 1-bit (0/255) mask."""
    g = _gray(img).astype(np.float32) / 255
    h, w = g.shape
    n = M.shape[0]
    thresh = np.tile(M, (h // n + 1, w // n + 1))[:h, :w]
    return np.where(g > thresh, 255, 0).astype(np.uint8)


def noise_dither(img: np.ndarray, seed: int = 0, amount: float = 1.0) -> np.ndarray:
    """Blend grayscale with stochastic dither: amount 0 is smooth, 1 is 1-bit."""
    g = _gray(img).astype(np.float32) / 255
    noise = np.random.default_rng(seed).random(g.shape, dtype=np.float32)
    dithered = (g > noise).astype(np.float32)
    return np.clip(np.rint((g + amount * (dithered - g)) * 255), 0, 255).astype(np.uint8)


def halftone(img: np.ndarray, cell: int = 6, angle: float = 15.0) -> np.ndarray:
    """Angled dot screen — dot radius grows with darkness. Returns 1-bit."""
    g = _gray(img).astype(np.float32) / 255
    h, w = g.shape
    yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
    a = np.deg2rad(angle)
    u = (xx * np.cos(a) + yy * np.sin(a)) / cell
    v = (-xx * np.sin(a) + yy * np.cos(a)) / cell
    r = np.sqrt((u - np.round(u)) ** 2 + (v - np.round(v)) ** 2)
    return np.where(r >= (1 - g) * 0.75, 255, 0).astype(np.uint8)


def threshold(img: np.ndarray, level: int = 128) -> np.ndarray:
    """Hard cut with no dither pattern. Returns a 1-bit (0/255) mask."""
    return np.where(_gray(img) >= level, 255, 0).astype(np.uint8)


def tone_curve(img: np.ndarray, strength: float = 8.0, mid: float = 0.5) -> np.ndarray:
    """Sigmoid contrast crush. Higher strength = harder shadows/highlights."""
    x = img.astype(np.float32) / 255
    y = 1 / (1 + np.exp(-strength * (x - mid)))
    lo = 1 / (1 + np.exp(strength * mid))
    hi = 1 / (1 + np.exp(-strength * (1 - mid)))
    return np.clip(np.rint((y - lo) / (hi - lo) * 255), 0, 255).astype(np.uint8)


def duotone(mask: np.ndarray, ink=(0, 0, 0), paper=(255, 255, 255)) -> np.ndarray:
    """Map grayscale to a BGR gradient: 0 -> ink, 255 -> paper."""
    weight = _gray(mask).astype(np.float32)[..., None] / 255
    ink = np.asarray(ink, dtype=np.float32)
    paper = np.asarray(paper, dtype=np.float32)
    return np.clip(np.rint(ink + weight * (paper - ink)), 0, 255).astype(np.uint8)


def grain(img: np.ndarray, amount: float = 12.0, seed: int = 0) -> np.ndarray:
    noise = np.random.default_rng(seed).normal(0, amount, img.shape[:2]).astype(np.float32)
    if img.ndim == 3:
        noise = noise[..., None]
    return np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8)


def plate_shift(img: np.ndarray, dx: int = 3, dy: int = 1, channel: int = 2) -> np.ndarray:
    """Misregistration: roll one BGR channel to fake an offset print plate."""
    out = img.copy()
    out[..., channel] = np.roll(img[..., channel], (dy, dx), axis=(0, 1))
    return out


PRESETS = {
    # Soft stochastic grain, black ink on cyan paper (colors are BGR)
    "monk": lambda img: grain(
        duotone(
            noise_dither(tone_curve(img, strength=8), amount=0.25),
            ink=(16, 8, 4), paper=(235, 200, 140),
        ),
        amount=3,
    ),
    # angled halftone, deep-purple ink on lavender paper, misregistered plate
    "astro": lambda img: plate_shift(
        grain(duotone(halftone(tone_curve(img, strength=6), cell=5), ink=(90, 20, 60), paper=(230, 175, 195)), amount=8)
    ),
    # fine gray dot screen on warm paper
    "fog": lambda img: duotone(halftone(tone_curve(img, strength=4), cell=4, angle=45), ink=(40, 40, 45), paper=(235, 240, 240)),
    "bayer": lambda img: duotone(ordered_dither(tone_curve(img))),
    # from lab: {strength: 27, dither: bayer4, palette: 4, grain: 3, shift: 3}
    "blanco": lambda img: plate_shift(
        grain(duotone(ordered_dither(tone_curve(img, strength=13.5), BAYER4), ink=(60, 30, 10), paper=(250, 250, 250)), amount=3),
        dx=3,
        dy=1,
    ),
    # blanco with ink/paper swapped
    "negro": lambda img: plate_shift(
        grain(duotone(ordered_dither(tone_curve(img, strength=13.5), BAYER4), ink=(250, 250, 250), paper=(60, 30, 10)), amount=3),
        dx=3,
        dy=1,
    ),
}
