"""
Tests for page_crop.py — content-based crop algorithm.

Tests cover:
- Edge detection via ink projections
- Spine shadow detection for book scans
- Narrow run filtering
- Paper format detection
- Sanity checks (min area, min border)
- End-to-end crop on synthetic images
"""

import numpy as np
import pytest

from ocr.pipeline.page_crop import (
    detect_and_crop_page,
    detect_page_splits,
    _detect_format,
    _detect_edge_projection,
    _detect_gutter_continuity,
    _detect_left_edge_shadow,
    _detect_right_edge_shadow,
    _detect_spine_shadow,
    _filter_narrow_runs,
)


# ---------------------------------------------------------------------------
# Helper: create synthetic images
# ---------------------------------------------------------------------------

def _make_white_image(h: int, w: int) -> np.ndarray:
    """Create a white BGR image."""
    return np.full((h, w, 3), 255, dtype=np.uint8)


def _make_image_with_content(
    h: int, w: int,
    content_rect: tuple,  # (y1, y2, x1, x2)
    bg_color: int = 255,
    content_color: int = 0,
) -> np.ndarray:
    """Create an image with a dark content rectangle on a light background."""
    img = np.full((h, w, 3), bg_color, dtype=np.uint8)
    y1, y2, x1, x2 = content_rect
    img[y1:y2, x1:x2] = content_color
    return img


def _make_book_scan(h: int = 1000, w: int = 800) -> np.ndarray:
    """Create a synthetic book scan with V-shaped spine shadow on the left.

    Left region has a V-shaped brightness dip (spine center at ~5% of width):
      x=0..spine_center: scanner bed or page edge (bright ~200) → spine (dark ~60)
      x=spine_center..shadow_end: spine (dark ~60) → white paper (bright ~240)
    Content area: scattered dark pixels (simulate text lines)
    Top/bottom 5%: white margins
    """
    img = np.full((h, w, 3), 240, dtype=np.uint8)

    # V-shaped spine shadow: center at ~5% of width
    spine_center = w * 5 // 100   # e.g. 40 for 800px
    shadow_half_w = w * 6 // 100  # e.g. 48 for 800px

    for x in range(spine_center + shadow_half_w + 1):
        dist = abs(x - spine_center)
        # Brightness dips from 200 (edge) to 60 (spine center)
        brightness = int(60 + (200 - 60) * min(dist / shadow_half_w, 1.0))
        img[:, x] = brightness

    # Content area: scatter some dark pixels (simulate text)
    content_top = h // 20      # 5% top margin
    content_bottom = h - h // 20  # 5% bottom margin
    content_left = spine_center + shadow_half_w + w // 20  # past shadow + margin
    content_right = w - w // 20  # 5% right margin

    rng = np.random.RandomState(42)
    for _ in range(500):
        y = rng.randint(content_top, content_bottom)
        x = rng.randint(content_left, content_right)
        # Small text-like blob
        y2 = min(y + 3, h)
        x2 = min(x + 10, w)
        img[y:y2, x:x2] = 20

    return img


# ---------------------------------------------------------------------------
# Tests: _filter_narrow_runs
# ---------------------------------------------------------------------------

class TestFilterNarrowRuns:
    def test_removes_short_runs(self):
        mask = np.array([False, True, True, False, False, True, False])
        result = _filter_narrow_runs(mask, min_run=3)
        # The run [True, True] (length 2) and [True] (length 1) should be removed
        assert not result.any()

    def test_keeps_long_runs(self):
        mask = np.array([False, True, True, True, True, False])
        result = _filter_narrow_runs(mask, min_run=3)
        expected = np.array([False, True, True, True, True, False])
        np.testing.assert_array_equal(result, expected)

    def test_min_run_1_keeps_all(self):
        mask = np.array([True, False, True])
        result = _filter_narrow_runs(mask, min_run=1)
        np.testing.assert_array_equal(result, mask)

    def test_empty_mask(self):
        mask = np.array([], dtype=bool)
        result = _filter_narrow_runs(mask, min_run=5)
        assert len(result) == 0

    def test_mixed_runs(self):
        mask = np.array([True, False, True, True, True, True, True, False, True, True])
        result = _filter_narrow_runs(mask, min_run=3)
        # Run of 1 at [0]: removed
        # Run of 5 at [2:7]: kept
        # Run of 2 at [8:10]: removed
        expected = np.array([False, False, True, True, True, True, True, False, False, False])
        np.testing.assert_array_equal(result, expected)


