"""Image diffing using pixelmatch algorithm.

Perceptual diffing that:
- Ignores anti-aliasing differences
- Handles slight color variations
- Produces visual diff overlay
- Calculates meaningful diff percentage

Based on: https://github.com/nicksrandall/pixelmatch-py
"""
from __future__ import annotations

import io
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Tuple

logger = logging.getLogger(__name__)

# Try importing PIL
try:
    from PIL import Image, ImageChops, ImageDraw, ImageFilter
    _PIL_AVAILABLE = True
except ImportError:
    _PIL_AVAILABLE = False
    Image = None

# Try importing numpy for vectorized pixel comparison (H-140)
try:
    import numpy as np
    _NUMPY_AVAILABLE = True
except ImportError:
    np = None
    _NUMPY_AVAILABLE = False

# Try importing scikit-image for SSIM comparison (H-141)
try:
    from skimage.metrics import structural_similarity as _ssim_func
    _SSIM_AVAILABLE = True
except ImportError:
    _ssim_func = None
    _SSIM_AVAILABLE = False

# Try importing imagehash for perceptual hash pre-filtering (P7-F6)
try:
    import imagehash as _imagehash
    _IMAGEHASH_AVAILABLE = True
except ImportError:
    _imagehash = None
    _IMAGEHASH_AVAILABLE = False


class DiffStatus(str, Enum):
    """Status of visual comparison."""
    MATCH = "match"           # Images are identical (within threshold)
    DIFF = "diff"             # Images have differences
    NEW = "new"               # No baseline exists
    ERROR = "error"           # Comparison failed
    SIZE_MISMATCH = "size_mismatch"  # Images have different dimensions


@dataclass
class DiffResult:
    """Result of visual comparison."""

    status: DiffStatus
    diff_percentage: float  # 0.0 - 100.0
    diff_pixels: int
    total_pixels: int
    baseline_size: Optional[Tuple[int, int]] = None
    current_size: Optional[Tuple[int, int]] = None
    diff_image: Optional[bytes] = None  # PNG bytes of diff visualization
    diff_regions: list = None  # List of bounding boxes for changed regions

    def __post_init__(self):
        if self.diff_regions is None:
            self.diff_regions = []

    @property
    def passed(self) -> bool:
        """Check if comparison passed (no meaningful differences)."""
        return self.status == DiffStatus.MATCH

    @property
    def has_changes(self) -> bool:
        """Check if there are visual changes."""
        return self.status == DiffStatus.DIFF

    def to_dict(self) -> dict:
        """Convert to dictionary for API response."""
        return {
            "status": self.status.value,
            "diff_percentage": round(self.diff_percentage, 2),
            "diff_pixels": self.diff_pixels,
            "total_pixels": self.total_pixels,
            "baseline_size": self.baseline_size,
            "current_size": self.current_size,
            "passed": self.passed,
            "diff_regions": self.diff_regions,
        }


