"""
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 page_crop import (
    detect_and_crop_page,
    _detect_format,
    _detect_edge_projection,
    _detect_left_edge_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 spine shadow on the left.

    Left 10%: gradient from dark (50) to white (255)
    Top 5%: white (empty scanner border)
    Bottom 5%: white (empty scanner border)
    Center: text-like content (dark pixels scattered)
    """
    img = np.full((h, w, 3), 255, dtype=np.uint8)

    # Spine shadow: left 10% has gradient from dark to bright
    shadow_w = w // 10
    for x in range(shadow_w):
        brightness = int(50 + (255 - 50) * x / shadow_w)
        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 = shadow_w + w // 20  # past shadow + small 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 TestDetectLeftEdgeShadow:
    def test_detects_shadow_gradient(self):
        """Synthetic image with left-side shadow gradient."""
        h, w = 500, 400
        gray = np.full((h, w), 255, dtype=np.uint8)
        binary = np.zeros((h, w), dtype=np.uint8)

        # Shadow: left 15% gradually darkens
        shadow_w = w * 15 // 100
        for x in range(shadow_w):
            brightness = int(50 + (255 - 50) * x / shadow_w)
            gray[:, x] = brightness

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

        edge = _detect_left_edge_shadow(gray, binary, w, h)
        # Edge should be within the shadow transition zone
        # The 60% threshold fires before the actual shadow boundary
        assert 0 < edge < shadow_w + 20

    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


# ---------------------------------------------------------------------------
# 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 wide white borders around dark content."""
        h, w = 400, 300
        img = _make_image_with_content(h, w, (80, 320, 60, 240))

        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
        # Content should be roughly 180x240 + margins (adaptive threshold may widen slightly)
        assert 160 <= result["cropped_size"]["width"] <= 260
        assert 220 <= result["cropped_size"]["height"] <= 300

    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 shadow is ~10% of width, plus some margin
        assert left_border > 0.05  # At least 5% 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
