"""
Normalize search engine result dicts into structured citation metadata.

Converts engine-specific field names and formats into CSL-JSON vocabulary.
Each engine has its own dict shape; this module handles the differences.
"""

import re
from datetime import date
from typing import Any, Optional


__all__ = [
    "normalize_citation",
    "normalize_issn",
    "detect_engine",
]


_ISSN_CHARS = re.compile(r"[^0-9Xx]")


def normalize_issn(s: Optional[str]) -> Optional[str]:
    """Canonicalize an ISSN to the 8-character no-dash form.

    Strips dashes, whitespace, and any non-digit/non-X characters; uppercases
    a trailing "x" check digit. Returns the canonical 8-char form, or None if
    the input is missing or cannot be coerced into 8 characters.

    Examples:
        normalize_issn("1522-9645") == "15229645"
        normalize_issn("15229645") == "15229645"
        normalize_issn("1234-567x") == "1234567X"
        normalize_issn("bad") is None
        normalize_issn(None) is None

    This is a structural canonicalization — it does not verify the ISSN
    checksum. The goal is format-independent equality for lookup.
    """
    if not s:
        return None
    cleaned = _ISSN_CHARS.sub("", s).upper()
    if len(cleaned) != 8:
        return None
    return cleaned


# Academic source engines that produce citation-worthy metadata
ACADEMIC_ENGINES = {
    "arxiv",
    "openalex",
    "semantic_scholar",
    "pubmed",
    "nasa_ads",
}

# URL patterns to detect source engine from URLs
_ENGINE_PATTERNS = [
    (re.compile(r"arxiv\.org"), "arxiv"),
    (re.compile(r"openalex\.org"), "openalex"),
    (re.compile(r"semanticscholar\.org"), "semantic_scholar"),
    (re.compile(r"ncbi\.nlm\.nih\.gov|pubmed"), "pubmed"),
    (re.compile(r"adsabs\.harvard\.edu|ui\.adsabs"), "nasa_ads"),
    (re.compile(r"doi\.org"), "doi"),
]


def detect_engine(source: dict) -> Optional[str]:
    """Detect which search engine produced this result.

    Checks explicit source_engine field first, then URL patterns.
    Returns None for non-academic sources (web, news, etc.).
    """
    # Explicit engine field
    engine = source.get("source_engine") or source.get("source")
    if engine:
        engine_lower = engine.lower().strip()
        if engine_lower in ACADEMIC_ENGINES:
            return engine_lower

    # Detect from URL
    url = source.get("link", "") or source.get("url", "")
    for pattern, engine_name in _ENGINE_PATTERNS:
        if pattern.search(url):
            return engine_name

    return None


def _parse_authors_list(authors: Any) -> Optional[list[dict]]:
    """Convert various author formats to CSL-JSON name objects.

    Handles:
    - List of strings: ["John Smith", "Jane Doe"]
    - Comma-separated string: "John Smith, Jane Doe"
    - List of dicts with "name": [{"name": "John Smith"}]
    - Already CSL format: [{"family": "Smith", "given": "John"}]
    """
    if not authors:
        return None

    if isinstance(authors, str):
        authors = [a.strip() for a in authors.split(",") if a.strip()]

    if not isinstance(authors, list):
        return None

    result = []
    for author in authors:
        if isinstance(author, dict):
            if "family" in author:
                # Whitelist only CSL name fields to ensure the dict is
                # JSON-serializable. Engines like OpenAlex and
                # Semantic Scholar attach nested affiliation objects,
                # ORCIDs, etc., which may contain non-primitive types
                # that would crash json.dumps() when stored in the
                # paper_metadata JSON column.
                safe = {"family": author["family"]}
                if "given" in author:
                    safe["given"] = author["given"]
                if "suffix" in author:
                    safe["suffix"] = author["suffix"]
                result.append(safe)
            elif "name" in author:
                result.append(_parse_name(author["name"]))
            elif "display_name" in author:
                result.append(_parse_name(author["display_name"]))
        elif isinstance(author, str):
            result.append(_parse_name(author))

    return result if result else None


