"""Composable bibliographic pipeline — orchestrates clients for end-to-end flows.

Flows:
    ref   → BibEntry             via universal resolve (DOI/arXiv/HAL/DBLP/title)
    title → Paper (with DOI)     via SemanticScholar → CrossRef → DBLP fallback
    DOI   → BibEntry             via CrossRef content negotiation
    title → BibEntry             chains the above
    DOI   → DownloadResult       via Unpaywall + httpx download
"""

from __future__ import annotations

import concurrent.futures
import logging
import os
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING

from httpx import HTTPError as _HTTPError

from axm_bib.clients.arxiv import ArXivError
from axm_bib.clients.crossref import CrossRefClient, CrossRefError
from axm_bib.clients.dblp import DBLPClient, DBLPError
from axm_bib.clients.hal import HALError
from axm_bib.clients.openalex import OpenAlexError
from axm_bib.clients.openreview import OpenReviewError
from axm_bib.clients.semantic_scholar import (
    SemanticScholarClient,
    SemanticScholarError,
)
from axm_bib.clients.unpaywall import UnpaywallClient
from axm_bib.download import doi_fallback_urls, fallback_bib_key, fetch_pdf_content
from axm_bib.exceptions import (
    AXMBibError,
    IdentifierNotFound,
    ResolutionError,
)
from axm_bib.extract import extract_pdf
from axm_bib.models import BibEntry, DownloadResult, Paper, SearchResult
from axm_bib.resolver import IdentifierType, detect_identifier
from axm_bib.search import dedup_papers, filter_papers, rank_by_relevance
from axm_bib.storage import FileSystemStorage, StorageBackend
from axm_bib.venue import VenueEnricher

if TYPE_CHECKING:
    from axm_bib.clients.arxiv import ArXivClient
    from axm_bib.clients.hal import HALClient
    from axm_bib.clients.openalex import OpenAlexClient
    from axm_bib.clients.openreview import OpenReviewClient

# Callable type for search lambdas in _resolve_title
_SearchFn = Callable[[str, int], list[Paper]]

# All recoverable client/network errors for search fallback
_SEARCH_ERRORS = (
    OSError,
    AXMBibError,
    _HTTPError,
    ArXivError,
    CrossRefError,
    DBLPError,
    HALError,
    OpenAlexError,
    OpenReviewError,
    SemanticScholarError,
)

__all__ = ["BibPipeline", "NoDOIError"]

logger = logging.getLogger(__name__)


class NoDOIError(Exception):
    """Raised when a paper has no DOI and BibTeX resolution is impossible."""


