"""
Tests for PP-DocLayout ONNX Document Layout Detection.

Uses mocking to avoid requiring the actual ONNX model file.
"""

import numpy as np
import pytest
from unittest.mock import patch, MagicMock

# We patch the module-level globals before importing to ensure clean state
# in tests that check "no model" behaviour.

import importlib


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _fresh_import():
    """Re-import ocr.detect.doclayout_detect with reset globals."""
    import ocr.detect.doclayout_detect as mod
    # Reset module-level caching so each test starts clean
    mod._onnx_session = None
    mod._model_path = None
    mod._load_attempted = False
    mod._load_error = None
    return mod


# ---------------------------------------------------------------------------
# 1. is_doclayout_available — no model present
# ---------------------------------------------------------------------------

class TestIsDoclayoutAvailableNoModel:
    def test_returns_false_when_no_onnx_file(self):
        mod = _fresh_import()
        with patch.object(mod, "_find_model_path", return_value=None):
            assert mod.is_doclayout_available() is False

    def test_returns_false_when_onnxruntime_missing(self):
        mod = _fresh_import()
        with patch.object(mod, "_find_model_path", return_value="/fake/model.onnx"):
            with patch.dict("sys.modules", {"onnxruntime": None}):
                # Force ImportError by making import fail
                import builtins
                real_import = builtins.__import__

                def fake_import(name, *args, **kwargs):
                    if name == "onnxruntime":
                        raise ImportError("no onnxruntime")
                    return real_import(name, *args, **kwargs)

                with patch("builtins.__import__", side_effect=fake_import):
                    assert mod.is_doclayout_available() is False


# ---------------------------------------------------------------------------
# 2. LayoutRegion dataclass
# ---------------------------------------------------------------------------

class TestLayoutRegionDataclass:
    def test_basic_creation(self):
        from ocr.detect.doclayout_detect import LayoutRegion
        region = LayoutRegion(
            x=10, y=20, width=100, height=200,
            label="figure", confidence=0.95, label_index=1,
        )
        assert region.x == 10
        assert region.y == 20
        assert region.width == 100
        assert region.height == 200
        assert region.label == "figure"
        assert region.confidence == 0.95
        assert region.label_index == 1

    def test_all_fields_present(self):
        from ocr.detect.doclayout_detect import LayoutRegion
        import dataclasses
        field_names = {f.name for f in dataclasses.fields(LayoutRegion)}
        expected = {"x", "y", "width", "height", "label", "confidence", "label_index"}
        assert field_names == expected

    def test_different_labels(self):
        from ocr.detect.doclayout_detect import LayoutRegion, DOCLAYOUT_CLASSES
        for idx, label in enumerate(DOCLAYOUT_CLASSES):
            region = LayoutRegion(
                x=0, y=0, width=50, height=50,
                label=label, confidence=0.8, label_index=idx,
            )
            assert region.label == label
            assert region.label_index == idx


# ---------------------------------------------------------------------------
# 3. detect_layout_regions — no model available
# ---------------------------------------------------------------------------

class TestDetectLayoutRegionsNoModel:
    def test_returns_empty_list_when_model_unavailable(self):
        mod = _fresh_import()
        with patch.object(mod, "_find_model_path", return_value=None):
            img = np.zeros((480, 640, 3), dtype=np.uint8)
            result = mod.detect_layout_regions(img)
            assert result == []

    def test_returns_empty_list_for_none_image(self):
        mod = _fresh_import()
        with patch.object(mod, "_find_model_path", return_value=None):
            result = mod.detect_layout_regions(None)
            assert result == []

    def test_returns_empty_list_for_empty_image(self):
        mod = _fresh_import()
        with patch.object(mod, "_find_model_path", return_value=None):
            img = np.array([], dtype=np.uint8)
            result = mod.detect_layout_regions(img)
            assert result == []


# ---------------------------------------------------------------------------
# 4. Preprocessing — tensor shape verification
# ---------------------------------------------------------------------------

