"""Citation formatter for adding hyperlinks and alternative citation styles."""

import re
from enum import Enum
from typing import Any, Dict, List, Tuple
from urllib.parse import urlparse

_SOURCES_SECTION_PATTERNS = [
    re.compile(
        r"^#{1,3}\s*(?:Sources|References|Bibliography|Citations)",
        re.MULTILINE | re.IGNORECASE,
    ),
    re.compile(
        r"^(?:Sources|References|Bibliography|Citations):?\s*$",
        re.MULTILINE | re.IGNORECASE,
    ),
]


def find_sources_section(content: str) -> int:
    """Find the start position of the sources/references section in *content*.

    Returns -1 if no section is found.
    """
    for pattern in _SOURCES_SECTION_PATTERNS:
        match = pattern.search(content)
        if match:
            return match.start()
    return -1


class CitationMode(Enum):
    """Available citation formatting modes."""

    NUMBER_HYPERLINKS = "number_hyperlinks"  # [1] with hyperlinks
    DOMAIN_HYPERLINKS = "domain_hyperlinks"  # [arxiv.org] with hyperlinks
    DOMAIN_ID_HYPERLINKS = (
        "domain_id_hyperlinks"  # [arxiv.org] or [arxiv.org-1] with smart IDs
    )
    DOMAIN_ID_ALWAYS_HYPERLINKS = (
        "domain_id_always_hyperlinks"  # [arxiv.org-1] always with IDs
    )
    SOURCE_TAGGED_HYPERLINKS = "source_tagged_hyperlinks"
    """Preserve the global citation number and prefix it with a short source
    tag derived from the URL: known academic sources via ``URLClassifier``
    (``arxiv-7``, ``pubmed-3``), domain otherwise (``nytimes.com-9``), and
    ``local-N`` for empty / local URLs. Unlike DOMAIN_ID_* modes the
    suffix is the original citation number, so labels never collide and
    match the bibliography order: ``[1]`` arxiv + ``[2]`` openai + ``[3]``
    arxiv -> ``arxiv-1``, ``openai-2``, ``arxiv-3``."""
    NO_HYPERLINKS = "no_hyperlinks"  # [1] without hyperlinks


