import re
from typing import Dict, List

from loguru import logger

from .url_utils import canonical_url_key


LANGUAGE_CODE_MAP = {
    "english": "en",
    "french": "fr",
    "german": "de",
    "spanish": "es",
    "italian": "it",
    "japanese": "ja",
    "chinese": "zh",
    "hindi": "hi",
    "arabic": "ar",
    "bengali": "bn",
    "portuguese": "pt",
    "russian": "ru",
    "korean": "ko",
}


def remove_think_tags(text: str) -> str:
    # NOTE: Fresh LLM responses from get_llm() are already <think>-stripped
    # centrally by ProcessingLLMWrapper (config/llm_config.py). Use this only on
    # text NOT from a fresh wrapped invoke (accumulated/concatenated text, or
    # agent/bind_tools output that bypasses the wrapper).
    # Remove paired <think>...</think> tags
    text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
    # Remove any orphaned opening or closing think tags
    text = re.sub(r"</think>", "", text)
    text = re.sub(r"<think>", "", text)
    return text.strip()


# Sentinel values used by the journal reputation filter alongside the
# numeric 1-10 quality scores. Distinguish structurally different
# "not scored" cases so the renderer can show the user *why* the tag
# isn't a numeric quality tier:
#
# - QUALITY_PENDING: reference DB hadn't finished building when the
#   search ran (first-search-during-install case).
# - QUALITY_PREPRINT: result has no journal_ref at all (pure arxiv
#   preprint or similar); there's no venue to score. Distinct from
#   "venue unknown to our catalog" (that becomes score 3, rendered
#   as Unranked).
QUALITY_PENDING = "pending"
QUALITY_PREPRINT = "preprint"


def _format_quality_tag(quality) -> str:
    """Format a journal quality score as a compact tag for source lists.

    The output is plaintext / Markdown. **Do NOT** render the containing
    string through a template filter like ``{{ foo|safe }}`` or
    ``DOMPurify.sanitize(..., {ALLOWED_TAGS:['a']})`` without first HTML-
    escaping the surrounding title — the tag itself is safe, but a
    downstream caller that concatenates ``title + quality_tag`` and
    emits the result as HTML will leak any tags in ``title`` (XSS).

    See :func:`_format_quality_tag_html` for the HTML-safe variant.

    Accepts int | None for scored journals, plus the string sentinels
    ``QUALITY_PENDING`` and ``QUALITY_PREPRINT``. Every numeric value
    in VALID_QUALITY_SCORES has its own explicit branch so a bad
    scoring-logic change can't silently rebucket a score — unexpected
    values fall through to a debug tag that shows the raw value.
    """
    if quality is None:
        return ""
    if quality == QUALITY_PENDING:
        return (
            " [journal quality data is downloading in the background; "
            "by the time you open /metrics/journals it may already "
            "be complete — re-run this search in a minute to get "
            "real quality scores]"
        )
    if quality == QUALITY_PREPRINT:
        # No venue at all (arxiv preprint / working paper / dataset).
        # Distinct from score 3 ("we looked and didn't find the
        # venue") — here there's nothing *to* look up.
        return " [preprint — not in journal catalog]"
    # Numeric tiers. Explicit per-score branches instead of ``>=``
    # ranges so boundary changes can't silently shift a bucket.
    if quality == 10:
        return " [Q1 ★★★★★]"
    # KNOWN-DEFERRED: quality == 9 is a dead branch —
    # constants.VALID_QUALITY_SCORES excludes 9 and the filter rejects
    # any LLM output of that value. Kept defensively so a future change
    # to VALID_QUALITY_SCORES does not require editing the formatter.
    # Post-merge candidate for removal together with any score-9
    # reintroduction work.
    if quality == 9:
        return " [Q1 ★★★★★]"
    if quality == 8:
        return " [Q1 ★★★★]"
    if quality == 7:
        return " [Q1 ★★★★]"
    if quality == 6:
        return " [Q2 ★★★]"
    if quality == 5:
        return " [Q2 ★★★]"
    if quality == 4:
        # JOURNAL_QUALITY_DEFAULT — venue found in the catalog but
        # with no h-index / quartile / DOAJ signal.
        return " [Unranked ★]"
    if quality == 3:
        # Low-confidence fallback — venue didn't match any tier. We
        # don't know the journal, not "we know it's low-quality".
        return " [Unranked ★]"
    if quality == 2:
        return " [Q4 ★]"
    if quality == 1:
        # Predatory. Usually auto-removed before this renderer sees
        # it, but surfaces if whitelisted or the threshold is 1.
        return " [Q4 ★]"
    # Out-of-set value — VALID_QUALITY_SCORES gates the inputs so this
    # is unreachable in normal operation. Show the raw value so bad
    # data surfaces visibly instead of silently bucketing into Q4.
    return f" [quality={quality!r}]"