# ---------------------------------------------------------------------------
# Tests: _detect_format
# ---------------------------------------------------------------------------

class TestDetectFormat:
    def test_a4_portrait(self):
        fmt, conf = _detect_format(210, 297)
        assert fmt == "A4"
        assert conf > 0.8

    def test_a4_landscape(self):
        fmt, conf = _detect_format(297, 210)
        assert fmt == "A4"
        assert conf > 0.8

    def test_letter(self):
        fmt, conf = _detect_format(850, 1100)
        assert fmt == "Letter"
        assert conf > 0.5

    def test_unknown_square(self):
        fmt, conf = _detect_format(100, 100)
        # Aspect ratio 1.0 doesn't match any paper format well
        assert fmt == "unknown" or conf < 0.5

    def test_zero_dimensions(self):
        fmt, conf = _detect_format(0, 100)
        assert fmt == "unknown"
        assert conf == 0.0


# ---------------------------------------------------------------------------
# Tests: _detect_edge_projection
# ---------------------------------------------------------------------------

class TestDetectEdgeProjection:
    def test_finds_first_ink_column(self):
        """Binary image with ink starting at column 50."""
        binary = np.zeros((100, 200), dtype=np.uint8)
        binary[10:90, 50:180] = 255  # Content from x=50 to x=180

        edge = _detect_edge_projection(binary, axis=0, from_start=True, dim=200)
        assert edge == 50

    def test_finds_last_ink_column(self):
        binary = np.zeros((100, 200), dtype=np.uint8)
        binary[10:90, 50:180] = 255

        edge = _detect_edge_projection(binary, axis=0, from_start=False, dim=200)
        assert edge == 179  # last column with ink

    def test_finds_first_ink_row(self):
        binary = np.zeros((200, 100), dtype=np.uint8)
        binary[30:170, 10:90] = 255

        edge = _detect_edge_projection(binary, axis=1, from_start=True, dim=200)
        assert edge == 30

    def test_finds_last_ink_row(self):
        binary = np.zeros((200, 100), dtype=np.uint8)
        binary[30:170, 10:90] = 255

        edge = _detect_edge_projection(binary, axis=1, from_start=False, dim=200)
        assert edge == 169

    def test_empty_image_returns_boundary(self):
        binary = np.zeros((100, 100), dtype=np.uint8)
        assert _detect_edge_projection(binary, axis=0, from_start=True, dim=100) == 0
        assert _detect_edge_projection(binary, axis=0, from_start=False, dim=100) == 100


# ---------------------------------------------------------------------------
# Tests: _detect_left_edge_shadow
# ---------------------------------------------------------------------------

class TestDetectSpineShadow:
    def test_detects_real_spine_v_shape(self):
        """V-shaped brightness dip (real spine shadow) should be detected."""
        h, w = 500, 800
        gray = np.full((h, w), 240, dtype=np.uint8)
        # Create a V-shaped spine shadow in the left 25% (200px)
        # Center of spine at x=30, brightness dips to 80
        for x in range(80):
            dist_from_center = abs(x - 30)
            brightness = int(80 + (240 - 80) * min(dist_from_center / 40, 1.0))
            gray[:, x] = brightness

        search_region = gray[:, :200]
        result = _detect_spine_shadow(gray, search_region, 0, w, "left")
        # Should find the spine near x=30
        assert result is not None
        assert 20 <= result <= 40

    def test_rejects_text_content_edge(self):
        """Sharp text edge (white margin → dense text) should NOT trigger."""
        h, w = 500, 800
        gray = np.full((h, w), 240, dtype=np.uint8)
        # Simulate text content starting at x=60: columns 60+ have
        # alternating bright/dark rows (text lines) → mean ~170
        for x in range(60, 200):
            for y_start in range(0, h, 20):
                gray[y_start:min(y_start + 8, h), x] = 30  # text line

        search_region = gray[:, :200]
        result = _detect_spine_shadow(gray, search_region, 0, w, "left")
        # Should NOT detect a spine — this is text content, not a shadow
        assert result is None

    def test_rejects_uniform_region(self):
        """Uniform brightness region (no shadow) should NOT trigger."""
        h, w = 500, 800
        gray = np.full((h, w), 230, dtype=np.uint8)
        search_region = gray[:, :200]
        result = _detect_spine_shadow(gray, search_region, 0, w, "left")
        assert result is None

    def test_rejects_bright_minimum(self):
        """Region where darkest column is still bright (>180) should NOT trigger."""
        h, w = 500, 800
        gray = np.full((h, w), 240, dtype=np.uint8)
        # Slight variation but everything stays bright
        gray[:, 50:80] = 195
        search_region = gray[:, :200]
        result = _detect_spine_shadow(gray, search_region, 0, w, "left")
        assert result is None

    def test_right_side_spine(self):
        """V-shaped spine shadow in right search region should be detected."""
        h, w = 500, 800
        gray = np.full((h, w), 240, dtype=np.uint8)
        # Spine shadow at x=750 (right side)
        for x in range(680, 800):
            dist_from_center = abs(x - 750)
            brightness = int(80 + (240 - 80) * min(dist_from_center / 40, 1.0))
            gray[:, x] = brightness

        right_start = w - w // 4  # 600
        search_region = gray[:, right_start:]
        result = _detect_spine_shadow(gray, search_region, right_start, w, "right")
        assert result is not None
        assert 740 <= result <= 760