class CitationFormatter:
    """Formats citations in markdown documents with various styles."""

    def __init__(self, mode: CitationMode = CitationMode.NUMBER_HYPERLINKS):
        self.mode = mode
        # Use negative lookbehind and lookahead to avoid matching already formatted citations
        # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate
        self.citation_pattern = re.compile(
            r"(?<![\[【])[\[【](\d+)[\]】](?![\]】])"
        )
        self.comma_citation_pattern = re.compile(
            r"[\[【](\d+(?:,\s*\d+)+)[\]】]"
        )
        # Also match "Source X" or "source X" patterns
        self.source_word_pattern = re.compile(r"\b[Ss]ource\s+(\d+)\b")
        self.sources_pattern = re.compile(
            r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$",
            re.MULTILINE,
        )

    def _create_source_word_replacer(self, formatter_func):
        """Create a replacement function for 'Source X' patterns.

        Args:
            formatter_func: A function that takes citation_num and returns formatted text

        Returns:
            A replacement function for use with regex sub
        """

        def replace_source_word(match):
            citation_num = match.group(1)
            return formatter_func(citation_num)

        return replace_source_word

    def _create_citation_formatter(self, sources_dict, format_pattern):
        """Create a formatter function for citations.

        Args:
            sources_dict: Dictionary mapping citation numbers to data
            format_pattern: A callable that takes (citation_num, data) and returns formatted string

        Returns:
            A function that formats citations or returns fallback
        """

        def formatter(citation_num):
            if citation_num in sources_dict:
                data = sources_dict[citation_num]
                return format_pattern(citation_num, data)
            return f"[{citation_num}]"

        return formatter

    def _replace_comma_citations(self, content, lookup, format_one):
        """Replace comma-separated citations like [1, 2, 3] using *lookup* and *format_one*.

        Args:
            content: Text to process
            lookup: Dict mapping citation number (str) to data
            format_one: ``(num, data) -> str`` callback that formats a single citation
        """

        def _replacer(match):
            nums = [n.strip() for n in match.group(1).split(",")]
            parts = []
            for num in nums:
                if num in lookup:
                    parts.append(format_one(num, lookup[num]))
                else:
                    parts.append(f"[{num}]")
            return "".join(parts)

        return self.comma_citation_pattern.sub(_replacer, content)

    def format_document(self, content: str) -> str:
        """Format citations and return the concatenated answer + sources blob.

        Kept for backward compatibility — most call sites only need the
        concatenated string. New code that needs to persist answer-only
        should use :meth:`format_document_split` instead so the boundary
        is returned explicitly (no re-parsing of the concatenated output).
        """
        formatted_answer, sources_md = self.format_document_split(content)
        return formatted_answer + sources_md

    def format_document_split(self, content: str) -> Tuple[str, str]:
        """Format citations and return (answer, sources_md) separately.

        The boundary between the LLM's answer and the trailing Sources
        section is computed inside this method. Callers that only want
        the answer (e.g. the chat-mode save site) get a clean split
        without re-applying a regex on concatenated output downstream.

        Returns ``(content, "")`` when the formatter is in NO_HYPERLINKS
        mode or when no Sources section can be found in ``content``.
        """
        if self.mode == CitationMode.NO_HYPERLINKS:
            return content, ""

        sources_start = self._find_sources_section(content)
        if sources_start == -1:
            return content, ""

        document_content = content[:sources_start]
        sources_content = content[sources_start:]

        sources = self._parse_sources(sources_content)

        if self.mode == CitationMode.NUMBER_HYPERLINKS:
            formatted_content = self._format_number_hyperlinks(
                document_content, sources
            )
        elif self.mode == CitationMode.DOMAIN_HYPERLINKS:
            formatted_content = self._format_domain_hyperlinks(
                document_content, sources
            )
        elif self.mode == CitationMode.DOMAIN_ID_HYPERLINKS:
            formatted_content = self._format_domain_id_hyperlinks(
                document_content, sources
            )
        elif self.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS:
            formatted_content = self._format_domain_id_always_hyperlinks(
                document_content, sources
            )
        elif self.mode == CitationMode.SOURCE_TAGGED_HYPERLINKS:
            formatted_content = self._format_source_tagged_hyperlinks(
                document_content,
                sources,
                self._parse_collections(sources_content),
            )
        else:
            formatted_content = document_content

        return formatted_content, sources_content

    def apply_inline_hyperlinks(
        self, content: str, sources: List[Dict[str, Any]]
    ) -> str:
        """Hyperlink ``[N]`` refs using a structured source list.

        Dispatches on ``self.mode`` so the user's chosen citation
        format (Settings → Report → Citation Format) is honored on
        the fallback path the same way it is in
        :meth:`format_document_split`. Inherits all the existing
        per-mode guards (lookbehind/lookahead against ``[[1]]``,
        comma-list handling like ``[1,2,3]``, ``Source N`` word form,
        missing-index pass-through, lenticular bracket support).

        Used as the safe fallback at save time when the LLM does NOT
        emit a Sources section in its prose — the structured source
        list (e.g. ``search_system.all_links_of_system``) is the
        canonical source of URLs and indices.
        """
        if not content or not sources:
            return content or ""
        if self.mode == CitationMode.NO_HYPERLINKS:
            return content

        # Search-engine result dicts use either "url" or "link" for the
        # destination — Searxng emits {"link": ..., "title": ..., "snippet": ...}
        # (search_engine_searxng.py:538) and other engines use "url".
        # Looking up only `s["url"]` silently dropped every Searxng-sourced
        # citation, leaving the answer body with plain `[N]` brackets even
        # though the Sources section beneath was fully populated. Accept
        # both keys so the hyperlink fallback works regardless of engine.
        def _src_url(s):
            return s.get("url") or s.get("link") or ""

        adapted: Dict[str, Tuple[str, str]] = {
            str(s["index"]): (s.get("title", "Untitled"), _src_url(s))
            for s in sources
            if _src_url(s) and s.get("index") is not None
        }
        if not adapted:
            return content

        # Per-mode dispatch — mirrors format_document_split so the user's
        # chosen citation format applies on this fallback path too.
        # Previously this was hard-coded to _format_number_hyperlinks,
        # which meant chat-mode answers (which always hit this fallback
        # because the langgraph-agent synthesis doesn't emit a ## Sources
        # block in its prose) ignored the report.citation_format setting
        # entirely — every chat answer came out as [[N]](url) even when
        # the user picked domain-based or source-tagged formatting.
        if self.mode == CitationMode.DOMAIN_HYPERLINKS:
            return self._format_domain_hyperlinks(content, adapted)
        if self.mode == CitationMode.DOMAIN_ID_HYPERLINKS:
            return self._format_domain_id_hyperlinks(content, adapted)
        if self.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS:
            return self._format_domain_id_always_hyperlinks(content, adapted)
        if self.mode == CitationMode.SOURCE_TAGGED_HYPERLINKS:
            # Pull collection names off the structured source dicts
            # (format_links_to_markdown uses the same shape:
            # link["metadata"]["collection_name"]) so the SOURCE_TAGGED
            # formatter can surface library/RAG tags as the citation
            # label when present.
            collections: Dict[str, str] = {}
            for s in sources:
                idx = s.get("index")
                if idx is None:
                    continue
                meta = s.get("metadata") or {}
                coll = meta.get("collection_name")
                if coll:
                    collections.setdefault(str(idx), str(coll))
            return self._format_source_tagged_hyperlinks(
                content, adapted, collections
            )
        # NUMBER_HYPERLINKS is the default and the catch-all for any
        # mode added later that doesn't have an explicit branch above.
        return self._format_number_hyperlinks(content, adapted)

    def _find_sources_section(self, content: str) -> int:
        """Find the start of the sources/references section."""
        return find_sources_section(content)

    def _parse_sources(
        self, sources_content: str
    ) -> Dict[str, Tuple[str, str]]:
        """
        Parse sources section to extract citation numbers, titles, and URLs.

        Returns:
            Dictionary mapping citation number to (title, url) tuple
        """
        sources = {}
        matches = list(self.sources_pattern.finditer(sources_content))

        for match in matches:
            citation_nums_str = match.group(1)
            title = match.group(2).strip()
            url = match.group(3).strip() if match.group(3) else ""

            # Handle comma-separated citation numbers like [36, 3]
            # Split by comma and strip whitespace
            individual_nums = [
                num.strip() for num in citation_nums_str.split(",")
            ]

            # Add an entry for each individual number
            for num in individual_nums:
                sources[num] = (title, url)

        return sources

    def _format_number_hyperlinks(
        self, content: str, sources: Dict[str, Tuple[str, str]]
    ) -> str:
        """Replace [1] with hyperlinked version where only the number is linked."""
        # Filter sources that have URLs
        url_sources = {
            num: (title, url) for num, (title, url) in sources.items() if url
        }

        # Create formatter for citations with number hyperlinks
        def format_number_link(citation_num, data):
            _, url = data
            return f"[[{citation_num}]]({url})"

        # Handle comma-separated citations like [1, 2, 3]
        content = self._replace_comma_citations(
            content, url_sources, format_number_link
        )

        formatter = self._create_citation_formatter(
            url_sources, format_number_link
        )

        # Handle individual citations
        def replace_citation(match):
            return (
                formatter(match.group(1))
                if match.group(1) in url_sources
                else match.group(0)
            )

        content = self.citation_pattern.sub(replace_citation, content)

        # Also handle "Source X" patterns
        return self.source_word_pattern.sub(
            self._create_source_word_replacer(formatter), content
        )

    def _format_domain_hyperlinks(
        self, content: str, sources: Dict[str, Tuple[str, str]]
    ) -> str:
        """Replace [1] with [domain.com] hyperlinked version."""

        # Filter sources that have URLs
        url_sources = {
            num: (title, url) for num, (title, url) in sources.items() if url
        }

        # Create formatter for citations with domain hyperlinks
        def format_domain_link(citation_num, data):
            _, url = data
            domain = self._extract_domain(url)
            return f"[[{domain}]]({url})"

        # Handle comma-separated citations like [1, 2, 3]
        content = self._replace_comma_citations(
            content, url_sources, format_domain_link
        )

        formatter = self._create_citation_formatter(
            url_sources, format_domain_link
        )

        # Handle individual citations
        def replace_citation(match):
            return (
                formatter(match.group(1))
                if match.group(1) in url_sources
                else match.group(0)
            )

        content = self.citation_pattern.sub(replace_citation, content)

        # Also handle "Source X" patterns
        return self.source_word_pattern.sub(
            self._create_source_word_replacer(formatter), content
        )

    def _format_domain_id_hyperlinks(
        self, content: str, sources: Dict[str, Tuple[str, str]]
    ) -> str:
        """Replace [1] with [domain.com-1] hyperlinked version with hyphen-separated IDs."""
        # First, create a mapping of domains to their citation numbers
        domain_citations: dict[str, list[Any]] = {}

        for citation_num, (title, url) in sources.items():
            if url:
                domain = self._extract_domain(url)
                if domain not in domain_citations:
                    domain_citations[domain] = []
                domain_citations[domain].append((citation_num, url))

        # Create a mapping from citation number to domain with ID
        citation_to_domain_id = {}
        for domain, citations in domain_citations.items():
            if len(citations) > 1:
                # Multiple citations from same domain - add hyphen and number
                for idx, (citation_num, url) in enumerate(citations, 1):
                    citation_to_domain_id[citation_num] = (
                        f"{domain}-{idx}",
                        url,
                    )
            else:
                # Single citation from domain - no ID needed
                citation_num, url = citations[0]
                citation_to_domain_id[citation_num] = (domain, url)

        # Create formatter for citations with domain_id hyperlinks
        def format_domain_id_link(citation_num, data):
            domain_id, url = data
            return f"[[{domain_id}]]({url})"

        # Handle comma-separated citations
        content = self._replace_comma_citations(
            content, citation_to_domain_id, format_domain_id_link
        )

        formatter = self._create_citation_formatter(
            citation_to_domain_id, format_domain_id_link
        )

        # Handle individual citations
        def replace_citation(match):
            return (
                formatter(match.group(1))
                if match.group(1) in citation_to_domain_id
                else match.group(0)
            )

        content = self.citation_pattern.sub(replace_citation, content)

        # Also handle "Source X" patterns
        return self.source_word_pattern.sub(
            self._create_source_word_replacer(formatter), content
        )

    def _format_domain_id_always_hyperlinks(
        self, content: str, sources: Dict[str, Tuple[str, str]]
    ) -> str:
        """Replace [1] with [domain.com-1] hyperlinked version, always with IDs."""
        # First, create a mapping of domains to their citation numbers
        domain_citations: dict[str, list[Any]] = {}

        for citation_num, (title, url) in sources.items():
            if url:
                domain = self._extract_domain(url)
                if domain not in domain_citations:
                    domain_citations[domain] = []
                domain_citations[domain].append((citation_num, url))

        # Create a mapping from citation number to domain with ID
        citation_to_domain_id = {}
        for domain, citations in domain_citations.items():
            # Always add hyphen and number for consistency
            for idx, (citation_num, url) in enumerate(citations, 1):
                citation_to_domain_id[citation_num] = (f"{domain}-{idx}", url)

        # Create formatter for citations with domain_id hyperlinks
        def format_domain_id_link(citation_num, data):
            domain_id, url = data
            return f"[[{domain_id}]]({url})"

        # Handle comma-separated citations
        content = self._replace_comma_citations(
            content, citation_to_domain_id, format_domain_id_link
        )

        formatter = self._create_citation_formatter(
            citation_to_domain_id, format_domain_id_link
        )

        # Handle individual citations
        def replace_citation(match):
            return (
                formatter(match.group(1))
                if match.group(1) in citation_to_domain_id
                else match.group(0)
            )

        content = self.citation_pattern.sub(replace_citation, content)

        # Also handle "Source X" patterns
        return self.source_word_pattern.sub(
            self._create_source_word_replacer(formatter), content
        )

    # Sources section may carry a "Collection: <name>" line for RAG /
    # library hits (emitted by ``utilities/search_utilities.format_links_to_markdown``).
    # The line sits between this ``[N]`` entry's ``URL:`` line and the
    # next ``[N+1]`` entry. We anchor the match on a non-greedy span up
    # to the next citation header (or end of string) to scope correctly.
    _collection_line_pattern = re.compile(
        r"^\[(\d+(?:,\s*\d+)*)\][^\n]*\n"  # the [N] header line
        r"(?:[^\n\[]*\n)*?"  # any non-[ lines (typically URL: ...)
        r"\s*Collection:\s*(.+?)\s*$",
        re.MULTILINE,
    )

    def _parse_collections(self, sources_content: str) -> Dict[str, str]:
        """Extract ``{citation_num: collection_name}`` from a sources
        block. Returns an empty dict when no ``Collection:`` lines exist
        — the absence of collection info is the common case (web URLs)
        and must never raise."""
        collections: Dict[str, str] = {}
        for match in self._collection_line_pattern.finditer(sources_content):
            citation_nums_str = match.group(1)
            collection = match.group(2).strip()
            if not collection:
                continue
            for num in (n.strip() for n in citation_nums_str.split(",")):
                collections[num] = collection
        return collections

    def _format_source_tagged_hyperlinks(
        self,
        content: str,
        sources: Dict[str, Tuple[str, str]],
        collections: Dict[str, str],
    ) -> str:
        """Replace ``[N]`` with ``[[source-N]](url)``.

        ``source`` resolves to (in order): the RAG ``Collection:``
        tag for library hits, the short URLClassifier tag for known
        academic sources (``arxiv``, ``pubmed``, ...), the cleaned
        domain otherwise, or ``local`` for empty/file URLs. ``N`` is
        the original global citation number — labels never collide and
        the suffix always matches the bibliography ordering.

        Args:
            content: Document body (sources section already split off).
            sources: ``{citation_num: (title, url)}`` parsed from the
                sources block.
            collections: ``{citation_num: collection_name}`` parsed from
                optional ``Collection:`` lines in the sources block
                (empty dict when no library/RAG hits are cited). Wins
                over URL-derived tags when present for a given citation.
        """

        def format_link(citation_num, data):
            _, url = data
            label = self._extract_source_label(
                url, collection=collections.get(citation_num)
            )
            tag = f"{label}-{citation_num}"
            # Only emit a hyperlink for http(s) URLs — local/file URLs are
            # rendered as plain bracketed tags so the markdown stays clean
            # and viewers don't try to navigate to a server-local path.
            return (
                f"[[{tag}]]({url})"
                if self._is_linkable_url(url)
                else f"[{tag}]"
            )

        # Handle comma-separated citations like [1, 2, 3]
        content = self._replace_comma_citations(content, sources, format_link)

        formatter = self._create_citation_formatter(sources, format_link)

        # Handle individual citations
        def replace_citation(match):
            return (
                formatter(match.group(1))
                if match.group(1) in sources
                else match.group(0)
            )

        content = self.citation_pattern.sub(replace_citation, content)

        # Also handle "Source X" patterns
        return self.source_word_pattern.sub(
            self._create_source_word_replacer(formatter), content
        )

    @staticmethod
    def _slugify_collection(name: str) -> str:
        """Make a user-set collection name safe for inline citations.

        Collection names are free-form strings (``"My Papers"``,
        ``"team/finance"``). Citations need a compact token that won't
        break markdown — strip whitespace, lowercase, replace runs of
        non-alphanumeric chars with a single hyphen, trim leading and
        trailing hyphens, and fall back to ``"local"`` if the result is
        empty. ``-N`` is appended downstream so we strip trailing
        hyphens to keep the join clean.
        """
        slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-")
        return slug or "local"

    @staticmethod
    def _is_linkable_url(url: str) -> bool:
        """Return True iff ``url`` is a http(s) URL safe to wrap in a
        markdown hyperlink. Empty strings and file:// / local: schemes
        are not linkable."""
        if not url:
            return False
        try:
            scheme = (urlparse(url).scheme or "").lower()
        except (ValueError, AttributeError):
            return False
        return scheme in ("http", "https")

    def _extract_source_label(
        self, url: str, collection: str | None = None
    ) -> str:
        """Return a short source tag for ``url``.

        Resolution order:
        1. ``collection`` (when supplied) wins outright — RAG / library
           hits surface their collection name as the citation tag
           (``mypapers``, ``personal-notes``, ...). The renderer in
           ``utilities/search_utilities.format_links_to_markdown``
           emits a ``Collection:`` line per source for library results,
           which the formatter parses back into this argument.
        2. Empty URL or non-http(s) scheme (``file://``, ``local:``, ...) →
           ``"local"``. Uniform fallback when no collection name is
           available.
        3. ``URLClassifier`` matches a known academic source → use the
           enum value (``arxiv``, ``pubmed``, ``pmc``, ``biorxiv``,
           ``medrxiv``, ``semantic_scholar``, ``doi``).
        4. Otherwise → fall back to ``_extract_domain`` (e.g.
           ``arxiv.org``, ``nytimes.com``).
        """
        if collection:
            return self._slugify_collection(collection)
        if not url:
            return "local"
        try:
            parsed = urlparse(url)
        except (ValueError, AttributeError):
            return "local"
        scheme = (parsed.scheme or "").lower()
        if scheme not in ("http", "https"):
            return "local"

        # Lazy import to keep the formatter usable when the content_fetcher
        # package isn't importable (e.g. minimal test setups).
        try:
            from ..content_fetcher.url_classifier import URLClassifier, URLType
        except ImportError:
            return self._extract_domain(url)

        url_type = URLClassifier.classify(url)
        # Generic HTML/PDF/INVALID → fall back to domain. Everything else
        # is a known academic source whose enum value is the short tag.
        if url_type in (URLType.HTML, URLType.PDF, URLType.INVALID):
            return self._extract_domain(url)
        return url_type.value

    def _extract_domain(self, url: str) -> str:
        """Extract domain name from URL."""
        try:
            parsed = urlparse(url)
            domain = parsed.netloc
            # Remove www. prefix if present
            if domain.startswith("www."):
                domain = domain[4:]
            # Keep known domains as-is
            known_domains = {
                "arxiv.org": "arxiv.org",
                "github.com": "github.com",
                "reddit.com": "reddit.com",
                "youtube.com": "youtube.com",
                "pypi.org": "pypi.org",
                "milvus.io": "milvus.io",
                "medium.com": "medium.com",
            }

            for known, display in known_domains.items():
                if known in domain:
                    return display

            # For other domains, extract main domain
            parts = domain.split(".")
            if len(parts) >= 2:
                return ".".join(parts[-2:])
            return domain
        except (ValueError, AttributeError):
            return "source"