def compare_images(
    baseline: bytes,
    current: bytes,
    threshold: float = 0.1,
    diff_color: Tuple[int, int, int, int] = (255, 0, 255, 255),
    anti_aliasing: bool = True,
    ignore_regions: list = None,
    max_diff_percentage: float = 0.1,
    max_diff_pixels: Optional[int] = None,
    size_tolerance: int = 0,
) -> DiffResult:
    """Compare two images and return diff result.

    Args:
        baseline: Baseline image as PNG bytes
        current: Current image as PNG bytes
        threshold: Color difference threshold (0.0-1.0). Lower = stricter.
        diff_color: RGBA color for highlighting differences
        anti_aliasing: Whether to detect and ignore anti-aliasing
        ignore_regions: List of (x, y, width, height) regions to ignore
        max_diff_percentage: Maximum % of pixels that can differ and still pass
        max_diff_pixels: Maximum absolute pixel count that can differ and still pass
        size_tolerance: Maximum pixel difference in width/height to still
            attempt an overlap comparison instead of returning SIZE_MISMATCH
            immediately (P7-F7). When > 0, the smaller image is padded to
            match and only the overlapping region is compared.

    Returns:
        DiffResult with comparison details
    """
    if not _PIL_AVAILABLE:
        logger.error("PIL not available. Install: pip install Pillow")
        return DiffResult(
            status=DiffStatus.ERROR,
            diff_percentage=0,
            diff_pixels=0,
            total_pixels=0,
        )

    try:
        # Load images
        img1 = Image.open(io.BytesIO(baseline)).convert("RGBA")
        img2 = Image.open(io.BytesIO(current)).convert("RGBA")

        # Perceptual hash fast-path (P7-F6): if images are perceptually
        # identical according to pHash, skip the expensive pixel comparison.
        if _IMAGEHASH_AVAILABLE and img1.size == img2.size:
            try:
                phash1 = _imagehash.phash(img1)
                phash2 = _imagehash.phash(img2)
                if phash1 == phash2:
                    total_pixels = img1.size[0] * img1.size[1]
                    return DiffResult(
                        status=DiffStatus.MATCH,
                        diff_percentage=0.0,
                        diff_pixels=0,
                        total_pixels=total_pixels,
                        baseline_size=img1.size,
                        current_size=img2.size,
                    )
            except Exception:
                pass  # Fall through to full comparison on any hash error

        # Check sizes -- attempt overlap comparison when within tolerance (P7-F7)
        if img1.size != img2.size:
            w_diff = abs(img1.size[0] - img2.size[0])
            h_diff = abs(img1.size[1] - img2.size[1])

            if size_tolerance > 0 and w_diff <= size_tolerance and h_diff <= size_tolerance:
                # Pad the smaller image with transparent pixels so both
                # images have the same dimensions, then compare the
                # overlapping region.  Non-overlapping pixels are counted
                # as diff pixels.
                target_w = max(img1.size[0], img2.size[0])
                target_h = max(img1.size[1], img2.size[1])

                def _pad(img, tw, th):
                    if img.size == (tw, th):
                        return img
                    padded = Image.new("RGBA", (tw, th), (0, 0, 0, 0))
                    padded.paste(img, (0, 0))
                    return padded

                img1 = _pad(img1, target_w, target_h)
                img2 = _pad(img2, target_w, target_h)
                # fall through to the normal comparison with padded images
            else:
                return DiffResult(
                    status=DiffStatus.SIZE_MISMATCH,
                    diff_percentage=100.0,
                    diff_pixels=max(img1.size[0] * img1.size[1], img2.size[0] * img2.size[1]),
                    total_pixels=max(img1.size[0] * img1.size[1], img2.size[0] * img2.size[1]),
                    baseline_size=img1.size,
                    current_size=img2.size,
                )

        width, height = img1.size
        total_pixels = width * height

        # Create diff image
        diff_img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
        diff_draw = ImageDraw.Draw(diff_img)

        # Get pixel data
        pixels1 = img1.load()
        pixels2 = img2.load()

        # Apply ignore regions mask
        ignore_mask = set()
        if ignore_regions:
            for region in ignore_regions:
                x, y, w, h = region
                for px in range(x, min(x + w, width)):
                    for py in range(y, min(y + h, height)):
                        ignore_mask.add((px, py))

        # Pre-compute anti-aliasing detection if enabled
        # NOTE (H-142): The anti-aliasing detection algorithm has known
        # pixelmatch limitations. It uses a simple 8-neighbor brightness
        # gradient heuristic (Vysniauskas 2009) which can produce false
        # positives on high-contrast edges and false negatives on subtle
        # sub-pixel AA. More sophisticated approaches (e.g., font-aware
        # AA detection or multi-scale analysis) could improve accuracy
        # but are not implemented here.
        aa_pixels = set()
        if anti_aliasing:
            aa_pixels = _detect_antialiasing(img1, img2)

        # Use numpy-accelerated path when available (H-140: ~100-600x faster)
        if _NUMPY_AVAILABLE:
            diff_pixels, diff_coords, diff_img = _compare_pixels_numpy(
                img1, img2, threshold, ignore_mask, aa_pixels, diff_color,
            )
        else:
            # Pure-Python fallback: pixel-by-pixel comparison
            diff_pixels = 0
            diff_coords = []

            # YIQ-based perceptual threshold (max YIQ distance is ~35215)
            color_threshold_sq = threshold * threshold * 35215

            for y in range(height):
                for x in range(width):
                    if (x, y) in ignore_mask:
                        continue

                    if (x, y) in aa_pixels:
                        continue

                    p1 = pixels1[x, y]
                    p2 = pixels2[x, y]

                    # Calculate perceptual color difference using YIQ color space
                    delta = _color_delta_yiq(p1, p2)

                    if delta > color_threshold_sq:
                        diff_pixels += 1
                        diff_coords.append((x, y))
                        diff_draw.point((x, y), fill=diff_color)

        # Calculate percentage
        diff_percentage = (diff_pixels / total_pixels) * 100 if total_pixels > 0 else 0

        # Determine status using configurable thresholds
        is_match = diff_percentage < max_diff_percentage
        if not is_match and max_diff_pixels is not None:
            is_match = diff_pixels < max_diff_pixels
        status = DiffStatus.MATCH if is_match else DiffStatus.DIFF

        # Find diff regions (bounding boxes)
        diff_regions = _find_diff_regions(diff_coords, width, height) if diff_coords else []

        # Composite diff overlay onto current image for visualization
        # When numpy path was used, diff_img was built there; otherwise
        # diff_img was built via diff_draw in the pure-Python fallback.
        if _NUMPY_AVAILABLE:
            visualization = Image.alpha_composite(img2, diff_img)
        else:
            visualization = Image.alpha_composite(img2, diff_img)

        # Convert to bytes
        diff_buffer = io.BytesIO()
        visualization.save(diff_buffer, format="PNG")
        diff_bytes = diff_buffer.getvalue()

        return DiffResult(
            status=status,
            diff_percentage=diff_percentage,
            diff_pixels=diff_pixels,
            total_pixels=total_pixels,
            baseline_size=img1.size,
            current_size=img2.size,
            diff_image=diff_bytes,
            diff_regions=diff_regions,
        )

    except Exception as e:
        logger.error(f"Image comparison failed: {e}")
        return DiffResult(
            status=DiffStatus.ERROR,
            diff_percentage=0,
            diff_pixels=0,
            total_pixels=0,
        )


