"""
Thumbnail generation utilities for PDF and web page previews.

Uses PyMuPDF (fitz) to extract the first page of PDFs as thumbnails.
Thumbnails are cached on disk to avoid regeneration.
"""
import os
import logging
from pathlib import Path
from typing import Optional
import asyncio

from refchecker.utils.cache_utils import get_cached_artifact_path
from refchecker.utils.arxiv_utils import download_arxiv_paper_pdf, get_arxiv_paper_by_id

logger = logging.getLogger(__name__)

# Thumbnail settings
THUMBNAIL_WIDTH = 200  # Target width in pixels for small thumbnails
THUMBNAIL_DPI = 150  # Higher DPI for sharper text rendering

# Preview settings (larger image for overlay view)
PREVIEW_WIDTH = 1600  # Target width in pixels for preview/overlay


def get_thumbnail_cache_path(
    source_identifier: str,
    check_id: Optional[int] = None,
    cache_dir: Optional[str] = None,
) -> str:
    """
    Get the cache path for a thumbnail.
    
    Args:
        source_identifier: A unique identifier for the source (URL, file path, or hash)
        check_id: Optional check ID for more unique naming
        
    Returns:
        Path to the thumbnail file (may not exist yet)
    """
    cached_path = get_cached_artifact_path(cache_dir, source_identifier, "thumbnail.png", create_dir=True)
    if cached_path:
        return cached_path

    raise ValueError("cache_dir is required for thumbnail storage")


def get_preview_cache_path(
    source_identifier: str,
    check_id: Optional[int] = None,
    cache_dir: Optional[str] = None,
) -> str:
    """
    Get the cache path for a preview (larger image for overlay).
    
    Args:
        source_identifier: A unique identifier for the source (URL, file path, or hash)
        check_id: Optional check ID for more unique naming
        
    Returns:
        Path to the preview file (may not exist yet)
    """
    cached_path = get_cached_artifact_path(cache_dir, source_identifier, "preview.png", create_dir=True)
    if cached_path:
        return cached_path

    raise ValueError("cache_dir is required for preview storage")


def get_pdf_storage_path(source_identifier: str, cache_dir: Optional[str] = None) -> str:
    """Return the cached PDF path for a source."""
    cached_path = get_cached_artifact_path(cache_dir, source_identifier, "paper.pdf", create_dir=True)
    if cached_path:
        return cached_path

    raise ValueError("cache_dir is required for PDF storage")


def get_pdf_page_preview_cache_path(
    source_identifier: str,
    page_index: int,
    cache_dir: Optional[str] = None,
) -> str:
    """Per-page preview cache path. Lets the "scrollable thumbnail" view
    pull a single page at a time without re-rendering the whole PDF."""
    cached_path = get_cached_artifact_path(
        cache_dir, source_identifier, f"page_{int(page_index):04d}.png", create_dir=True,
    )
    if cached_path:
        return cached_path
    raise ValueError("cache_dir is required for page-preview storage")


def get_pdf_page_count(pdf_path: str) -> int:
    """Return the number of pages in a PDF, or 0 on any failure."""
    try:
        import fitz  # PyMuPDF
        if not os.path.exists(pdf_path):
            return 0
        doc = fitz.open(pdf_path)
        try:
            return int(len(doc))
        finally:
            doc.close()
    except Exception as e:
        logger.debug(f"page-count probe failed for {pdf_path}: {e}")
        return 0


