import json
import math
import re
import threading
from abc import ABC
from textwrap import dedent
from typing import Optional, Tuple, Union

import numpy as np
import torch
import torch.nn as nn
from PIL import Image, ImageDraw, ImageFont, ImageOps
from torchvision import transforms
from transformers.image_utils import ImageInput
from transformers.processing_utils import ProcessorMixin
from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
from transformers.utils import logging

logger = logging.get_logger(__name__)


DS_OCR_PATCH_SIZE = 16
DS_OCR_IMG_TOKEN = "<image>"
DS_OCR_DOWNSAMPLE_RATIO = 4
DS_OCR_STOP_STR = "<｜end▁of▁sentence｜>"
DS_OCR_TILE_SIZE = 640
DS_OCR_BASE_SIZE = 1024

DEFAULT_OCR_PROMPT = (
    "Transcribe the provided document image into a clean Markdown format, preserving the natural reading order."
)

DS_OCR_DEFAULT_CHAT_TEMPLATE = dedent(
    """
    {%- for message in messages %}
        {%- if message['content'] is string %}
{{ message['content'].rstrip() }}
        {%- else %}
            {%- set ns = namespace(previous_was_image=False) %}
            {%- for content in message['content'] %}
                {%- if content['type'] == 'image' %}
{{ "<image>" }}
                    {%- set ns.previous_was_image = True %}
                {%- elif content['type'] == 'text' %}
{{- ('\\n' if ns.previous_was_image else '') + content['text'].rstrip() }}
                    {%- set ns.previous_was_image = False %}
                {%- endif %}
            {%- endfor %}
        {%- endif %}
        {%- if not loop.last %}
{{ " " }}
        {%- endif %}
    {%- endfor %}
    """
).strip()


JINA_OCR_CHAT_TEMPLATE = dedent(
    """
{%- if messages and messages[0]['role'] == 'system' -%}
    {%- set system_message = messages[0]['content'] -%}
    {%- set messages = messages[1:] -%}
{%- else -%}
    {%- set system_message = '' -%}
{%- endif -%}
{{- system_message -}}
{%- if system_message %}{{ '\\n' }}{%- endif -%}
{%- for message in messages -%}
    {%- if message['role'] == 'user' -%}
        {%- set role_prefix = '<|User|>:\n' -%}
    {%- elif message['role'] == 'assistant' -%}
        {%- set role_prefix = '<|Assistant|>:\n' -%}
    {%- else -%}
        {%- set role_prefix = '<|' ~ message['role']|capitalize ~ '|>:\n' -%}
    {%- endif -%}

    {%- if message['content'] is string -%}
        {{- role_prefix + message['content'].rstrip() -}}
    {%- else -%}
        {%- set ns = namespace(prefix_printed=False, last_was_image=False) -%}
        {%- for content in message['content'] -%}
            {%- set is_image = content.get('type') in ['image', 'image_pil', 'image_url'] -%}
            {%- set content_text = '<image>' if is_image else content.get('text', '').rstrip() -%}
            {%- if ns.last_was_image and not is_image %}{{ '\\n' }}{%- endif -%}
            {%- if not ns.prefix_printed and (message['role'] == 'user' or not is_image) -%}
                {{- role_prefix -}}
                {%- set ns.prefix_printed = True -%}
            {%- endif -%}
            {{- content_text -}}
            {%- set ns.last_was_image = is_image -%}
        {%- endfor -%}
    {%- endif -%}
    {%- if not loop.last %}{{ '\\n' }}{%- endif -%}
{%- endfor -%}
{%- if messages and messages|length > 0 and messages[messages|length - 1]['role'] == 'assistant' -%}
    {{- eos_token|default('') -}}
{%- elif add_generation_prompt -%}
    {%- if messages %}{{ '\\n' }}{%- endif -%}
    {{- '<|Assistant|>:\n' -}}
{%- endif -%}
    """
).strip()


# Known element types - if label is not one of these, it's an image with alt text
KNOWN_ELEMENT_TYPES = {
    "title",
    "text",
    "paragraph",
    "button",
    "link",
    "icon",
    "header",
    "footer",
    "table",
    "list",
    "code",
    "formula",
    "caption",
    "label",
    "input",
    "checkbox",
    "radio",
    "dropdown",
    "menu",
    "navigation",
    "sidebar",
    "logo",
    "banner",
    "card",
}

# Regex pattern for matching ref/det tags
REF_DET_PATTERN = re.compile(
    r"(<\|ref\|>(.*?)<\|/ref\|><\|det\|>(.*?)<\|/det\|>)",
    re.DOTALL,
)

# Regex pattern for matching markdown image syntax: ![alt_text](**/x1_y1_x2_y2.png|jpg|jpeg)
MARKDOWN_IMAGE_PATTERN = re.compile(
    r"!\[([^\]]*)\]\([^)]*?(\d+)_(\d+)_(\d+)_(\d+)\.(?:png|jpg|jpeg)\)",
    re.DOTALL,
)