class QuartoExporter:
    """Export markdown documents to Quarto (.qmd) format."""

    def __init__(self):
        # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate
        self.citation_pattern = re.compile(
            r"(?<![\[【])[\[【](\d+)[\]】](?![\]】])"
        )
        self.comma_citation_pattern = re.compile(
            r"[\[【](\d+(?:,\s*\d+)+)[\]】]"
        )

    def export_to_quarto(self, content: str, title: str | None = None) -> str:
        """
        Convert markdown document to Quarto format.

        Args:
            content: Markdown content
            title: Document title (if None, will extract from content)

        Returns:
            Quarto formatted content
        """
        # Extract title from markdown if not provided
        if not title:
            title_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
            title = title_match.group(1) if title_match else "Research Report"

        # Create Quarto YAML header
        from datetime import datetime, UTC

        current_date = datetime.now(UTC).strftime("%Y-%m-%d")
        yaml_header = f"""---
title: "{title}"
author: "Local Deep Research"
date: "{current_date}"
format:
  html:
    toc: true
    toc-depth: 3
    number-sections: true
  pdf:
    toc: true
    number-sections: true
    colorlinks: true
bibliography: references.bib
csl: apa.csl
---

"""

        # Process content
        processed_content = content

        # First handle comma-separated citations like [1, 2, 3]
        def replace_comma_citations(match):
            citation_nums = match.group(1)
            # Split by comma and strip whitespace
            nums = [num.strip() for num in citation_nums.split(",")]
            refs = [f"@ref{num}" for num in nums]
            return f"[{', '.join(refs)}]"

        processed_content = self.comma_citation_pattern.sub(
            replace_comma_citations, processed_content
        )

        # Then convert individual citations to Quarto format [@citation]
        def replace_citation(match):
            citation_num = match.group(1)
            return f"[@ref{citation_num}]"

        processed_content = self.citation_pattern.sub(
            replace_citation, processed_content
        )

        # Generate bibliography file content
        bib_content = self._generate_bibliography(content)

        # Add note about bibliography file
        bibliography_note = (
            "\n\n::: {.callout-note}\n## Bibliography File Required\n\nThis document requires a `references.bib` file in the same directory with the following content:\n\n```bibtex\n"
            + bib_content
            + "\n```\n:::\n"
        )

        return yaml_header + processed_content + bibliography_note

    def _generate_bibliography(self, content: str) -> str:
        """Generate BibTeX bibliography from sources."""
        sources_pattern = re.compile(
            r"^\[(\d+)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", re.MULTILINE
        )

        bibliography = ""
        matches = list(sources_pattern.finditer(content))

        for match in matches:
            citation_num = match.group(1)
            title = match.group(2).strip()
            url = match.group(3).strip() if match.group(3) else ""

            # Generate BibTeX entry
            bib_entry = f"@misc{{ref{citation_num},\n"
            bib_entry += f'  title = "{{{title}}}",\n'
            if url:
                bib_entry += f"  url = {{{url}}},\n"
                bib_entry += f'  howpublished = "\\url{{{url}}}",\n'
            bib_entry += f"  year = {{{2024}}},\n"
            bib_entry += '  note = "Accessed: \\today"\n'
            bib_entry += "}\n"

            bibliography += bib_entry + "\n"

        return bibliography.strip()


