"""
Service for managing research sources/resources in the database.

This service handles saving and retrieving sources from research
in a proper relational way using the ResearchResource table.
"""

from typing import List, Dict, Any, Optional
from datetime import datetime, UTC
from loguru import logger
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, load_only

from ...database.models import (
    Journal,
    Paper,
    PaperAppearance,
    ResearchResource,
    ResearchHistory,
)
from ...database.session_context import get_user_db_session
from ...utilities.citation_normalizer import normalize_citation


class ResearchSourcesService:
    """Service for managing research sources in the database."""

    @staticmethod
    def save_research_sources(
        research_id: str,
        sources: List[Dict[str, Any]],
        username: Optional[str] = None,
    ) -> int:
        """
        Save sources from research to the ResearchResource table.

        Args:
            research_id: The UUID of the research
            sources: List of source dictionaries with url, title, snippet, etc.
            username: Username for database access

        Returns:
            Number of sources saved
        """
        if not sources:
            logger.info(f"No sources to save for research {research_id}")
            return 0

        saved_count = 0
        # Failed-source counter. The per-source try/except below catches
        # broad exceptions to keep one bad source from killing the batch,
        # but without this counter the caller had no way to distinguish
        # "all N saved" from "some silently dropped". Emitted in the
        # final log line so admins can spot save-failure trends.
        failed_count = 0

        try:
            with get_user_db_session(username) as db_session:
                # First check if resources already exist for this research
                existing = (
                    db_session.query(ResearchResource)
                    .filter_by(research_id=research_id)
                    .count()
                )

                if existing > 0:
                    logger.info(
                        f"Research {research_id} already has {existing} resources, skipping save"
                    )
                    return int(existing)

                # Save each source as a ResearchResource.
                # Each source runs inside a SAVEPOINT so a per-source
                # failure can be rolled back cleanly without losing
                # any previously saved sources in this batch.
                # Per-batch memoization: container_title → journal_id
                # avoids redundant Journal lookups when multiple sources
                # share the same venue (common in topic-focused searches).
                journal_id_cache: Dict[Optional[str], Optional[int]] = {}
                for source in sources:
                    sp = None
                    try:
                        # Extract fields from various possible formats
                        url = source.get("url", "") or source.get("link", "")
                        title = source.get("title", "") or source.get(
                            "name", ""
                        )
                        snippet = (
                            source.get("snippet", "")
                            or source.get("content_preview", "")
                            or source.get("description", "")
                        )
                        source_type = source.get("source_type", "web")

                        # Skip if no URL
                        if not url:
                            continue

                        # Start savepoint for this source — any rollback
                        # inside this block (including the IntegrityError
                        # retry path below) only affects this source.
                        sp = db_session.begin_nested()

                        # Create resource record.
                        # Sanitize the source dict before embedding it in
                        # resource_metadata — raw engine dicts can contain
                        # non-JSON-serializable values (nested objects,
                        # numpy types, affiliation sub-dicts, etc.) which
                        # would crash json.dumps() at flush time.
                        safe_source = _json_safe(source)
                        resource = ResearchResource(
                            research_id=research_id,
                            title=title or "Untitled",
                            url=url,
                            content_preview=snippet[:1000]
                            if snippet
                            else None,  # Limit preview length
                            source_type=source_type,
                            resource_metadata={
                                "added_at": datetime.now(UTC).isoformat(),
                                "original_data": safe_source,
                            },
                            created_at=datetime.now(UTC).isoformat(),
                        )

                        db_session.add(resource)
                        db_session.flush()  # Get resource.id for FK

                        # Create or reuse Paper for academic sources
                        citation_fields = normalize_citation(source)
                        if citation_fields:
                            source_engine = citation_fields.pop(
                                "source_engine", None
                            )
                            # Try to link to existing Journal record
                            # (container_title stays in citation_fields so
                            # it ends up in the metadata blob for citation
                            # export). Memoized per batch to avoid repeat
                            # lookups for the same venue.
                            ct = citation_fields.get("container_title")
                            if ct in journal_id_cache:
                                journal_id = journal_id_cache[ct]
                            else:
                                journal_id = _resolve_journal_id(db_session, ct)
                                journal_id_cache[ct] = journal_id

                            # Separate indexed columns from metadata blob.
                            # Only doi/arxiv_id/pmid/journal_id/
                            # container_title/year are real columns on
                            # Paper; everything else is bundled into
                            # the metadata JSON blob. Quality is NOT
                            # stored per-Paper — the dashboard resolves
                            # it live (Tier 4: journals.quality via
                            # container_title lookup; Tier 1-3: bundled
                            # reference DB) so a re-scored journal
                            # propagates automatically.
                            # container_title: prefer the filter's
                            # cleaned matched name (what actually keyed
                            # the successful score); fall back to the raw
                            # CSL container_title if the filter didn't
                            # run (e.g. journal_reputation disabled).
                            #
                            # .pop() removes it from citation_fields so it
                            # doesn't end up duplicated in paper_metadata
                            # JSON. The Paper column is the sole source
                            # of truth; CSL-JSON export already captured
                            # the raw value inside citation_fields[
                            # "csl_json"] during normalize_citation.
                            ct_raw = citation_fields.pop(
                                "container_title", None
                            )
                            ct_matched = (
                                source.get("journal_name_matched") or ct_raw
                            )
                            if ct_matched and len(ct_matched) > 500:
                                logger.debug(
                                    f"Truncating container_title to 500 "
                                    f"chars: {ct_matched[:80]}..."
                                )
                                ct_matched = ct_matched[:500]
                            # `year` intentionally stays in citation_fields
                            # (JSON blob) AND is copied to the indexed column.
                            # The JSON blob remains the CSL-JSON source of
                            # truth; the column is a denormalized index
                            # surface for dashboard year queries.
                            indexed = {
                                "doi": citation_fields.pop("doi", None),
                                "arxiv_id": citation_fields.pop(
                                    "arxiv_id", None
                                ),
                                "pmid": citation_fields.pop("pmid", None),
                                "journal_id": journal_id,
                                "container_title": ct_matched,
                                "year": citation_fields.get("year"),
                            }

                            # Dedup: find existing paper by DOI/arxiv/pmid.
                            # The UNIQUE constraints on doi/arxiv_id/pmid
                            # prevent duplicates from concurrent writers,
                            # but we still need to handle the race where
                            # our SELECT missed and another writer's
                            # INSERT succeeds first — catch IntegrityError
                            # and re-query.
                            paper = _find_existing_paper(db_session, indexed)
                            if paper is not None:
                                _merge_identifiers(
                                    paper, indexed, citation_fields
                                )
                            else:
                                paper = Paper(
                                    **indexed,
                                    paper_metadata=citation_fields or None,
                                )
                                db_session.add(paper)
                                try:
                                    db_session.flush()
                                except IntegrityError:
                                    # Concurrent writer inserted same
                                    # paper. Roll back this SAVEPOINT
                                    # only (not the whole batch), then
                                    # restart a nested one and re-fetch
                                    # the existing row for merging.
                                    sp.rollback()
                                    sp = db_session.begin_nested()
                                    # After savepoint rollback we also
                                    # need to re-create the resource
                                    # since its flush was undone.
                                    resource = ResearchResource(
                                        research_id=research_id,
                                        title=title or "Untitled",
                                        url=url,
                                        content_preview=snippet[:1000]
                                        if snippet
                                        else None,
                                        source_type=source_type,
                                        resource_metadata={
                                            "added_at": datetime.now(
                                                UTC
                                            ).isoformat(),
                                            "original_data": safe_source,
                                        },
                                        created_at=datetime.now(
                                            UTC
                                        ).isoformat(),
                                    )
                                    db_session.add(resource)
                                    db_session.flush()
                                    paper = _find_existing_paper(
                                        db_session, indexed
                                    )
                                    if paper is None:
                                        # Truly unexpected — concurrent
                                        # writer's row is gone.
                                        raise
                                    _merge_identifiers(
                                        paper, indexed, citation_fields
                                    )

                            # Link paper to this resource
                            appearance = PaperAppearance(
                                paper_id=paper.id,
                                resource_id=resource.id,
                                source_engine=source_engine,
                            )
                            db_session.add(appearance)

                        # Commit the savepoint so this source's writes
                        # persist even if a later source fails.
                        sp.commit()
                        saved_count += 1

                    except Exception:
                        # Roll back just this source's savepoint; earlier
                        # sources in the batch stay committed at the
                        # outer transaction level.
                        if sp is not None and sp.is_active:
                            sp.rollback()
                        failed_count += 1
                        logger.exception(
                            f"Failed to save source {source.get('url', 'unknown')}"
                        )
                        continue

                # Commit all resources
                if saved_count > 0:
                    db_session.commit()
                    if failed_count > 0:
                        logger.warning(
                            f"Saved {saved_count} sources for research "
                            f"{research_id} — {failed_count} source(s) "
                            f"failed and were skipped (see earlier "
                            f"ERROR logs for per-source stack traces)"
                        )
                    else:
                        logger.info(
                            f"Saved {saved_count} sources for research {research_id}"
                        )
                elif failed_count > 0:
                    logger.warning(
                        f"No sources saved for research {research_id} — "
                        f"all {failed_count} sources in the batch failed "
                        f"(see earlier ERROR logs for per-source stack "
                        f"traces)"
                    )

        except Exception:
            logger.exception("Error saving research sources")
            raise

        return saved_count

    @staticmethod
    def get_research_sources(
        research_id: str, username: Optional[str] = None
    ) -> List[Dict[str, Any]]:
        """
        Get all sources for a research from the database.

        Args:
            research_id: The UUID of the research
            username: Username for database access

        Returns:
            List of source dictionaries
        """
        sources = []

        try:
            with get_user_db_session(username) as db_session:
                resources = (
                    db_session.query(ResearchResource)
                    .filter_by(research_id=research_id)
                    .order_by(ResearchResource.id.asc())
                    .all()
                )

                for resource in resources:
                    sources.append(
                        {
                            "id": resource.id,
                            "url": resource.url,
                            "title": resource.title,
                            "snippet": resource.content_preview,
                            "content_preview": resource.content_preview,
                            "source_type": resource.source_type,
                            "metadata": resource.resource_metadata or {},
                            "created_at": resource.created_at,
                        }
                    )

                logger.info(
                    f"Retrieved {len(sources)} sources for research {research_id}"
                )

        except Exception:
            logger.exception("Error retrieving research sources")
            raise

        return sources

    @staticmethod
    def update_research_with_sources(
        research_id: str,
        all_links_of_system: List[Dict[str, Any]],
        username: Optional[str] = None,
    ) -> bool:
        """
        Update a completed research with its sources.
        This should be called when research completes.

        Args:
            research_id: The UUID of the research
            all_links_of_system: List of all sources found during research
            username: Username for database access

        Returns:
            True if successful
        """
        try:
            # Save sources to ResearchResource table
            saved_count = ResearchSourcesService.save_research_sources(
                research_id, all_links_of_system, username
            )

            # Also update the research metadata to include source count
            with get_user_db_session(username) as db_session:
                research = (
                    db_session.query(ResearchHistory)
                    .filter_by(id=research_id)
                    .first()
                )

                if research:
                    if not research.research_meta:
                        research.research_meta = {}

                    # Update metadata with source information
                    research.research_meta["sources_count"] = saved_count
                    research.research_meta["has_sources"] = saved_count > 0

                    db_session.commit()
                    logger.info(
                        f"Updated research {research_id} with {saved_count} sources"
                    )
                    return True
                logger.warning(
                    f"Research {research_id} not found for source update"
                )
                return False

        except Exception:
            logger.exception("Error updating research with sources")
            return False