def normalize_markdown_images_to_ref_det(text: str) -> str:
    """
    Normalize markdown image syntax to ref/det format.

    Converts: ![alt_description](**/x1_y1_x2_y2.png)
    To: <|ref|>alt_description<|/ref|><|det|>[[x1,y1,x2,y2]]<|/det|>

    If alt_description is empty, uses "image" as the default label.

    Args:
        text: Input text containing markdown image syntax

    Returns:
        Text with markdown images converted to ref/det format
    """

    def replace_match(match: re.Match) -> str:
        alt_text = match.group(1).strip()
        x1 = match.group(2)
        y1 = match.group(3)
        x2 = match.group(4)
        y2 = match.group(5)

        # Use "image" as default label if alt text is empty
        label = alt_text if alt_text else "image"

        return f"<|ref|>{label}<|/ref|><|det|>[[{x1},{y1},{x2},{y2}]]<|/det|>"

    return MARKDOWN_IMAGE_PATTERN.sub(replace_match, text)


def parse_coords(coords_str: str) -> list[list[int]]:
    """
    Parse a coordinate string into a list of bounding boxes.

    Args:
        coords_str: JSON string of coordinates, e.g. "[[100,200,300,400]]" or
                    "[[100,200,300,400],[500,600,700,800]]"

    Returns:
        List of [x1, y1, x2, y2] integer coordinates.
        Empty list if parsing fails.

    Note:
        All coordinates are normalized into 1000 bins (range [0, 999]).
    """
    try:
        coords_list = json.loads(coords_str)
        return [[int(a), int(b), int(c), int(d)] for a, b, c, d in coords_list]
    except Exception:
        return []


def normalize_to_pixel(x: int, y: int, image_width: int, image_height: int) -> tuple[int, int]:
    """
    Convert normalized coordinates (0-999) to actual pixel coordinates.

    Args:
        x: Normalized x coordinate (0-999)
        y: Normalized y coordinate (0-999)
        image_width: Actual image width in pixels
        image_height: Actual image height in pixels

    Returns:
        Tuple of (pixel_x, pixel_y)

    Note:
        All coordinates are normalized into 1000 bins (range [0, 999]).
        Formula: pixel = int(normalized / 999 * dimension)
    """
    return int(x / 999 * image_width), int(y / 999 * image_height)


def coords_to_filename(x1: int, y1: int, x2: int, y2: int, ext: str = "png") -> str:
    """
    Generate a filename from bounding box coordinates.

    Args:
        x1, y1: Top-left corner (normalized 0-999)
        x2, y2: Bottom-right corner (normalized 0-999)
        ext: File extension (default: "png")

    Returns:
        Filename string like "100_200_300_400.png"
    """
    return f"{x1}_{y1}_{x2}_{y2}.{ext}"


def is_image_label(label: str) -> bool:
    """
    Determine if a label represents an image (i.e., not a known element type).

    Args:
        label: The label string from a ref tag

    Returns:
        True if this is an image (label is NOT a known element type),
        False if it's a known UI element type.

    Note:
        When True, the label serves as the alt text for the image in markdown.
    """
    return label.lower() not in KNOWN_ELEMENT_TYPES


def parse_refs(text: str) -> list[dict]:
    """
    Extract labeled bounding box references from text.

    Matches patterns like:
        <|ref|>label<|/ref|><|det|>[[x1,y1,x2,y2]]<|/det|>                    # single bounding box
        <|ref|>label<|/ref|><|det|>[[x1,y1,x2,y2],[x1,y1,x2,y2]]<|/det|>     # multiple bounding boxes

    Coordinate System:
        All coordinates are normalized into 1000 bins (range [0, 999]),
        representing positions relative to the original image dimensions.

        - (x1, y1): Top-left corner of the bounding box
        - (x2, y2): Bottom-right corner of the bounding box

        To convert to actual pixel coordinates, use normalize_to_pixel().

        Visual representation:
            (0,0) ─────────────────────────────► x (width)
              │
              │    (x1,y1) ┌────────────┐
              │            │            │
              │            │  element   │
              │            │            │
              │            └────────────┘ (x2,y2)
              │
              ▼
              y (height)

    Image detection:
        If the label is NOT a known element type (see KNOWN_ELEMENT_TYPES),
        it is treated as an image where the label serves as the alt text.

    Args:
        text: The text to parse for refs

    Returns:
        List of dicts with keys:
            - "label": str - The identifier between <|ref|> and <|/ref|>
            - "coords_literal": str - The raw coordinate string between <|det|> and <|/det|>
            - "triplet": [full_match, label, coords_literal] - Structured representation
            - "coords": list[list[int]] - Parsed coordinates as [[x1,y1,x2,y2], ...]
            - "span": (start, end) - Character positions in original text
            - "is_image": bool - True if label is NOT a known element type
    """
    refs = []
    for m in REF_DET_PATTERN.finditer(text):
        full_match = m.group(1)
        label = m.group(2)
        coords_literal = m.group(3)
        refs.append(
            {
                "label": label,
                "coords_literal": coords_literal,
                "triplet": [full_match, label, coords_literal],
                "coords": parse_coords(coords_literal),
                "span": (m.start(), m.end()),
                "is_image": is_image_label(label),
            }
        )
    return refs


def re_match(text):
    """
    Extract labeled bounding box references from text, split by image vs non-image.

    This is a convenience wrapper around parse_refs() that returns results
    grouped by whether they are images or known element types.

    See parse_refs() for full documentation on coordinate system and pattern matching.

    Returns:
        tuple: (matches, matches_image, matches_other)
            - matches: list of tuples (full_match, label, coords_literal)
            - matches_image: list of tuples (full_match, label, coords_literal) for images
            - matches_other: list of full_match strings for non-image elements
    """
    refs = parse_refs(text)
    matches = [tuple(r["triplet"]) for r in refs]
    matches_image = [tuple(r["triplet"]) for r in refs if r["is_image"]]
    matches_other = [r["triplet"][0] for r in refs if not r["is_image"]]
    return matches, matches_image, matches_other