class TestDetectLeftEdgeShadow:
    def test_detects_shadow_gradient(self):
        """Synthetic image with left-side V-shaped shadow gradient."""
        h, w = 500, 400
        gray = np.full((h, w), 240, dtype=np.uint8)
        binary = np.zeros((h, w), dtype=np.uint8)

        # V-shaped shadow: center at x=20, dips to brightness 60
        shadow_center = 20
        shadow_half_w = 30
        for x in range(shadow_center + shadow_half_w):
            dist = abs(x - shadow_center)
            brightness = int(60 + (240 - 60) * min(dist / shadow_half_w, 1.0))
            gray[:, x] = brightness

        # Content starts after shadow
        binary[:, shadow_center + shadow_half_w + 10:w - 10] = 255

        edge = _detect_left_edge_shadow(gray, binary, w, h)
        # Edge should be near the spine center (x~20)
        assert 10 <= edge <= 35

    def test_no_shadow_uses_binary_fallback(self):
        """When shadow range is small, falls back to binary projection."""
        h, w = 400, 400
        gray = np.full((h, w), 200, dtype=np.uint8)
        binary = np.zeros((h, w), dtype=np.uint8)
        # Content block from x=80 onward (large enough to survive noise filtering)
        binary[50:350, 80:380] = 255

        edge = _detect_left_edge_shadow(gray, binary, w, h)
        # Should find content start via projection fallback (near x=80)
        assert edge <= 85

    def test_text_content_uses_binary_fallback(self):
        """Dense text in left region should NOT trigger spine detection."""
        h, w = 500, 800
        gray = np.full((h, w), 240, dtype=np.uint8)
        binary = np.zeros((h, w), dtype=np.uint8)

        # Simulate text content from x=50 onward
        for x in range(50, w - 20):
            for y_start in range(20, h - 20, 20):
                gray[y_start:min(y_start + 8, h), x] = 30
                binary[y_start:min(y_start + 8, h), x] = 255

        edge = _detect_left_edge_shadow(gray, binary, w, h)
        # Should use binary fallback and find content at ~x=50
        assert 40 <= edge <= 60


# ---------------------------------------------------------------------------
# Tests: detect_and_crop_page (end-to-end)
# ---------------------------------------------------------------------------