class RISExporter:
    """Export references to RIS format for reference managers like Zotero."""

    def __init__(self):
        self.sources_pattern = re.compile(
            r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$",
            re.MULTILINE,
        )

    def export_to_ris(self, content: str) -> str:
        """
        Extract references from markdown and convert to RIS format.

        Args:
            content: Markdown content with sources

        Returns:
            RIS formatted references
        """
        # Find sources section
        sources_start = find_sources_section(content)
        if sources_start == -1:
            return ""

        # Find the end of the first sources section (before any other major section)
        sources_content = content[sources_start:]

        # Look for the next major section to avoid duplicates
        next_section_markers = [
            "\n## ALL SOURCES",
            "\n### ALL SOURCES",
            "\n## Research Metrics",
            "\n### Research Metrics",
            "\n## SEARCH QUESTIONS",
            "\n### SEARCH QUESTIONS",
            "\n## DETAILED FINDINGS",
            "\n### DETAILED FINDINGS",
            "\n---",  # Horizontal rule often separates sections
        ]

        sources_end = len(sources_content)
        for marker in next_section_markers:
            pos = sources_content.find(marker)
            if pos != -1 and pos < sources_end:
                sources_end = pos

        sources_content = sources_content[:sources_end]

        # Parse sources and generate RIS entries
        ris_entries = []
        seen_refs = set()  # Track which references we've already processed

        # Split sources into individual entries
        import re

        # Pattern to match each source entry. Accept both ASCII "[N]" and
        # lenticular "【N】" openers/closers — the inline citation patterns
        # in this file already handle lenticular brackets (some LLMs emit
        # them), so the source-list parser must stay consistent or it would
        # silently drop lenticular-bracketed source entries.
        source_entry_pattern = re.compile(
            r"^[\[【](\d+)[\]】]\s*(.+?)(?=^[\[【]\d+[\]】]|\Z)",
            re.MULTILINE | re.DOTALL,
        )

        for match in source_entry_pattern.finditer(sources_content):
            citation_num = match.group(1)
            entry_text = match.group(2).strip()

            # Extract the title (first line)
            lines = entry_text.split("\n")
            title = lines[0].strip()

            # Extract URL, DOI, and other metadata from subsequent lines
            url = ""
            metadata = {}
            for line in lines[1:]:
                line = line.strip()
                if line.startswith("URL:"):
                    url = line[4:].strip()
                elif line.startswith("DOI:"):
                    metadata["doi"] = line[4:].strip()
                elif line.startswith("Published in"):
                    metadata["journal"] = line[12:].strip()
                # Add more metadata parsing as needed
                elif line:
                    # Store other lines as additional metadata
                    if "additional" not in metadata:
                        metadata["additional"] = []
                    additional = metadata["additional"]
                    if isinstance(additional, list):
                        additional.append(line)

            # Combine title with additional metadata lines for full context
            full_text = entry_text

            # Create a unique key to avoid duplicates
            ref_key = (citation_num, title, url)
            if ref_key not in seen_refs:
                seen_refs.add(ref_key)
                # Create RIS entry with full text for metadata extraction
                ris_entry = self._create_ris_entry(
                    citation_num, full_text, url, metadata
                )
                ris_entries.append(ris_entry)

        return "\n".join(ris_entries)

    def _create_ris_entry(
        self,
        ref_id: str,
        full_text: str,
        url: str = "",
        metadata: dict | None = None,
    ) -> str:
        """Create a single RIS entry."""
        lines = []

        # Parse metadata from full text
        import re

        if metadata is None:
            metadata = {}

        # Extract title from first line. NB: split into a *separate* variable —
        # ``lines`` is the RIS-output accumulator initialized above and appended
        # to below; reusing it here previously overwrote it with the source
        # text, so every entry emitted the raw source body before the mandatory
        # leading ``TY  - `` tag and reference managers rejected the file.
        text_lines = full_text.split("\n")
        title = text_lines[0].strip()

        # Extract year from full text (looks for 4-digit year)
        year_match = re.search(r"\b(19\d{2}|20\d{2})\b", full_text)
        year = year_match.group(1) if year_match else None

        # Extract authors if present (looks for "by Author1, Author2")
        authors_match = re.search(
            r"\bby\s+([^.\n]+?)(?:\.|\n|$)", full_text, re.IGNORECASE
        )
        authors = []
        if authors_match:
            authors_text = authors_match.group(1)
            # Split by 'and' or ','
            author_parts = re.split(r"\s*(?:,|\sand\s|&)\s*", authors_text)
            authors = [a.strip() for a in author_parts if a.strip()]

        # Extract DOI from metadata or text
        doi = metadata.get("doi")
        if not doi:
            doi_match = re.search(
                r"DOI:\s*([^\s\n]+)", full_text, re.IGNORECASE
            )
            doi = doi_match.group(1) if doi_match else None

        # Clean title - remove author and metadata info for cleaner title
        clean_title = title
        if authors_match and authors_match.start() < len(title):
            clean_title = (
                title[: authors_match.start()] + title[authors_match.end() :]
                if authors_match.end() < len(title)
                else title[: authors_match.start()]
            )
        clean_title = re.sub(
            r"\s*DOI:\s*[^\s]+", "", clean_title, flags=re.IGNORECASE
        )
        clean_title = re.sub(
            r"\s*Published in.*", "", clean_title, flags=re.IGNORECASE
        )
        clean_title = re.sub(
            r"\s*Volume.*", "", clean_title, flags=re.IGNORECASE
        )
        clean_title = re.sub(
            r"\s*Pages.*", "", clean_title, flags=re.IGNORECASE
        )
        clean_title = clean_title.strip()

        # TY - Type of reference (ELEC for electronic source/website)
        lines.append("TY  - ELEC")

        # ID - Reference ID
        lines.append(f"ID  - ref{ref_id}")

        # TI - Title
        lines.append(f"TI  - {clean_title if clean_title else title}")

        # AU - Authors
        for author in authors:
            lines.append(f"AU  - {author}")

        # DO - DOI
        if doi:
            lines.append(f"DO  - {doi}")

        # PY - Publication year (if found in title)
        if year:
            lines.append(f"PY  - {year}")

        # UR - URL
        if url:
            lines.append(f"UR  - {url}")

            # Try to extract domain as publisher
            try:
                from urllib.parse import urlparse

                parsed = urlparse(url)
                domain = parsed.netloc
                if domain.startswith("www."):
                    domain = domain[4:]
                # Extract readable publisher name from domain
                if domain == "github.com" or domain.endswith(".github.com"):
                    lines.append("PB  - GitHub")
                elif domain == "arxiv.org" or domain.endswith(".arxiv.org"):
                    lines.append("PB  - arXiv")
                elif domain == "reddit.com" or domain.endswith(".reddit.com"):
                    lines.append("PB  - Reddit")
                elif (
                    domain == "youtube.com"
                    or domain == "m.youtube.com"
                    or domain.endswith(".youtube.com")
                ):
                    lines.append("PB  - YouTube")
                elif domain == "medium.com" or domain.endswith(".medium.com"):
                    lines.append("PB  - Medium")
                elif domain == "pypi.org" or domain.endswith(".pypi.org"):
                    lines.append("PB  - Python Package Index (PyPI)")
                else:
                    # Use domain as publisher
                    lines.append(f"PB  - {domain}")
            except (ValueError, AttributeError):
                pass

        # Y1 - Year accessed (current year)
        from datetime import datetime, UTC

        current_year = datetime.now(UTC).year
        lines.append(f"Y1  - {current_year}")

        # DA - Date accessed
        current_date = datetime.now(UTC).strftime("%Y/%m/%d")
        lines.append(f"DA  - {current_date}")

        # LA - Language
        lines.append("LA  - en")

        # ER - End of reference
        lines.append("ER  - ")

        return "\n".join(lines)


