"""Resolve image references in markdown and HTML for the ingest pipeline.

Three responsibilities:

1. **Markdown image parsing** — find ``![alt](path)`` and ``![[path]]``
   (Obsidian wikilink) references inside markdown source text.
2. **Local sibling resolution** — given the original file's directory,
   resolve relative paths (``./assets/photo.png``, ``Pasted image.png``,
   ``../images/diagram.jpg``) to actual files on disk.
3. **Remote image download** — fetch ``https://`` image URLs via httpx
   with size/type guards so we don't pull tracking pixels or 50 MB PNGs.
4. **HTML image extraction** — parse ``<img src="">`` tags from raw HTML
   for the web-scraping pipeline.

All functions are pure I/O helpers — no LLM calls. The caller (kb/service.py
or agent/tools.py) is responsible for sending resolved images through the
vision pipeline.

Design notes:
- Size cap: 10 MB per image (matches ``raw_watcher._MAX_FILE_BYTES``).
- Skip patterns: tracking pixels (1x1), data URIs, SVG inline, common
  ad/tracking domains.
- Concurrency: ``download_images`` uses a thread pool for parallel fetches.
"""

from __future__ import annotations

import logging
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from urllib.parse import urljoin, urlparse

log = logging.getLogger(__name__)

# ── Constants ────────────────────────────────────────────────────────────────

_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".tiff", ".tif"})  # arch:deterministic — image format capabilities of the vision pipeline, mirrors raw_watcher._IMAGE_EXTS

_MAX_IMAGE_BYTES = 10 * 1024 * 1024  # 10 MB

# Domains that serve tracking pixels / ads, not real content images.
_SKIP_DOMAINS = frozenset({  # arch:deterministic — ad/tracking infrastructure, not content policy
    "pixel.", "tracking.", "analytics.", "doubleclick.net",
    "googlesyndication.com", "facebook.com/tr", "ads.",
})

# Markdown image reference patterns.
# Standard: ![alt text](path/to/image.png)
# Also matches ![alt text](path "title")
_MD_IMAGE_RE = re.compile(
    r"!\[(?P<alt>[^\]]*)\]\((?P<url>[^)\s]+)(?:\s+\"[^\"]*\")?\)"
)

# Obsidian wikilink images: ![[Pasted image 20250412.png]]
# May include size hints: ![[image.png|400]]
_OBSIDIAN_IMAGE_RE = re.compile(
    r"!\[\[(?P<path>[^\]|]+?)(?:\|[^\]]+)?\]\]"
)

# HTML <img> tags — loose match that handles single/double/no quotes.
_HTML_IMG_RE = re.compile(
    r"""<img\s[^>]*?src\s*=\s*(?:["'](?P<q>[^"']+)["']|(?P<nq>\S+))""",
    re.IGNORECASE,
)

# Common decorative / UI image paths to skip.
_DECORATIVE_PATTERNS = re.compile(
    r"(logo|icon|avatar|favicon|badge|button|arrow|spacer|pixel|tracking|"
    r"sprite|emoji|loading|spinner|placeholder)",
    re.IGNORECASE,
)


# ── Dataclass for resolved images ────────────────────────────────────────────

class ResolvedImage:
    """A single image reference that has been located or downloaded."""

    __slots__ = ("original_ref", "alt_text", "local_path", "source_url", "error")

    def __init__(
        self,
        original_ref: str,
        alt_text: str = "",
        local_path: Path | None = None,
        source_url: str = "",
        error: str = "",
    ):
        self.original_ref = original_ref
        self.alt_text = alt_text
        self.local_path = local_path
        self.source_url = source_url
        self.error = error

    @property
    def resolved(self) -> bool:
        return self.local_path is not None and self.local_path.exists()


# ── Markdown image reference parsing ─────────────────────────────────────────

def find_markdown_image_refs(text: str) -> list[dict]:
    """Parse all image references from markdown text.

    Returns a list of dicts with keys: ``ref`` (the full match string),
    ``path`` (the URL or file path), ``alt`` (alt text), ``style``
    (``"standard"`` or ``"obsidian"``).
    """
    refs: list[dict] = []
    seen: set[str] = set()

    for m in _MD_IMAGE_RE.finditer(text):
        path = m.group("url")
        if path in seen:
            continue
        seen.add(path)
        refs.append({
            "ref": m.group(0),
            "path": path,
            "alt": m.group("alt"),
            "style": "standard",
        })

    for m in _OBSIDIAN_IMAGE_RE.finditer(text):
        path = m.group("path")
        if path in seen:
            continue
        seen.add(path)
        refs.append({
            "ref": m.group(0),
            "path": path,
            "alt": "",
            "style": "obsidian",
        })

    return refs