def _json_safe(value: Any, _depth: int = 0, _seen: Optional[set] = None) -> Any:
    """Recursively coerce a value into a JSON-serializable form.

    Used before embedding arbitrary engine result dicts into JSON
    columns. Non-primitive values (datetime, date, set, tuple,
    custom objects) are converted to strings or dropped. This is a
    last-resort sanitizer — callers should still prefer structured
    whitelisting (e.g., Paper.paper_metadata only stores known CSL
    fields).

    Depth limit and cycle detection prevent RecursionError on
    pathological input (circular dict/list references).
    """
    # Depth limit as a belt-and-braces guard
    if _depth > 32:
        return str(value)

    # JSON primitives pass through unchanged
    if value is None or isinstance(value, (bool, int, float, str)):
        return value

    # Container cycle detection via id() tracking
    if isinstance(value, (dict, list, tuple, set, frozenset)):
        if _seen is None:
            _seen = set()
        if id(value) in _seen:
            return "<circular>"
        _seen = _seen | {id(value)}

    if isinstance(value, dict):
        return {
            str(k): _json_safe(v, _depth + 1, _seen)
            for k, v in value.items()
            if isinstance(k, (str, int, float, bool))
        }
    if isinstance(value, (list, tuple, set, frozenset)):
        return [_json_safe(v, _depth + 1, _seen) for v in value]
    # datetime/date and anything else: coerce to string
    return str(value)


