#!/usr/bin/env python3
"""Pixel-level visual diff for golden image comparison.

Compares two PNG images per-channel with configurable tolerance.
Reads default tolerance from tests/visual.json if available.

Usage:
  tooling/visual-diff EXPECTED ACTUAL [--tolerance N] [--diff-output PATH] [--config PATH]

Exit codes:
  0 = images match (all pixels within tolerance)
  1 = images differ
  2 = size mismatch or fatal error
"""

import argparse
import json
import struct
import sys
import zlib
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG = ROOT / "tests" / "visual.json"
DEFAULT_TOLERANCE = 5

# ---------------------------------------------------------------------------
# PNG reading — prefer PIL, fallback to pure stdlib
# ---------------------------------------------------------------------------

_USE_PIL = False
try:
    from PIL import Image as _PILImage

    _USE_PIL = True
except ImportError:
    pass


def _read_png_pil(path: str) -> tuple[int, int, bytes]:
    """Read PNG via Pillow, return (width, height, RGBA bytes)."""
    img = _PILImage.open(path).convert("RGBA")
    return img.width, img.height, img.tobytes()


def _paeth(a: int, b: int, c: int) -> int:
    p = a + b - c
    pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
    if pa <= pb and pa <= pc:
        return a
    if pb <= pc:
        return b
    return c


def _read_png_stdlib(path: str) -> tuple[int, int, bytes]:
    """Read an RGBA (color type 6) PNG using only struct + zlib.

    Handles multiple IDAT chunks and all 5 PNG filter types.
    """
    with open(path, "rb") as f:
        sig = f.read(8)
        if sig != b"\x89PNG\r\n\x1a\n":
            print(f"ERROR: {path} is not a valid PNG", file=sys.stderr)
            sys.exit(2)

        width = height = 0
        bit_depth = color_type = 0
        idat_chunks: list[bytes] = []

        while True:
            header = f.read(8)
            if len(header) < 8:
                break
            length, chunk_type = struct.unpack(">I4s", header)
            data = f.read(length)
            _crc = f.read(4)

            if chunk_type == b"IHDR":
                width, height, bit_depth, color_type = struct.unpack(
                    ">IIBB", data[:10]
                )
                if color_type != 6:
                    print(
                        f"ERROR: {path} has color type {color_type}, expected 6 (RGBA)",
                        file=sys.stderr,
                    )
                    sys.exit(2)
                if bit_depth != 8:
                    print(
                        f"ERROR: {path} has bit depth {bit_depth}, expected 8",
                        file=sys.stderr,
                    )
                    sys.exit(2)
            elif chunk_type == b"IDAT":
                idat_chunks.append(data)
            elif chunk_type == b"IEND":
                break

    raw = zlib.decompress(b"".join(idat_chunks))

    bpp = 4  # RGBA = 4 bytes per pixel
    stride = width * bpp
    pixels = bytearray(height * stride)

    pos = 0
    for y in range(height):
        filter_type = raw[pos]
        pos += 1
        row_start = y * stride

        for x in range(stride):
            cur = raw[pos]
            pos += 1

            a = pixels[row_start + x - bpp] if x >= bpp else 0
            b = pixels[row_start - stride + x] if y > 0 else 0
            c = (
                pixels[row_start - stride + x - bpp]
                if y > 0 and x >= bpp
                else 0
            )

            if filter_type == 0:  # None
                val = cur
            elif filter_type == 1:  # Sub
                val = (cur + a) & 0xFF
            elif filter_type == 2:  # Up
                val = (cur + b) & 0xFF
            elif filter_type == 3:  # Average
                val = (cur + ((a + b) >> 1)) & 0xFF
            elif filter_type == 4:  # Paeth
                val = (cur + _paeth(a, b, c)) & 0xFF
            else:
                print(
                    f"ERROR: unknown PNG filter type {filter_type} at row {y}",
                    file=sys.stderr,
                )
                sys.exit(2)

            pixels[row_start + x] = val

    return width, height, bytes(pixels)


def read_png(path: str) -> tuple[int, int, bytes]:
    """Read PNG, return (width, height, RGBA bytes)."""
    if _USE_PIL:
        return _read_png_pil(path)
    return _read_png_stdlib(path)


# ---------------------------------------------------------------------------
# Diff PNG writing — prefer PIL, fallback to pure stdlib
# ---------------------------------------------------------------------------


def _write_png_pil(path: str, width: int, height: int, rgba: bytes) -> None:
    img = _PILImage.frombytes("RGBA", (width, height), rgba)
    img.save(path)


def _write_png_stdlib(
    path: str, width: int, height: int, rgba: bytes
) -> None:
    """Write a minimal RGBA PNG using zlib + struct (filter type 0/None)."""

    def _chunk(chunk_type: bytes, data: bytes) -> bytes:
        crc = zlib.crc32(chunk_type + data) & 0xFFFFFFFF
        return struct.pack(">I", len(data)) + chunk_type + data + struct.pack(">I", crc)

    # IHDR: width, height, bit_depth=8, color_type=6, compress=0, filter=0, interlace=0
    ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)

    # Build raw scanlines with filter byte 0 (None) per row
    stride = width * 4
    raw = bytearray()
    for y in range(height):
        raw.append(0)  # filter type None
        offset = y * stride
        raw.extend(rgba[offset : offset + stride])

    compressed = zlib.compress(bytes(raw))

    with open(path, "wb") as f:
        f.write(b"\x89PNG\r\n\x1a\n")
        f.write(_chunk(b"IHDR", ihdr_data))
        f.write(_chunk(b"IDAT", compressed))
        f.write(_chunk(b"IEND", b""))