class TestDetectAndCropPage:
    def test_no_crop_needed_all_content(self):
        """Image that is all content — no borders to crop."""
        img = np.full((100, 80, 3), 40, dtype=np.uint8)  # Dark content everywhere
        cropped, result = detect_and_crop_page(img)
        # Should return original (all borders < 2%)
        assert not result["crop_applied"]
        assert result["cropped_size"] == {"width": 80, "height": 100}

    def test_crops_white_borders(self):
        """Image with white borders around dark content."""
        h, w = 400, 300
        # Content area big enough to pass the 40% sanity check
        img = _make_image_with_content(h, w, (40, 360, 30, 270))

        cropped, result = detect_and_crop_page(img)
        assert result["crop_applied"]
        # Cropped size should be close to the content area (with margin)
        assert result["cropped_size"]["width"] < w
        assert result["cropped_size"]["height"] < h

    def test_book_scan_detects_spine_shadow(self):
        """Synthetic book scan with spine shadow on left."""
        img = _make_book_scan(1000, 800)
        cropped, result = detect_and_crop_page(img)

        # Should crop the spine shadow area
        left_border = result["border_fractions"]["left"]
        # Spine center is at ~5% of width, so left border should be >= 4%
        assert left_border >= 0.04  # At least 4% left border detected

    def test_sanity_check_too_small_crop(self):
        """If detected content area is too small, skip crop."""
        h, w = 500, 500
        # Tiny content area (5x5 pixels) — should fail sanity check
        img = _make_white_image(h, w)
        # Add tiny dark spot
        img[248:253, 248:253] = 0

        cropped, result = detect_and_crop_page(img)
        # Should either not crop or crop is too small (< 40%)
        if result["crop_applied"]:
            crop_area = result["cropped_size"]["width"] * result["cropped_size"]["height"]
            assert crop_area >= 0.4 * h * w

    def test_crop_preserves_content(self):
        """Verify that content is preserved after cropping."""
        h, w = 300, 200
        img = _make_image_with_content(h, w, (50, 250, 40, 160))
        cropped, result = detect_and_crop_page(img)

        if result["crop_applied"]:
            # Cropped image should contain dark pixels (content)
            gray = np.mean(cropped, axis=2)
            assert np.min(gray) < 50  # Content is dark

    def test_result_structure(self):
        """Verify all expected keys are present in result dict."""
        img = _make_white_image(100, 100)
        _, result = detect_and_crop_page(img)

        assert "crop_applied" in result
        assert "original_size" in result
        assert "cropped_size" in result
        assert "border_fractions" in result
        assert "detected_format" in result
        assert "format_confidence" in result
        assert "aspect_ratio" in result

    def test_margin_parameter(self):
        """Custom margin_frac should affect crop bounds."""
        h, w = 400, 300
        img = _make_image_with_content(h, w, (80, 320, 60, 240))

        _, result_small = detect_and_crop_page(img, margin_frac=0.005)
        _, result_large = detect_and_crop_page(img, margin_frac=0.05)

        if result_small["crop_applied"] and result_large["crop_applied"]:
            # Larger margin should produce a larger crop
            small_area = result_small["cropped_size"]["width"] * result_small["cropped_size"]["height"]
            large_area = result_large["cropped_size"]["width"] * result_large["cropped_size"]["height"]
            assert large_area >= small_area

    def test_crop_rect_pct_values(self):
        """crop_rect_pct values should be in 0-100 range."""
        h, w = 400, 300
        img = _make_image_with_content(h, w, (80, 320, 60, 240))
        _, result = detect_and_crop_page(img)

        if result["crop_applied"] and result["crop_rect_pct"]:
            pct = result["crop_rect_pct"]
            assert 0 <= pct["x"] <= 100
            assert 0 <= pct["y"] <= 100
            assert 0 < pct["width"] <= 100
            assert 0 < pct["height"] <= 100


class TestCropDeterminism:
    """A3: Verify that page crop produces identical results across N runs."""

    @pytest.mark.parametrize("image_factory,desc", [
        (
            lambda: _make_image_with_content(800, 600, (100, 700, 80, 520)),
            "standard content",
        ),
        (
            lambda: _make_book_scan(1000, 800),
            "book scan with spine shadow",
        ),
    ])
    def test_determinism_10_runs(self, image_factory, desc):
        """Same image must produce identical crops in 10 consecutive runs."""
        img = image_factory()
        results = []
        for _ in range(10):
            cropped, result = detect_and_crop_page(img.copy())
            results.append({
                "crop_applied": result["crop_applied"],
                "cropped_size": result["cropped_size"],
                "border_fractions": result["border_fractions"],
                "shape": cropped.shape,
            })

        first = results[0]
        for i, r in enumerate(results[1:], 1):
            assert r["crop_applied"] == first["crop_applied"], (
                f"Run {i} crop_applied differs from run 0 ({desc})"
            )
            assert r["cropped_size"] == first["cropped_size"], (
                f"Run {i} cropped_size differs from run 0 ({desc})"
            )
            assert r["shape"] == first["shape"], (
                f"Run {i} output shape differs from run 0 ({desc})"
            )

    def test_determinism_pixel_identical(self):
        """Crop output pixels must be identical across runs."""
        img = _make_image_with_content(800, 600, (100, 700, 80, 520))
        ref_crop, _ = detect_and_crop_page(img.copy())

        for i in range(5):
            crop, _ = detect_and_crop_page(img.copy())
            assert np.array_equal(ref_crop, crop), (
                f"Run {i} produced different pixel output"
            )