def extract_coordinates_and_label(ref_text):
    """
    Extract label and coordinate list from a ref_text triplet.

    Args:
        ref_text: tuple/list of (full_match, label, coords_literal)
            - full_match: the complete matched string
            - label: the identifier (or alt text for images)
            - coords_literal: the coordinate string, e.g. "[[100,200,300,400]]"

    Returns:
        tuple: (label_type, coords_list) where coords_list is a list of [x1, y1, x2, y2] boxes
        None: if parsing fails
    """
    try:
        label_type = ref_text[1]
        coords_list = parse_coords(ref_text[2])
        if not coords_list:
            return None
        return (label_type, coords_list)
    except Exception as e:
        print(e)
        return None


def text_encode(tokenizer, text: str, bos: bool = True, eos: bool = False):
    """
    Encode text using the tokenizer with optional BOS/EOS tokens.

    Args:
        tokenizer: The tokenizer to use
        text: Text to encode
        bos: Whether to prepend BOS token (default: True)
        eos: Whether to append EOS token (default: False)

    Returns:
        List of token IDs
    """
    t = tokenizer.encode(text, add_special_tokens=False)
    bos_id = 0
    eos_id = 1
    if bos:
        t = [bos_id] + t
    if eos:
        t = t + [eos_id]

    return t


# =============================================================================
# Image Transform Utilities
# =============================================================================


def normalize_transform(mean, std):
    """
    Create a normalization transform for image tensors.

    Args:
        mean: Mean values for normalization (per channel)
        std: Standard deviation values for normalization (per channel)

    Returns:
        transforms.Normalize instance or None if both mean and std are None
    """
    if mean is None and std is None:
        transform = None
    elif mean is None and std is not None:
        mean = [0.0] * len(std)
        transform = transforms.Normalize(mean=mean, std=std)
    elif mean is not None and std is None:
        std = [1.0] * len(mean)
        transform = transforms.Normalize(mean=mean, std=std)
    else:
        transform = transforms.Normalize(mean=mean, std=std)

    return transform


def find_closest_aspect_ratio(aspect_ratio, target_ratios, width, height, image_size):
    """
    Find the closest aspect ratio from a set of target ratios.

    Args:
        aspect_ratio: The aspect ratio to match
        target_ratios: Set of (width_ratio, height_ratio) tuples to choose from
        width: Image width
        height: Image height
        image_size: Base tile size

    Returns:
        Tuple (width_ratio, height_ratio) representing the best match
    """
    best_ratio_diff = float("inf")
    best_ratio = (1, 1)
    area = width * height
    for ratio in target_ratios:
        target_aspect_ratio = ratio[0] / ratio[1]
        ratio_diff = abs(aspect_ratio - target_aspect_ratio)
        if ratio_diff < best_ratio_diff:
            best_ratio_diff = ratio_diff
            best_ratio = ratio
        elif ratio_diff == best_ratio_diff:
            if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]:
                best_ratio = ratio
    return best_ratio


