"""
AI 可调图像处理：缩放/裁剪/色彩/绘制/叠加/拼接及 OpenCV 常见算子。

Pillow 始终可用；OpenCV 算子需 ``pip install aicd[opencv]`` 或 ``opencv-python-headless``。
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Callable

import numpy as np
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps

MAX_IMAGE_BYTES = 45 * 1024 * 1024
MAX_PIXELS = 20_000_000  # ~20MP
MAX_SIDE = 16_384
MAX_CONCAT_IMAGES = 8


def _try_import_cv2():  # type: ignore[no-untyped-def]
    try:
        import cv2  # noqa: WPS433

        return cv2
    except ImportError:
        return None


def _check_size(w: int, h: int) -> str | None:
    if w < 1 or h < 1:
        return "invalid image dimensions"
    if w > MAX_SIDE or h > MAX_SIDE:
        return f"image side exceeds {MAX_SIDE}"
    if w * h > MAX_PIXELS:
        return f"image pixel count exceeds {MAX_PIXELS}"
    return None


def _load_rgba(path: Path) -> tuple[Image.Image, str | None]:
    sz = path.stat().st_size
    if sz > MAX_IMAGE_BYTES:
        return Image.new("RGBA", (1, 1)), f"file too large ({sz} bytes)"
    im = Image.open(path)
    im.load()
    err = _check_size(*im.size)
    if err:
        return Image.new("RGBA", (1, 1)), err
    return im.convert("RGBA"), None


def _save(im: Image.Image, out: Path) -> None:
    out.parent.mkdir(parents=True, exist_ok=True)
    suf = out.suffix.lower()
    if suf in (".jpg", ".jpeg"):
        rgb = Image.new("RGB", im.size, (255, 255, 255))
        rgb.paste(im, mask=im.split()[3] if im.mode == "RGBA" else None)
        rgb.save(out, format="JPEG", quality=92)
    else:
        im.save(out, format="PNG")


def _parse_rgba(spec: Any, default: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
    if spec is None:
        return default
    if isinstance(spec, (list, tuple)) and len(spec) >= 3:
        r, g, b = int(spec[0]), int(spec[1]), int(spec[2])
        a = int(spec[3]) if len(spec) > 3 else 255
        return (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)), max(0, min(255, a)))
    return default


def _f(x: Any, default: float) -> float:
    try:
        return float(x)
    except (TypeError, ValueError):
        return default


def _i(x: Any, default: int) -> int:
    try:
        return int(x)
    except (TypeError, ValueError):
        return default


def run_image_transform(
    operation: str,
    input_path: Path,
    output_path: Path,
    *,
    params: dict[str, Any] | None,
    policy_check: Callable[[Path], None],
) -> dict[str, Any]:
    op = (operation or "").strip().lower().replace("-", "_")
    p = dict(params or {})
    if op in ("help", "list_operations", "operations_help"):
        return {
            "ok": True,
            "operations_help": operations_help(),
            "known_operations": list_image_operations(),
        }

    inp = input_path.expanduser().resolve()
    outp = output_path.expanduser().resolve()
    policy_check(outp)

    if op == "solid_canvas":
        try:
            out_im, meta = _op_solid_canvas(p)
        except ValueError as e:
            return {"ok": False, "error": str(e)}
        except Exception as e:
            return {"ok": False, "error": f"{type(e).__name__}: {e}"}
        e2 = _check_size(*out_im.size)
        if e2:
            return {"ok": False, "error": f"output {e2}"}
        try:
            _save(out_im, outp)
        except OSError as e:
            return {"ok": False, "error": str(e)}
        return {"ok": True, "path": str(outp), "operation": op, "meta": meta}

    policy_check(inp)
    if not inp.is_file():
        return {"ok": False, "error": f"input not found: {inp}"}

    im, err = _load_rgba(inp)
    if err:
        return {"ok": False, "error": err}

    try:
        if op == "resize":
            out_im, meta = _op_resize(im, p)
        elif op == "crop":
            out_im, meta = _op_crop(im, p)
        elif op == "rotate":
            out_im, meta = _op_rotate(im, p)
        elif op == "flip":
            out_im, meta = _op_flip(im, p)
        elif op == "brightness":
            out_im, meta = _op_enhance(im, p, "brightness")
        elif op == "contrast":
            out_im, meta = _op_enhance(im, p, "contrast")
        elif op == "saturation":
            out_im, meta = _op_enhance(im, p, "color")
        elif op == "sharpness":
            out_im, meta = _op_enhance(im, p, "sharpness")
        elif op == "grayscale":
            out_im, meta = _op_grayscale(im, p)
        elif op == "invert":
            out_im, meta = _op_invert(im, p)
        elif op == "posterize":
            out_im, meta = _op_posterize(im, p)
        elif op == "autocontrast":
            out_im, meta = _op_autocontrast(im, p)
        elif op == "equalize":
            out_im, meta = _op_equalize(im, p)
        elif op == "paste_overlay":
            out_im, meta = _op_paste_overlay(im, p, policy_check)
        elif op == "blend":
            out_im, meta = _op_blend(im, p, policy_check)
        elif op == "concat_horizontal":
            out_im, meta = _op_concat(im, p, policy_check, horizontal=True)
        elif op == "concat_vertical":
            out_im, meta = _op_concat(im, p, policy_check, horizontal=False)
        elif op == "draw_rectangle":
            out_im, meta = _op_draw_rect(im, p)
        elif op == "draw_ellipse":
            out_im, meta = _op_draw_ellipse(im, p)
        elif op == "draw_line":
            out_im, meta = _op_draw_line(im, p)
        elif op == "draw_polygon":
            out_im, meta = _op_draw_polygon(im, p)
        elif op == "draw_text":
            out_im, meta = _op_draw_text(im, p)
        elif op == "chroma_key":
            out_im, meta = _op_chroma_key(im, p)
        elif op == "tint":
            out_im, meta = _op_tint(im, p)
        elif op == "edge_canny":
            out_im, meta = _op_edge_canny(im, p)
        elif op == "gaussian_blur":
            out_im, meta = _op_gaussian_blur(im, p)
        elif op == "bilateral":
            out_im, meta = _op_bilateral(im, p)
        elif op == "morphology":
            out_im, meta = _op_morphology(im, p)
        elif op == "threshold":
            out_im, meta = _op_threshold(im, p)
        elif op == "laplacian":
            out_im, meta = _op_laplacian(im, p)
        elif op == "sobel":
            out_im, meta = _op_sobel(im, p)
        elif op == "grabcut_rect":
            out_im, meta = _op_grabcut_rect(im, p)
        else:
            return {
                "ok": False,
                "error": f"unknown operation: {operation!r}",
                "known_operations": list_image_operations(),
            }
    except ValueError as e:
        return {"ok": False, "error": str(e)}
    except Exception as e:
        return {"ok": False, "error": f"{type(e).__name__}: {e}"}

    e2 = _check_size(*out_im.size)
    if e2:
        return {"ok": False, "error": f"output {e2}"}
    _save(out_im, outp)
    return {"ok": True, "path": str(outp), "operation": op, "meta": meta}


def list_image_operations() -> list[str]:
    """公开给工具/测试：当前支持的操作名列表。"""
    return sorted(_known_ops())


def _known_ops() -> frozenset[str]:
    return frozenset(
        {
            "resize",
            "crop",
            "rotate",
            "flip",
            "brightness",
            "contrast",
            "saturation",
            "sharpness",
            "grayscale",
            "invert",
            "posterize",
            "autocontrast",
            "equalize",
            "paste_overlay",
            "blend",
            "concat_horizontal",
            "concat_vertical",
            "draw_rectangle",
            "draw_ellipse",
            "draw_line",
            "draw_polygon",
            "draw_text",
            "solid_canvas",
            "chroma_key",
            "tint",
            "edge_canny",
            "gaussian_blur",
            "bilateral",
            "morphology",
            "threshold",
            "laplacian",
            "sobel",
            "grabcut_rect",
        }
    )


def operations_help() -> dict[str, Any]:
    """供工具描述引用的简要参数说明。"""
    return {
        "resize": {
            "params": {
                "width": "int target width (optional if height set)",
                "height": "int target height",
                "mode": "fit | stretch | cover (default fit)",
            }
        },
        "crop": {"params": {"left": 0, "top": 0, "width": 100, "height": 100}},
        "rotate": {"params": {"degrees": 90, "expand": True, "fill": [255, 255, 255, 0]}},
        "flip": {"params": {"axis": "horizontal | vertical"}},
        "brightness": {"params": {"factor": 1.0, "note": "1.0 unchanged; try 0.5–2.0"}},
        "contrast": {"params": {"factor": 1.0}},
        "saturation": {"params": {"factor": 1.0}},
        "sharpness": {"params": {"factor": 1.0}},
        "grayscale": {},
        "invert": {},
        "posterize": {"params": {"bits": 4}},
        "autocontrast": {"params": {"cutoff": 0}},
        "equalize": {},
        "paste_overlay": {
            "params": {
                "overlay_path": "absolute path",
                "x": 0,
                "y": 0,
                "alpha": 1.0,
            }
        },
        "blend": {"params": {"second_path": "abs path", "alpha": 0.5}},
        "concat_horizontal": {
            "params": {
                "second_path": "optional",
                "paths": "optional list of abs paths; max 8 images total with input",
            }
        },
        "concat_vertical": {
            "params": {
                "second_path": "optional",
                "paths": "optional list of abs paths; max 8 images total with input",
            }
        },
        "draw_rectangle": {
            "params": {
                "xyxy": "[x0,y0,x1,y1]",
                "outline": "optional rgba",
                "fill": "optional rgba",
                "width": 2,
            }
        },
        "draw_ellipse": {
            "params": {"xyxy": "[x0,y0,x1,y1]", "outline": "optional rgba", "fill": "optional rgba"}
        },
        "draw_line": {
            "params": {"xyxy": "[x0,y0,x1,y1]", "width": 2, "fill": "[r,g,b,a]"}
        },
        "draw_polygon": {
            "params": {
                "points": "array of [x,y]",
                "outline": "optional rgba",
                "fill": "optional rgba",
            }
        },
        "draw_text": {
            "params": {
                "x": 0,
                "y": 0,
                "text": "str",
                "font_size": 16,
                "fill": [0, 0, 0, 255],
            }
        },
        "solid_canvas": {
            "params": {"width": 800, "height": 600, "fill": [255, 255, 255, 255]},
            "note": "ignores input image",
        },
        "chroma_key": {
            "params": {
                "lower_hsv": "[h,s,v] uint8 0-255",
                "upper_hsv": "[h,s,v]",
                "softness": 0,
            }
        },
        "tint": {"params": {"color": "[r,g,b]", "alpha": 0.3}},
        "opencv": {
            "edge_canny": {"low": 50, "high": 150},
            "gaussian_blur": {"ksize": 5, "sigma": 0},
            "bilateral": {"d": 9, "sigma_color": 75, "sigma_space": 75},
            "morphology": {"op": "erode|dilate|open|close", "ksize": 3, "iterations": 1},
            "threshold": {"type": "binary|otsu|adaptive", "thresh": 127, "maxval": 255},
            "laplacian": {"ksize": 3},
            "sobel": {"dx": 1, "dy": 0, "ksize": 3},
            "grabcut_rect": {"x": 0, "y": 0, "width": 100, "height": 100, "iterations": 5},
        },
    }


# --- Pillow ops ---


def _op_resize(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    w, h = im.size
    tw = _i(p.get("width"), 0)
    th = _i(p.get("height"), 0)
    mode = str(p.get("mode") or "fit").lower()
    if tw <= 0 and th <= 0:
        raise ValueError("resize requires width and/or height")
    if tw <= 0:
        tw = max(1, int(w * th / h))
    if th <= 0:
        th = max(1, int(h * tw / w))
    if mode == "stretch":
        out = im.resize((tw, th), Image.Resampling.LANCZOS)
    elif mode == "cover":
        scale = max(tw / w, th / h)
        nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
        tmp = im.resize((nw, nh), Image.Resampling.LANCZOS)
        l = (nw - tw) // 2
        t = (nh - th) // 2
        out = tmp.crop((l, t, l + tw, t + th))
    else:  # fit
        scale = min(tw / w, th / h)
        nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
        out = im.resize((nw, nh), Image.Resampling.LANCZOS)
    return out, {"size": out.size, "mode": mode}


def _op_crop(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    left = _i(p.get("left"), 0)
    top = _i(p.get("top"), 0)
    width = _i(p.get("width"), 0)
    height = _i(p.get("height"), 0)
    if width <= 0 or height <= 0:
        raise ValueError("crop requires positive width and height")
    box = (left, top, left + width, top + height)
    return im.crop(box), {"box": box}


def _op_rotate(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    deg = _f(p.get("degrees"), 0)
    expand = bool(p.get("expand", True))
    fill = _parse_rgba(p.get("fill"), (255, 255, 255, 0))
    out = im.rotate(
        -deg,
        resample=Image.Resampling.BICUBIC,
        expand=expand,
        fillcolor=fill,
    )
    return out, {"degrees": deg, "expand": expand}


def _op_flip(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    axis = str(p.get("axis") or "horizontal").lower()
    if axis == "vertical":
        return ImageOps.flip(im), {"axis": "vertical"}
    return ImageOps.mirror(im), {"axis": "horizontal"}


def _op_enhance(
    im: Image.Image, p: dict[str, Any], kind: str
) -> tuple[Image.Image, dict[str, Any]]:
    fac = _f(p.get("factor"), 1.0)
    if fac <= 0:
        raise ValueError("factor must be positive")
    base = im.convert("RGB")
    if kind == "brightness":
        e = ImageEnhance.Brightness(base)
    elif kind == "contrast":
        e = ImageEnhance.Contrast(base)
    elif kind == "color":
        e = ImageEnhance.Color(base)
    else:
        e = ImageEnhance.Sharpness(base)
    rgb = e.enhance(fac)
    r, g, b = rgb.split()
    a = im.split()[3]
    out = Image.merge("RGBA", (r, g, b, a))
    return out, {"factor": fac, "kind": kind}


def _op_grayscale(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    g = ImageOps.grayscale(im.convert("RGB"))
    a = im.split()[3]
    out = Image.merge("RGBA", (g, g, g, a))
    return out, {}


def _op_invert(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    rgb = ImageOps.invert(im.convert("RGB"))
    r, g, b = rgb.split()
    a = im.split()[3]
    return Image.merge("RGBA", (r, g, b, a)), {}


def _op_posterize(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    bits = max(1, min(8, _i(p.get("bits"), 4)))
    rgb = ImageOps.posterize(im.convert("RGB"), bits)
    r, g, b = rgb.split()
    a = im.split()[3]
    return Image.merge("RGBA", (r, g, b, a)), {"bits": bits}


def _op_autocontrast(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    co = max(0, min(50, _i(p.get("cutoff"), 0)))
    rgb = ImageOps.autocontrast(im.convert("RGB"), cutoff=co)
    r, g, b = rgb.split()
    a = im.split()[3]
    return Image.merge("RGBA", (r, g, b, a)), {"cutoff": co}


def _op_equalize(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    rgb = ImageOps.equalize(im.convert("RGB"))
    r, g, b = rgb.split()
    a = im.split()[3]
    return Image.merge("RGBA", (r, g, b, a)), {}


def _op_paste_overlay(
    im: Image.Image, p: dict[str, Any], policy_check: Callable[[Path], None]
) -> tuple[Image.Image, dict[str, Any]]:
    opath = p.get("overlay_path")
    if not opath:
        raise ValueError("paste_overlay requires params.overlay_path")
    ovp = Path(str(opath)).expanduser().resolve()
    policy_check(ovp)
    ov, err = _load_rgba(ovp)
    if err:
        raise ValueError(err)
    x, y = _i(p.get("x"), 0), _i(p.get("y"), 0)
    alpha = max(0.0, min(1.0, _f(p.get("alpha"), 1.0)))
    if alpha < 1.0:
        ov = ov.copy()
        _, _, _, a = ov.split()
        a = a.point(lambda v: int(v * alpha))
        ov.putalpha(a)
    canvas = im.copy()
    canvas.paste(ov, (x, y), ov)
    return canvas, {"overlay": str(ovp), "x": x, "y": y, "alpha": alpha}


def _op_blend(
    im: Image.Image, p: dict[str, Any], policy_check: Callable[[Path], None]
) -> tuple[Image.Image, dict[str, Any]]:
    sp = p.get("second_path")
    if not sp:
        raise ValueError("blend requires params.second_path")
    p2 = Path(str(sp)).expanduser().resolve()
    policy_check(p2)
    b, err = _load_rgba(p2)
    if err:
        raise ValueError(err)
    if b.size != im.size:
        b = b.resize(im.size, Image.Resampling.LANCZOS)
    a = max(0.0, min(1.0, _f(p.get("alpha"), 0.5)))
    out = Image.blend(im, b, a)
    return out, {"alpha": a, "second": str(p2)}


def _op_concat(
    im: Image.Image,
    p: dict[str, Any],
    policy_check: Callable[[Path], None],
    *,
    horizontal: bool,
) -> tuple[Image.Image, dict[str, Any]]:
    paths: list[Path] = []
    if p.get("second_path"):
        paths.append(Path(str(p["second_path"])).expanduser().resolve())
    extra = p.get("paths")
    if isinstance(extra, list):
        for x in extra:
            paths.append(Path(str(x)).expanduser().resolve())
    images: list[Image.Image] = [im]
    for path in paths:
        policy_check(path)
        o, err = _load_rgba(path)
        if err:
            raise ValueError(err)
        images.append(o)
    if len(images) > MAX_CONCAT_IMAGES:
        raise ValueError(f"at most {MAX_CONCAT_IMAGES} images")
    if horizontal:
        h = max(x.size[1] for x in images)
        resized = []
        for x in images:
            if x.size[1] != h:
                nw = max(1, int(x.size[0] * h / x.size[1]))
                resized.append(x.resize((nw, h), Image.Resampling.LANCZOS))
            else:
                resized.append(x)
        w = sum(x.size[0] for x in resized)
        out = Image.new("RGBA", (w, h), (0, 0, 0, 0))
        ox = 0
        for x in resized:
            out.paste(x, (ox, 0), x)
            ox += x.size[0]
    else:
        w = max(x.size[0] for x in images)
        resized = []
        for x in images:
            if x.size[0] != w:
                nh = max(1, int(x.size[1] * w / x.size[0]))
                resized.append(x.resize((w, nh), Image.Resampling.LANCZOS))
            else:
                resized.append(x)
        h = sum(x.size[1] for x in resized)
        out = Image.new("RGBA", (w, h), (0, 0, 0, 0))
        oy = 0
        for x in resized:
            out.paste(x, (0, oy), x)
            oy += x.size[1]
    return out, {"count": len(images), "horizontal": horizontal}


def _op_draw_rect(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    xy = p.get("xyxy")
    if not isinstance(xy, (list, tuple)) or len(xy) < 4:
        raise ValueError("draw_rectangle needs params.xyxy [x0,y0,x1,y1]")
    x0, y0, x1, y1 = int(xy[0]), int(xy[1]), int(xy[2]), int(xy[3])
    outline = p.get("outline")
    fill = p.get("fill")
    width = max(1, _i(p.get("width"), 2))
    canvas = im.copy()
    dr = ImageDraw.Draw(canvas, "RGBA")
    kw: dict[str, Any] = {"width": width}
    if outline is not None:
        kw["outline"] = _parse_rgba(outline, (0, 0, 0, 255))
    if fill is not None:
        kw["fill"] = _parse_rgba(fill, (255, 0, 0, 128))
    dr.rectangle([x0, y0, x1, y1], **kw)
    return canvas, {"xyxy": [x0, y0, x1, y1]}


def _op_draw_ellipse(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    xy = p.get("xyxy")
    if not isinstance(xy, (list, tuple)) or len(xy) < 4:
        raise ValueError("draw_ellipse needs params.xyxy")
    x0, y0, x1, y1 = int(xy[0]), int(xy[1]), int(xy[2]), int(xy[3])
    width = max(1, _i(p.get("width"), 2))
    canvas = im.copy()
    dr = ImageDraw.Draw(canvas, "RGBA")
    kw: dict[str, Any] = {"width": width}
    if p.get("outline") is not None:
        kw["outline"] = _parse_rgba(p["outline"], (0, 0, 0, 255))
    if p.get("fill") is not None:
        kw["fill"] = _parse_rgba(p["fill"], (255, 0, 0, 80))
    dr.ellipse([x0, y0, x1, y1], **kw)
    return canvas, {}


def _op_draw_line(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    xy = p.get("xyxy")
    if not isinstance(xy, (list, tuple)) or len(xy) < 4:
        raise ValueError("draw_line needs params.xyxy")
    width = max(1, _i(p.get("width"), 2))
    fill = _parse_rgba(p.get("fill"), (0, 0, 0, 255))
    canvas = im.copy()
    dr = ImageDraw.Draw(canvas, "RGBA")
    dr.line(
        [(int(xy[0]), int(xy[1])), (int(xy[2]), int(xy[3]))],
        fill=fill,
        width=width,
    )
    return canvas, {}


def _op_draw_polygon(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    pts = p.get("points")
    if not isinstance(pts, list) or len(pts) < 2:
        raise ValueError("draw_polygon needs params.points [[x,y],...]")
    flat = []
    for q in pts:
        if isinstance(q, (list, tuple)) and len(q) >= 2:
            flat.append((int(q[0]), int(q[1])))
    if len(flat) < 2:
        raise ValueError("invalid points")
    canvas = im.copy()
    dr = ImageDraw.Draw(canvas, "RGBA")
    kw: dict[str, Any] = {}
    if p.get("outline") is not None:
        kw["outline"] = _parse_rgba(p["outline"], (0, 0, 0, 255))
    if p.get("fill") is not None:
        kw["fill"] = _parse_rgba(p["fill"], (0, 128, 255, 100))
    dr.polygon(flat, **kw)
    return canvas, {"vertices": len(flat)}


def _op_draw_text(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    text = str(p.get("text") or "")
    if not text:
        raise ValueError("draw_text needs params.text")
    x, y = _i(p.get("x"), 0), _i(p.get("y"), 0)
    fs = max(8, min(256, _i(p.get("font_size"), 16)))
    fill = _parse_rgba(p.get("fill"), (0, 0, 0, 255))
    canvas = im.copy()
    dr = ImageDraw.Draw(canvas, "RGBA")
    try:
        font = ImageFont.truetype("arial.ttf", fs)
    except OSError:
        try:
            font = ImageFont.truetype("DejaVuSans.ttf", fs)
        except OSError:
            font = ImageFont.load_default()
    dr.text((x, y), text, fill=fill, font=font)
    return canvas, {"chars": len(text)}


def _op_solid_canvas(p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    w0, h0 = _i(p.get("width"), 0), _i(p.get("height"), 0)
    if w0 <= 0 or h0 <= 0:
        raise ValueError("solid_canvas requires positive params.width and params.height")
    w = max(1, min(MAX_SIDE, w0))
    h = max(1, min(MAX_SIDE, h0))
    if w * h > MAX_PIXELS:
        raise ValueError("canvas exceeds MAX_PIXELS")
    fill = _parse_rgba(p.get("fill"), (255, 255, 255, 255))
    return Image.new("RGBA", (w, h), fill), {"size": (w, h)}


def _op_chroma_key(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    lo = p.get("lower_hsv")
    hi = p.get("upper_hsv")
    if not isinstance(lo, (list, tuple)) or not isinstance(hi, (list, tuple)):
        raise ValueError("chroma_key needs lower_hsv and upper_hsv [h,s,v] 0-255 uint8 style")
    rgb = im.convert("RGB")
    hsv = rgb.convert("HSV")
    arr = np.array(hsv)
    loa = np.array([int(lo[0]), int(lo[1]), int(lo[2])], dtype=np.uint8)
    hia = np.array([int(hi[0]), int(hi[1]), int(hi[2])], dtype=np.uint8)
    mask = np.all((arr >= loa) & (arr <= hia), axis=-1)
    a = np.array(im.split()[3])
    a[mask] = 0
    out = Image.merge("RGBA", (*rgb.split(), Image.fromarray(a)))
    return out, {"masked_pixels": int(mask.sum())}


def _op_tint(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    c = p.get("color")
    if not isinstance(c, (list, tuple)) or len(c) < 3:
        raise ValueError("tint needs params.color [r,g,b]")
    alpha = max(0.0, min(1.0, _f(p.get("alpha"), 0.3)))
    tint = Image.new("RGBA", im.size, (int(c[0]), int(c[1]), int(c[2]), int(255 * alpha)))
    return Image.alpha_composite(im, tint), {"alpha": alpha}


def _pil_to_cv_bgr(im: Image.Image):  # type: ignore[no-untyped-def]
    import numpy as np

    rgb = np.array(im.convert("RGB"))
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("OpenCV not installed; pip install opencv-python-headless")
    return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)


def _cv_bgr_to_pil_rgba(bgr, alpha: Image.Image | None = None) -> Image.Image:  # type: ignore[no-untyped-def]
    import numpy as np

    cv2 = _try_import_cv2()
    assert cv2 is not None
    rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
    pil = Image.fromarray(rgb)
    if alpha is not None and alpha.size == pil.size:
        return Image.merge("RGBA", (*pil.split(), alpha))
    return pil.convert("RGBA")


def _op_edge_canny(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("edge_canny requires OpenCV (pip install opencv-python-headless)")
    low = max(0, _i(p.get("low"), 50))
    high = max(0, _i(p.get("high"), 150))
    bgr = _pil_to_cv_bgr(im)
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray, low, high)
    edges_rgba = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGRA)
    edges_rgba[:, :, 3] = 255
    rgb = cv2.cvtColor(edges_rgba, cv2.COLOR_BGRA2RGBA)
    return Image.fromarray(rgb), {"low": low, "high": high}


def _op_gaussian_blur(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("gaussian_blur requires OpenCV")
    k = max(1, _i(p.get("ksize"), 5)) | 1  # odd
    sigma = _f(p.get("sigma"), 0)
    bgr = _pil_to_cv_bgr(im)
    blur = cv2.GaussianBlur(bgr, (k, k), sigma)
    a = im.split()[3]
    return _cv_bgr_to_pil_rgba(blur, a), {"ksize": k}


def _op_bilateral(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("bilateral requires OpenCV")
    d = max(1, _i(p.get("d"), 9))
    sc = _f(p.get("sigma_color"), 75)
    ss = _f(p.get("sigma_space"), 75)
    bgr = _pil_to_cv_bgr(im)
    out = cv2.bilateralFilter(bgr, d, sc, ss)
    a = im.split()[3]
    return _cv_bgr_to_pil_rgba(out, a), {}


def _op_morphology(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("morphology requires OpenCV")
    opn = str(p.get("op") or "close").lower()
    ks = max(1, _i(p.get("ksize"), 3)) | 1
    it = max(1, _i(p.get("iterations"), 1))
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (ks, ks))
    gray = cv2.cvtColor(_pil_to_cv_bgr(im), cv2.COLOR_BGR2GRAY)
    _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    if opn == "erode":
        proc = cv2.erode(bw, kernel, iterations=it)
    elif opn == "dilate":
        proc = cv2.dilate(bw, kernel, iterations=it)
    elif opn == "open":
        proc = cv2.morphologyEx(bw, cv2.MORPH_OPEN, kernel, iterations=it)
    else:
        proc = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, kernel, iterations=it)
    bgra = cv2.cvtColor(proc, cv2.COLOR_GRAY2BGRA)
    bgra[:, :, 3] = 255
    return Image.fromarray(cv2.cvtColor(bgra, cv2.COLOR_BGRA2RGBA)), {"op": opn}


def _op_threshold(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("threshold requires OpenCV")
    typ = str(p.get("type") or "binary").lower()
    gray = cv2.cvtColor(_pil_to_cv_bgr(im), cv2.COLOR_BGR2GRAY)
    maxv = _i(p.get("maxval"), 255)
    if typ == "otsu":
        _, bw = cv2.threshold(gray, 0, maxv, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    elif typ == "adaptive":
        bw = cv2.adaptiveThreshold(
            gray, maxv, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2
        )
    else:
        th = _i(p.get("thresh"), 127)
        _, bw = cv2.threshold(gray, th, maxv, cv2.THRESH_BINARY)
    rgba = cv2.cvtColor(bw, cv2.COLOR_GRAY2RGBA)
    return Image.fromarray(rgba), {"type": typ}


def _op_laplacian(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("laplacian requires OpenCV")
    ks = max(1, _i(p.get("ksize"), 3)) | 1
    gray = cv2.cvtColor(_pil_to_cv_bgr(im), cv2.COLOR_BGR2GRAY)
    lap = cv2.Laplacian(gray, cv2.CV_16S, ksize=ks)
    lap = cv2.convertScaleAbs(lap)
    rgba = cv2.cvtColor(lap, cv2.COLOR_GRAY2RGBA)
    return Image.fromarray(rgba), {"ksize": ks}


def _op_sobel(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("sobel requires OpenCV")
    dx = max(0, min(2, _i(p.get("dx"), 1)))
    dy = max(0, min(2, _i(p.get("dy"), 0)))
    ks = max(1, _i(p.get("ksize"), 3)) | 1
    gray = cv2.cvtColor(_pil_to_cv_bgr(im), cv2.COLOR_BGR2GRAY)
    s = cv2.Sobel(gray, cv2.CV_16S, dx, dy, ksize=ks)
    s = cv2.convertScaleAbs(s)
    rgba = cv2.cvtColor(s, cv2.COLOR_GRAY2RGBA)
    return Image.fromarray(rgba), {"dx": dx, "dy": dy}


def _op_grabcut_rect(im: Image.Image, p: dict[str, Any]) -> tuple[Image.Image, dict[str, Any]]:
    cv2 = _try_import_cv2()
    if cv2 is None:
        raise ValueError("grabcut_rect requires OpenCV")
    x, y = _i(p.get("x"), 0), _i(p.get("y"), 0)
    w, h = _i(p.get("width"), 0), _i(p.get("height"), 0)
    if w <= 0 or h <= 0:
        raise ValueError("grabcut_rect needs positive width/height")
    it = max(1, min(10, _i(p.get("iterations"), 5)))
    bgr = _pil_to_cv_bgr(im)
    H0, W0 = bgr.shape[0], bgr.shape[1]
    x = max(0, min(W0 - 1, x))
    y = max(0, min(H0 - 1, y))
    w = max(1, min(W0 - x, w))
    h = max(1, min(H0 - y, h))
    mask = np.zeros(bgr.shape[:2], np.uint8)
    bgd = np.zeros((1, 65), np.float64)
    fgd = np.zeros((1, 65), np.float64)
    rect = (x, y, w, h)
    cv2.grabCut(bgr, mask, rect, bgd, fgd, it, cv2.GC_INIT_WITH_RECT)
    mask2 = np.where((mask == 2) | (mask == 0), 0, 255).astype("uint8")  # type: ignore[name-defined]
    rgba = cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA)
    rgba[:, :, 3] = mask2
    pil = Image.fromarray(cv2.cvtColor(rgba, cv2.COLOR_BGRA2RGBA))
    return pil, {"rect": list(rect), "iterations": it}