def _resolve_journal_id(
    db_session: Session, container_title: Optional[str]
) -> Optional[int]:
    """Look up a Journal record by name. Returns journal.id or None.

    The journal reputation filter writes Journal rows using the
    cleaned journal name as returned by its regex cleanup (NFKC-
    normalized, whitespace-stripped, but NOT lowercased). We match
    against that by applying the same NFKC+strip normalization here
    and using a case-insensitive comparison so mismatched capitalization
    in the container_title doesn't break the lookup.
    """
    if not container_title:
        return None
    import unicodedata

    name_norm = unicodedata.normalize("NFKC", container_title).strip()
    # Query name_lower, not func.lower(name): expression-wrapping the
    # indexed column forces a full scan.
    row = (
        db_session.query(Journal.id)
        .filter(Journal.name_lower == name_norm.lower())
        .first()
    )
    return row[0] if row else None


def _find_existing_paper(
    db_session: Session, fields: dict
) -> Optional["Paper"]:
    """Find an existing Paper by any of DOI, arXiv ID, or PMID.

    Issues a single OR-query across all provided identifiers so that a
    caller with multiple IDs doesn't miss dedup because the first one
    (e.g. DOI) is absent from the stored row but a later one (e.g.
    arXiv) would have matched. The previous waterfall short-circuited
    on the first non-null input and never tried the remaining IDs.

    Uses load_only to skip the ``paper_metadata`` JSON blob on the
    dedup lookup — we only need the identifier columns. The blob is
    lazy-loaded if the caller later touches ``paper.paper_metadata``.
    """
    id_only = load_only(
        Paper.id,
        Paper.doi,
        Paper.arxiv_id,
        Paper.pmid,
        Paper.journal_id,
    )

    conditions = []
    doi = fields.get("doi")
    arxiv_id = fields.get("arxiv_id")
    pmid = fields.get("pmid")
    if doi:
        conditions.append(Paper.doi == doi)
    if arxiv_id:
        conditions.append(Paper.arxiv_id == arxiv_id)
    if pmid:
        conditions.append(Paper.pmid == pmid)

    if not conditions:
        return None

    matches = (
        db_session.query(Paper).options(id_only).filter(or_(*conditions)).all()
    )

    if not matches:
        return None
    if len(matches) == 1:
        return matches[0]

    # Multiple distinct rows matched different identifiers of the same
    # incoming record. This indicates a prior mismerge; deterministic
    # tie-break on oldest (lowest) id so repeat runs don't oscillate.
    winner = min(matches, key=lambda p: p.id)
    logger.warning(
        f"Paper dedup conflict on {sorted(k for k in ('doi', 'arxiv_id', 'pmid') if fields.get(k))}: "
        f"{len(matches)} rows (ids {sorted(m.id for m in matches)}); "
        f"using id {winner.id}. Manual review recommended."
    )
    return winner