def _rgb_to_y(r: int, g: int, b: int) -> float:
    """Convert RGB to YIQ luminance (Y channel)."""
    return r * 0.29889531 + g * 0.58662247 + b * 0.11448223


def _color_delta_yiq(p1: tuple, p2: tuple) -> float:
    """Compute perceptual color difference using YIQ NTSC color space.

    Based on Kotsarenko & Ramos 2010, as used by pixelmatch.
    Returns squared distance weighted by perceptual importance.
    """
    r1, g1, b1 = p1[0], p1[1], p1[2]
    r2, g2, b2 = p2[0], p2[1], p2[2]

    dy = (r1 - r2) * 0.29889531 + (g1 - g2) * 0.58662247 + (b1 - b2) * 0.11448223
    di = (r1 - r2) * 0.59597799 - (g1 - g2) * 0.27417610 - (b1 - b2) * 0.32180189
    dq = (r1 - r2) * 0.21147017 - (g1 - g2) * 0.52261711 + (b1 - b2) * 0.31114694

    return 0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq


def _detect_antialiasing(img1: Image.Image, img2: Image.Image) -> set:
    """Detect anti-aliased pixels using the pixelmatch algorithm (Vysniauskas 2009).

    For each pixel, check 8 neighbors for brightness gradient:
    - Must have BOTH brighter AND darker neighbors (edge transition)
    - Must have <= 2 equal-brightness neighbors
    - The other image must also have an edge at this location
    """
    aa_pixels = set()

    try:
        width, height = img1.size
        gray1 = img1.convert("L")
        gray2 = img2.convert("L")
        g1 = gray1.load()
        g2 = gray2.load()

        def _is_aa_pixel(gimg, x, y, w, h):
            """Check if pixel at (x,y) is anti-aliased in given grayscale image."""
            center = gimg[x, y]
            min_val = center
            max_val = center
            equal_count = 0

            for dx in [-1, 0, 1]:
                for dy in [-1, 0, 1]:
                    if dx == 0 and dy == 0:
                        continue
                    nx, ny = x + dx, y + dy
                    if 0 <= nx < w and 0 <= ny < h:
                        val = gimg[nx, ny]
                        if val < min_val:
                            min_val = val
                        if val > max_val:
                            max_val = val
                        if abs(val - center) <= 1:
                            equal_count += 1

            # Must have both brighter AND darker neighbors (edge)
            # AND at most 2 equal neighbors (not a flat region)
            return (max_val - center) > 10 and (center - min_val) > 10 and equal_count <= 2

        for y in range(height):
            for x in range(width):
                # Only check pixels that actually differ
                if g1[x, y] != g2[x, y]:
                    # Pixel is AA if it's on an edge in EITHER image
                    if _is_aa_pixel(g1, x, y, width, height) or _is_aa_pixel(g2, x, y, width, height):
                        aa_pixels.add((x, y))

    except Exception as e:
        logger.warning(f"Anti-aliasing detection failed: {e}")

    return aa_pixels