def _is_image_path(path: str) -> bool:
    """Check if a path/URL looks like an image file."""
    # Strip query params for extension check
    clean = path.split("?")[0].split("#")[0]
    ext = Path(clean).suffix.lower()
    return ext in _IMAGE_EXTS


def _is_skip_url(url: str) -> bool:
    """Check if a URL should be skipped (tracking pixel, ad, etc.)."""
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    for skip in _SKIP_DOMAINS:
        if skip in host:
            return True
    # Data URIs
    if url.startswith("data:"):
        return True
    return False


# ── Local sibling resolution ─────────────────────────────────────────────────

def resolve_local_images(
    refs: list[dict],
    source_dir: Path,
    search_dirs: list[Path] | None = None,
) -> list[ResolvedImage]:
    """Resolve image references to local files.

    For each ref, checks:
    1. Relative to ``source_dir`` (the markdown file's parent).
    2. Common Obsidian attachment dirs: ``source_dir/assets/``,
       ``source_dir/attachments/``, ``source_dir/images/``.
    3. Any extra ``search_dirs`` provided (e.g., vault root, inbox dir).

    Only resolves local paths (not http:// URLs). Returns a list of
    ``ResolvedImage`` — some may have ``error`` set if the file wasn't found.
    """
    results: list[ResolvedImage] = []
    attachment_subdirs = ["", "assets", "attachments", "images", "files", "media"]

    all_search = [source_dir]
    if search_dirs:
        all_search.extend(search_dirs)

    for ref in refs:
        path_str = ref["path"]

        # Skip remote URLs — they're handled by download_images
        if path_str.startswith(("http://", "https://")):
            continue

        # Skip non-image extensions
        if not _is_image_path(path_str):
            continue

        found_path: Path | None = None

        for base in all_search:
            if not base.is_dir():
                continue
            for subdir in attachment_subdirs:
                candidate = base / subdir / path_str if subdir else base / path_str
                try:
                    if candidate.exists() and candidate.is_file():
                        found_path = candidate
                        break
                except OSError:
                    continue
            if found_path:
                break

        if found_path:
            results.append(ResolvedImage(
                original_ref=ref["ref"],
                alt_text=ref.get("alt", ""),
                local_path=found_path,
            ))
        else:
            results.append(ResolvedImage(
                original_ref=ref["ref"],
                alt_text=ref.get("alt", ""),
                error=f"File not found: {path_str}",
            ))

    return results


# ── Remote image download ────────────────────────────────────────────────────

def _download_single(url: str, dest_dir: Path, timeout: int = 15) -> ResolvedImage:
    """Download a single image URL to ``dest_dir``. Returns a ResolvedImage."""
    if _is_skip_url(url):
        return ResolvedImage(original_ref=url, source_url=url, error="skipped (tracking/ad)")

    try:
        import httpx
    except ImportError:
        return ResolvedImage(original_ref=url, source_url=url, error="httpx not installed")

    # Use a browser-like User-Agent — many CDNs (Wikimedia, Cloudflare)
    # reject bare httpx/python requests or return text/plain.
    _headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
        "Accept": "image/*,*/*;q=0.8",
    }

    try:
        with httpx.Client(timeout=timeout, follow_redirects=True, verify=False, headers=_headers) as client:
            # HEAD first to check size — skip content-type check here
            # because many CDNs return wrong content-types for HEAD.
            try:
                head = client.head(url)
                cl = int(head.headers.get("content-length", "0") or "0")

                if cl > _MAX_IMAGE_BYTES:
                    return ResolvedImage(
                        original_ref=url, source_url=url,
                        error=f"too large ({cl} bytes)",
                    )
            except Exception:
                pass  # HEAD failed — try GET anyway

            resp = client.get(url)
            if resp.status_code >= 400:
                return ResolvedImage(
                    original_ref=url, source_url=url,
                    error=f"HTTP {resp.status_code}",
                )

            data = resp.content
            if len(data) > _MAX_IMAGE_BYTES:
                return ResolvedImage(
                    original_ref=url, source_url=url,
                    error=f"too large ({len(data)} bytes)",
                )
            if len(data) < 100:
                return ResolvedImage(
                    original_ref=url, source_url=url,
                    error="too small (likely tracking pixel)",
                )

            # Derive filename from URL
            parsed = urlparse(url)
            path_part = parsed.path.rstrip("/")
            fname = Path(path_part).name or "image"
            # Ensure it has an image extension
            if not _is_image_path(fname):
                ct = resp.headers.get("content-type", "")
                ext = ".jpg"  # safe default
                if "png" in ct:
                    ext = ".png"
                elif "gif" in ct:
                    ext = ".gif"
                elif "webp" in ct:
                    ext = ".webp"
                elif "svg" in ct:
                    ext = ".svg"
                fname = fname + ext

            # Sanitize filename
            fname = re.sub(r"[^\w.\-]", "_", fname)[:120]

            dest = dest_dir / fname
            # Avoid collisions
            counter = 1
            stem = dest.stem
            while dest.exists():
                dest = dest_dir / f"{stem}_{counter}{dest.suffix}"
                counter += 1

            dest.write_bytes(data)
            return ResolvedImage(
                original_ref=url, source_url=url,
                alt_text="", local_path=dest,
            )

    except Exception as exc:
        return ResolvedImage(
            original_ref=url, source_url=url,
            error=f"{type(exc).__name__}: {exc}",
        )


