"""OpenAlex search engine implementation for academic papers and research."""

from typing import Any, Dict, List, Optional

from langchain_core.language_models import BaseLLM
from loguru import logger

from ...constants import SNIPPET_LENGTH_LONG, USER_AGENT
from ...security.safe_requests import safe_get
from ..rate_limiting import RateLimitError
from ..search_engine_base import BaseSearchEngine


class OpenAlexSearchEngine(BaseSearchEngine):
    """OpenAlex search engine implementation with natural language query support."""

    # Mark as public search engine
    is_public = True
    # Scientific/academic search engine
    is_scientific = True
    is_lexical = True
    needs_llm_relevance_filter = True

    def __init__(
        self,
        max_results: int = 25,
        email: Optional[str] = None,
        sort_by: str = "relevance",
        filter_open_access: bool = False,
        min_citations: int = 0,
        from_publication_date: Optional[str] = None,
        llm: Optional[BaseLLM] = None,
        max_filtered_results: Optional[int] = None,
        settings_snapshot: Optional[Dict[str, Any]] = None,
        **kwargs,
    ):
        """
        Initialize the OpenAlex search engine.

        Args:
            max_results: Maximum number of search results
            email: Email for polite pool (gets faster response) - optional
            sort_by: Sort order ('relevance', 'cited_by_count', 'publication_date')
            filter_open_access: Only return open access papers
            min_citations: Minimum citation count filter
            from_publication_date: Filter papers from this date (YYYY-MM-DD)
            llm: Language model for relevance filtering
            max_filtered_results: Maximum number of results to keep after filtering
            settings_snapshot: Settings snapshot for configuration
            **kwargs: Additional parameters to pass to parent class
        """
        # Journal filter runs before LLM relevance (Tiers 1-3 are instant)
        preview_filters = []
        journal_filter = self._create_journal_filter(
            "openalex", llm, settings_snapshot
        )
        if journal_filter is not None:
            preview_filters.append(journal_filter)

        super().__init__(
            llm=llm,
            max_filtered_results=max_filtered_results,
            max_results=max_results,
            preview_filters=preview_filters,  # type: ignore[arg-type]
            settings_snapshot=settings_snapshot,
            **kwargs,
        )

        self.sort_by = sort_by
        self.filter_open_access = filter_open_access
        self.min_citations = min_citations
        # Only set from_publication_date if it's not empty or "False"
        self.from_publication_date = (
            from_publication_date
            if from_publication_date and from_publication_date != "False"
            else None
        )

        # Get email from settings if not provided
        if not email and settings_snapshot:
            from ...config.search_config import get_setting_from_snapshot

            try:
                email = get_setting_from_snapshot(
                    "search.engine.web.openalex.email",
                    settings_snapshot=settings_snapshot,
                )
            except Exception:
                logger.debug(
                    "Failed to read openalex.email from settings snapshot",
                    exc_info=True,
                )

        # Handle "False" string for email
        self.email = email if email and email != "False" else None

        # API configuration
        self.api_base = "https://api.openalex.org"
        self.headers = {
            "User-Agent": f"{USER_AGENT} ({email})" if email else USER_AGENT,
            "Accept": "application/json",
        }

        if email:
            # Email allows access to polite pool with faster response times
            logger.info(f"Using OpenAlex polite pool with email: {email}")
        else:
            logger.info(
                "Using OpenAlex without email (consider adding email for faster responses)"
            )

    def _get_previews(self, query: str) -> List[Dict[str, Any]]:
        """
        Get preview information for OpenAlex search results.

        Args:
            query: The search query (natural language supported!)

        Returns:
            List of preview dictionaries
        """
        logger.info(f"Searching OpenAlex for: {query}")

        # Build the search URL with parameters
        params = {
            "search": query,  # OpenAlex handles natural language beautifully
            "per_page": min(self.max_results, 200),  # OpenAlex allows up to 200
            "page": 1,
            # Request specific fields including abstract for snippets
            "select": "id,display_name,publication_year,publication_date,doi,primary_location,authorships,cited_by_count,open_access,best_oa_location,abstract_inverted_index",
        }

        # Add optional filters
        filters = []

        if self.filter_open_access:
            filters.append("is_oa:true")

        if self.min_citations > 0:
            filters.append(f"cited_by_count:>{self.min_citations}")

        if self.from_publication_date and self.from_publication_date != "False":
            filters.append(
                f"from_publication_date:{self.from_publication_date}"
            )

        if filters:
            params["filter"] = ",".join(filters)

        # Add sorting
        sort_map = {
            "relevance": "relevance_score:desc",
            "cited_by_count": "cited_by_count:desc",
            "publication_date": "publication_date:desc",
        }
        params["sort"] = sort_map.get(self.sort_by, "relevance_score:desc")

        # Add email to params for polite pool
        if self.email and self.email != "False":
            params["mailto"] = self.email

        try:
            # Apply rate limiting before making the request (simple like PubMed)
            self._last_wait_time = self.rate_tracker.apply_rate_limit(
                self.engine_type
            )
            logger.debug(
                f"Applied rate limit wait: {self._last_wait_time:.2f}s"
            )

            # Make the API request
            logger.info(f"Making OpenAlex API request with params: {params}")
            response = safe_get(
                f"{self.api_base}/works",
                params=params,
                headers=self.headers,
                timeout=30,
            )
            logger.info(f"OpenAlex API response status: {response.status_code}")

            # Log rate limit info if available
            if "x-ratelimit-remaining" in response.headers:
                remaining = response.headers.get("x-ratelimit-remaining")
                limit = response.headers.get("x-ratelimit-limit", "unknown")
                logger.debug(
                    f"OpenAlex rate limit: {remaining}/{limit} requests remaining"
                )

            if response.status_code == 200:
                data = response.json()
                results = data.get("results", [])
                meta = data.get("meta", {})
                total_count = meta.get("count", 0)

                logger.info(
                    f"OpenAlex returned {len(results)} results (total available: {total_count:,})"
                )

                # Log first result structure for debugging
                if results:
                    first_result = results[0]
                    logger.debug(
                        f"First result keys: {list(first_result.keys())}"
                    )
                    logger.debug(
                        f"First result has abstract: {'abstract_inverted_index' in first_result}"
                    )
                    if "open_access" in first_result:
                        logger.debug(
                            f"Open access structure: {first_result['open_access']}"
                        )

                # Format results as previews
                previews = []
                for i, work in enumerate(results):
                    logger.debug(
                        f"Formatting work {i + 1}/{len(results)}: {(work.get('display_name') or 'Unknown')[:50]}"
                    )
                    preview = self._format_work_preview(work)
                    if preview:
                        previews.append(preview)
                        logger.debug(
                            f"Preview created with snippet: {preview.get('snippet', '')[:100]}..."
                        )
                    else:
                        logger.warning(f"Failed to format work {i + 1}")

                logger.info(
                    f"Successfully formatted {len(previews)} previews from {len(results)} results"
                )
                return previews

            if response.status_code == 429:
                # Rate limited (very rare with OpenAlex)
                logger.warning("OpenAlex rate limit reached")
                raise RateLimitError("OpenAlex rate limit exceeded")  # noqa: TRY301 — re-raised by except RateLimitError for base class retry

            logger.error(
                f"OpenAlex API error: {response.status_code} - {response.text[:200]}"
            )
            return []

        except RateLimitError:
            # Re-raise rate limit errors for base class retry handling
            raise
        except Exception:
            logger.exception("Error searching OpenAlex")
            return []

    def _format_work_preview(
        self, work: Dict[str, Any]
    ) -> Optional[Dict[str, Any]]:
        """
        Format an OpenAlex work as a preview dictionary.

        Args:
            work: OpenAlex work object

        Returns:
            Formatted preview dictionary or None if formatting fails
        """
        try:
            # Extract basic information
            # Use `or` instead of dict.get default — OpenAlex routinely
            # returns these keys with explicit None values, which would
            # bypass the default and crash on downstream string ops.
            work_id = work.get("id") or ""
            title = work.get("display_name") or "No title"
            logger.debug(f"Formatting work: {title[:50]}")

            # Build snippet from abstract or first part of title
            abstract = None
            if work.get("abstract_inverted_index"):
                logger.debug(
                    f"Found abstract_inverted_index with {len(work['abstract_inverted_index'])} words"
                )
                # Reconstruct abstract from inverted index
                abstract = self._reconstruct_abstract(
                    work["abstract_inverted_index"]
                )
                logger.debug(
                    f"Reconstructed abstract length: {len(abstract) if abstract else 0}"
                )
            else:
                logger.debug("No abstract_inverted_index found")

            snippet = (
                abstract[:SNIPPET_LENGTH_LONG]
                if abstract
                else f"Academic paper: {title}"
            )
            logger.debug(f"Created snippet: {snippet[:100]}...")

            # Get publication info
            publication_year = work.get("publication_year", "unknown")
            publication_date = work.get("publication_date", "unknown")

            # Get venue/journal info
            venue = work.get("primary_location", {})
            journal_name = "unknown"
            openalex_source_id = None
            source_type = None
            issn = None
            if venue:
                source = venue.get("source", {})
                if source:
                    journal_name = source.get("display_name") or "unknown"
                    # Extract source ID for journal quality lookups
                    raw_sid = source.get("id") or ""
                    if raw_sid:
                        openalex_source_id = raw_sid.split("/")[-1]
                    source_type = source.get("type")
                    # Forward the linking ISSN so the reputation filter's
                    # Tier 2/3 lookups can use it instead of falling back
                    # to fuzzy name matching.
                    issn = source.get("issn_l") or None

            # Get authors
            authors = []
            for authorship in work.get("authorships", [])[
                :5
            ]:  # Limit to 5 authors
                author = authorship.get("author", {})
                if author:
                    authors.append(author.get("display_name", ""))

            authors_str = ", ".join(authors)
            if len(work.get("authorships", [])) > 5:
                authors_str += " et al."

            # Extract author affiliations for the institution-tier scoring.
            # Each entry is a dict with the OpenAlex institution id, ROR id,
            # and display name — the lookup_institution() helper accepts any
            # of those three.
            affiliations: list[dict] = []
            seen_inst_ids: set[str] = set()
            for authorship in work.get("authorships", []):
                for inst in authorship.get("institutions", []) or []:
                    raw_id = inst.get("id") or ""
                    short_id = raw_id.split("/")[-1] if raw_id else ""
                    if short_id and short_id in seen_inst_ids:
                        continue
                    if short_id:
                        seen_inst_ids.add(short_id)
                    affiliations.append(
                        {
                            "openalex_id": short_id or None,
                            "ror": (inst.get("ror") or "")
                            .rstrip("/")
                            .split("/")[-1]
                            or None,
                            "name": inst.get("display_name"),
                        }
                    )

            # Get metrics
            cited_by_count = work.get("cited_by_count", 0)

            # Get URL - prefer DOI, fallback to OpenAlex URL.
            # `.get("doi", work_id)` is wrong: when the key exists with value
            # None (common for non-DOI works) it returns None, not the
            # default. Use `or` so a None DOI falls through to work_id.
            url = work.get("doi") or work_id
            if not url.startswith("http"):
                if url.startswith("https://doi.org/"):
                    pass  # Already a full DOI URL
                elif url.startswith("10."):
                    url = f"https://doi.org/{url}"
                else:
                    url = work_id  # OpenAlex URL

            # Check if open access
            open_access_info = work.get("open_access", {})
            is_oa = (
                open_access_info.get("is_oa", False)
                if open_access_info
                else False
            )
            oa_url = None
            if is_oa:
                best_location = work.get("best_oa_location", {})
                if best_location:
                    oa_url = best_location.get("pdf_url") or best_location.get(
                        "landing_page_url"
                    )

            return {
                "id": work_id,
                "title": title,
                "link": url,
                "snippet": snippet,
                "authors": authors_str,
                "year": publication_year,
                "date": publication_date,
                # Both fields emit None (not the "unknown" sentinel) when
                # OpenAlex has no venue for this work. Downstream consumers
                # (citation normalizer, journal reputation filter) treat
                # missing venue as "no scoring signal", which is accurate;
                # the old "unknown" sentinel leaked through the normalizer
                # as a literal container_title and even matched a real
                # OpenAlex source named "unknown" (h_index=5, Q1) in the
                # reference DB.
                "journal": journal_name if journal_name != "unknown" else None,
                "journal_ref": journal_name
                if journal_name != "unknown"
                else None,
                "issn": issn,
                "affiliations": affiliations or None,
                "openalex_source_id": openalex_source_id,
                "source_type": source_type,
                "citations": cited_by_count,
                "is_open_access": is_oa,
                "oa_url": oa_url,
                "abstract": abstract,
                "type": "academic_paper",
            }

        except Exception:
            logger.exception(
                f"Error formatting OpenAlex work: {work.get('id', 'unknown')}"
            )
            return None

    def _reconstruct_abstract(
        self, inverted_index: Dict[str, List[int]]
    ) -> str:
        """
        Reconstruct abstract text from OpenAlex inverted index format.

        Args:
            inverted_index: Dictionary mapping words to their positions

        Returns:
            Reconstructed abstract text
        """
        try:
            # Create position-word mapping
            position_word = {}
            for word, positions in inverted_index.items():
                for pos in positions:
                    position_word[pos] = word

            # Sort by position and reconstruct
            sorted_positions = sorted(position_word.keys())
            words = [position_word[pos] for pos in sorted_positions]

            return " ".join(words)

        except Exception:
            logger.debug("Could not reconstruct abstract from inverted index")
            return ""

    def _get_full_content(
        self, relevant_items: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        """
        Get full content for relevant items (OpenAlex provides most content in preview).

        Args:
            relevant_items: List of relevant preview dictionaries

        Returns:
            List of result dictionaries with full content
        """
        # OpenAlex returns comprehensive data in the initial search,
        # so we don't need a separate full content fetch
        results = []
        for item in relevant_items:
            result = {
                "title": item.get("title", ""),
                "link": item.get("link", ""),
                "snippet": item.get("snippet", ""),
                "content": item.get("abstract", item.get("snippet", "")),
                # Forward journal quality fields for content filters
                "journal_ref": item.get("journal_ref"),
                "openalex_source_id": item.get("openalex_source_id"),
                "source_type": item.get("source_type"),
                "affiliations": item.get("affiliations"),
                "metadata": {
                    "authors": item.get("authors", ""),
                    "year": item.get("year", ""),
                    "journal": item.get("journal", ""),
                    "citations": item.get("citations", 0),
                    "is_open_access": item.get("is_open_access", False),
                    "oa_url": item.get("oa_url"),
                    "affiliations": item.get("affiliations"),
                },
            }
            results.append(result)

        return results