def _find_diff_regions(coords: list, width: int, height: int, min_region_size: int = 10) -> list:
    """Find bounding boxes around clusters of diff pixels.

    Uses simple grid-based clustering to group nearby diff pixels.
    """
    if not coords:
        return []

    # Grid-based clustering
    grid_size = 50  # pixels
    grid = {}

    for x, y in coords:
        gx, gy = x // grid_size, y // grid_size
        key = (gx, gy)
        if key not in grid:
            grid[key] = {"min_x": x, "max_x": x, "min_y": y, "max_y": y, "count": 0}
        grid[key]["min_x"] = min(grid[key]["min_x"], x)
        grid[key]["max_x"] = max(grid[key]["max_x"], x)
        grid[key]["min_y"] = min(grid[key]["min_y"], y)
        grid[key]["max_y"] = max(grid[key]["max_y"], y)
        grid[key]["count"] += 1

    # Filter cells below threshold, then merge adjacent cells (P7-F8)
    padding = 5
    raw_regions = []
    for key, data in grid.items():
        if data["count"] >= min_region_size:
            raw_regions.append({
                "x": max(0, data["min_x"] - padding),
                "y": max(0, data["min_y"] - padding),
                "width": min(width, data["max_x"] + padding) - max(0, data["min_x"] - padding),
                "height": min(height, data["max_y"] + padding) - max(0, data["min_y"] - padding),
                "pixel_count": data["count"],
            })

    # Merge overlapping or adjacent bounding boxes so that a contiguous
    # changed area spanning multiple grid cells produces a single region.
    regions = _merge_overlapping_regions(raw_regions, width, height)

    return regions


def _merge_overlapping_regions(
    regions: list, width: int, height: int, merge_gap: int = 5
) -> list:
    """Merge overlapping or adjacent bounding-box regions (P7-F8).

    Uses iterative expansion: on each pass, merge any two regions whose
    bounding boxes overlap or are within *merge_gap* pixels of each other.
    Repeat until no more merges occur.

    Args:
        regions: List of region dicts with x, y, width, height, pixel_count
        width: Image width (for clamping)
        height: Image height (for clamping)
        merge_gap: Maximum gap in pixels to still consider regions adjacent

    Returns:
        Merged list of region dicts
    """
    if len(regions) <= 1:
        return regions

    merged = list(regions)
    changed = True
    while changed:
        changed = False
        new_merged = []
        used = set()
        for i in range(len(merged)):
            if i in used:
                continue
            current = merged[i]
            for j in range(i + 1, len(merged)):
                if j in used:
                    continue
                other = merged[j]
                # Check if bounding boxes overlap or are within merge_gap
                if (
                    current["x"] - merge_gap <= other["x"] + other["width"]
                    and other["x"] - merge_gap <= current["x"] + current["width"]
                    and current["y"] - merge_gap <= other["y"] + other["height"]
                    and other["y"] - merge_gap <= current["y"] + current["height"]
                ):
                    # Merge into current
                    nx = min(current["x"], other["x"])
                    ny = min(current["y"], other["y"])
                    nx2 = max(
                        current["x"] + current["width"],
                        other["x"] + other["width"],
                    )
                    ny2 = max(
                        current["y"] + current["height"],
                        other["y"] + other["height"],
                    )
                    current = {
                        "x": max(0, nx),
                        "y": max(0, ny),
                        "width": min(width, nx2) - max(0, nx),
                        "height": min(height, ny2) - max(0, ny),
                        "pixel_count": current["pixel_count"] + other["pixel_count"],
                    }
                    used.add(j)
                    changed = True
            new_merged.append(current)
            used.add(i)
        merged = new_merged

    return merged