class LaTeXExporter:
    """Export markdown documents to LaTeX format."""

    def __init__(self):
        # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate
        self.citation_pattern = re.compile(r"[\[【](\d+)[\]】]")
        self.heading_patterns = [
            (re.compile(r"^# (.+)$", re.MULTILINE), r"\\section{\1}"),
            (re.compile(r"^## (.+)$", re.MULTILINE), r"\\subsection{\1}"),
            (re.compile(r"^### (.+)$", re.MULTILINE), r"\\subsubsection{\1}"),
        ]
        self.emphasis_patterns = [
            (re.compile(r"\*\*(.+?)\*\*"), r"\\textbf{\1}"),
            (re.compile(r"\*(.+?)\*"), r"\\textit{\1}"),
            (re.compile(r"`(.+?)`"), r"\\texttt{\1}"),
        ]

    def export_to_latex(self, content: str) -> str:
        """
        Convert markdown document to LaTeX format.

        Args:
            content: Markdown content

        Returns:
            LaTeX formatted content
        """
        latex_content = self._create_latex_header()

        # Convert markdown to LaTeX
        body_content = content

        # Escape special LaTeX characters but preserve math mode
        # Split by $ to preserve math sections
        parts = body_content.split("$")
        for i in range(len(parts)):
            # Even indices are outside math mode
            if i % 2 == 0:
                # Only escape if not inside $$
                if not (
                    i > 0
                    and parts[i - 1] == ""
                    and i < len(parts) - 1
                    and parts[i + 1] == ""
                ):
                    # Preserve certain patterns that will be processed later
                    # like headings (#), emphasis (*), and citations ([n])
                    lines = parts[i].split("\n")
                    for j, line in enumerate(lines):
                        # Don't escape lines that start with # (headings)
                        if not line.strip().startswith("#"):
                            # Don't escape emphasis markers or citations for now
                            # They'll be handled by their own patterns
                            temp_line = line
                            # Escape special chars except *, #, [, ]
                            temp_line = temp_line.replace("&", r"\&")
                            temp_line = temp_line.replace("%", r"\%")
                            temp_line = temp_line.replace("_", r"\_")
                            # Don't escape { } inside citations
                            lines[j] = temp_line
                    parts[i] = "\n".join(lines)
        body_content = "$".join(parts)

        # Convert headings
        for pattern, replacement in self.heading_patterns:
            body_content = pattern.sub(replacement, body_content)

        # Convert emphasis
        for pattern, replacement in self.emphasis_patterns:
            body_content = pattern.sub(replacement, body_content)

        # Convert citations to LaTeX \cite{} format
        body_content = self.citation_pattern.sub(r"\\cite{\1}", body_content)

        # Convert lists
        body_content = self._convert_lists(body_content)

        # Add body content
        latex_content += body_content

        # Add bibliography section
        latex_content += self._create_bibliography(content)

        # Add footer
        latex_content += self._create_latex_footer()

        return latex_content

    def _create_latex_header(self) -> str:
        """Create LaTeX document header."""
        return r"""\documentclass[12pt]{article}
\usepackage[utf8]{inputenc}
\usepackage{hyperref}
\usepackage{cite}
\usepackage{url}

\title{Research Report}
\date{\today}

\begin{document}
\maketitle

"""

    def _create_latex_footer(self) -> str:
        """Create LaTeX document footer."""
        return "\n\\end{document}\n"

    def _escape_latex(self, text: str) -> str:
        """Escape special LaTeX characters in text."""
        # Escape special LaTeX characters
        replacements = [
            ("\\", r"\textbackslash{}"),  # Must be first
            ("&", r"\&"),
            ("%", r"\%"),
            ("$", r"\$"),
            ("#", r"\#"),
            ("_", r"\_"),
            ("{", r"\{"),
            ("}", r"\}"),
            ("~", r"\textasciitilde{}"),
            ("^", r"\textasciicircum{}"),
        ]

        for old, new in replacements:
            text = text.replace(old, new)

        return text

    def _convert_lists(self, content: str) -> str:
        """Convert markdown lists to LaTeX format."""
        # Simple conversion for bullet points
        content = re.sub(r"^- (.+)$", r"\\item \1", content, flags=re.MULTILINE)

        # Add itemize environment around list items
        lines = content.split("\n")
        result = []
        in_list = False

        for line in lines:
            if line.strip().startswith("\\item"):
                if not in_list:
                    result.append("\\begin{itemize}")
                    in_list = True
                result.append(line)
            else:
                if in_list and line.strip():
                    result.append("\\end{itemize}")
                    in_list = False
                result.append(line)

        if in_list:
            result.append("\\end{itemize}")

        return "\n".join(result)

    def _create_bibliography(self, content: str) -> str:
        """Extract sources and create LaTeX bibliography."""
        sources_start = find_sources_section(content)
        if sources_start == -1:
            return ""

        sources_content = content[sources_start:]
        pattern = re.compile(
            r"^\[(\d+)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", re.MULTILINE
        )

        bibliography = "\n\\begin{thebibliography}{99}\n"

        for match in pattern.finditer(sources_content):
            citation_num = match.group(1)
            title = match.group(2).strip()
            url = match.group(3).strip() if match.group(3) else ""

            # Escape special LaTeX characters in title
            escaped_title = self._escape_latex(title)

            if url:
                bibliography += f"\\bibitem{{{citation_num}}} {escaped_title}. \\url{{{url}}}\n"
            else:
                bibliography += (
                    f"\\bibitem{{{citation_num}}} {escaped_title}.\n"
                )

        bibliography += "\\end{thebibliography}\n"

        return bibliography
