# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for Moondream3 multimodal processing.

Includes:
- Processor creation and application tests
- Image tokenization and placeholder expansion tests
- Tiling and cropping logic tests (CPU-based)
- Pixel normalization tests
"""

import numpy as np
import pytest
import torch

from vllm.multimodal import MULTIMODAL_REGISTRY

from ....conftest import ImageTestAssets
from ...utils import build_model_context

MOONDREAM3_MODEL_ID = "moondream/moondream3-preview"
# Expected multimodal prefix: BOS + 729 image tokens.
EXPECTED_IMAGE_TOKENS = 730
# Vision encoder constants
CROP_SIZE = 378
PATCH_SIZE = 14
MAX_CROPS = 12


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_processor_creation(model_id: str):
    """Test that Moondream3 processor can be created."""
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
    assert processor is not None


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_processor_apply(
    image_assets: ImageTestAssets,
    model_id: str,
):
    """Test that Moondream3 processor can process inputs.

    NOTE: The prompt includes the leading BOS token because Moondream3
    pre-fills BOS and image embeddings together.
    """
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)

    prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>"  # noqa: E501
    mm_data = {"image": [image_assets[0].pil_image]}

    processed_inputs = processor(
        prompt,
        mm_items=processor.info.parse_mm_data(mm_data),
        hf_processor_mm_kwargs={},
    )

    assert "prompt_token_ids" in processed_inputs
    image_placeholders = processed_inputs["mm_placeholders"]["image"]
    assert len(image_placeholders) == 1
    assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_processor_pixel_values(
    image_assets: ImageTestAssets,
    model_id: str,
):
    """Test that pixel values are correctly produced."""
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)

    prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>"  # noqa: E501
    mm_data = {"image": [image_assets[0].pil_image]}

    processed_inputs = processor(
        prompt,
        mm_items=processor.info.parse_mm_data(mm_data),
        hf_processor_mm_kwargs={},
    )

    # Check mm_kwargs contains pixel_values
    mm_kwargs = processed_inputs.get("mm_kwargs")
    assert mm_kwargs is not None
    mm_data_result = mm_kwargs.get_data()
    assert "pixel_values" in mm_data_result

    # Verify pixel_values shape
    pixel_values = mm_data_result["pixel_values"]
    assert pixel_values.dim() == 5  # [batch, num_crops, C, H, W]
    assert pixel_values.shape[2] == 3  # RGB channels
    assert pixel_values.shape[3] == 378  # crop height
    assert pixel_values.shape[4] == 378  # crop width


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_processor_image_token_expansion(
    image_assets: ImageTestAssets,
    model_id: str,
):
    """Test that <image> placeholder is expanded to correct number of tokens."""
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)

    prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>Describe.<|md_reserved_2|>"  # noqa: E501
    mm_data = {"image": [image_assets[0].pil_image]}

    processed_inputs = processor(
        prompt,
        mm_items=processor.info.parse_mm_data(mm_data),
        hf_processor_mm_kwargs={},
    )
    image_placeholders = processed_inputs["mm_placeholders"]["image"]
    assert len(image_placeholders) == 1
    assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_multi_crop_tiling(
    model_id: str,
):
    """Test that large images produce correct multi-crop tiling."""
    from PIL import Image

    from vllm.transformers_utils.processors.moondream3 import Moondream3Processor

    processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)

    # Create a large image that requires multiple crops
    large_image = Image.new("RGB", (1000, 1000), color="blue")
    pixel_values, tiling = processor.preprocess_image(large_image)

    # Large images should produce more than 1x1 tiling
    assert tiling[0] >= 1 and tiling[1] >= 1
    # Check that we have global crop + local crops
    expected_crops = tiling[0] * tiling[1] + 1
    assert pixel_values.shape[0] == expected_crops


@pytest.mark.parametrize(
    "image_size",
    [
        (500, 500),
        (800, 600),
        (1920, 1080),
    ],
)
@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_tiling_various_sizes(
    image_size: tuple[int, int],
    model_id: str,
):
    """Test tiling with various image sizes."""
    from PIL import Image

    from vllm.transformers_utils.processors.moondream3 import Moondream3Processor

    processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)

    width, height = image_size
    image = Image.new("RGB", (width, height), color="red")
    pixel_values, tiling = processor.preprocess_image(image)

    # Basic shape checks
    assert pixel_values.dim() == 4  # [num_crops, C, H, W]
    assert pixel_values.shape[1] == 3  # RGB
    assert pixel_values.shape[2] == 378  # crop height
    assert pixel_values.shape[3] == 378  # crop width

    # Tiling should respect max_crops (12)
    assert tiling[0] * tiling[1] <= 12


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_pixel_normalization(
    model_id: str,
):
    """Test that pixel values are normalized to [-1, 1] range."""
    from PIL import Image

    from vllm.transformers_utils.processors.moondream3 import Moondream3Processor

    processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)

    # Create test image
    image = Image.new("RGB", (378, 378), color="green")
    pixel_values, _ = processor.preprocess_image(image)

    # Normalization: (x - 0.5) / 0.5 = 2*x - 1
    # For input [0, 1], output should be [-1, 1]
    assert pixel_values.min() >= -1.0
    assert pixel_values.max() <= 1.0


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_chat_template_with_image(
    image_assets: ImageTestAssets,
    model_id: str,
):
    """Test that chat template correctly formats BOS + image + prompt."""
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
    tokenizer = ctx.tokenizer

    # Use the chat template format
    prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>"  # noqa: E501
    mm_data = {"image": [image_assets[0].pil_image]}

    processed_inputs = processor(
        prompt,
        mm_items=processor.info.parse_mm_data(mm_data),
        hf_processor_mm_kwargs={},
    )
    token_ids = processed_inputs["prompt_token_ids"]

    # BOS token (<|endoftext|>) should be token ID 0
    bos_token_id = tokenizer.encode("<|endoftext|>", add_special_tokens=False)[0]
    assert bos_token_id == 0

    # First token should be BOS
    assert token_ids[0] == bos_token_id


@pytest.mark.parametrize(
    "content",
    [
        pytest.param(
            [
                {
                    "type": "image_url",
                    "image_url": {"url": "https://example.invalid/image.png"},
                },
                {"type": "text", "text": "What is in this image?"},
            ],
            id="image-first",
        ),
        pytest.param(
            [
                {"type": "text", "text": "What is in this image?"},
                {
                    "type": "image_url",
                    "image_url": {"url": "https://example.invalid/image.png"},
                },
            ],
            id="text-first",
        ),
    ],
)
@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_chat_template_content_list_uses_moondream_image_prefix(
    image_assets: ImageTestAssets,
    content: list[dict[str, object]],
    model_id: str,
):
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
    hf_processor = processor.info.get_hf_processor()

    prompt = hf_processor.tokenizer.apply_chat_template(
        [{"role": "user", "content": content}],
        chat_template=hf_processor.chat_template,
        tokenize=False,
    )

    expected_prompt = (
        "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>"
        "What is in this image?<|md_reserved_2|>"
    )
    assert prompt == expected_prompt

    processed_inputs = processor(
        prompt,
        mm_items=processor.info.parse_mm_data({"image": [image_assets[0].pil_image]}),
        hf_processor_mm_kwargs={},
    )
    image_placeholders = processed_inputs["mm_placeholders"]["image"]
    assert len(image_placeholders) == 1
    assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_bos_token_always_first(
    image_assets: ImageTestAssets,
    model_id: str,
):
    """Test that BOS token (ID 0) is always at position 0."""
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)

    # Start with BOS token explicitly
    prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>Describe this image.<|md_reserved_2|>"  # noqa: E501
    mm_data = {"image": [image_assets[0].pil_image]}

    processed_inputs = processor(
        prompt,
        mm_items=processor.info.parse_mm_data(mm_data),
        hf_processor_mm_kwargs={},
    )
    token_ids = processed_inputs["prompt_token_ids"]

    # Token ID 0 (<|endoftext|>) should be the first token
    assert token_ids[0] == 0, (
        f"Expected BOS token (0) at position 0, got {token_ids[0]}"
    )


@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_processor_with_small_image(
    model_id: str,
):
    """Test processor with image smaller than crop size."""
    from PIL import Image

    from vllm.transformers_utils.processors.moondream3 import Moondream3Processor

    processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)

    # Small image (smaller than crop size)
    small_image = Image.new("RGB", (100, 100), color="yellow")
    pixel_values, tiling = processor.preprocess_image(small_image)

    # Small images should use 1x1 tiling
    assert tiling == (1, 1)
    # Should have 2 crops (global + 1 local)
    assert pixel_values.shape[0] == 2


@pytest.mark.parametrize(
    "image_kind",
    [
        pytest.param("numpy_hwc", id="numpy-hwc"),
        pytest.param("numpy_chw", id="numpy-chw"),
        pytest.param("torch_chw", id="torch-chw"),
    ],
)
@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_preprocess_image_accepts_non_pil_inputs(
    image_assets: ImageTestAssets,
    image_kind: str,
    model_id: str,
):
    from vllm.transformers_utils.processors.moondream3 import Moondream3Processor

    processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)
    pil_image = image_assets[0].pil_image.convert("RGB")
    hwc_array = np.asarray(pil_image)
    expected_pixel_values, expected_tiling = processor.preprocess_image(pil_image)

    if image_kind == "numpy_hwc":
        image = hwc_array
    elif image_kind == "numpy_chw":
        image = np.transpose(hwc_array, (2, 0, 1))
    else:
        image = torch.from_numpy(np.transpose(hwc_array, (2, 0, 1)).copy())

    pixel_values, tiling = processor.preprocess_image(image)

    assert tiling == expected_tiling
    assert pixel_values.shape == expected_pixel_values.shape
    assert pixel_values.dtype == torch.bfloat16
    assert torch.equal(pixel_values, expected_pixel_values)


@pytest.mark.parametrize("image_kind", ["numpy_chw", "torch_chw"])
@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
def test_processor_apply_accepts_non_pil_image_inputs(
    image_assets: ImageTestAssets,
    image_kind: str,
    model_id: str,
):
    ctx = build_model_context(
        model_id,
        limit_mm_per_prompt={"image": 1},
    )
    processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)

    prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>"  # noqa: E501
    hwc_array = np.asarray(image_assets[0].pil_image.convert("RGB"))
    chw_array = np.transpose(hwc_array, (2, 0, 1)).copy()
    image = chw_array if image_kind == "numpy_chw" else torch.from_numpy(chw_array)

    processed_inputs = processor(
        prompt,
        mm_items=processor.info.parse_mm_data({"image": [image]}),
        hf_processor_mm_kwargs={},
    )

    image_placeholders = processed_inputs["mm_placeholders"]["image"]
    assert len(image_placeholders) == 1
    assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS

    mm_kwargs = processed_inputs["mm_kwargs"].get_data()
    assert mm_kwargs["pixel_values"].shape[2:] == (3, 378, 378)


class TestMoondream3TilingLogic:
    """CPU-based tests for Moondream3 tiling selection logic.

    These tests validate the select_tiling() function which determines
    how images are divided into crops for the vision encoder.
    """

    def test_small_image_no_tiling(self):
        """Small images should use 1x1 tiling."""
        from vllm.transformers_utils.processors.moondream3 import select_tiling

        tiling = select_tiling(
            height=300, width=300, crop_size=CROP_SIZE, max_crops=MAX_CROPS
        )
        assert tiling == (1, 1)

    def test_exact_crop_size(self):
        """Image exactly at crop size should use 1x1."""
        from vllm.transformers_utils.processors.moondream3 import select_tiling

        tiling = select_tiling(
            height=CROP_SIZE, width=CROP_SIZE, crop_size=CROP_SIZE, max_crops=MAX_CROPS
        )
        assert tiling == (1, 1)

    def test_large_square_image(self):
        """Large square image should use multiple tiles."""
        from vllm.transformers_utils.processors.moondream3 import select_tiling

        tiling = select_tiling(
            height=800, width=800, crop_size=CROP_SIZE, max_crops=MAX_CROPS
        )
        h_tiles, w_tiles = tiling
        assert h_tiles >= 2
        assert w_tiles >= 2
        assert h_tiles * w_tiles <= MAX_CROPS

    def test_wide_image(self):
        """Wide image should have more width tiles."""
        from vllm.transformers_utils.processors.moondream3 import select_tiling

        tiling = select_tiling(
            height=400, width=1200, crop_size=CROP_SIZE, max_crops=MAX_CROPS
        )
        h_tiles, w_tiles = tiling
        assert w_tiles >= h_tiles

    def test_tall_image(self):
        """Tall image should have more height tiles."""
        from vllm.transformers_utils.processors.moondream3 import select_tiling

        tiling = select_tiling(
            height=1200, width=400, crop_size=CROP_SIZE, max_crops=MAX_CROPS
        )
        h_tiles, w_tiles = tiling
        assert h_tiles >= w_tiles

    def test_respects_max_crops(self):
        """Tiling should not exceed max_crops."""
        from vllm.transformers_utils.processors.moondream3 import select_tiling

        tiling = select_tiling(
            height=2000, width=2000, crop_size=CROP_SIZE, max_crops=4
        )
        h_tiles, w_tiles = tiling
        assert h_tiles * w_tiles <= 4


class TestMoondream3VisionShapes:
    """CPU-based tests for vision encoder expected shapes.

    These tests verify the mathematical relationships between
    crop size, patch size, and token counts.
    """

    def test_expected_patch_count(self):
        """Test 378/14 = 27 patches per side, 729 total."""
        patches_per_side = CROP_SIZE // PATCH_SIZE
        total_patches = patches_per_side**2

        assert patches_per_side == 27
        assert total_patches == EXPECTED_IMAGE_TOKENS - 1

    def test_patch_embedding_input_dim(self):
        """Test patch embedding input dimension."""
        channels = 3
        input_dim = PATCH_SIZE * PATCH_SIZE * channels

        assert input_dim == 14 * 14 * 3
        assert input_dim == 588


class TestMoondream3TauAttention:
    """CPU-based tests for tau attention scaling components.

    These tests validate the tau attention formula used in Moondream3:
    - Token-based: tok_q = tanh(gelu(qkv) @ tau_wq.T)
    - Position-based: tau_pos = 1 + (sigmoid(alpha * log(pos+1)) - 0.5)
    """

    def test_tau_position_range(self):
        """Test tau position scaling produces values in valid range."""
        num_heads = 32
        seq_len = 100

        tau_alpha = torch.randn(num_heads)
        positions = torch.arange(seq_len)

        pos_float = (positions.float() + 1.0).clamp(min=1e-6)
        pos_log = pos_float.log()
        tau_pos = 1.0 + (torch.sigmoid(tau_alpha[:, None] * pos_log[None, :]) - 0.5)

        assert tau_pos.shape == (num_heads, seq_len)
        # tau_pos should be between 0.5 and 1.5
        assert tau_pos.min() >= 0.5
        assert tau_pos.max() <= 1.5

    def test_tau_token_output_range(self):
        """Test tau token scaling output is bounded by tanh."""
        import torch.nn.functional as F

        seq_len = 100
        qkv_dim = 6144  # 2048 * 3
        num_heads = 32

        qkv = torch.randn(seq_len, qkv_dim)
        tau_wq = torch.randn(num_heads, qkv_dim)

        tok_feat = F.gelu(qkv)
        tok_q = torch.tanh(tok_feat @ tau_wq.t())

        assert tok_q.shape == (seq_len, num_heads)
        # tanh output is bounded by [-1, 1]
        assert tok_q.min() >= -1.0
        assert tok_q.max() <= 1.0