# ---------------------------------------------------------------------------
# Tests: detect_page_splits — spine scoring logic
# ---------------------------------------------------------------------------

def _make_book_spread(h: int = 1616, w: int = 2288) -> np.ndarray:
    """Create a synthetic landscape book spread (two pages side by side).

    Simulates the ad810209 failure case:
    - A narrow spine shadow near the center (~50% of width)
    - A wider dark area off-center (~35% of width), simulating a text column
    - Bright paper flanking the spine on both sides
    """
    img = np.full((h, w, 3), 230, dtype=np.uint8)

    # --- Spine shadow: narrow dark valley centered at x = w/2 (1144) ---
    spine_center = w // 2
    spine_half_w = 30  # ~60px wide total
    for x in range(spine_center - spine_half_w, spine_center + spine_half_w + 1):
        dist = abs(x - spine_center)
        # Brightness dips from 230 (paper) to 130 (spine)
        brightness = int(130 + (230 - 130) * min(dist / spine_half_w, 1.0))
        img[:, x] = brightness

    # --- Off-center dark area at ~35% of width (x=799), wider than spine ---
    dark_center = int(w * 0.35)
    dark_half_w = 80  # ~160px wide total (wider than spine)
    for x in range(dark_center - dark_half_w, dark_center + dark_half_w + 1):
        dist = abs(x - dark_center)
        # Brightness dips from 230 to 140 (slightly less dark than spine)
        brightness = int(140 + (230 - 140) * min(dist / dark_half_w, 1.0))
        img[:, x] = min(img[0, x, 0], brightness)  # don't overwrite spine if overlapping

    return img


class TestDetectPageSplits:
    def test_portrait_image_returns_empty(self):
        """Portrait images (width < height * 1.15) should not be split."""
        img = np.full((1000, 800, 3), 200, dtype=np.uint8)
        assert detect_page_splits(img) == []

    def test_uniform_image_returns_empty(self):
        """Uniform brightness image should not detect any spine."""
        img = np.full((800, 1600, 3), 220, dtype=np.uint8)
        assert detect_page_splits(img) == []

    def test_prefers_centered_spine_over_wider_offcenter_dark(self):
        """Scoring should pick the centered narrow spine over a wider off-center dark area.

        This is the regression test for session ad810209 where the old algorithm
        picked x=799 (35%) instead of x=1144 (50%).
        """
        img = _make_book_spread(h=1616, w=2288)
        pages = detect_page_splits(img)

        assert len(pages) == 2, f"Expected 2 pages, got {len(pages)}"

        # Split point should be near the center (x ~ 1144), not at ~799
        split_x = pages[0]["width"]  # pages[0] width = split point
        center = 2288 / 2  # 1144

        assert abs(split_x - center) < 100, (
            f"Split at x={split_x}, expected near center {center:.0f}. "
            f"Old bug would have split at ~799."
        )

    def test_split_produces_two_reasonable_pages(self):
        """Both pages should be at least 15% of total width."""
        img = _make_book_spread()
        pages = detect_page_splits(img)

        if len(pages) == 2:
            w = img.shape[1]
            for p in pages:
                assert p["width"] >= w * 0.15, (
                    f"Page {p['page_index']} too narrow: {p['width']}px "
                    f"(< {w * 0.15:.0f}px)"
                )

    def test_page_indices_sequential(self):
        """Page indices should be 0, 1, ..."""
        img = _make_book_spread()
        pages = detect_page_splits(img)
        if pages:
            indices = [p["page_index"] for p in pages]
            assert indices == list(range(len(pages)))

    def test_pages_cover_full_width(self):
        """Pages should cover the full image width without gaps or overlaps."""
        img = _make_book_spread()
        pages = detect_page_splits(img)
        if len(pages) >= 2:
            w = img.shape[1]
            assert pages[0]["x"] == 0
            total_w = sum(p["width"] for p in pages)
            assert total_w == w, f"Total page width {total_w} != image width {w}"


# ---------------------------------------------------------------------------
# Tests: _detect_gutter_continuity (camera book scans)
# ---------------------------------------------------------------------------