def _format_quality_tag_html(quality, *, title: str = "") -> str:
    """HTML-safe wrapper for :func:`_format_quality_tag`.

    Callers that render search-result titles + quality tags into an
    HTML page must use this variant and pass the raw ``title`` so both
    are escaped together. The quality tag itself is plaintext, but the
    brackets and stars are safe to emit verbatim — the danger is the
    untrusted ``title`` that a downstream HTML template may concatenate
    alongside the tag.

    Returns:
        ``"{escaped_title}{quality_tag}"`` where ``escaped_title`` is
        HTML-escaped with ``html.escape(..., quote=True)`` so quotes,
        angle brackets, and ampersands are rendered as text.
    """
    import html as _html

    return _html.escape(title, quote=True) + _format_quality_tag(quality)


def extract_links_from_search_results(search_results: List[Dict]) -> List[Dict]:
    """
    Extracts links and titles from a list of search result dictionaries.

    Each dictionary is expected to have at least the keys "title" and "link".

    Returns a list of dictionaries with 'title' and 'url' keys.
    """
    links = []
    if not search_results:
        return links

    for result in search_results:
        try:
            # Ensure we handle None values safely before calling strip()
            title = result.get("title", "")
            url = result.get("link", "")
            index = result.get("index", "")

            # Apply strip() only if the values are not None
            title = title.strip() if title is not None else ""
            url = url.strip() if url is not None else ""
            index = index.strip() if index is not None else ""

            if title and url:
                link = {
                    "title": title,
                    "url": url,
                    "index": index,
                    "journal_quality": result.get("journal_quality"),
                }
                # Preserve citation-relevant fields from search engines
                # so they reach the database (previously lost here)
                for key in (
                    "doi",
                    "authors",
                    "published",
                    "publication_date",
                    "year",
                    "date",
                    "volume",
                    "issue",
                    "pages",
                    "journal_ref",
                    "journal",
                    "venue",
                    "publisher",
                    "source_type",
                    "openalex_source_id",
                    "source",
                    "source_engine",
                    "pmid",
                    "pmcid",
                    "arxiv_id",
                    "isbn",
                    "citations",
                    "is_open_access",
                    "abstract",
                    "metadata",
                ):
                    val = result.get(key)
                    if val is not None:
                        link[key] = val
                links.append(link)
        except Exception:
            # Log the specific error for debugging
            logger.exception("Error extracting link from result")
            continue
    return links


def format_links_to_markdown(all_links: List[Dict]) -> str:
    parts: list[str] = []
    logger.info(f"Formatting {len(all_links)} links to markdown...")

    if all_links:
        # Group links by canonical URL (collapses trailing slash, utm
        # params, fragments, default ports, scheme/host case, userinfo).
        # The canonical form is also what gets displayed so the Sources
        # section stays clean — no utm_*/fbclid clutter, no embedded
        # credentials, no scheme/host casing noise. Click-through is
        # unaffected (tracking params carry no content).
        url_to_indices: dict[str, list] = {}
        canon_to_title: dict[str, str] = {}
        canon_to_quality: dict[str, int] = {}
        # Track the RAG/library collection name per canonical URL so the
        # citation formatter's source-tagged mode can surface it as the
        # citation tag (e.g. `[mypapers-7]`) instead of falling back to
        # the generic `local` label.
        canon_to_collection: dict[str, str] = {}
        for link in all_links:
            raw = link.get("url") or link.get("link") or ""
            canon = canonical_url_key(raw)
            if not canon:
                continue
            url_to_indices.setdefault(canon, []).append(link.get("index", ""))
            canon_to_title.setdefault(canon, link.get("title", "Untitled"))
            # Track journal quality per canonical URL (first non-None wins)
            if canon not in canon_to_quality and link.get("journal_quality"):
                canon_to_quality[canon] = link["journal_quality"]
            # First non-empty collection name wins (mirrors title/quality).
            if canon not in canon_to_collection:
                metadata = link.get("metadata") or {}
                collection = metadata.get("collection_name")
                if collection:
                    canon_to_collection[canon] = str(collection)

        # Emit each unique source once, in first-seen order.
        seen: set[str] = set()
        for link in all_links:
            raw = link.get("url") or link.get("link") or ""
            canon = canonical_url_key(raw)
            if not canon or canon in seen:
                continue
            title = canon_to_title[canon]
            # Indices arrive as int (from strategy enumeration) or str (from
            # _build_sources_markdown's fallback). Coerce so dedup collapses
            # 1 and "1", and sorted() doesn't TypeError on mixed types.
            indices = sorted(
                {str(i) for i in url_to_indices[canon]},
                key=lambda s: (0, int(s)) if s.isdigit() else (1, s),
            )
            indices_str = f"[{', '.join(indices)}]"
            quality_tag = _format_quality_tag(canon_to_quality.get(canon))
            collection_line = (
                f"   Collection: {canon_to_collection[canon]}\n"
                if canon in canon_to_collection
                else ""
            )
            parts.append(
                f"{indices_str} {title}{quality_tag} "
                f"(source nr: {', '.join(map(str, indices))})\n"
                f"   URL: {canon}\n"
                f"{collection_line}"
                f"\n"
            )
            seen.add(canon)

        parts.append("\n")

    return "".join(parts)