def _merge_identifiers(paper: "Paper", indexed: dict, metadata: dict) -> None:
    """Enrich an existing Paper with identifiers from a new encounter.

    E.g., an ArXiv paper later found via OpenAlex gains a DOI.

    Args:
        paper: The existing Paper row to enrich.
        indexed: New values for the real columns (doi, arxiv_id,
            pmid, journal_id). Only applied if the column is
            currently empty.
        metadata: Additional bibliographic fields (pmcid, authors,
            csl_json, etc.) to merge into paper.paper_metadata. Only
            keys that aren't already present in the existing blob
            are added — first write wins, to preserve the original
            enrichment.
    """
    # Indexed columns — first write wins. Avoids churning rows when
    # the same paper turns up across many research sessions with
    # slightly different scoring / cleaned names.
    if indexed.get("doi") and not paper.doi:
        paper.doi = indexed["doi"]
    if indexed.get("arxiv_id") and not paper.arxiv_id:
        paper.arxiv_id = indexed["arxiv_id"]
    if indexed.get("pmid") and not paper.pmid:
        paper.pmid = indexed["pmid"]
    if indexed.get("journal_id") and not paper.journal_id:
        paper.journal_id = indexed["journal_id"]
    if indexed.get("container_title") and not paper.container_title:
        paper.container_title = indexed["container_title"]
    if indexed.get("year") is not None and paper.year is None:
        paper.year = indexed["year"]

    # Metadata blob — merge any missing keys.
    # IMPORTANT: we must build a NEW dict and reassign the attribute so
    # that SQLAlchemy's plain JSON column marks it dirty. In-place
    # mutation of the existing dict is not detected without
    # MutableDict.as_mutable() — which this column does not use, to
    # stay consistent with other JSON columns in the project.
    if metadata:
        existing = dict(paper.paper_metadata) if paper.paper_metadata else {}
        changed = False
        for key, value in metadata.items():
            if value is not None and key not in existing:
                existing[key] = value
                changed = True
        if changed:
            paper.paper_metadata = existing or None
