"""Reconstruct the legacy "full report" view from structured storage.

`research.report_content` stores only the synthesized answer (with inline
`[N](url)` hyperlinks). Sources live in the `research_resources` table;
metrics live in `research.research_meta`. This module rebuilds the
combined `answer + ## Sources + ## Research Metrics` view on demand for
display and export — chat reads `research.report_content` directly and
never goes through this module.

DO NOT write the assembled output back to `research.report_content` —
that column must stay answer-only. Writing assembled output back would
silently re-introduce the regex over-strip class of bugs that
answer-only storage avoids.
"""

import re
from typing import Any, Dict, List, Optional

from loguru import logger
from sqlalchemy.orm import Session

from ...database.models.research import ResearchHistory, ResearchResource
from ...utilities.search_utilities import format_links_to_markdown

# Line-anchored regexes for the legacy-row guard. See `assemble_full_report`
# for why a substring `in body` check is too loose.
_LEGACY_SOURCES_RE = re.compile(r"^## Sources\b", re.MULTILINE)
_LEGACY_METRICS_RE = re.compile(r"^## Research Metrics\b", re.MULTILINE)


def assemble_full_report(
    research: Optional[ResearchHistory], db_session: Session
) -> Optional[str]:
    """Reconstruct the legacy report shape from structured storage.

    Args:
        research: The ResearchHistory ORM row. Must be loaded inside
            the supplied ``db_session`` to avoid DetachedInstanceError
            when accessing ``research.research_meta`` lazily.
        db_session: Active SQLAlchemy session bound to the user DB.
            Used to query ``research_resources`` for the sources block.

    Returns:
        ``None`` when ``research`` is ``None`` (caller should map to
        404). Otherwise assembled markdown: answer + optional
        ``## Sources`` block + optional ``## Research Metrics`` block.
        An existing row with no body / sources / metrics returns an
        empty string ``""`` (a valid empty-but-found response).
    """
    if research is None:
        return None

    body = research.report_content or ""

    # Legacy-row guard: older rows already contain inline
    # `## Sources` / `## Research Metrics` blocks in report_content. If
    # we appended freshly-assembled sections to those rows we'd render
    # the blocks twice. Match only at line-start to avoid false positives
    # from prose that happens to contain the substring `## Sources` inline
    # (e.g. an answer that quotes another markdown document).
    has_legacy_sources = bool(_LEGACY_SOURCES_RE.search(body))
    has_legacy_metrics = bool(_LEGACY_METRICS_RE.search(body))

    parts = [body]

    if not has_legacy_sources:
        # Let any failure propagate: the callers already wrap this in a
        # try/except that returns HTTP 500. Swallowing it here would emit a
        # report that looks complete but is silently missing all sources.
        sources_md = _build_sources_markdown(research, db_session)
        if sources_md:
            parts.append("## Sources\n\n" + sources_md)

    if not has_legacy_metrics:
        metrics_md = _build_metrics_markdown(research)
        if metrics_md:
            parts.append("## Research Metrics\n" + metrics_md)

    return "\n\n".join(parts)


def _build_metrics_markdown(research: ResearchHistory) -> str:
    """Render the Research Metrics block from persisted metadata.

    Today the inline metrics block (research_service.py quick-summary
    path) used ``results["iterations"]`` and a fresh save-time
    timestamp. Both end up in ``research.research_meta`` (the save site
    persists ``metadata["iterations"]`` and ``metadata["generated_at"]``)
    so this read recovers the same values. Falls back to
    ``research.completed_at`` for the timestamp when ``generated_at`` is
    missing (legacy rows or scheduler-saved research).

    Returns an empty string when nothing meaningful can be rendered.
    """
    meta = research.research_meta or {}
    iterations = meta.get("iterations")
    generated_at = meta.get("generated_at") or research.completed_at
    lines = []
    if iterations is not None:
        lines.append(f"- Search Iterations: {iterations}")
    if generated_at:
        lines.append(f"- Generated at: {generated_at}")
    return "\n".join(lines)