def generate_pdf_page_preview(
    pdf_path: str,
    page_index: int,
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """Render and cache a single page of a PDF at preview resolution.

    Powers the multi-page scrollable preview overlay. Each page caches as
    its own PNG so the frontend can request pages lazily.
    """
    try:
        import fitz  # PyMuPDF
        if not os.path.exists(pdf_path):
            logger.error(f"PDF file not found: {pdf_path}")
            return None
        if page_index < 0:
            return None
        output_path = get_pdf_page_preview_cache_path(
            source_identifier or pdf_path, page_index, cache_dir=cache_dir,
        )
        if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
            return output_path
        doc = fitz.open(pdf_path)
        try:
            if page_index >= len(doc):
                return None
            page = doc[page_index]
            zoom = PREVIEW_WIDTH / max(1, page.rect.width)
            mat = fitz.Matrix(zoom, zoom)
            pix = page.get_pixmap(matrix=mat, alpha=False)
            try:
                from PIL import Image, ImageEnhance
                import io
                img = Image.open(io.BytesIO(pix.tobytes("png")))
                # Same contrast/sharpness pass `generate_pdf_preview` uses
                # so a single page from the multi-page overlay matches the
                # single-image preview visually (the smaller `thumbnail`
                # function pushes contrast higher to compensate for its
                # lower resolution; we don't need that here).
                img = ImageEnhance.Contrast(img).enhance(1.2)
                img = ImageEnhance.Sharpness(img).enhance(1.1)
                img.save(output_path, "PNG")
            except ImportError:
                pix.save(output_path)
            return output_path
        finally:
            doc.close()
    except ImportError:
        logger.error("PyMuPDF (fitz) is not installed. Install with: pip install pymupdf")
        return None
    except Exception as e:
        logger.error(f"Error rendering page {page_index} of {pdf_path}: {e}")
        return None


async def generate_pdf_page_preview_async(
    pdf_path: str,
    page_index: int,
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    return await asyncio.to_thread(
        generate_pdf_page_preview, pdf_path, page_index, source_identifier, cache_dir,
    )


async def get_pdf_page_count_async(pdf_path: str) -> int:
    return await asyncio.to_thread(get_pdf_page_count, pdf_path)


def generate_pdf_thumbnail(
    pdf_path: str,
    output_path: Optional[str] = None,
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
    force: bool = False,
) -> Optional[str]:
    """
    Generate a thumbnail from the first page of a PDF.
    
    Args:
        pdf_path: Path to the PDF file
        output_path: Optional path for the output thumbnail. If not provided,
                     uses the cache directory.
                     
    Returns:
        Path to the generated thumbnail, or None if generation failed
    """
    try:
        import fitz  # PyMuPDF
        
        if not os.path.exists(pdf_path):
            logger.error(f"PDF file not found: {pdf_path}")
            return None
        
        # Determine output path
        if output_path is None:
            output_path = get_thumbnail_cache_path(source_identifier or pdf_path, cache_dir=cache_dir)
        
        # Check if thumbnail already exists
        if os.path.exists(output_path) and not force:
            logger.debug(f"Thumbnail already exists: {output_path}")
            return output_path
        
        # Open the PDF
        doc = fitz.open(pdf_path)
        
        if len(doc) == 0:
            logger.warning(f"PDF has no pages: {pdf_path}")
            doc.close()
            return None
        
        # Get the first page
        page = doc[0]
        
        # Calculate zoom factor to get desired width
        page_width = page.rect.width
        zoom = THUMBNAIL_WIDTH / page_width
        
        # Create transformation matrix
        mat = fitz.Matrix(zoom, zoom)
        
        # Render page to pixmap with higher quality
        pix = page.get_pixmap(matrix=mat, alpha=False)
        
        # Enhance contrast to make text darker/more readable
        try:
            from PIL import Image, ImageEnhance
            import io
            
            # Convert pixmap to PIL Image
            img_data = pix.tobytes("png")
            img = Image.open(io.BytesIO(img_data))
            
            # Increase contrast (1.3 = 30% more contrast)
            enhancer = ImageEnhance.Contrast(img)
            img = enhancer.enhance(1.3)
            
            # Slightly increase sharpness
            enhancer = ImageEnhance.Sharpness(img)
            img = enhancer.enhance(1.2)
            
            # Save enhanced image
            img.save(output_path, "PNG")
        except ImportError:
            # Fallback: save without enhancement if PIL not available
            pix.save(output_path)
        
        doc.close()
        
        logger.info(f"Generated thumbnail: {output_path} ({pix.width}x{pix.height})")
        return output_path
        
    except ImportError:
        logger.error("PyMuPDF (fitz) is not installed. Install with: pip install pymupdf")
        return None
    except Exception as e:
        logger.error(f"Error generating PDF thumbnail: {e}")
        return None


async def generate_pdf_thumbnail_async(
    pdf_path: str,
    output_path: Optional[str] = None,
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
    force: bool = False,
) -> Optional[str]:
    """
    Async wrapper for PDF thumbnail generation.
    
    Args:
        pdf_path: Path to the PDF file
        output_path: Optional path for the output thumbnail
        
    Returns:
        Path to the generated thumbnail, or None if generation failed
    """
    return await asyncio.to_thread(
        generate_pdf_thumbnail,
        pdf_path,
        output_path,
        source_identifier,
        cache_dir,
        force,
    )


def is_probably_placeholder_thumbnail(path: str) -> bool:
    """Return True for tiny text-placeholder thumbnails, not rendered PDFs."""
    try:
        if not path or not os.path.exists(path) or os.path.getsize(path) > 4096:
            return False

        from PIL import Image

        with Image.open(path).convert("RGB") as image:
            width, height = image.size
            if width != THUMBNAIL_WIDTH or height != int(THUMBNAIL_WIDTH * 1.4):
                return False

            pixels = image.getdata()
            non_white = sum(
                1
                for r, g, b in pixels
                if not (r >= 240 and g >= 240 and b >= 240)
            )
            return non_white / max(width * height, 1) < 0.02
    except Exception:
        return False


def generate_pdf_preview(
    pdf_path: str,
    output_path: Optional[str] = None,
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Generate a high-resolution preview from the first page of a PDF.
    
    Args:
        pdf_path: Path to the PDF file
        output_path: Optional path for the output preview. If not provided,
                     uses the cache directory.
                     
    Returns:
        Path to the generated preview, or None if generation failed
    """
    try:
        import fitz  # PyMuPDF
        
        if not os.path.exists(pdf_path):
            logger.error(f"PDF file not found: {pdf_path}")
            return None
        
        # Determine output path
        if output_path is None:
            output_path = get_preview_cache_path(source_identifier or pdf_path, cache_dir=cache_dir)
        
        # Check if preview already exists
        if os.path.exists(output_path):
            logger.debug(f"Preview already exists: {output_path}")
            return output_path
        
        # Open the PDF
        doc = fitz.open(pdf_path)
        
        if len(doc) == 0:
            logger.warning(f"PDF has no pages: {pdf_path}")
            doc.close()
            return None
        
        # Get the first page
        page = doc[0]
        
        # Calculate zoom factor to get desired width (larger for preview)
        page_width = page.rect.width
        zoom = PREVIEW_WIDTH / page_width
        
        # Create transformation matrix
        mat = fitz.Matrix(zoom, zoom)
        
        # Render page to pixmap with higher quality
        pix = page.get_pixmap(matrix=mat, alpha=False)
        
        # Enhance contrast to make text darker/more readable
        try:
            from PIL import Image, ImageEnhance
            import io
            
            # Convert pixmap to PIL Image
            img_data = pix.tobytes("png")
            img = Image.open(io.BytesIO(img_data))
            
            # Increase contrast (1.2 = 20% more contrast)
            enhancer = ImageEnhance.Contrast(img)
            img = enhancer.enhance(1.2)
            
            # Slightly increase sharpness
            enhancer = ImageEnhance.Sharpness(img)
            img = enhancer.enhance(1.1)
            
            # Save enhanced image
            img.save(output_path, "PNG", optimize=True)
        except ImportError:
            # Fallback: save without enhancement if PIL not available
            pix.save(output_path)
        
        doc.close()
        
        logger.info(f"Generated preview: {output_path} ({pix.width}x{pix.height})")
        return output_path
        
    except ImportError:
        logger.error("PyMuPDF (fitz) is not installed. Install with: pip install pymupdf")
        return None
    except Exception as e:
        logger.error(f"Error generating PDF preview: {e}")
        return None


async def generate_pdf_preview_async(
    pdf_path: str,
    output_path: Optional[str] = None,
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Async wrapper for PDF preview generation.
    
    Args:
        pdf_path: Path to the PDF file
        output_path: Optional path for the output preview
        
    Returns:
        Path to the generated preview, or None if generation failed
    """
    return await asyncio.to_thread(
        generate_pdf_preview,
        pdf_path,
        output_path,
        source_identifier,
        cache_dir,
    )


def _download_arxiv_pdf(arxiv_id: str, pdf_path: str) -> bool:
    """Download an ArXiv PDF with retry logic for rate limits.

    Returns True if the PDF was successfully downloaded (or already cached).
    """
    pdf_path = Path(pdf_path)
    if pdf_path.exists() and pdf_path.stat().st_size > 0:
        return True

    import time as _time

    paper = get_arxiv_paper_by_id(arxiv_id)
    if not paper:
        logger.error(f"ArXiv paper not found for thumbnail: {arxiv_id}")
        return False

    # Result.download_pdf is deprecated in newer arxiv lib ("use result.pdf_url
    # directly"). Drop down to urlretrieve so we don't depend on a moving target.
    import urllib.request
    pdf_url = getattr(paper, 'pdf_url', None) or f"https://arxiv.org/pdf/{arxiv_id}"
    for attempt in range(3):
        try:
            download_arxiv_paper_pdf(paper, str(pdf_path), arxiv_id)
            if pdf_path.exists() and pdf_path.stat().st_size > 0:
                return True
        except Exception as e:
            wait = 3 * (attempt + 1)
            logger.warning(f"ArXiv PDF download attempt {attempt + 1}/3 failed ({e}), retrying in {wait}s")
            _time.sleep(wait)

    logger.error(f"ArXiv PDF download failed after 3 attempts for {arxiv_id}")
    return False


def generate_arxiv_thumbnail(
    arxiv_id: str,
    check_id: Optional[int] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Generate a thumbnail for an ArXiv paper.
    
    Downloads the PDF and generates a thumbnail of the first page.
    
    Args:
        arxiv_id: ArXiv paper ID (e.g., "2311.12022")
        check_id: Optional check ID for cache naming
        
    Returns:
        Path to the generated thumbnail, or None if generation failed
    """
    try:
        # Check if thumbnail already exists
        source_identifier = f"arxiv_{arxiv_id}"
        output_path = get_thumbnail_cache_path(source_identifier, check_id, cache_dir=cache_dir)
        if os.path.exists(output_path):
            logger.debug(f"ArXiv thumbnail already exists: {output_path}")
            return output_path

        pdf_path = get_pdf_storage_path(source_identifier, cache_dir=cache_dir)
        
        if not _download_arxiv_pdf(arxiv_id, pdf_path):
            return None
        
        # Generate thumbnail from the PDF
        return generate_pdf_thumbnail(pdf_path, output_path)
        
    except Exception as e:
        logger.error(f"Error generating ArXiv thumbnail: {e}")
        return None


async def generate_arxiv_thumbnail_async(
    arxiv_id: str,
    check_id: Optional[int] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Async wrapper for ArXiv thumbnail generation.
    
    Args:
        arxiv_id: ArXiv paper ID
        check_id: Optional check ID for cache naming
        
    Returns:
        Path to the generated thumbnail, or None if generation failed
    """
    return await asyncio.to_thread(generate_arxiv_thumbnail, arxiv_id, check_id, cache_dir)


def generate_arxiv_preview(
    arxiv_id: str,
    check_id: Optional[int] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Generate a high-resolution preview for an ArXiv paper.
    
    Downloads the PDF and generates a preview of the first page.
    
    Args:
        arxiv_id: ArXiv paper ID (e.g., "2311.12022")
        check_id: Optional check ID for cache naming
        
    Returns:
        Path to the generated preview, or None if generation failed
    """
    try:
        # Check if preview already exists
        source_identifier = f"arxiv_{arxiv_id}"
        output_path = get_preview_cache_path(source_identifier, check_id, cache_dir=cache_dir)
        if os.path.exists(output_path):
            logger.debug(f"ArXiv preview already exists: {output_path}")
            return output_path

        pdf_path = get_pdf_storage_path(source_identifier, cache_dir=cache_dir)
        
        if not _download_arxiv_pdf(arxiv_id, pdf_path):
            return None
        
        # Generate preview from the PDF
        return generate_pdf_preview(pdf_path, output_path)
        
    except Exception as e:
        logger.error(f"Error generating ArXiv preview: {e}")
        return None


async def generate_arxiv_preview_async(
    arxiv_id: str,
    check_id: Optional[int] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Async wrapper for ArXiv preview generation.
    
    Args:
        arxiv_id: ArXiv paper ID
        check_id: Optional check ID for cache naming
        
    Returns:
        Path to the generated preview, or None if generation failed
    """
    return await asyncio.to_thread(generate_arxiv_preview, arxiv_id, check_id, cache_dir)


def get_text_thumbnail(
    check_id: int,
    text_preview: str = "",
    text_file_path: str = "",
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Generate a thumbnail for pasted text showing actual content.
    
    Creates an image with the first few lines of the text content.
    
    Args:
        check_id: Check ID for naming
        text_preview: Optional first few lines of text to display
        text_file_path: Optional path to the text file to read content from
        
    Returns:
        Path to the generated thumbnail, or None if generation failed
    """
    try:
        import fitz
        
        output_path = get_thumbnail_cache_path(source_identifier or f"text_{check_id}", check_id, cache_dir=cache_dir)

        if os.path.exists(output_path):
            return output_path
        
        # Try to read text content from file
        text_content = text_preview
        if text_file_path and os.path.exists(text_file_path):
            try:
                with open(text_file_path, 'r', encoding='utf-8') as f:
                    text_content = f.read()
            except Exception as e:
                logger.warning(f"Could not read text file: {e}")
        
        # Clean up text content - remove excessive blank lines that cause rendering issues
        if text_content:
            # Normalize line endings and remove consecutive blank lines
            lines = text_content.replace('\r\n', '\n').replace('\r', '\n').split('\n')
            # Keep only non-empty lines
            text_content = '\n'.join(line for line in lines if line.strip())
        
        # Create a document-like image with actual text content
        doc = fitz.open()
        page = doc.new_page(width=THUMBNAIL_WIDTH, height=int(THUMBNAIL_WIDTH * 1.4))
        
        # Fill with white/off-white background
        page.draw_rect(page.rect, color=(0.95, 0.95, 0.95), fill=(0.99, 0.99, 0.99))
        
        # Draw border
        page.draw_rect(page.rect, color=(0.8, 0.8, 0.8), width=1)
        
        # Draw actual text content if available
        margin = 10
        if text_content:
            # Create a text box for the content
            text_rect = fitz.Rect(margin, margin, THUMBNAIL_WIDTH - margin, int(THUMBNAIL_WIDTH * 1.4) - margin)
            
            # Truncate to fit thumbnail — ~2000 chars with small font
            display_text = text_content[:2000]
            if len(text_content) > 2000:
                display_text += "\n..."
            
            # Insert text with small font
            page.insert_textbox(
                text_rect,
                display_text,
                fontsize=6,
                color=(0.2, 0.2, 0.2),
                fontname="helv"
            )
        else:
            # Fallback: Draw placeholder lines
            line_height = 12
            y = margin + 30
            
            # Draw a "T" icon at top
            text_rect = fitz.Rect(margin, margin, margin + 30, margin + 25)
            page.insert_textbox(text_rect, "T", fontsize=20, color=(0.4, 0.4, 0.6))
            
            for i in range(10):
                line_width = THUMBNAIL_WIDTH - 2 * margin
                if i % 3 == 2:
                    line_width = line_width * 0.7
                
                page.draw_line(
                    fitz.Point(margin, y),
                    fitz.Point(margin + line_width, y),
                    color=(0.7, 0.7, 0.7),
                    width=2
                )
                y += line_height
        
        # Render to pixmap and save
        pix = page.get_pixmap(alpha=False)
        pix.save(output_path)
        doc.close()
        
        logger.info(f"Generated text thumbnail: {output_path}")
        return output_path
        
    except ImportError:
        logger.error("PyMuPDF (fitz) is not installed")
        return None
    except Exception as e:
        logger.error(f"Error generating text thumbnail: {e}")
        return None


def get_text_preview(
    check_id: int,
    text_preview: str = "",
    text_file_path: str = "",
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """
    Generate a high-resolution preview for pasted text showing actual content.
    
    Creates a larger image (similar to PDF previews) with the text content.
    
    Args:
        check_id: Check ID for naming
        text_preview: Optional first few lines of text to display
        text_file_path: Optional path to the text file to read content from
        
    Returns:
        Path to the generated preview, or None if generation failed
    """
    try:
        import fitz
        
        output_path = get_preview_cache_path(source_identifier or f"text_{check_id}", check_id, cache_dir=cache_dir)

        if os.path.exists(output_path):
            return output_path
        
        # Try to read text content from file
        text_content = text_preview
        if text_file_path and os.path.exists(text_file_path):
            try:
                with open(text_file_path, 'r', encoding='utf-8') as f:
                    text_content = f.read()
            except Exception as e:
                logger.warning(f"Could not read text file: {e}")
        
        # Clean up text content - remove excessive blank lines that cause rendering issues
        if text_content:
            # Normalize line endings and remove consecutive blank lines
            lines = text_content.replace('\r\n', '\n').replace('\r', '\n').split('\n')
            # Keep only non-empty lines
            text_content = '\n'.join(line for line in lines if line.strip())
        
        # Create a document-like image with actual text content at high resolution
        doc = fitz.open()
        page = doc.new_page(width=PREVIEW_WIDTH, height=int(PREVIEW_WIDTH * 1.4))
        
        # Fill with white/off-white background
        page.draw_rect(page.rect, color=(0.9, 0.9, 0.9), fill=(0.98, 0.98, 0.98))
        
        # Draw border
        page.draw_rect(page.rect, color=(0.7, 0.7, 0.7), width=2)
        
        # Draw actual text content if available
        margin = 40
        if text_content:
            # Create a text box for the content
            text_rect = fitz.Rect(margin, margin, PREVIEW_WIDTH - margin, int(PREVIEW_WIDTH * 1.4) - margin)
            
            # Show as much text as fits the preview page
            display_text = text_content[:16000]
            if len(text_content) > 16000:
                display_text += "\n\n..."
            
            # Insert text with readable font size
            page.insert_textbox(
                text_rect,
                display_text,
                fontsize=14,
                color=(0.15, 0.15, 0.15),
                fontname="helv"
            )
        else:
            # Fallback: Draw placeholder
            header_rect = fitz.Rect(margin, margin, PREVIEW_WIDTH - margin, margin + 60)
            page.insert_textbox(header_rect, "Pasted Text", fontsize=36, color=(0.3, 0.3, 0.5))
            
            # Draw placeholder lines
            line_height = 24
            y = margin + 100
            
            for i in range(20):
                line_width = PREVIEW_WIDTH - 2 * margin
                if i % 3 == 2:
                    line_width = line_width * 0.7
                
                page.draw_line(
                    fitz.Point(margin, y),
                    fitz.Point(margin + line_width, y),
                    color=(0.7, 0.7, 0.7),
                    width=3
                )
                y += line_height
        
        # Render to pixmap and save
        pix = page.get_pixmap(alpha=False)
        pix.save(output_path)
        doc.close()
        
        logger.info(f"Generated text preview: {output_path}")
        return output_path
        
    except ImportError:
        logger.error("PyMuPDF (fitz) is not installed")
        return None
    except Exception as e:
        logger.error(f"Error generating text preview: {e}")
        return None


async def get_text_preview_async(
    check_id: int,
    text_preview: str = "",
    text_file_path: str = "",
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """Async wrapper for text preview generation."""
    return await asyncio.to_thread(
        get_text_preview,
        check_id,
        text_preview,
        text_file_path,
        source_identifier,
        cache_dir,
    )


async def get_text_thumbnail_async(
    check_id: int,
    text_preview: str = "",
    text_file_path: str = "",
    source_identifier: Optional[str] = None,
    cache_dir: Optional[str] = None,
) -> Optional[str]:
    """Async wrapper for text thumbnail generation."""
    return await asyncio.to_thread(
        get_text_thumbnail,
        check_id,
        text_preview,
        text_file_path,
        source_identifier,
        cache_dir,
    )


def cleanup_old_thumbnails(cache_dir: str, max_age_days: int = 30):
    """
    Clean up old thumbnails from the cache.
    
    Args:
        max_age_days: Maximum age in days before thumbnails are deleted
    """
    try:
        import time
        
        cache_root = Path(cache_dir)
        if not cache_root.exists():
            return
        
        max_age_seconds = max_age_days * 24 * 60 * 60
        current_time = time.time()
        
        for artifact_name in ("thumbnail.png", "preview.png"):
            for thumb_path in cache_root.glob(f"**/{artifact_name}"):
                try:
                    file_age = current_time - thumb_path.stat().st_mtime
                    if file_age > max_age_seconds:
                        thumb_path.unlink()
                        logger.debug(f"Deleted old thumbnail artifact: {thumb_path}")
                except Exception as e:
                    logger.warning(f"Error deleting thumbnail artifact {thumb_path}: {e}")
                
    except Exception as e:
        logger.error(f"Error cleaning up thumbnails: {e}")


def _progressive_needles(text):
    """Progressively shorter needles (full text → first sentence → first N words)
    to tolerate whitespace/hyphenation differences between extracted text and the
    PDF."""
    import re as _re
    text = (text or "").strip()
    if len(text) < 8:
        return []
    cands = [text]
    first_sent = _re.split(r"(?<=[.!?])\s+", text)[0].strip()
    if first_sent and first_sent != text and len(first_sent) >= 12:
        cands.append(first_sent)
    words = text.split()
    if len(words) > 10:
        cands.append(" ".join(words[:10]))
    if len(words) > 5:
        cands.append(" ".join(words[:5]))
    return cands


def _norm_word(w):
    """Lowercase a token and strip leading/trailing punctuation so hyphenation,
    quotes and trailing periods don't defeat first/last-word anchoring."""
    import re as _re
    return _re.sub(r"^[^\w]+|[^\w]+$", "", (w or "").lower())


def _token_anchored_span_on_page(page, text, max_rects=24, anchor_n=3):
    """R13: token-anchored WHOLE-sentence span.

    When ``page.search_for(full_needle)`` fails because the PDF text layer has a
    soft line break / hyphenation that the extracted needle lacks, locate the
    sentence by its endpoints instead of as one contiguous string:

      1. ``page.get_text('words')`` → ordered (rect, word) tokens in reading order.
      2. Fuzzy-align the first ``anchor_n`` normalized needle words to a START
         token and the last ``anchor_n`` normalized needle words to an END token
         that comes after START.
      3. Union the rects of every word BETWEEN those anchors (inclusive),
         grouped per text line, so the highlight covers the whole sentence —
         not a 5-word prefix.

    Returns a list of normalized 0..1 rects (one per covered line), or [] if the
    endpoints can't be anchored (caller then degrades to the 5-word fallback).
    Never fabricates a position.
    """
    needle_words = [_norm_word(w) for w in (text or "").split()]
    needle_words = [w for w in needle_words if w]
    if len(needle_words) < 4:
        return []  # too short to anchor by endpoints — let the prefix fallback handle it

    try:
        raw = page.get_text("words")  # (x0, y0, x1, y1, word, block, line, word_no)
    except Exception:
        return []
    if not raw:
        return []

    pw = float(page.rect.width) or 1.0
    ph = float(page.rect.height) or 1.0

    toks = []
    for w in raw:
        nw = _norm_word(w[4])
        if not nw:
            continue
        toks.append((float(w[0]), float(w[1]), float(w[2]), float(w[3]),
                     nw, int(w[5]), int(w[6])))
    if not toks:
        return []
    page_words = [t[4] for t in toks]
    n = len(page_words)

    head = needle_words[:anchor_n]
    tail = needle_words[-anchor_n:]

    def _match_run(anchor, start_at):
        """Index of the first token >= start_at where as many of ``anchor`` words
        as possible align in order (tolerating a missing/hyphen-split word).
        Requires at least the first anchor word to match exactly."""
        a0 = anchor[0]
        for i in range(start_at, n):
            if page_words[i] != a0:
                continue
            ai, ti, miss = 1, i + 1, 0
            while ai < len(anchor) and ti < n and miss <= 1:
                if page_words[ti] == anchor[ai]:
                    ai += 1
                    ti += 1
                else:
                    miss += 1
                    ti += 1
            if ai >= 1:  # head word matched; trailing anchors are a bonus
                return i
        return -1

    start_i = _match_run(head, 0)
    if start_i < 0:
        return []
    # End anchor: the NEAREST occurrence of the tail's first word at/after
    # start_i whose following tokens confirm the rest of the tail run (>=2 tail
    # words aligned in order, tolerating one missing/hyphen-split word). Using
    # the nearest *confirmed* run — rather than the LAST occurrence of the tail's
    # first word — stops the union from over-running past the real sentence end
    # when that word (often a stopword like "the"/"and") repeats later on the
    # page. A single-word tail (anchor_n==1) has nothing to confirm, so it falls
    # back to the first occurrence.
    t0 = tail[0]
    tail_needs_confirm = len(tail) >= 2

    def _confirm_tail_at(i):
        """If a tail run starts at token ``i``, return the index of its final
        aligned tail token; else -1. Requires >=2 tail words to align (in order,
        tolerating one miss) so a lone repeated first word can't anchor the end."""
        last = i
        matched = 1  # tail[0] already matched at i
        ai, ti, miss = 1, i + 1, 0
        while ai < len(tail) and ti < n and miss <= 1:
            if page_words[ti] == tail[ai]:
                last = ti
                matched += 1
                ai += 1
                ti += 1
            else:
                miss += 1
                ti += 1
        if matched >= 2:
            return last
        return -1

    end_first = -1
    end_i = -1
    for i in range(start_i, n):
        if page_words[i] != t0:
            continue
        if not tail_needs_confirm:
            end_first, end_i = i, i
            break
        last = _confirm_tail_at(i)
        if last >= 0:
            end_first, end_i = i, last
            break
    if end_first < 0:
        return []
    if end_i < start_i:
        return []

    # Guard against runaway spans (anchors that matched far-apart noise): the
    # span shouldn't be wildly longer than the sentence itself.
    if (end_i - start_i + 1) > len(needle_words) * 4 + 12:
        return []

    # Union the covered tokens per (block, line) into line rects.
    by_line = {}
    for t in toks[start_i:end_i + 1]:
        key = (t[5], t[6])
        x0, y0, x1, y1 = t[0], t[1], t[2], t[3]
        if key in by_line:
            bx0, by0, bx1, by1 = by_line[key]
            by_line[key] = (min(bx0, x0), min(by0, y0), max(bx1, x1), max(by1, y1))
        else:
            by_line[key] = (x0, y0, x1, y1)

    rects = []
    for key in sorted(by_line):
        x0, y0, x1, y1 = by_line[key]
        rects.append([round(x0 / pw, 4), round(y0 / ph, 4),
                      round(x1 / pw, 4), round(y1 / ph, 4)])
        if len(rects) >= max_rects:
            break
    return rects


def _search_pages_for_text(pages, text, max_rects=12):
    """Return (page_index, [normalized rects]) for the first page on which the
    text is found, else (None, []). Never fabricates a position. Rects are 0..1
    page-normalized so they overlay at any display size.

    Match order per page (R13):
      1. ``search_for`` the full text / first sentence (contiguous match).
      2. If that fails, a TOKEN-ANCHORED whole-sentence span (union of word
         rects between the fuzzy-aligned first + last words) — so a sentence
         broken by a soft line break / hyphenation is still highlighted whole.
      3. Only if BOTH fail, the 5-word prefix needle (last resort)."""
    cands = _progressive_needles(text)
    if not cands:
        return None, []
    # The contiguous needles to try first: everything EXCEPT the short 5-word
    # prefix (index -1 when present), which is the explicit last-resort fallback.
    words = (text or "").split()
    has_prefix_fallback = len(words) > 5
    contiguous = cands[:-1] if has_prefix_fallback else cands

    for pno, page in enumerate(pages):
        pw = float(page.rect.width) or 1.0
        ph = float(page.rect.height) or 1.0
        rects = []
        # 1) contiguous search_for on the full text / first sentence.
        for needle in contiguous:
            if len(needle) < 8:
                continue
            try:
                hits = page.search_for(needle)
            except Exception:
                hits = []
            if hits:
                for r in hits[:max_rects]:
                    rects.append([round(r.x0 / pw, 4), round(r.y0 / ph, 4),
                                  round(r.x1 / pw, 4), round(r.y1 / ph, 4)])
                break
        if rects:
            return pno, rects
        # 2) token-anchored whole-sentence span (covers soft-break/hyphenation).
        anchored = _token_anchored_span_on_page(page, text, max_rects=max_rects)
        if anchored:
            return pno, anchored

    # 3) Last resort: the 5-word prefix needle (only when token-anchoring failed
    #    on every page), so a short prefix highlight is better than nothing.
    if has_prefix_fallback:
        prefix = cands[-1]
        for pno, page in enumerate(pages):
            pw = float(page.rect.width) or 1.0
            ph = float(page.rect.height) or 1.0
            if len(prefix) < 8:
                break
            try:
                hits = page.search_for(prefix)
            except Exception:
                hits = []
            if hits:
                return pno, [[round(r.x0 / pw, 4), round(r.y0 / ph, 4),
                              round(r.x1 / pw, 4), round(r.y1 / ph, 4)]
                             for r in hits[:max_rects]]
    return None, []


def locate_text_spans_in_pdf(pdf_path, targets, max_pages=80):
    """Locate target texts inside a PDF and return normalized rectangles.

    For each target ({text, span_index, span_type, band, reason, model_score,
    ref_id, ref_text}) search the PDF with PyMuPDF, returning the page index and
    rect(s) in normalized 0..1 page coordinates (render-independent, so the
    frontend can overlay them on the page image at any display size). Uses
    progressively shorter needles (full text, first sentence, first N words) to
    tolerate the whitespace/hyphenation differences between extracted text and
    the PDF.

    R28/R29/R30: ``ref_id`` is echoed for EVERY span (including AI spans that
    carry no ``ref_text``) so the frontend can branch uniformly over results.
    When a span carries ``ref_text`` (the cited reference's title/text), the
    matching reference-list ENTRY rect is also located inside the same PDF and
    returned as ``ref_found``/``ref_page``/``ref_rects`` so an inline-citation
    click can scroll + flash that entry in-document instead of switching tabs.

    Never fabricates a position: a target that cannot be found returns
    found=False; a reference entry that cannot be found returns ref_found=False.
    """
    out = []

    def _empty(t):
        return {
            "span_index": t.get("span_index"),
            "span_type": t.get("span_type"),
            "ref_id": t.get("ref_id"),
            "found": False, "page": None, "rects": [],
            "ref_found": False, "ref_page": None, "ref_rects": [],
        }

    try:
        import fitz  # PyMuPDF
    except Exception:
        return [_empty(t) for t in targets]
    try:
        doc = fitz.open(pdf_path)
    except Exception:
        return [_empty(t) for t in targets]
    try:
        npages = min(doc.page_count, max_pages)
        pages = [doc.load_page(p) for p in range(npages)]
        for t in targets:
            text = (t.get("text") or "").strip()
            ref_text = (t.get("ref_text") or "").strip()
            res = {
                "span_index": t.get("span_index"),
                "span_type": t.get("span_type"),
                "band": t.get("band"),
                "reason": t.get("reason"),
                "model_score": t.get("model_score"),
                # ref_id is echoed for every span (incl. AI spans) — never dropped.
                "ref_id": t.get("ref_id"),
                "found": False, "page": None, "rects": [],
                # reference-list entry rect (in-PDF jump target); never fabricated.
                "ref_found": False, "ref_page": None, "ref_rects": [],
            }
            if len(text) >= 8:
                pno, rects = _search_pages_for_text(pages, text)
                if rects:
                    res.update(found=True, page=pno, rects=rects)
            if len(ref_text) >= 8:
                rpno, rrects = _search_pages_for_text(pages, ref_text)
                if rrects:
                    res.update(ref_found=True, ref_page=rpno, ref_rects=rrects)
            out.append(res)
        return out
    finally:
        try:
            doc.close()
        except Exception:
            pass