def format_findings(
    findings_list: List[Dict],
    synthesized_content: str,
    questions_by_iteration: Dict[int, List[str]],
) -> str:
    """Format findings into a detailed text output.

    Args:
        findings_list: List of finding dictionaries
        synthesized_content: The synthesized content from the LLM.
        questions_by_iteration: Dictionary mapping iteration numbers to lists of questions

    Returns:
        str: Formatted text output
    """
    logger.info(
        f"Inside format_findings utility. Findings count: {len(findings_list)}, Questions iterations: {len(questions_by_iteration)}"
    )
    parts: list[str] = []

    # Extract all sources from findings
    all_links = []
    for finding in findings_list:
        search_results = finding.get("search_results", [])
        if search_results:
            try:
                links = extract_links_from_search_results(search_results)
                all_links.extend(links)
            except Exception:
                logger.exception("Error processing search results/links")

    # Start with the synthesized content (passed as synthesized_content)
    parts.append(f"{synthesized_content}\n\n")

    # Add sources section after synthesized content if sources exist
    parts.append(format_links_to_markdown(all_links))

    parts.append("\n\n")  # Separator after synthesized content

    # Add Search Questions by Iteration section
    if questions_by_iteration:
        parts.append("## SEARCH QUESTIONS BY ITERATION\n")
        parts.append("\n")
        for iter_num, questions in questions_by_iteration.items():
            parts.append(f"\n #### Iteration {iter_num}:\n")
            for i, q in enumerate(questions, 1):
                parts.append(f"{i}. {q}\n")
        parts.append("\n\n\n")
    else:
        logger.warning("No questions by iteration found to format.")

    # Add Detailed Findings section
    if findings_list:
        parts.append("## DETAILED FINDINGS\n\n")
        logger.info(f"Formatting {len(findings_list)} detailed finding items.")

        for idx, finding in enumerate(findings_list):
            logger.debug(
                f"Formatting finding item {idx}. Keys: {list(finding.keys())}"
            )
            # Use .get() for safety
            phase = finding.get("phase", "Unknown Phase")
            content = finding.get("content", "No content available.")
            search_results = finding.get("search_results", [])

            # Phase header
            parts.append(f"\n### {phase}\n\n\n")

            question_displayed = False
            # If this is a follow-up phase, try to show the corresponding question
            if isinstance(phase, str) and phase.startswith("Follow-up"):
                try:
                    phase_parts = phase.replace(
                        "Follow-up Iteration ", ""
                    ).split(".")
                    if len(phase_parts) == 2:
                        iteration = int(phase_parts[0])
                        question_index = int(phase_parts[1]) - 1
                        if (
                            iteration in questions_by_iteration
                            and 0
                            <= question_index
                            < len(questions_by_iteration[iteration])
                        ):
                            parts.append(
                                f"#### {questions_by_iteration[iteration][question_index]}\n\n"
                            )
                            question_displayed = True
                        else:
                            logger.warning(
                                f"Could not find matching question for phase: {phase}"
                            )
                    else:
                        logger.warning(
                            f"Could not parse iteration/index from phase: {phase}"
                        )
                except ValueError:
                    logger.warning(
                        f"Could not parse iteration/index from phase: {phase}"
                    )
            # Handle Sub-query phases from IterDRAG strategy
            elif isinstance(phase, str) and phase.startswith("Sub-query"):
                try:
                    # Extract the index number from "Sub-query X"
                    query_index = int(phase.replace("Sub-query ", "")) - 1
                    # In IterDRAG, sub-queries are stored in iteration 0
                    if 0 in questions_by_iteration and query_index < len(
                        questions_by_iteration[0]
                    ):
                        parts.append(
                            f"#### {questions_by_iteration[0][query_index]}\n\n"
                        )
                        question_displayed = True
                    else:
                        logger.warning(
                            f"Could not find matching question for phase: {phase}"
                        )
                except ValueError:
                    logger.warning(
                        f"Could not parse question index from phase: {phase}"
                    )

            # If the question is in the finding itself, display it
            if (
                not question_displayed
                and "question" in finding
                and finding["question"]
            ):
                parts.append(f"### SEARCH QUESTION:\n{finding['question']}\n\n")

            # Content
            parts.append(f"\n\n{content}\n\n")

            # Search results if they exist
            if search_results:
                try:
                    links = extract_links_from_search_results(search_results)
                    if links:
                        parts.append("### SOURCES USED IN THIS SECTION:\n")
                        parts.append(format_links_to_markdown(links) + "\n\n")
                except Exception:
                    logger.exception(
                        f"Error processing search results/links for finding {idx}"
                    )
            else:
                logger.debug(f"No search_results found for finding item {idx}.")

            parts.append(f"{'_' * 80}\n\n")
    else:
        logger.warning("No detailed findings found to format.")

    # Add summary of all sources at the end
    if all_links:
        parts.append("## ALL SOURCES:\n")
        parts.append(format_links_to_markdown(all_links))
    else:
        logger.info("No unique sources found across all findings to list.")

    logger.info("Finished format_findings utility.")
    return "".join(parts)