def _build_sources_markdown(
    research: ResearchHistory, db_session: Session
) -> str:
    """Render the Sources block from the ``research_resources`` table.

    Maps each ResearchResource row back to the dict shape
    ``format_links_to_markdown`` expects, preferring the original
    citation index from ``resource_metadata['original_data']['index']``
    (assigned by the search system at search time, and the number the
    inline ``[N]`` references in the saved answer point to). Falls
    back to row order when the original index was lost on save.
    """
    resources = (
        db_session.query(ResearchResource)
        .filter_by(research_id=research.id)
        .order_by(ResearchResource.id.asc())
        .all()
    )

    all_links: List[Dict[str, Any]] = []
    missing_index_count = 0
    for fallback_idx, r in enumerate(resources, start=1):
        # Defensive: legacy rows may have stored metadata as a string.
        meta = (
            r.resource_metadata if isinstance(r.resource_metadata, dict) else {}
        )
        original = (
            meta.get("original_data")
            if isinstance(meta.get("original_data"), dict)
            else {}
        )
        # ``is None`` (not ``not``) so 0 isn't treated as missing.
        index = original.get("index")
        if index is None or index == "":
            missing_index_count += 1
            index = str(fallback_idx)
        all_links.append(
            {
                "url": str(r.url) if r.url else "",
                "title": str(r.title) if r.title else "Untitled",
                "index": index,
                "journal_quality": original.get("journal_quality"),
            }
        )

    if missing_index_count:
        # DEBUG (not WARNING): expected for legacy rows / URL-less
        # entries skipped at save time. Render correctness is preserved
        # via row-order fallback. Bind research_id so the message
        # routes through the per-research log table.
        logger.bind(research_id=research.id).debug(
            "_build_sources_markdown: {} of {} rows missing original "
            "citation index; using row order. Common cause: URL-less "
            "entries were skipped at save time.",
            missing_index_count,
            len(resources),
        )

    return format_links_to_markdown(all_links)


def get_research_source_links(
    research_id: str, db_session: Session, limit: int = 3
) -> List[Dict[str, str]]:
    """Top-N source links for a research, in row-insertion order.

    Returns dicts shaped ``{"url": str, "title": str}`` matching the
    news feed's ``links`` contract (``news/api.py`` consumers). Titles
    are domain-fallback when missing, truncated to 50 chars to match
    the existing list-card rendering in the news UI.

    Args:
        research_id: The ResearchHistory id.
        db_session: Active SQLAlchemy session bound to the user DB.
        limit: Maximum number of links to return.
    """
    rows = (
        db_session.query(ResearchResource)
        .filter_by(research_id=research_id)
        .filter(ResearchResource.url.isnot(None))
        .order_by(ResearchResource.id.asc())
        .limit(limit)
        .all()
    )
    out: List[Dict[str, str]] = []
    for r in rows:
        url = (r.url or "").strip()
        if not url.startswith("http"):
            continue
        title = (r.title or "").strip()
        if not title:
            domain = url.split("//")[-1].split("/")[0]
            title = domain.replace("www.", "")
        if len(title) > 50:
            title = title[:50] + "..."
        out.append({"url": url, "title": title})
    return out


def get_research_source_links_batch(
    research_ids: List[str], db_session: Session, limit: Optional[int] = 3
) -> Dict[str, List[Dict[str, str]]]:
    """Batched variant of :func:`get_research_source_links`.

    For news-feed list views that would otherwise fire one query per
    research item (N+1). One ``WHERE research_id IN (...)`` query plus
    Python-side grouping. Returned dict maps each research_id to its
    top-N links (same shape as :func:`get_research_source_links`).
    Research ids with zero rows map to ``[]``.

    ``limit=None`` returns every link for each research (no cap) — used by
    the report API, which exposes the full source list rather than a top-N.
    """
    result: Dict[str, List[Dict[str, str]]] = {rid: [] for rid in research_ids}
    if not research_ids:
        return result

    rows = (
        db_session.query(ResearchResource)
        .filter(ResearchResource.research_id.in_(research_ids))
        .filter(ResearchResource.url.isnot(None))
        .order_by(ResearchResource.research_id, ResearchResource.id.asc())
        .all()
    )
    for r in rows:
        bucket = result.setdefault(r.research_id, [])
        if limit is not None and len(bucket) >= limit:
            continue
        url = (r.url or "").strip()
        if not url.startswith("http"):
            continue
        title = (r.title or "").strip()
        if not title:
            domain = url.split("//")[-1].split("/")[0]
            title = domain.replace("www.", "")
        if len(title) > 50:
            title = title[:50] + "..."
        bucket.append({"url": url, "title": title})
    return result