class TestPreprocessingShapes:
    def test_square_image(self):
        from ocr.detect.doclayout_detect import preprocess_image
        img = np.random.randint(0, 255, (800, 800, 3), dtype=np.uint8)
        tensor, scale, pad_x, pad_y = preprocess_image(img)
        assert tensor.shape == (1, 3, 800, 800)
        assert tensor.dtype == np.float32
        assert 0.0 <= tensor.min()
        assert tensor.max() <= 1.0

    def test_landscape_image(self):
        from ocr.detect.doclayout_detect import preprocess_image
        img = np.random.randint(0, 255, (600, 1200, 3), dtype=np.uint8)
        tensor, scale, pad_x, pad_y = preprocess_image(img)
        assert tensor.shape == (1, 3, 800, 800)
        # Landscape: scale by width, should have vertical padding
        expected_scale = 800 / 1200
        assert abs(scale - expected_scale) < 1e-5
        assert pad_y > 0  # vertical padding expected

    def test_portrait_image(self):
        from ocr.detect.doclayout_detect import preprocess_image
        img = np.random.randint(0, 255, (1200, 600, 3), dtype=np.uint8)
        tensor, scale, pad_x, pad_y = preprocess_image(img)
        assert tensor.shape == (1, 3, 800, 800)
        # Portrait: scale by height, should have horizontal padding
        expected_scale = 800 / 1200
        assert abs(scale - expected_scale) < 1e-5
        assert pad_x > 0  # horizontal padding expected

    def test_small_image(self):
        from ocr.detect.doclayout_detect import preprocess_image
        img = np.random.randint(0, 255, (100, 200, 3), dtype=np.uint8)
        tensor, scale, pad_x, pad_y = preprocess_image(img)
        assert tensor.shape == (1, 3, 800, 800)

    def test_typical_scan_a4(self):
        """A4 scan at 300dpi: roughly 2480x3508 pixels."""
        from ocr.detect.doclayout_detect import preprocess_image
        img = np.random.randint(0, 255, (3508, 2480, 3), dtype=np.uint8)
        tensor, scale, pad_x, pad_y = preprocess_image(img)
        assert tensor.shape == (1, 3, 800, 800)

    def test_values_normalized(self):
        from ocr.detect.doclayout_detect import preprocess_image
        # All white image
        img = np.full((400, 400, 3), 255, dtype=np.uint8)
        tensor, _, _, _ = preprocess_image(img)
        # The padded region is 114/255 ≈ 0.447, the image region is 1.0
        assert tensor.max() <= 1.0
        assert tensor.min() >= 0.0


# ---------------------------------------------------------------------------
# 5. NMS logic
# ---------------------------------------------------------------------------

class TestNmsLogic:
    def test_empty_input(self):
        from ocr.detect.doclayout_detect import nms
        boxes = np.array([]).reshape(0, 4)
        scores = np.array([])
        assert nms(boxes, scores) == []

    def test_single_box(self):
        from ocr.detect.doclayout_detect import nms
        boxes = np.array([[10, 10, 100, 100]], dtype=np.float32)
        scores = np.array([0.9])
        kept = nms(boxes, scores, iou_threshold=0.5)
        assert kept == [0]

    def test_non_overlapping_boxes(self):
        from ocr.detect.doclayout_detect import nms
        boxes = np.array([
            [0, 0, 50, 50],
            [200, 200, 300, 300],
            [400, 400, 500, 500],
        ], dtype=np.float32)
        scores = np.array([0.9, 0.8, 0.7])
        kept = nms(boxes, scores, iou_threshold=0.5)
        assert len(kept) == 3
        assert set(kept) == {0, 1, 2}

    def test_overlapping_boxes_suppressed(self):
        from ocr.detect.doclayout_detect import nms
        # Two boxes that heavily overlap
        boxes = np.array([
            [10, 10, 110, 110],   # 100x100
            [15, 15, 115, 115],   # 100x100, heavily overlapping with first
        ], dtype=np.float32)
        scores = np.array([0.95, 0.80])
        kept = nms(boxes, scores, iou_threshold=0.5)
        # Only the higher-confidence box should survive
        assert kept == [0]

    def test_partially_overlapping_boxes_kept(self):
        from ocr.detect.doclayout_detect import nms
        # Two boxes that overlap ~25% (below 0.5 threshold)
        boxes = np.array([
            [0, 0, 100, 100],     # 100x100
            [75, 0, 175, 100],    # 100x100, overlap 25x100 = 2500
        ], dtype=np.float32)
        scores = np.array([0.9, 0.8])
        # IoU = 2500 / (10000 + 10000 - 2500) = 2500/17500 ≈ 0.143
        kept = nms(boxes, scores, iou_threshold=0.5)
        assert len(kept) == 2

    def test_nms_respects_score_ordering(self):
        from ocr.detect.doclayout_detect import nms
        # Three overlapping boxes — highest confidence should be kept first
        boxes = np.array([
            [10, 10, 110, 110],
            [12, 12, 112, 112],
            [14, 14, 114, 114],
        ], dtype=np.float32)
        scores = np.array([0.5, 0.9, 0.7])
        kept = nms(boxes, scores, iou_threshold=0.5)
        # Index 1 has highest score → kept first, suppresses 0 and 2
        assert kept[0] == 1

    def test_iou_computation(self):
        from ocr.detect.doclayout_detect import _compute_iou
        box_a = np.array([0, 0, 100, 100], dtype=np.float32)
        box_b = np.array([0, 0, 100, 100], dtype=np.float32)
        assert abs(_compute_iou(box_a, box_b) - 1.0) < 1e-5

        box_c = np.array([200, 200, 300, 300], dtype=np.float32)
        assert _compute_iou(box_a, box_c) == 0.0


# ---------------------------------------------------------------------------
# 6. DOCLAYOUT_CLASSES verification
# ---------------------------------------------------------------------------