def _parse_name(name: str) -> dict:
    """Parse a name string into CSL {"family", "given"} format."""
    name = name.strip()
    if not name:
        return {"literal": ""}

    # Handle "Last, First" format
    if "," in name:
        parts = name.split(",", 1)
        return {"family": parts[0].strip(), "given": parts[1].strip()}

    # Handle "First Last" format
    parts = name.rsplit(" ", 1)
    if len(parts) == 2:
        return {"given": parts[0].strip(), "family": parts[1].strip()}

    return {"literal": name}


def _parse_date(source: dict) -> tuple[Optional[date], Optional[int]]:
    """Extract publication date and year from various formats.

    Returns (date_obj, year_int). Either may be None.
    """
    year = None

    # Try explicit year field
    raw_year = source.get("year") or source.get("publication_year")
    if raw_year:
        try:
            year = int(raw_year)
        except (ValueError, TypeError):
            pass

    # Try date string
    date_str = (
        source.get("publication_date")
        or source.get("published")
        or source.get("date")
        or source.get("pubdate")
    )
    if date_str and isinstance(date_str, str):
        # Try ISO format: YYYY-MM-DD
        match = re.match(r"(\d{4})-(\d{1,2})-(\d{1,2})", date_str)
        if match:
            try:
                d = date(int(match[1]), int(match[2]), int(match[3]))
                if year is None:
                    year = d.year
                return d, year
            except ValueError:
                pass
        # Try just year
        if year is None:
            match = re.match(r"(\d{4})", date_str)
            if match:
                year = int(match[1])

    return None, year


def _extract_arxiv_id(source: dict) -> Optional[str]:
    """Extract arXiv ID from URL or explicit field."""
    arxiv_id = source.get("arxiv_id")
    if arxiv_id:
        return arxiv_id

    url = source.get("link", "") or source.get("url", "")
    # Old-style (pre-Apr 2007): archive(.subject-class)?/YYMMNNN, e.g.
    # cond-mat/0501001, math.AG/0601001, hep-th/9802150.
    # New-style: YYMM.NNNN or YYMM.NNNNN (5-digit seq from 2015 onwards),
    # with optional vN version suffix, e.g. 2501.12345, 0704.0001v2.
    match = re.search(
        r"arxiv\.org/abs/((?:[a-z-]+(?:\.[A-Z]+)?/\d{7}|\d{4}\.\d{4,5})(?:v\d+)?)",
        url,
    )
    if match:
        return match.group(1)
    return None


def _extract_doi(source: dict) -> Optional[str]:
    """Extract a DOI string from a result/source dict.

    Tries (in order):
      1. ``source["doi"]`` — set by ArXiv, NASA ADS, OpenAlex
      2. ``source["external_ids"]["DOI"]`` / ``externalIds["DOI"]`` — Semantic
         Scholar style
      3. A DOI embedded in ``source["link"]`` (https://doi.org/...)

    URL prefixes (``https://doi.org/``, ``http://doi.org/``, ``doi:``) are
    stripped so the returned value is a bare DOI like ``10.1038/...``. This
    is the single source of truth for DOI extraction across the codebase —
    `openalex_enrichment` and other modules import it from here.
    """
    doi = source.get("doi")
    if isinstance(doi, list):
        doi = doi[0] if doi else None

    # Semantic Scholar exposes DOIs through external_ids / externalIds
    if not doi:
        ext_ids = source.get("external_ids") or source.get("externalIds") or {}
        if isinstance(ext_ids, dict):
            doi = ext_ids.get("DOI") or ext_ids.get("doi")

    # DOI embedded in a link URL — anchor on scheme to avoid CodeQL
    # py/incomplete-url-substring-sanitization (an attacker-controlled
    # URL path could contain "doi.org/" otherwise).
    if not doi:
        link = source.get("link") or ""
        if isinstance(link, str):
            for prefix in (
                "https://doi.org/",
                "http://doi.org/",
                "https://dx.doi.org/",
                "http://dx.doi.org/",
            ):
                if link.startswith(prefix):
                    doi = link[len(prefix) :]
                    break

    if not doi:
        return None

    doi = str(doi)
    for prefix in (
        "https://doi.org/",
        "http://doi.org/",
        "https://dx.doi.org/",
        "http://dx.doi.org/",
        "doi:",
    ):
        if doi.startswith(prefix):
            doi = doi[len(prefix) :]
            break
    return doi if doi else None