def _make_camera_book_scan(h: int = 2400, w: int = 1700, gutter_side: str = "right") -> np.ndarray:
    """Create a synthetic camera book scan with a subtle gutter shadow.

    Camera gutter shadows are much subtler than scanner shadows:
    - Page brightness ~250 (well-lit)
    - Gutter brightness ~210-230 (slight shadow)
    - Shadow runs continuously from top to bottom
    - Gradient is ~40px wide
    """
    img = np.full((h, w, 3), 250, dtype=np.uint8)

    # Add some variation to make it realistic
    rng = np.random.RandomState(99)

    # Subtle gutter gradient at the specified side
    gutter_w = int(w * 0.04)  # ~4% of width
    gradient_w = int(w * 0.03)  # transition zone

    if gutter_side == "right":
        gutter_start = w - gutter_w - gradient_w
        for x in range(gutter_start, w):
            dist_from_start = x - gutter_start
            # Linear gradient from 250 down to 210
            brightness = int(250 - 40 * min(dist_from_start / (gutter_w + gradient_w), 1.0))
            img[:, x] = brightness
    else:
        gutter_end = gutter_w + gradient_w
        for x in range(gutter_end):
            dist_from_edge = gutter_end - x
            brightness = int(250 - 40 * min(dist_from_edge / (gutter_w + gradient_w), 1.0))
            img[:, x] = brightness

    # Scatter some text (dark pixels) in the content area
    content_left = gutter_end + 20 if gutter_side == "left" else 50
    content_right = gutter_start - 20 if gutter_side == "right" else w - 50
    for _ in range(800):
        y = rng.randint(h // 10, h - h // 10)
        x = rng.randint(content_left, content_right)
        y2 = min(y + 3, h)
        x2 = min(x + 15, w)
        img[y:y2, x:x2] = 20

    return img


class TestDetectGutterContinuity:
    """Tests for camera gutter shadow detection via vertical continuity."""

    def test_detects_right_gutter(self):
        """Should detect a subtle gutter shadow on the right side."""
        img = _make_camera_book_scan(gutter_side="right")
        h, w = img.shape[:2]
        gray = np.mean(img, axis=2).astype(np.uint8)
        search_w = w // 4
        right_start = w - search_w
        result = _detect_gutter_continuity(
            gray, gray[:, right_start:], right_start, w, "right",
        )
        assert result is not None
        # Gutter starts roughly at 93% of width (w - 4% - 3%)
        assert result > w * 0.85, f"Gutter x={result} too far left"
        assert result < w * 0.98, f"Gutter x={result} too close to edge"

    def test_detects_left_gutter(self):
        """Should detect a subtle gutter shadow on the left side."""
        img = _make_camera_book_scan(gutter_side="left")
        h, w = img.shape[:2]
        gray = np.mean(img, axis=2).astype(np.uint8)
        search_w = w // 4
        result = _detect_gutter_continuity(
            gray, gray[:, :search_w], 0, w, "left",
        )
        assert result is not None
        assert result > w * 0.02, f"Gutter x={result} too close to edge"
        assert result < w * 0.15, f"Gutter x={result} too far right"

    def test_no_gutter_on_clean_page(self):
        """Should NOT detect a gutter on a uniformly bright page."""
        img = np.full((2000, 1600, 3), 250, dtype=np.uint8)
        # Add some text but no gutter
        rng = np.random.RandomState(42)
        for _ in range(500):
            y = rng.randint(100, 1900)
            x = rng.randint(100, 1500)
            img[y:min(y+3, 2000), x:min(x+15, 1600)] = 20
        gray = np.mean(img, axis=2).astype(np.uint8)
        w = 1600
        search_w = w // 4
        right_start = w - search_w
        result_r = _detect_gutter_continuity(gray, gray[:, right_start:], right_start, w, "right")
        result_l = _detect_gutter_continuity(gray, gray[:, :search_w], 0, w, "left")
        assert result_r is None, f"False positive on right: x={result_r}"
        assert result_l is None, f"False positive on left: x={result_l}"

    def test_integrated_with_crop(self):
        """End-to-end: detect_and_crop_page should crop at the gutter."""
        img = _make_camera_book_scan(gutter_side="right")
        cropped, result = detect_and_crop_page(img)
        # The right border should be > 0 (gutter cropped)
        right_border = result["border_fractions"]["right"]
        assert right_border > 0.01, f"Right border {right_border} — gutter not cropped"