class TestDoclayoutClasses:
    def test_correct_class_list(self):
        from ocr.detect.doclayout_detect import DOCLAYOUT_CLASSES
        expected = [
            "table", "figure", "title", "text", "list",
            "header", "footer", "equation", "reference", "abstract",
        ]
        assert DOCLAYOUT_CLASSES == expected

    def test_class_count(self):
        from ocr.detect.doclayout_detect import DOCLAYOUT_CLASSES
        assert len(DOCLAYOUT_CLASSES) == 10

    def test_no_duplicates(self):
        from ocr.detect.doclayout_detect import DOCLAYOUT_CLASSES
        assert len(DOCLAYOUT_CLASSES) == len(set(DOCLAYOUT_CLASSES))

    def test_all_lowercase(self):
        from ocr.detect.doclayout_detect import DOCLAYOUT_CLASSES
        for cls in DOCLAYOUT_CLASSES:
            assert cls == cls.lower(), f"Class '{cls}' should be lowercase"


# ---------------------------------------------------------------------------
# 7. get_doclayout_status
# ---------------------------------------------------------------------------

class TestGetDoclayoutStatus:
    def test_status_when_unavailable(self):
        mod = _fresh_import()
        with patch.object(mod, "_find_model_path", return_value=None):
            status = mod.get_doclayout_status()
            assert status["available"] is False
            assert status["model_path"] is None
            assert status["load_error"] is not None
            assert status["classes"] == mod.DOCLAYOUT_CLASSES
            assert status["class_count"] == 10


# ---------------------------------------------------------------------------
# 8. Post-processing with mocked ONNX outputs
# ---------------------------------------------------------------------------

class TestPostprocessing:
    def test_single_tensor_format_6cols(self):
        """Test parsing of (1, N, 6) output format: x1,y1,x2,y2,score,class."""
        from ocr.detect.doclayout_detect import _postprocess

        # One detection: figure at (100,100)-(300,300) in 800x800 space
        raw = np.array([[[100, 100, 300, 300, 0.92, 1]]], dtype=np.float32)
        regions = _postprocess(
            outputs=[raw],
            scale=1.0, pad_x=0, pad_y=0,
            orig_w=800, orig_h=800,
            confidence_threshold=0.5,
            max_regions=50,
        )
        assert len(regions) == 1
        assert regions[0].label == "figure"
        assert regions[0].confidence >= 0.9

    def test_three_tensor_format(self):
        """Test parsing of 3-tensor output: boxes, scores, class_ids."""
        from ocr.detect.doclayout_detect import _postprocess

        boxes = np.array([[50, 50, 200, 150]], dtype=np.float32)
        scores = np.array([0.88], dtype=np.float32)
        class_ids = np.array([0], dtype=np.float32)  # table

        regions = _postprocess(
            outputs=[boxes, scores, class_ids],
            scale=1.0, pad_x=0, pad_y=0,
            orig_w=800, orig_h=800,
            confidence_threshold=0.5,
            max_regions=50,
        )
        assert len(regions) == 1
        assert regions[0].label == "table"

    def test_confidence_filtering(self):
        """Detections below threshold should be excluded."""
        from ocr.detect.doclayout_detect import _postprocess

        raw = np.array([
            [100, 100, 200, 200, 0.9, 1],   # above threshold
            [300, 300, 400, 400, 0.3, 2],   # below threshold
        ], dtype=np.float32).reshape(1, 2, 6)

        regions = _postprocess(
            outputs=[raw],
            scale=1.0, pad_x=0, pad_y=0,
            orig_w=800, orig_h=800,
            confidence_threshold=0.5,
            max_regions=50,
        )
        assert len(regions) == 1
        assert regions[0].label == "figure"

    def test_coordinate_scaling(self):
        """Verify coordinates are correctly scaled back to original image."""
        from ocr.detect.doclayout_detect import _postprocess

        # Image was 1600x1200, scaled to fit 800x800 → scale=0.5, pad_y offset
        scale = 800 / 1600  # 0.5
        pad_x = 0
        pad_y = (800 - int(1200 * scale)) // 2  # (800-600)//2 = 100

        # Detection in 800x800 space at (100, 200) to (300, 400)
        raw = np.array([[[100, 200, 300, 400, 0.95, 0]]], dtype=np.float32)

        regions = _postprocess(
            outputs=[raw],
            scale=scale, pad_x=pad_x, pad_y=pad_y,
            orig_w=1600, orig_h=1200,
            confidence_threshold=0.5,
            max_regions=50,
        )
        assert len(regions) == 1
        r = regions[0]
        # x1 = (100 - 0) / 0.5 = 200
        assert r.x == 200
        # y1 = (200 - 100) / 0.5 = 200
        assert r.y == 200

    def test_empty_output(self):
        from ocr.detect.doclayout_detect import _postprocess
        raw = np.array([]).reshape(1, 0, 6).astype(np.float32)
        regions = _postprocess(
            outputs=[raw],
            scale=1.0, pad_x=0, pad_y=0,
            orig_w=800, orig_h=800,
            confidence_threshold=0.5,
            max_regions=50,
        )
        assert regions == []