def dynamic_preprocess(image, min_num=2, max_num=9, image_size=640, use_thumbnail=False):
    """
    Dynamically preprocess an image by tiling it based on aspect ratio.

    Splits the image into multiple tiles based on the closest matching aspect ratio
    from a set of valid tile arrangements.

    Args:
        image: PIL Image to preprocess
        min_num: Minimum number of tiles (default: 2)
        max_num: Maximum number of tiles (default: 9)
        image_size: Size of each tile (default: 640)
        use_thumbnail: Whether to append a thumbnail of the full image (default: False)

    Returns:
        Tuple of (processed_images, target_aspect_ratio) where:
            - processed_images: List of PIL Image tiles
            - target_aspect_ratio: Tuple (width_tiles, height_tiles)
    """
    orig_width, orig_height = image.size
    aspect_ratio = orig_width / orig_height

    # calculate the existing image aspect ratio
    target_ratios = set(
        (i, j)
        for n in range(min_num, max_num + 1)
        for i in range(1, n + 1)
        for j in range(1, n + 1)
        if i * j <= max_num and i * j >= min_num
    )
    target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])

    # find the closest aspect ratio to the target
    target_aspect_ratio = find_closest_aspect_ratio(aspect_ratio, target_ratios, orig_width, orig_height, image_size)

    # calculate the target width and height
    target_width = image_size * target_aspect_ratio[0]
    target_height = image_size * target_aspect_ratio[1]
    blocks = target_aspect_ratio[0] * target_aspect_ratio[1]

    # resize the image
    resized_img = image.resize((target_width, target_height))
    processed_images = []
    for i in range(blocks):
        box = (
            (i % (target_width // image_size)) * image_size,
            (i // (target_width // image_size)) * image_size,
            ((i % (target_width // image_size)) + 1) * image_size,
            ((i // (target_width // image_size)) + 1) * image_size,
        )
        # split the image
        split_img = resized_img.crop(box)
        processed_images.append(split_img)
    assert len(processed_images) == blocks
    if use_thumbnail and len(processed_images) != 1:
        thumbnail_img = image.resize((image_size, image_size))
        processed_images.append(thumbnail_img)
    return processed_images, target_aspect_ratio


class BaseTransform(ABC):
    """Abstract base class for image transforms."""

    def set_rng(self, *args, **kwargs):
        pass

    def __call__(self, *args, **kwargs) -> torch.Tensor:
        pass

    @property
    def default_shape(self):
        raise NotImplementedError


class BasicImageTransform(BaseTransform):
    """
    Basic image transform that converts PIL images to normalized tensors.

    Args:
        mean: Mean values for normalization (default: (0.5, 0.5, 0.5))
        std: Standard deviation values for normalization (default: (0.5, 0.5, 0.5))
        normalize: Whether to apply normalization (default: True)
    """

    def __init__(
        self,
        mean: Optional[Tuple[float, float, float]] = (0.5, 0.5, 0.5),
        std: Optional[Tuple[float, float, float]] = (0.5, 0.5, 0.5),
        normalize: bool = True,
    ):
        self.mean = mean
        self.std = std

        transform_pipelines = [transforms.ToTensor()]

        normalize_op = normalize_transform(mean, std) if normalize else nn.Identity()
        if normalize_op is not None:
            transform_pipelines.append(normalize_op)

        self.transform = transforms.Compose(transform_pipelines)

    def __call__(self, x):
        x = self.transform(x)
        return x


class DeepseekOCRProcessor(ProcessorMixin):
    attributes = ["tokenizer"]
    # image_processor_class = "DeepseekOcrImageProcessorFast"
    tokenizer_class = "AutoTokenizer"

    def __init__(
        self,
        tokenizer,
        use_fast: Optional[bool] = None,
        crop_mode: bool = True,
        base_size: int = DS_OCR_BASE_SIZE,
        image_size: int = DS_OCR_TILE_SIZE,
        **kwargs,
    ):
        """
        Initialize the DeepseekOCRProcessor.

        Args:
            tokenizer: A HuggingFace tokenizer that must contain the special image token
                (defaults to ``DS_OCR_IMG_TOKEN = "<image>"``).
            use_fast: Whether the tokenizer is a fast tokenizer. If provided, it must
                match the actual ``tokenizer.is_fast`` value. Kept for API compatibility.
            crop_mode: Default resolution strategy for image preprocessing.
                - ``True``  (Gundam / dynamic resolution): processes a global view at
                  ``base_size`` **and** up to 9 local tiles at ``image_size``. Best for
                  high-resolution documents where fine spatial detail matters.
                - ``False`` (native / fixed resolution): resizes the image to a single
                  ``image_size × image_size`` tensor. Simpler and faster; used by smaller
                  model variants (Tiny / Small / Base / Large).
                Can be overridden per-call via the ``crop_mode`` argument of ``__call__``.
            **kwargs: Additional keyword arguments forwarded to ``ProcessorMixin``.
                Supports ``chat_template`` (``"jina"``, ``"deepseek"``, or a full
                template string).
        """
        if hasattr(tokenizer, "image_token"):
            self.image_token = tokenizer.image_token
        else:
            self.image_token = kwargs.pop("image_token", DS_OCR_IMG_TOKEN)

        self.image_token_id = tokenizer.convert_tokens_to_ids(self.image_token)
        if self.image_token_id is None:
            raise ValueError(
                f"The tokenizer does not contain the special image token `{self.image_token}`. "
                "Please make sure it is added to the vocabulary before instantiating the processor."
            )

        # WARNING: use_fast is parameter for tokenizer, but here we keep it for compatibility.
        self.use_fast = use_fast

        if self.use_fast is not None:
            assert self.use_fast == tokenizer.is_fast, (
                f"The tokenizer fast option use_fast={self.use_fast} does not match the actual "
                f"tokenizer type (is_fast={tokenizer.is_fast})."
            )

        if "chat_template" not in kwargs:
            kwargs["chat_template"] = JINA_OCR_CHAT_TEMPLATE

        if kwargs["chat_template"] == "jina":
            kwargs["chat_template"] = JINA_OCR_CHAT_TEMPLATE
        elif kwargs["chat_template"] == "deepseek":
            kwargs["chat_template"] = DS_OCR_DEFAULT_CHAT_TEMPLATE
        # else:
        #     raise ValueError(f"Invalid chat template: {kwargs['chat_template']}")

        tokenizer.chat_template = kwargs["chat_template"]

        super().__init__(tokenizer, **kwargs)

        self.image_transform = BasicImageTransform(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5), normalize=True)
        self.crop_mode = crop_mode
        self.base_size = base_size
        self.image_size = image_size

    def __call__(
        self,
        images: Optional[ImageInput] = None,
        text: Optional[Union[TextInput, PreTokenizedInput, list[TextInput], list[PreTokenizedInput]]] = None,
        base_size: Optional[int] = None,
        image_size: Optional[int] = None,
        crop_mode: Optional[bool] = None,
        return_tensors: str = "pt",
        thread_lock: Optional[threading.Lock] = None,
        **kwargs,
    ):
        """
        Preprocess a single (image, text) pair into model-ready tensors.

        Args:
            images: A list containing exactly one PIL ``Image``. Only single-image
                inputs are supported.
            text: The prompt string (or a single-element list thereof). Any markdown
                image syntax (``![alt](path/x1_y1_x2_y2.png)``) is automatically
                converted to the ``<|ref|>…<|/ref|><|det|>…<|/det|>`` format before
                tokenization.
            base_size: Side length in pixels of the **global view** tile, used only
                when ``crop_mode=True``.  The entire input image is padded/resized to
                ``base_size × base_size`` and encoded as a single high-level overview.
                The number of image tokens produced is
                ``(ceil(base_size / PATCH_SIZE / DOWNSAMPLE_RATIO))² + 1`` per row/col.
                Default: ``DS_OCR_BASE_SIZE = 1024``.
            image_size: Side length in pixels of each **local tile**, used by
                ``dynamic_preprocess``.
                - When ``crop_mode=True``: the image is split into 2–9 tiles of
                  ``image_size × image_size`` arranged in the grid layout that best
                  preserves the original aspect ratio (e.g. 2×3, 1×4, …).  Each tile
                  generates its own token sequence appended after the global-view tokens.
                - When ``crop_mode=False``: the image is simply resized/padded to
                  ``image_size × image_size`` and this is the only resolution used.
                Default: ``DS_OCR_TILE_SIZE = 640``.
            crop_mode: Resolution strategy for this call.  Overrides the instance-level
                ``self.crop_mode`` when provided.
                - ``True``  — dual-stream: global view (``base_size``) + local tiles
                  (``image_size``).  Captures both layout context and fine detail.
                - ``False`` — single-stream: image resized to ``image_size`` only.
                  Lower memory footprint; suitable for smaller model variants.
                Defaults to ``None``, which falls back to ``self.crop_mode``.
            return_tensors: Output tensor format. Only ``"pt"`` (PyTorch) is supported.
            thread_lock: An optional ``threading.Lock`` to serialise tokenizer calls.
                The HuggingFace tokenizer is not thread-safe; pass a shared lock when
                calling this processor from multiple threads concurrently.
            **kwargs: Unused; accepted for forward-compatibility.

        Returns:
            A dict with the following keys:

            - ``"input_ids"`` (``LongTensor``, shape ``[1, seq_len]``): Token IDs for
              the full prompt, with image placeholder tokens interleaved.
            - ``"images"`` (``tuple[Tensor, Tensor]``): ``(images_crop, images_ori)``
              where ``images_ori`` holds the global view(s) (shape
              ``[n_views, 3, base_size, base_size]``) and ``images_crop`` holds the
              local tiles (shape ``[n_tiles, 3, image_size, image_size]``), or an
              empty tensor when ``crop_mode=False``.
            - ``"images_seq_mask"`` (``BoolTensor``, shape ``[seq_len]``): ``True`` at
              every position occupied by an image placeholder token.
            - ``"images_spatial_crop"`` (``LongTensor``, shape ``[n_views, 2]``): Each
              row is ``[width_tile_count, height_tile_count]`` for the corresponding
              image, encoding the grid layout chosen by ``dynamic_preprocess``.
        """
        assert return_tensors == "pt", "Only PyTorch tensors are supported for DeepseekOCRProcessor."

        if isinstance(text, list):
            assert len(text) == 1, "Only single text input is supported."
            text = text[0]

        assert len(images) == 1, "Only single image input is supported."
        base_size = self.base_size if base_size is None else base_size
        image_size = self.image_size if image_size is None else image_size

        # Normalize markdown image syntax to ref/det format
        assert isinstance(text, str), "Text must be a string at this point."
        text = normalize_markdown_images_to_ref_det(text)

        input_ids, images_seq_mask, images_crop, images_ori, images_spatial_crop = self.preprocess_prompt_and_image(
            prompt=text,
            image=images[0],
            base_size=base_size,
            image_size=image_size,
            crop_mode=self.crop_mode if crop_mode is None else crop_mode,
            thread_lock=thread_lock,
        )

        batched_ids = input_ids.unsqueeze(0)
        return {
            "input_ids": batched_ids,
            "attention_mask": torch.ones_like(batched_ids),
            "images": (images_crop, images_ori),
            "images_seq_mask": images_seq_mask,
            "images_spatial_crop": images_spatial_crop,
        }

    @staticmethod
    def to_device(batch: dict, device):
        """Move processor outputs (including the ``images`` tensor tuple) to ``device``."""

        def _move(value):
            if torch.is_tensor(value):
                return value.to(device)
            if isinstance(value, (list, tuple)):
                return type(value)(_move(v) for v in value)
            return value

        return {key: _move(value) for key, value in batch.items()}

    def prepare_ocr_inputs(
        self,
        image: Image.Image,
        prompt: Optional[str] = None,
        device=None,
        **kwargs,
    ) -> dict:
        """Apply the chat template and return generation-ready tensors."""
        prompt = DEFAULT_OCR_PROMPT if prompt is None else prompt
        conversation = [
            {
                "role": "user",
                "content": [
                    {"type": "image", "image": image},
                    {"type": "text", "text": prompt},
                ],
            }
        ]
        text = self.apply_chat_template(conversation, add_generation_prompt=True)
        inputs = self(text=[text], images=[image], return_tensors="pt", **kwargs)
        # generate() expects a batch of (crop, ori) pairs for zip(*images).
        if isinstance(inputs.get("images"), tuple) and len(inputs["images"]) == 2:
            inputs["images"] = [inputs["images"]]
        if device is not None:
            inputs = self.to_device(inputs, device)
        return inputs

    def decode_ocr(self, sequences, input_ids: torch.Tensor) -> str:
        """Decode generated tokens after the prompt.

        DeepSeek-OCR training decodes with ``skip_special_tokens=False`` because
        ``<image>``, ``<|ref|>``, and EOS are all marked special — skipping them
        turns an EOS/image-only continuation into an empty string.
        """
        if hasattr(sequences, "sequences"):
            sequences = sequences.sequences
        generated = sequences[0][input_ids.shape[-1] :]
        text = self.tokenizer.decode(generated, skip_special_tokens=False)
        for tok in (
            getattr(self.tokenizer, "eos_token", None),
            getattr(self.tokenizer, "pad_token", None),
            getattr(self.tokenizer, "bos_token", None),
            self.image_token,
        ):
            if tok:
                text = text.replace(tok, "")
        text = text.strip()
        if not text and generated.numel() > 0:
            unique = generated.detach().cpu().unique().tolist()
            logger.warning(
                "decode_ocr is empty after stripping specials from %s new tokens "
                "(unique ids=%s). generate() likely never produced text — check "
                "eos_token_id and that the model is not repeating <image>/EOS.",
                generated.numel(),
                unique[:16],
            )
        return text

    def preprocess_prompt_and_image(
        self,
        prompt: str,
        image: Image.Image,
        base_size: int,
        image_size: int,
        crop_mode: bool,
        thread_lock: Optional[threading.Lock] = None,
    ):
        tokenized_image, images_list, images_crop_list, images_spatial_crop = self.preprocess_image(
            image=image,
            base_size=base_size,
            image_size=image_size,
            crop_mode=crop_mode,
        )

        prompt_chunks = prompt.split(self.image_token)
        # Protect tokenizer calls with thread lock if provided (tokenizer is not thread-safe)
        if thread_lock is not None:
            with thread_lock:
                pre_tokens, post_tokens = [
                    text_encode(self.tokenizer, pc, bos=False, eos=False) for pc in prompt_chunks
                ]
        else:
            pre_tokens, post_tokens = [text_encode(self.tokenizer, pc, bos=False, eos=False) for pc in prompt_chunks]
        tokenized_str = [*pre_tokens, *tokenized_image, *post_tokens]

        input_ids = torch.LongTensor(tokenized_str)

        images_crop = torch.zeros(0, 3, image_size, image_size)
        if len(images_list) == 0:
            ori_size = base_size if crop_mode else image_size
            images_ori = torch.zeros((1, 3, ori_size, ori_size))
            images_spatial_crop = torch.zeros((1, 2), dtype=torch.long)
        else:
            images_ori = torch.stack(images_list, dim=0)
            images_spatial_crop = torch.tensor(images_spatial_crop, dtype=torch.long)

        if images_crop_list:
            images_crop = torch.stack(images_crop_list, dim=0)

        images_seq_mask = (input_ids == self.image_token_id).to(torch.bool)

        return input_ids, images_seq_mask, images_crop, images_ori, images_spatial_crop

    def tokenize_prompt_with_image_count(
        self,
        prompt: str,
        image_token_count: int,
        thread_lock: Optional[threading.Lock] = None,
    ) -> torch.LongTensor:
        """Tokenize text while reusing an already-processed image token count."""
        prompt = normalize_markdown_images_to_ref_det(prompt)
        prompt_chunks = prompt.split(self.image_token)
        if len(prompt_chunks) != 2:
            raise ValueError(f"Expected exactly one {self.image_token} placeholder, found {len(prompt_chunks) - 1}.")

        if thread_lock is not None:
            with thread_lock:
                pre_tokens, post_tokens = [
                    text_encode(self.tokenizer, chunk, bos=False, eos=False) for chunk in prompt_chunks
                ]
        else:
            pre_tokens, post_tokens = [
                text_encode(self.tokenizer, chunk, bos=False, eos=False) for chunk in prompt_chunks
            ]

        return torch.LongTensor([*pre_tokens, *([self.image_token_id] * image_token_count), *post_tokens])

    def preprocess_image(
        self,
        image: Image.Image,
        base_size: int,
        image_size: int,
        crop_mode: bool,
    ):
        """
        Convert a single PIL image into token IDs and normalized tensors.

        Depending on ``crop_mode`` two pipelines are available:

        **crop_mode = True** (Gundam / dynamic resolution)
            1. The full image is padded to ``base_size × base_size`` → *global view*.
            2. If the image is larger than ``image_size`` in either dimension it is
               also passed through ``dynamic_preprocess``, which selects the grid
               layout (``width_crop_num × height_crop_num``, 2–9 tiles) whose aspect
               ratio is closest to the original, resizes the image to fit that grid,
               and slices it into ``image_size × image_size`` tiles → *local tiles*.
            3. Image tokens: global-view tokens (derived from ``base_size``) followed
               by patch tokens for each local tile (derived from ``image_size``).

        **crop_mode = False** (native / fixed resolution)
            The image is resized directly to ``image_size × image_size``.  No tiling
            is performed and only global-view tokens are emitted.

        Args:
            image: Input PIL image in any mode/size.
            base_size: Target size for the global view (used in ``crop_mode=True``).
                See ``__call__`` for details.
            image_size: Target tile size for local crops (``crop_mode=True``) or the
                single target resolution (``crop_mode=False``).
                See ``__call__`` for details.
            crop_mode: Whether to apply dynamic tiling. See ``__call__`` for details.

        Returns:
            A 4-tuple ``(tokenized_image, images_list, images_crop_list,
            images_spatial_crop)``:

            - ``tokenized_image`` (``list[int]``): Image placeholder token IDs to be
              spliced into the prompt token sequence.
            - ``images_list`` (``list[Tensor]``): Normalized global-view tensor(s),
              each of shape ``[3, base_size, base_size]``.
            - ``images_crop_list`` (``list[Tensor]``): Normalized local-tile tensor(s),
              each of shape ``[3, image_size, image_size]``. Empty when no tiling
              occurs.
            - ``images_spatial_crop`` (``list[[int, int]]``): ``[[width_crop_num,
              height_crop_num]]`` recording the tile grid dimensions.
        """
        images_list, images_crop_list = [], []
        images_spatial_crop = []

        if image.mode != "RGB":
            image = image.convert("RGB")

        width_crop_num, height_crop_num = 1, 1
        if crop_mode:  # Dynamic resolution (Gundam)
            if not (image.size[0] <= image_size and image.size[1] <= image_size):
                images_crop_raw, (width_crop_num, height_crop_num) = dynamic_preprocess(image, image_size=image_size)

            # Process the global view
            global_view = self.pad_img(image, base_size)
            images_list.append(self.image_transform(global_view))

            if width_crop_num > 1 or height_crop_num > 1:
                # Process the local views
                for i in range(len(images_crop_raw)):
                    images_crop_list.append(self.image_transform(images_crop_raw[i]))

            num_queries = self.compute_n_queries(image_size)
            num_queries_base = self.compute_n_queries(base_size)

            # Add image tokens
            tokenized_image = self.image_tokens(num_queries_base)
            if width_crop_num > 1 or height_crop_num > 1:
                tokenized_image += self.patch_image_tokens(num_queries, width_crop_num, height_crop_num)
        else:  # Native resolution (Tiny/Small/Base/Large)
            # Process the global view
            if image_size <= 640:
                # Tiny/Small mode: image fits within target, directly resize
                image = image.resize((image_size, image_size))
            global_view = self.pad_img(image, image_size)
            images_list.append(self.image_transform(global_view))

            # Add image tokens"""
            num_queries = self.compute_n_queries(image_size)
            tokenized_image = self.image_tokens(num_queries)
        images_spatial_crop.append([width_crop_num, height_crop_num])

        return tokenized_image, images_list, images_crop_list, images_spatial_crop

    def compute_n_queries(
        self,
        image_size: int,
    ):
        """
        Compute the number of image query tokens per row (or column) for a square tile.

        The vision encoder first divides the image into patches of size
        ``DS_OCR_PATCH_SIZE`` (16 px), then a pixel-shuffle / downsample operation
        reduces the spatial grid by ``DS_OCR_DOWNSAMPLE_RATIO`` (4×) in each
        dimension.  The result is the 1-D query count used to build the 2-D grid of
        image placeholder tokens.

        Formula::

            n = ceil((image_size // PATCH_SIZE) / DOWNSAMPLE_RATIO)
              = ceil((image_size // 16) / 4)

        Examples:
            - ``image_size=640``  → ``ceil(40 / 4)`` = **10** queries per side
            - ``image_size=1024`` → ``ceil(64 / 4)`` = **16** queries per side

        Args:
            image_size: Side length of the (square) tile in pixels.

        Returns:
            Number of query tokens along one spatial dimension.
        """
        num_queries = math.ceil((image_size // DS_OCR_PATCH_SIZE) / DS_OCR_DOWNSAMPLE_RATIO)
        return num_queries

    def compute_valid_img_tokens(
        self,
        base_size: int,
        ratio: float,
    ):
        if base_size == 1024:
            return int(256 * ratio)
        elif base_size == 1280:
            return int(400 * ratio)
        elif base_size == 640:
            return int(100 * 1)
        elif base_size == 512:
            return int(64 * 1)
        raise ValueError("Unsupported base_size for valid image tokens computation.")

    def pad_img(self, img, size):
        """
        Pad (and/or resize) a PIL image to a square canvas of ``size × size`` pixels.

        Uses ``ImageOps.pad``, which scales the image down to fit within the target
        square while preserving aspect ratio, then fills the remaining border with the
        mean colour of the normalisation transform (converted from [0, 1] to [0, 255]).
        This avoids introducing out-of-distribution black or white borders.

        Args:
            img: Input PIL image.
            size: Target side length in pixels for the output square canvas.

        Returns:
            A new PIL image of size ``(size, size)``.
        """
        color = tuple(int(x * 255) for x in self.image_transform.mean)
        return ImageOps.pad(
            img,
            (size, size),
            color=color,
        )

    def image_tokens(self, num_queries) -> list[int]:
        """
        Build image placeholder token IDs for one square view.

        The model encodes this as ``num_queries`` rows of visual features, appends
        one newline token to each row, then appends one final view separator token.

        Args:
            num_queries: Number of query tokens per spatial dimension (from
                ``compute_n_queries``).

        Returns:
            A flat ``list[int]`` of length ``num_queries * (num_queries + 1) + 1``.
        """
        base = [self.image_token_id] * num_queries + [self.image_token_id]
        return base * num_queries + [self.image_token_id]

    def patch_image_tokens(self, num_queries, width_crop_num, height_crop_num):
        """
        Build the flat list of image placeholder token IDs for all local tiles.

        Generates tokens for the full ``width_crop_num × height_crop_num`` tile grid
        produced by ``dynamic_preprocess``.  Each tile contributes ``num_queries``
        tokens along its dimension, plus one separator token per row/column.

        Layout (width=W tiles, height=H tiles, q queries per tile side)::

            row length = num_queries * width_crop_num  + 1  (one separator per row)
            n_rows     = num_queries * height_crop_num      (no trailing separator)

        Args:
            num_queries: Number of query tokens per spatial dimension per tile (from
                ``compute_n_queries`` called with ``image_size``).
            width_crop_num: Number of tile columns in the grid (``target_aspect_ratio[0]``).
            height_crop_num: Number of tile rows in the grid (``target_aspect_ratio[1]``).

        Returns:
            A flat ``list[int]`` of image token IDs covering the entire local-tile
            grid.
        """
        base = [self.image_token_id] * (num_queries * width_crop_num) + [self.image_token_id]
        return base * (num_queries * height_crop_num)

    def build_ref_texts_from_output(self, text: str):
        """Extract triplets from parsed refs for backward compatibility."""
        return [r["triplet"] for r in parse_refs(text)]

    def visualize_results(self, image, ref_texts):
        if isinstance(image, torch.Tensor):
            image = Image.fromarray((image.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8))
        width, height = image.size
        img_draw = image.copy()
        draw = ImageDraw.Draw(img_draw)
        overlay = Image.new("RGBA", img_draw.size, (0, 0, 0, 0))
        draw2 = ImageDraw.Draw(overlay)
        try:
            font = ImageFont.load_default()
        except Exception:
            font = None

        for ref in ref_texts:
            label_type, boxes = extract_coordinates_and_label(ref)
            color = (
                np.random.randint(0, 200),
                np.random.randint(0, 200),
                np.random.randint(0, 255),
            )
            color_a = color + (20,)
            for orig_x1, orig_y1, orig_x2, orig_y2 in boxes:
                x1, y1 = normalize_to_pixel(orig_x1, orig_y1, width, height)
                x2, y2 = normalize_to_pixel(orig_x2, orig_y2, width, height)
                if label_type == "title":
                    draw.rectangle([x1, y1, x2, y2], outline=color, width=4)
                    draw2.rectangle([x1, y1, x2, y2], fill=color_a)
                else:
                    draw.rectangle([x1, y1, x2, y2], outline=color, width=2)
                    draw2.rectangle([x1, y1, x2, y2], fill=color_a)
                if font:
                    text_x, text_y = x1, max(0, y1 - 15)
                    textbox_width, textbox_height = draw.textbbox((0, 0), label_type, font=font)[2:]
                    draw.rectangle(
                        [
                            text_x,
                            text_y,
                            text_x + textbox_width,
                            text_y + textbox_height,
                        ],
                        fill=(255, 255, 255, 30),
                    )
                    draw.text((text_x, text_y), label_type, font=font, fill=color)
        img_draw.paste(overlay, (0, 0), overlay)
        return img_draw

    def extract_markdown_and_crops(
        self,
        text: str,
        image,
        image_placeholder_fmt="![{alt_text}](images/{x1}_{y1}_{x2}_{y2}.png)",
        cleanup=True,
    ):
        """
        Extract markdown text and image crops from model output.

        Parses ref/det tags in the output text and:
        - Replaces image references with markdown image syntax (e.g., `![alt](images/x1_y1_x2_y2.png)`)
        - Removes non-image element references (e.g., title, text, button) from the output
        - Crops the corresponding image regions for each detected image

        Image detection: A ref is considered an image if its label is NOT one of the known
        UI element types (title, text, button, etc.). The label then serves as the alt text.

        Example:
            Input:  "<|ref|>A chart showing sales<|/ref|><|det|>[[100,200,300,400]]<|/det|>"
            Output: "![A chart showing sales](images/100_200_300_400.png)"

        Args:
            text: Model output text containing `<|ref|>...<|/ref|><|det|>...<|/det|>` tags
            image: Source image as PIL Image or torch.Tensor (C, H, W)
            image_placeholder_fmt: Format string for markdown image syntax.
                Available placeholders: {alt_text}, {x1}, {y1}, {x2}, {y2}
            cleanup: If True, replace common LaTeX symbols (e.g., \\coloneqq -> :=)

        Returns:
            tuple: (markdown_text, crops)
                - markdown_text: Processed text with image refs converted to markdown
                - crops: List of (cropped_image, alt_text, filename) tuples for each image
        """
        refs = parse_refs(text)
        pieces = []
        last = 0
        crops = []
        if isinstance(image, torch.Tensor):
            image = Image.fromarray((image.permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8))
        W, H = image.size

        for r in refs:
            start, end = r["span"]
            pieces.append(text[last:start])
            if r["is_image"]:
                alt_text = r["label"]
                for x1, y1, x2, y2 in r["coords"]:
                    X1, Y1 = normalize_to_pixel(x1, y1, W, H)
                    X2, Y2 = normalize_to_pixel(x2, y2, W, H)
                    if X2 > X1 and Y2 > Y1:
                        filename = coords_to_filename(x1, y1, x2, y2)
                        crops.append((image.crop((X1, Y1, X2, Y2)), alt_text, filename))
                        pieces.append(image_placeholder_fmt.format(alt_text=alt_text, x1=x1, y1=y1, x2=x2, y2=y2))
            # non-image refs are dropped from text
            last = end
        pieces.append(text[last:])

        md = "".join(pieces)
        if cleanup:
            md = md.replace("\\coloneqq", ":=").replace("\\eqqcolon", "=:")
        return md, crops