def _compare_pixels_numpy(
    img1: "Image.Image",
    img2: "Image.Image",
    threshold: float,
    ignore_mask_coords: set,
    aa_pixels: set,
    diff_color: Tuple[int, int, int, int],
) -> Tuple[int, list, "Image.Image"]:
    """Vectorized pixel comparison using numpy (H-140).

    Replaces the pure-Python pixel-by-pixel loop with numpy array
    operations for a ~100-600x speedup on typical image sizes.

    Args:
        img1: First RGBA image
        img2: Second RGBA image
        threshold: Color difference threshold
        ignore_mask_coords: Set of (x, y) coords to ignore
        aa_pixels: Set of (x, y) anti-aliased pixel coords
        diff_color: RGBA color for diff overlay

    Returns:
        Tuple of (diff_pixel_count, diff_coords_list, diff_image)
    """
    width, height = img1.size
    arr1 = np.array(img1, dtype=np.float64)[:, :, :3]  # H x W x 3 (RGB)
    arr2 = np.array(img2, dtype=np.float64)[:, :, :3]

    dr = arr1[:, :, 0] - arr2[:, :, 0]
    dg = arr1[:, :, 1] - arr2[:, :, 1]
    db = arr1[:, :, 2] - arr2[:, :, 2]

    # YIQ perceptual distance (same coefficients as _color_delta_yiq)
    dy = dr * 0.29889531 + dg * 0.58662247 + db * 0.11448223
    di = dr * 0.59597799 - dg * 0.27417610 - db * 0.32180189
    dq = dr * 0.21147017 - dg * 0.52261711 + db * 0.31114694

    delta = 0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq

    color_threshold_sq = threshold * threshold * 35215
    diff_mask = delta > color_threshold_sq  # H x W boolean

    # Zero out ignored / AA pixels (fall back to coordinate sets)
    if ignore_mask_coords or aa_pixels:
        excluded = ignore_mask_coords | aa_pixels
        for x, y in excluded:
            if 0 <= y < height and 0 <= x < width:
                diff_mask[y, x] = False

    diff_pixels = int(np.count_nonzero(diff_mask))
    diff_coords = list(zip(*np.where(diff_mask)))  # list of (y, x)
    diff_coords_xy = [(x, y) for y, x in diff_coords]

    # Build diff image
    diff_arr = np.zeros((height, width, 4), dtype=np.uint8)
    diff_arr[diff_mask] = diff_color
    diff_img = Image.fromarray(diff_arr, "RGBA")

    return diff_pixels, diff_coords_xy, diff_img


def compare_images_ssim(
    baseline: bytes,
    current: bytes,
    max_diff_percentage: float = 0.1,
) -> DiffResult:
    """Compare two images using Structural Similarity Index (SSIM) (H-141).

    SSIM is a perceptual metric that considers luminance, contrast, and
    structure. It often correlates better with human perception than
    pixel-level comparison.

    Requires scikit-image: pip install scikit-image

    Args:
        baseline: Baseline image as PNG bytes
        current: Current image as PNG bytes
        max_diff_percentage: Maximum diff percentage to consider a match

    Returns:
        DiffResult with SSIM-based comparison
    """
    if not _SSIM_AVAILABLE:
        logger.error("scikit-image not available. Install: pip install scikit-image")
        return DiffResult(
            status=DiffStatus.ERROR,
            diff_percentage=0,
            diff_pixels=0,
            total_pixels=0,
        )
    if not _PIL_AVAILABLE:
        logger.error("PIL not available. Install: pip install Pillow")
        return DiffResult(
            status=DiffStatus.ERROR,
            diff_percentage=0,
            diff_pixels=0,
            total_pixels=0,
        )

    try:
        img1 = Image.open(io.BytesIO(baseline)).convert("L")  # grayscale
        img2 = Image.open(io.BytesIO(current)).convert("L")

        if img1.size != img2.size:
            return DiffResult(
                status=DiffStatus.SIZE_MISMATCH,
                diff_percentage=100.0,
                diff_pixels=max(img1.size[0] * img1.size[1], img2.size[0] * img2.size[1]),
                total_pixels=max(img1.size[0] * img1.size[1], img2.size[0] * img2.size[1]),
                baseline_size=img1.size,
                current_size=img2.size,
            )

        arr1 = np.array(img1)
        arr2 = np.array(img2)

        # Compute SSIM; full=True returns the per-pixel SSIM map
        score, ssim_map = _ssim_func(arr1, arr2, full=True)

        # Convert SSIM score (1.0 = identical) to diff percentage
        diff_percentage = (1.0 - score) * 100.0
        total_pixels = arr1.shape[0] * arr1.shape[1]

        # Count pixels below a local SSIM threshold (< 0.9)
        diff_pixels = int(np.count_nonzero(ssim_map < 0.9))

        status = DiffStatus.MATCH if diff_percentage < max_diff_percentage else DiffStatus.DIFF

        return DiffResult(
            status=status,
            diff_percentage=diff_percentage,
            diff_pixels=diff_pixels,
            total_pixels=total_pixels,
            baseline_size=img1.size,
            current_size=img2.size,
        )

    except Exception as e:
        logger.error(f"SSIM comparison failed: {e}")
        return DiffResult(
            status=DiffStatus.ERROR,
            diff_percentage=0,
            diff_pixels=0,
            total_pixels=0,
        )