def write_png(path: str, width: int, height: int, rgba: bytes) -> None:
    if _USE_PIL:
        _write_png_pil(path, width, height, rgba)
    else:
        _write_png_stdlib(path, width, height, rgba)


# ---------------------------------------------------------------------------
# Comparison
# ---------------------------------------------------------------------------


def compare(
    expected: bytes,
    actual: bytes,
    width: int,
    height: int,
    tolerance: int,
) -> tuple[int, bytes | None]:
    """Compare two RGBA buffers. Returns (diff_count, diff_rgba_or_None)."""
    total = width * height
    diff_count = 0
    diff_buf = bytearray(total * 4)

    for i in range(total):
        off = i * 4
        er, eg, eb, ea = expected[off], expected[off + 1], expected[off + 2], expected[off + 3]
        ar, ag, ab, aa = actual[off], actual[off + 1], actual[off + 2], actual[off + 3]

        if (
            abs(er - ar) > tolerance
            or abs(eg - ag) > tolerance
            or abs(eb - ab) > tolerance
            or abs(ea - aa) > tolerance
        ):
            diff_count += 1
            diff_buf[off] = 0xFF
            diff_buf[off + 1] = 0x00
            diff_buf[off + 2] = 0x00
            diff_buf[off + 3] = 0xFF
        # else: remains (0, 0, 0, 0) — transparent

    return diff_count, bytes(diff_buf)


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------


def load_config(config_path: Path | None) -> dict:
    """Read visual test config, return dict with tolerance and max_diff_pct."""
    if config_path is None:
        config_path = DEFAULT_CONFIG
    defaults = {"tolerance": DEFAULT_TOLERANCE, "max_diff_pct": 0.0}
    if not config_path.exists():
        return defaults
    try:
        with open(config_path) as f:
            data = json.load(f)
        return {
            "tolerance": int(data.get("tolerance", DEFAULT_TOLERANCE)),
            "max_diff_pct": float(data.get("max_diff_pct", 0.0)),
        }
    except (json.JSONDecodeError, ValueError, OSError):
        return defaults


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Pixel-level visual diff for golden image comparison."
    )
    parser.add_argument("expected", help="Path to golden PNG")
    parser.add_argument("actual", help="Path to captured PNG")
    parser.add_argument(
        "--tolerance",
        type=int,
        default=None,
        help="Per-channel pixel tolerance (default: from config or 5)",
    )
    parser.add_argument(
        "--max-diff-pct",
        type=float,
        default=None,
        help="Max allowed diff percentage (default: from config or 0.0)",
    )
    parser.add_argument(
        "--diff-output",
        default=None,
        help="Path to write diff PNG highlighting changed pixels",
    )
    parser.add_argument(
        "--config",
        default=None,
        help="Path to tests/visual.json (default: auto-detect)",
    )
    args = parser.parse_args()

    # Resolve settings: CLI > config > fallback
    config_path = Path(args.config) if args.config else None
    cfg = load_config(config_path)
    tolerance = args.tolerance if args.tolerance is not None else cfg["tolerance"]
    max_diff_pct = args.max_diff_pct if args.max_diff_pct is not None else cfg["max_diff_pct"]

    # Read images
    try:
        ew, eh, epx = read_png(args.expected)
    except FileNotFoundError:
        print(f"ERROR: expected image not found: {args.expected}", file=sys.stderr)
        return 2
    except Exception as exc:
        print(f"ERROR: failed to read expected image: {exc}", file=sys.stderr)
        return 2

    try:
        aw, ah, apx = read_png(args.actual)
    except FileNotFoundError:
        print(f"ERROR: actual image not found: {args.actual}", file=sys.stderr)
        return 2
    except Exception as exc:
        print(f"ERROR: failed to read actual image: {exc}", file=sys.stderr)
        return 2

    # Size check
    if ew != aw or eh != ah:
        print(
            f"ERROR: size mismatch — expected {ew}x{eh}, actual {aw}x{ah}",
            file=sys.stderr,
        )
        return 2

    # Compare
    diff_count, diff_buf = compare(epx, apx, ew, eh, tolerance)
    total = ew * eh

    if diff_count == 0:
        print(f"PASS: images match ({ew}x{eh})")
        return 0

    pct = diff_count / total * 100

    if pct <= max_diff_pct:
        print(f"PASS: {diff_count} of {total} pixels differ ({pct:.1f}%, within {max_diff_pct}% threshold)")
        return 0

    print(f"FAIL: {diff_count} of {total} pixels differ ({pct:.1f}%)")

    if args.diff_output and diff_buf:
        Path(args.diff_output).parent.mkdir(parents=True, exist_ok=True)
        write_png(args.diff_output, ew, eh, diff_buf)

    return 1


if __name__ == "__main__":
    sys.exit(main())