def download_images(
    urls: list[str],
    dest_dir: Path,
    *,
    max_images: int = 10,
    timeout: int = 15,
    concurrency: int = 5,
) -> list[ResolvedImage]:
    """Download multiple image URLs in parallel.

    Filters out non-image URLs and decorative patterns. Caps at
    ``max_images`` to avoid pulling 50 images from a carousel page.
    """
    dest_dir.mkdir(parents=True, exist_ok=True)

    # Filter to actual images
    filtered: list[str] = []
    for url in urls:
        if not url or _is_skip_url(url):
            continue
        # Must look like an image URL OR we allow it and let content-type decide
        basename = Path(urlparse(url).path).name
        if _DECORATIVE_PATTERNS.search(basename):
            continue
        filtered.append(url)
        if len(filtered) >= max_images:
            break

    if not filtered:
        return []

    results: list[ResolvedImage] = []
    workers = max(1, min(concurrency, len(filtered)))
    with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="img-dl") as pool:
        future_to_url = {
            pool.submit(_download_single, url, dest_dir, timeout): url
            for url in filtered
        }
        for fut in as_completed(future_to_url):
            try:
                results.append(fut.result())
            except Exception as exc:
                url = future_to_url[fut]
                results.append(ResolvedImage(
                    original_ref=url, source_url=url,
                    error=f"{type(exc).__name__}: {exc}",
                ))

    return results


# ── HTML image extraction ────────────────────────────────────────────────────

def extract_image_urls_from_html(html: str, base_url: str = "") -> list[str]:
    """Extract image URLs from raw HTML ``<img>`` tags.

    Resolves relative URLs against ``base_url`` when provided.
    Filters out data URIs, tracking pixels, and decorative images.
    """
    if not html:
        return []

    urls: list[str] = []
    seen: set[str] = set()

    for m in _HTML_IMG_RE.finditer(html):
        src = m.group("q") or m.group("nq") or ""
        if not src or src.startswith("data:"):
            continue

        # Resolve relative URLs
        if base_url and not src.startswith(("http://", "https://")):
            src = urljoin(base_url, src)

        if src in seen:
            continue
        seen.add(src)

        if _is_skip_url(src):
            continue

        basename = Path(urlparse(src).path).name
        if _DECORATIVE_PATTERNS.search(basename):
            continue

        urls.append(src)

    return urls


# ── Convenience: full resolve pipeline for markdown ──────────────────────────

def resolve_all_images(
    text: str,
    source_dir: Path,
    dest_dir: Path,
    search_dirs: list[Path] | None = None,
    *,
    max_remote: int = 10,
    download_timeout: int = 15,
) -> list[ResolvedImage]:
    """Parse markdown, resolve local images, download remote ones.

    This is the main entry point for the ingest pipeline.
    Returns all resolved images (both local and downloaded).
    """
    refs = find_markdown_image_refs(text)
    if not refs:
        return []

    # Split into local and remote.
    # For remote URLs we trust the markdown author's intent — a URL inside
    # ``![alt](url)`` is declared as an image even without a .jpg/.png
    # extension (CDNs like Unsplash, Imgur, Cloudinary use extensionless
    # URLs).  The downloader validates content-type on the actual response.
    local_refs = [r for r in refs if not r["path"].startswith(("http://", "https://"))]
    remote_urls = [
        r["path"] for r in refs
        if r["path"].startswith(("http://", "https://"))
        and not _is_skip_url(r["path"])
    ]

    results: list[ResolvedImage] = []

    # Resolve local images
    if local_refs:
        results.extend(resolve_local_images(local_refs, source_dir, search_dirs))

    # Download remote images
    if remote_urls:
        results.extend(download_images(
            remote_urls, dest_dir,
            max_images=max_remote,
            timeout=download_timeout,
        ))

    return results