class VisualDiff:
    """High-level visual diff interface.

    Usage:
        differ = VisualDiff(threshold=0.1)
        result = differ.compare(baseline_bytes, current_bytes)
        if result.has_changes:
            save_diff_image(result.diff_image)
    """

    def __init__(
        self,
        threshold: float = 0.1,
        anti_aliasing: bool = True,
        diff_color: Tuple[int, int, int, int] = (255, 0, 255, 255),
        max_diff_percentage: float = 0.1,
        max_diff_pixels: Optional[int] = None,
        size_tolerance: int = 0,
    ):
        """Initialize visual differ.

        Args:
            threshold: Color difference threshold (0.0-1.0)
            anti_aliasing: Ignore anti-aliasing differences
            diff_color: RGBA color for diff highlighting
            max_diff_percentage: Maximum % of pixels that can differ and still pass
            max_diff_pixels: Maximum absolute pixel count that can differ and still pass
            size_tolerance: Maximum pixel difference in width/height to still
                attempt overlap comparison (P7-F7). Default 0 = strict match.
        """
        self.threshold = threshold
        self.anti_aliasing = anti_aliasing
        self.diff_color = diff_color
        self.max_diff_percentage = max_diff_percentage
        self.max_diff_pixels = max_diff_pixels
        self.size_tolerance = size_tolerance

    def compare(
        self,
        baseline: bytes,
        current: bytes,
        ignore_regions: list = None,
    ) -> DiffResult:
        """Compare baseline and current images.

        Args:
            baseline: Baseline PNG bytes
            current: Current PNG bytes
            ignore_regions: Regions to ignore [(x, y, w, h), ...]

        Returns:
            DiffResult
        """
        return compare_images(
            baseline=baseline,
            current=current,
            threshold=self.threshold,
            diff_color=self.diff_color,
            anti_aliasing=self.anti_aliasing,
            ignore_regions=ignore_regions,
            max_diff_percentage=self.max_diff_percentage,
            max_diff_pixels=self.max_diff_pixels,
            size_tolerance=self.size_tolerance,
        )

    def compare_ssim(
        self,
        baseline: bytes,
        current: bytes,
    ) -> DiffResult:
        """Compare images using SSIM (Structural Similarity Index).

        Requires scikit-image. Falls back to pixel comparison if unavailable.

        Args:
            baseline: Baseline PNG bytes
            current: Current PNG bytes

        Returns:
            DiffResult with SSIM-based comparison
        """
        if _SSIM_AVAILABLE and _NUMPY_AVAILABLE:
            return compare_images_ssim(
                baseline=baseline,
                current=current,
                max_diff_percentage=self.max_diff_percentage,
            )
        # Fall back to pixel-level comparison
        logger.info("SSIM not available, falling back to pixel comparison")
        return self.compare(baseline, current)

    def create_side_by_side(
        self,
        baseline: bytes,
        current: bytes,
        diff: bytes,
    ) -> bytes:
        """Create side-by-side comparison image.

        Args:
            baseline: Baseline PNG
            current: Current PNG
            diff: Diff overlay PNG

        Returns:
            Combined image as PNG bytes
        """
        if not _PIL_AVAILABLE:
            return diff

        try:
            img_baseline = Image.open(io.BytesIO(baseline)).convert("RGBA")
            img_current = Image.open(io.BytesIO(current)).convert("RGBA")
            img_diff = Image.open(io.BytesIO(diff)).convert("RGBA")

            width = img_baseline.width
            height = img_baseline.height

            # Create combined image (baseline | current | diff)
            combined = Image.new("RGBA", (width * 3 + 20, height + 40), (30, 30, 30, 255))

            # Add labels
            draw = ImageDraw.Draw(combined)
            draw.text((width // 2 - 30, 5), "Baseline", fill=(200, 200, 200, 255))
            draw.text((width + 10 + width // 2 - 30, 5), "Current", fill=(200, 200, 200, 255))
            draw.text((width * 2 + 20 + width // 2 - 20, 5), "Diff", fill=(255, 100, 100, 255))

            # Paste images
            combined.paste(img_baseline, (0, 30))
            combined.paste(img_current, (width + 10, 30))
            combined.paste(img_diff, (width * 2 + 20, 30))

            # Convert to bytes
            buffer = io.BytesIO()
            combined.save(buffer, format="PNG")
            return buffer.getvalue()

        except Exception as e:
            logger.error(f"Failed to create side-by-side: {e}")
            return diff