class BibPipeline:
    """Composable bibliographic pipeline.

    Orchestrates clients for title→DOI→BibTeX→PDF workflows.

    Args:
        mailto: Email for polite pool (CrossRef + Unpaywall).
        storage: PDF storage backend (default: FileSystemStorage).
    """

    def __init__(
        self,
        mailto: str = "",
        storage: StorageBackend | None = None,
    ) -> None:
        self._crossref = CrossRefClient(mailto=mailto)
        self._scholar = SemanticScholarClient(
            # See docs/reference/config.md for env var documentation
            api_key=os.environ.get("S2_API_KEY"),
        )
        self._dblp = DBLPClient()
        self._unpaywall = UnpaywallClient(email=mailto)
        self._storage = storage or FileSystemStorage()
        self._venue_enricher = VenueEnricher(self._scholar, self._dblp)
        # Lazy-initialized clients (used only by resolve)
        self._arxiv: ArXivClient | None = None
        self._hal: HALClient | None = None
        self._openreview: OpenReviewClient | None = None
        self._openalex: OpenAlexClient | None = None

    @property
    def arxiv(self) -> ArXivClient:
        """Lazy-initialized arXiv client."""
        if self._arxiv is None:
            from axm_bib.clients.arxiv import ArXivClient

            self._arxiv = ArXivClient()
        return self._arxiv

    @property
    def hal(self) -> HALClient:
        """Lazy-initialized HAL client."""
        if self._hal is None:
            from axm_bib.clients.hal import HALClient

            self._hal = HALClient()
        return self._hal

    @property
    def openreview(self) -> OpenReviewClient:
        """Lazy-initialized OpenReview client."""
        if self._openreview is None:
            from axm_bib.clients.openreview import OpenReviewClient

            self._openreview = OpenReviewClient()
        return self._openreview

    @property
    def openalex(self) -> OpenAlexClient:
        """Lazy-initialized OpenAlex client."""
        if self._openalex is None:
            from axm_bib.clients.openalex import OpenAlexClient

            self._openalex = OpenAlexClient()
        return self._openalex

    _MAX_LIMIT = 100
    _SEARCH_MAX_WORKERS = 6

    def resolve(self, ref: str) -> BibEntry:
        """Resolve an exact identifier to a BibEntry (universal router).

        Accepts DOI, arXiv ID, HAL ID, DBLP key, or OpenReview URL.
        Rejects plain titles — use ``search()`` for discovery.

        Args:
            ref: Any scholarly identifier (not a title).

        Returns:
            Resolved BibEntry.

        Raises:
            ValueError: If ref is empty or is a plain title.
        """
        ref = ref.strip()
        if not ref:
            msg = "identifier is required"
            raise ResolutionError(msg)

        id_type, value = detect_identifier(ref)

        match id_type:
            case IdentifierType.DOI:
                return self.resolve_bibtex(value)
            case IdentifierType.ARXIV:
                entry = self.arxiv.bibtex(value)
                return self._enrich_venue(value, entry)
            case IdentifierType.HAL:
                return self.hal.bibtex(value)
            case IdentifierType.DBLP:
                return self._dblp.bibtex(value)
            case IdentifierType.OPENREVIEW:
                return self.openreview.bibtex(value)
            case IdentifierType.TITLE:
                msg = (
                    f"'{ref}' looks like a title, not an identifier. "
                    "Use bib_search to discover papers, then resolve "
                    "by DOI or arXiv ID."
                )
                raise ResolutionError(msg)

    def search(
        self,
        query: str,
        *,
        limit: int = 10,
        venue: str | None = None,
        author: str | None = None,
        year: int | None = None,
    ) -> SearchResult:
        """Search papers across all sources with optional filters.

        Queries all 6 sources (S2, CrossRef, DBLP, OpenReview, arXiv,
        OpenAlex) **in parallel** via :class:`~concurrent.futures.ThreadPoolExecutor`,
        catches errors per-source, deduplicates by DOI / arXiv ID / title,
        and applies post-filters.

        Args:
            query: Search query (title, keywords).
            limit: Maximum results (1-100, clamped if larger).
            venue: Filter by venue name (case-insensitive substring).
            author: Filter by author name (case-insensitive substring).
            year: Filter by publication year (exact match).

        Returns:
            SearchResult with papers, sources_queried, and sources_failed.

        Raises:
            ValueError: If limit < 1.
        """
        if limit < 1:
            msg = f"limit must be ≥ 1, got {limit}"
            raise ValueError(msg)
        limit = min(limit, self._MAX_LIMIT)

        sources: list[tuple[str, _SearchFn]] = [
            ("S2", lambda q, n: self._scholar.search_paper(q, limit=n)),
            ("CrossRef", lambda q, n: self._crossref.search_by_title(q, limit=n)),
            ("DBLP", lambda q, n: self._dblp.search(q, limit=n)),
            ("OpenReview", lambda q, n: self.openreview.search(q, limit=n)),
            ("arXiv", lambda q, n: self.arxiv.search(q, limit=n)),
            ("OpenAlex", lambda q, n: self.openalex.search(q, limit=n)),
        ]

        sources_queried = [name for name, _ in sources]
        all_papers: list[Paper] = []
        sources_failed: list[str] = []

        with concurrent.futures.ThreadPoolExecutor(
            max_workers=self._SEARCH_MAX_WORKERS,
        ) as executor:
            future_to_name = {
                executor.submit(search_fn, query, limit): name
                for name, search_fn in sources
            }
            for future in concurrent.futures.as_completed(future_to_name):
                name = future_to_name[future]
                try:
                    all_papers.extend(future.result())
                except _SEARCH_ERRORS as exc:
                    sources_failed.append(name)
                    logger.warning("%s search failed (%s), continuing", name, exc)

        deduped = dedup_papers(all_papers)
        filtered = filter_papers(deduped, venue=venue, author=author, year=year)
        ranked = rank_by_relevance(query, filtered)
        return SearchResult(
            papers=ranked[:limit],
            sources_queried=sources_queried,
            sources_failed=sources_failed,
        )

    _GRAPH_LIMIT_MAX = 9999
    _VALID_DIRECTIONS = frozenset({"citations", "references", "similar"})

    # ── Venue enrichment (delegates to VenueEnricher) ─────────────────────

    def _enrich_venue(self, arxiv_id: str, entry: BibEntry) -> BibEntry:
        """Enrich an arXiv BibEntry with venue metadata (S2/DBLP).

        Delegates to :class:`~axm_bib.venue.VenueEnricher`.
        """
        return self._venue_enricher.enrich(arxiv_id, entry)

    # ── Graph traversal ───────────────────────────────────────────────────

    def graph(
        self,
        paper_id: str,
        *,
        direction: str = "citations",
        limit: int = 20,
    ) -> list[Paper]:
        """Traverse the citation graph for a paper.

        Uses Semantic Scholar as primary source, with OpenAlex fallback
        for ``citations`` and ``references`` (no fallback for ``similar``).

        Args:
            paper_id: Paper identifier — DOI, arXiv ID (``arXiv:XXXXX``),
                or S2 paper ID. S2 accepts all formats natively.
            direction: One of ``"citations"``, ``"references"``, or
                ``"similar"``.
            limit: Maximum results (clamped to 9999).

        Returns:
            List of related ``Paper`` objects.

        Raises:
            ValueError: If ``direction`` is not valid or ``paper_id``
                is empty.
        """
        if not paper_id.strip():
            msg = "paper_id is required"
            raise ValueError(msg)

        if direction not in self._VALID_DIRECTIONS:
            msg = (
                f"invalid direction '{direction}', "
                f"must be one of: {', '.join(sorted(self._VALID_DIRECTIONS))}"
            )
            raise ValueError(msg)

        limit = min(limit, self._GRAPH_LIMIT_MAX)

        try:
            return self._graph_s2(paper_id, direction, limit)
        except _SEARCH_ERRORS as s2_exc:
            if direction == "similar":
                # No OpenAlex equivalent for recommendations
                raise
            logger.info(
                "S2 %s failed (%s), trying OpenAlex fallback",
                direction,
                s2_exc,
            )
            return self._graph_openalex(paper_id, direction, limit)

    def _graph_s2(self, paper_id: str, direction: str, limit: int) -> list[Paper]:
        """Dispatch graph query to Semantic Scholar."""
        match direction:
            case "citations":
                return self._scholar.citations(paper_id, limit=limit)
            case "references":
                return self._scholar.references(paper_id, limit=limit)
            case "similar":
                return self._scholar.recommendations(paper_id, limit=limit)
            case _:  # pragma: no cover
                msg = f"unexpected direction: {direction}"
                raise ValueError(msg)

    def _graph_openalex(self, paper_id: str, direction: str, limit: int) -> list[Paper]:
        """Fallback graph query via OpenAlex.

        Resolves the paper via OpenAlex search to get a work ID,
        then queries citations or references.
        """
        # Resolve paper_id to an OpenAlex work ID
        candidates = self.openalex.search(paper_id, limit=1)
        if not candidates:
            msg = f"paper not found in OpenAlex: {paper_id}"
            raise IdentifierNotFound(msg)
        work_id = candidates[0].openalex_id
        if not work_id:
            msg = f"no OpenAlex ID for paper: {paper_id}"
            raise IdentifierNotFound(msg)

        match direction:
            case "citations":
                return self.openalex.cited_by(work_id, limit=limit)
            case "references":
                return self.openalex.referenced_works(work_id)[:limit]
            case _:  # pragma: no cover
                msg = f"no OpenAlex fallback for direction: {direction}"
                raise ValueError(msg)

    def resolve_bibtex(self, doi: str) -> BibEntry:
        """Resolve a DOI to a clean BibTeX entry.

        Args:
            doi: Digital Object Identifier.

        Returns:
            Parsed BibEntry with normalized key.
        """
        return self._crossref.doi_to_bibtex(doi)

    # ── PDF download ──────────────────────────────────────────────────────

    def download_pdf(self, ref: str) -> DownloadResult | None:
        """Download open-access PDF, extract content, and write BibTeX.

        Accepts any scholarly identifier (DOI, arXiv ID, etc.) and builds
        a priority-ordered PDF URL chain from multiple sources:
        S2 OA → arXiv direct → Unpaywall → DOI fallbacks.

        Args:
            ref: Any scholarly identifier (DOI, arXiv ID, HAL ID, etc.).

        Returns:
            DownloadResult with full pipeline metadata, None if not open access.

        Raises:
            ValueError: If ``ref`` is empty.
        """
        ref = ref.strip()
        if not ref:
            msg = "identifier is required"
            raise ResolutionError(msg)

        id_type, value = detect_identifier(ref)

        # Build priority-ordered PDF URL list from all available sources
        pdf_urls = self._build_pdf_urls(ref, id_type, value)

        if not pdf_urls:
            return None

        pdf_content = fetch_pdf_content(pdf_urls)
        if pdf_content is None:
            return None

        # Resolve BibTeX via universal router (lazy — after successful download)
        entry: BibEntry | None = None
        doi: str | None = None
        try:
            t0 = time.monotonic()
            entry = self.resolve(ref)
            logger.info("Resolved %s in %.1fs", ref, time.monotonic() - t0)
            doi = entry.doi
        except (*_SEARCH_ERRORS, ValueError, KeyError) as exc:
            # PDF downloadable but no BibTeX source — continue without
            logger.warning("BibTeX resolution failed for %s: %s", ref, exc)

        # Derive a bib_key for folder naming
        bib_key = entry.key if entry else fallback_bib_key(ref)

        # Save PDF via storage backend (creates {key}/{key}.pdf)
        path = self._storage.save(
            ref=ref,
            pdf_bytes=pdf_content,
            bib_key=bib_key,
        )

        # Write paper.bib (only if BibTeX was resolved)
        bibtex_path: str | None = None
        if entry:
            bibtex_path = self._storage.save_bibtex(
                bib_key=bib_key,
                raw_bibtex=entry.raw,
            )

        # Extract markdown + figures
        paper_dir = str(self._storage.paper_dir(bib_key))
        extraction = extract_pdf(Path(path), Path(paper_dir))
        logger.info("Downloaded %s to %s", ref, paper_dir)

        return DownloadResult(
            doi=doi,
            path=path,
            size_bytes=len(pdf_content),
            is_open_access=True,
            paper_dir=paper_dir,
            bibtex_path=bibtex_path,
            extraction=extraction,
        )

    def _build_pdf_urls(
        self,
        ref: str,
        id_type: IdentifierType,
        value: str,
    ) -> list[str]:
        """Build priority-ordered PDF URL list from all available sources.

        Priority: S2 OA → arXiv direct → Unpaywall → DOI fallbacks.

        Args:
            ref: Original identifier string.
            id_type: Detected identifier type.
            value: Normalized identifier value.

        Returns:
            Ordered list of candidate PDF URLs.
        """
        urls: list[str] = []
        seen: set[str] = set()

        def _add(url: str) -> None:
            if url and url not in seen:
                urls.append(url)
                seen.add(url)

        # 1. S2 open_access_pdf — try to get from paper metadata
        try:
            papers = self._scholar.search_paper(ref, limit=1)
            if papers and papers[0].open_access_pdf:
                _add(papers[0].open_access_pdf)
        except _SEARCH_ERRORS:
            pass

        # 2. arXiv direct PDF — if arXiv ID detected or extractable
        if id_type == IdentifierType.ARXIV:
            from axm_bib.clients.arxiv import ArXivClient

            _add(ArXivClient.pdf_url(value))

        # 3. Unpaywall — if DOI available
        doi = value if id_type == IdentifierType.DOI else None
        if doi:
            try:
                for u in self._unpaywall.get_pdf_urls(doi):
                    _add(u)
            except _SEARCH_ERRORS:
                pass

        # 4. DOI-based fallback URLs (arXiv from DOI, ACL Anthology, etc.)
        if doi:
            for u in doi_fallback_urls(doi):
                _add(u)

        return urls