def normalize_citation(source: dict) -> Optional[dict]:
    """Normalize a search result dict into citation metadata fields.

    Returns a dict of field values ready for Paper creation,
    or None if the source is not an academic result.

    The returned dict has keys matching Paper column names.
    """
    engine = detect_engine(source)
    if engine is None:
        return None

    pub_date, year = _parse_date(source)
    doi = _extract_doi(source)

    # Guard against source["metadata"] being a non-dict truthy value
    # (e.g., a string) — .get({}).get() crashes with AttributeError
    # in that case because the default only applies when the key is
    # absent/None.
    nested_meta = source.get("metadata")
    if not isinstance(nested_meta, dict):
        nested_meta = {}

    # Prefer a structured CSL list (e.g. NASA ADS publishes "Last, First"
    # names which lose their pairing if re-split from a comma-joined
    # display string) over the display-string fallback.
    authors_input = (
        source.get("authors_csl")
        or nested_meta.get("authors_csl")
        or source.get("authors")
        or nested_meta.get("authors")
    )
    authors = _parse_authors_list(authors_input)

    # Container title (journal/conference name).
    # Checks CSL-style ``container_title`` and ``container-title`` too
    # so callers that already use CSL vocabulary don't have their
    # journal silently dropped.
    container = (
        source.get("journal_ref")
        or source.get("journal")
        or source.get("venue")
        or source.get("container_title")
        or source.get("container-title")
        or nested_meta.get("journal")
    )
    # Placeholder sentinels from upstream engines (e.g. OpenAlex /
    # NASA ADS set these when no venue is indexed). Filter them out
    # so they don't become container_title literals — there's an
    # actual OpenAlex source named "unknown" (Q1, h_index=5) that
    # would otherwise get hit by the name-based lookup.
    if isinstance(container, str) and container.strip().lower() in (
        "unknown",
        "",
    ):
        container = None

    # Item type (CSL vocabulary)
    item_type = _infer_item_type(engine, source)

    fields = {
        "source_engine": engine,
        "doi": doi,
        "arxiv_id": _extract_arxiv_id(source) if engine == "arxiv" else None,
        "pmid": source.get("pmid"),
        "pmcid": source.get("pmcid"),
        "authors": authors,
        "publication_date": pub_date,
        "year": year,
        "volume": source.get("volume"),
        "issue": source.get("issue"),
        "pages": source.get("pages"),
        "container_title": container,
        "publisher": source.get("publisher"),
        "item_type": item_type,
    }

    # Build CSL-JSON item (uses the date object while still native)
    fields["csl_json"] = _build_csl_json(source, fields)

    # Convert publication_date to ISO string so it survives JSON
    # serialization when stored in paper_metadata. _build_csl_json has
    # already consumed the native date object above.
    if fields.get("publication_date") is not None:
        fields["publication_date"] = fields["publication_date"].isoformat()

    # Strip None values to avoid unnecessary DB writes
    return {k: v for k, v in fields.items() if v is not None}


def _infer_item_type(engine: str, source: dict) -> str:
    """Infer CSL item type from engine and source metadata."""
    source_type = source.get("source_type", "")
    if source_type == "conference":
        return "paper-conference"

    if engine == "arxiv":
        # ArXiv papers with journal_ref are published; without are preprints
        return "article-journal" if source.get("journal_ref") else "article"

    return "article-journal"


def _build_csl_json(source: dict, fields: dict) -> dict:
    """Build a CSL-JSON item from normalized fields."""
    csl: dict[str, Any] = {
        "type": fields.get("item_type", "article-journal"),
        "title": source.get("title", ""),
    }

    if fields.get("authors"):
        csl["author"] = fields["authors"]

    if fields.get("container_title"):
        csl["container-title"] = fields["container_title"]

    if fields.get("doi"):
        csl["DOI"] = fields["doi"]

    if fields.get("volume"):
        csl["volume"] = fields["volume"]
    if fields.get("issue"):
        csl["issue"] = fields["issue"]
    if fields.get("pages"):
        csl["page"] = fields["pages"]

    if fields.get("publisher"):
        csl["publisher"] = fields["publisher"]

    if fields.get("year"):
        date_parts = [[fields["year"]]]
        if fields.get("publication_date"):
            d = fields["publication_date"]
            date_parts = [[d.year, d.month, d.day]]
        csl["issued"] = {"date-parts": date_parts}

    url = source.get("link") or source.get("url")
    if url:
        csl["URL"] = url

    return csl
