import time
from datetime import timedelta
from typing import Any, Dict, List, Optional

from langchain_core.language_models.chat_models import BaseChatModel
from loguru import logger
from methodtools import lru_cache
from sqlalchemy.orm import Session

from ...config.llm_config import get_llm
from ...database.models import Journal
from ...database.session_context import get_user_db_session
from ...search_system import AdvancedSearchSystem
from ...utilities.resource_utils import safe_close
from ...utilities.thread_context import get_search_context
from ...web_search_engines.search_engine_factory import create_search_engine
from .base_filter import BaseFilter


class JournalFilterError(Exception):
    """
    Custom exception for errors related to journal filtering.
    """


class JournalReputationFilter(BaseFilter):
    """
    A filter for academic results that considers the reputation of journals.

    Note that this filter requires SearXNG to be available in order to work.
    """

    def __init__(
        self,
        model: BaseChatModel | None = None,
        reliability_threshold: int | None = None,
        max_context: int | None = None,
        exclude_non_published: bool | None = None,
        quality_reanalysis_period: timedelta | None = None,
        settings_snapshot: Dict[str, Any] | None = None,
    ):
        """
        Args:
            model: The LLM model to use for analysis.
            reliability_threshold: The filter scores journal reliability on a
                scale of 1-10. Results from any journal with a reliability
                below this threshold will be culled. Will be read from the
                settings if not specified.
            max_context: The maximum number of characters to feed into the
                LLM when assessing journal reliability.
            exclude_non_published: If true, it will exclude any results that
                don't have an associated journal publication.
            quality_reanalysis_period: Period at which to update journal
                quality assessments.
            settings_snapshot: Settings snapshot for thread context.

        """
        super().__init__(model)

        self._owns_llm = self.model is None
        if self.model is None:
            self.model = get_llm()

        self.__threshold = reliability_threshold
        if self.__threshold is None:
            # Import here to avoid circular import
            from ...config.search_config import get_setting_from_snapshot

            self.__threshold = int(
                get_setting_from_snapshot(
                    "search.journal_reputation.threshold",
                    4,
                    settings_snapshot=settings_snapshot,
                )
            )
        self.__max_context = max_context
        if self.__max_context is None:
            self.__max_context = int(
                get_setting_from_snapshot(
                    "search.journal_reputation.max_context",
                    3000,
                    settings_snapshot=settings_snapshot,
                )
            )
        self.__exclude_non_published = exclude_non_published
        if self.__exclude_non_published is None:
            self.__exclude_non_published = bool(
                get_setting_from_snapshot(
                    "search.journal_reputation.exclude_non_published",
                    False,
                    settings_snapshot=settings_snapshot,
                )
            )
        self.__quality_reanalysis_period = quality_reanalysis_period
        if self.__quality_reanalysis_period is None:
            self.__quality_reanalysis_period = timedelta(
                days=int(
                    get_setting_from_snapshot(
                        "search.journal_reputation.reanalysis_period",
                        365,
                        settings_snapshot=settings_snapshot,
                    )
                )
            )

        # Store settings_snapshot for later use
        self.__settings_snapshot = settings_snapshot

        # SearXNG is required so we can search the open web for reputational
        # information.
        self.__engine = create_search_engine(
            "searxng", llm=self.model, settings_snapshot=settings_snapshot
        )
        # create_search_engine() can return a non-None engine whose backing
        # service is unreachable (e.g. SearXNG is down), so we also verify
        # the engine reports itself as available.
        if self.__engine is None or not getattr(
            self.__engine, "is_available", False
        ):
            raise JournalFilterError(
                "SearXNG initialization failed or not available."
            )

    def close(self) -> None:
        """Close the SearXNG engine and LLM client."""
        if hasattr(self, "_JournalReputationFilter__engine"):
            safe_close(self.__engine, "SearXNG engine")
        if self._owns_llm:
            safe_close(self.model, "journal filter LLM")

    @classmethod
    def create_default(
        cls,
        model: BaseChatModel | None = None,
        *,
        engine_name: str,
        settings_snapshot: Dict[str, Any] | None = None,
    ) -> Optional["JournalReputationFilter"]:
        """
        Initializes a default configuration of the filter based on the settings.

        Args:
            model: Explicitly specify the LLM to use.
            engine_name: The name of the search engine. Will be used to check
                the enablement status for that engine.
            settings_snapshot: Settings snapshot for thread context.

        Returns:
            The filter that it created, or None if filtering is disabled in
            the settings, or misconfigured.

        """
        # Import here to avoid circular import
        from ...config.search_config import get_setting_from_snapshot

        if not bool(
            get_setting_from_snapshot(
                f"search.engine.web.{engine_name}.journal_reputation.enabled",
                True,
                settings_snapshot=settings_snapshot,
            )
        ):
            return None

        try:
            # Initialize the filter with default settings.
            return JournalReputationFilter(
                model=model, settings_snapshot=settings_snapshot
            )
        except JournalFilterError:
            logger.exception(
                "SearXNG is not configured, but is required for "
                "journal reputation filtering. Disabling filtering."
            )
            return None

    @staticmethod
    def __db_session() -> Session:
        """
        Returns:
            The database session to use.

        """
        context = get_search_context()
        username = context.get("username")
        password = context.get("user_password")

        return get_user_db_session(username=username, password=password)

    def __make_search_system(self) -> AdvancedSearchSystem:
        """
        Creates a new `AdvancedSearchSystem` instance.

        Returns:
            The system it created.

        """
        return AdvancedSearchSystem(
            llm=self.model,
            search=self.__engine,
            # We clamp down on the default iterations and questions for speed.
            max_iterations=1,
            questions_per_iteration=3,
            settings_snapshot=self.__settings_snapshot,
        )

    @lru_cache(maxsize=1024)
    def __analyze_journal_reputation(self, journal_name: str) -> int:
        """
        Analyzes the reputation of a particular journal.

        Args:
            journal_name: The name of the journal.

        Returns:
            The reputation of the journal, on a scale from 1-10.

        """
        logger.info(f"Analyzing reputation of journal '{journal_name}'...")

        # Perform a search for information about this journal.
        search_system = self.__make_search_system()
        try:
            journal_info = search_system.analyze_topic(
                f'Assess the reputability and reliability of the journal "'
                f'{journal_name}", with a particular focus on its quartile '
                f"ranking and peer review status. Be sure to specify the journal "
                f"name in any generated questions."
            )
        finally:
            safe_close(search_system, "journal search system")
        journal_info = "\n".join(
            [f["content"] for f in journal_info["findings"]]
        )
        logger.debug(f"Received raw info about journal: {journal_info}")

        # Have the LLM assess the reliability based on this information.
        prompt = f"""
        You are a research assistant helping to assess the reliability and
        reputability of scientific journals. A reputable journal should be
        peer-reviewed, not predatory, and high-impact. Please review the
        following  information on the journal "{journal_name}" and output a
        reputability score between 1 and 10, where 1-3 is not reputable and
        probably predatory, 4-6 is reputable but low-impact (Q2 or Q3),
        and 7-10 is reputable Q1 journals. Only output the number, do not
        provide any explanation or other output.

        JOURNAL INFORMATION:

        {journal_info}
        """
        if len(prompt) > self.__max_context:
            # If the prompt is too long, truncate it to fit within the max context size.
            prompt = prompt[: self.__max_context] + "..."

        # Generate a response from the LLM model.
        response = self.model.invoke(prompt).content
        logger.debug(f"Got raw LLM response: {response}")

        # Extract the score from the response.
        try:
            reputation_score = int(response.strip())
        except ValueError:
            logger.exception(
                "Failed to parse reputation score from LLM response."
            )
            raise ValueError(
                "Failed to parse reputation score from LLM response."
            )

        return max(min(reputation_score, 10), 1)

    def __add_journal_to_db(self, *, name: str, quality: int) -> None:
        """
        Saves the journal quality information to the database.

        Args:
            name: The name of the journal.
            quality: The quality assessment for the journal.

        """
        with self.__db_session() as db_session:
            journal = db_session.query(Journal).filter_by(name=name).first()
            if journal is not None:
                journal.quality = quality
                journal.quality_model = self.model.name
                journal.quality_analysis_time = int(time.time())
            else:
                journal = Journal(
                    name=name,
                    quality=quality,
                    quality_model=self.model.name,
                    quality_analysis_time=int(time.time()),
                )
                db_session.add(journal)

            db_session.commit()

    def __clean_journal_name(self, journal_name: str) -> str:
        """
        Cleans up the name of a journal to remove any extraneous information.
        This is mostly to make caching more effective.

        Args:
            journal_name: The raw name of the journal.

        Returns:
            The cleaned name.

        """
        logger.debug(f"Cleaning raw journal name: {journal_name}")

        prompt = f"""
        Clean up the following journal or conference name:

        "{journal_name}"

        Remove any references to volumes, pages, months, or years. Expand
        abbreviations if possible. For conferences, remove locations. Only
        output the clean name, do not provide any explanation or other output.
        """

        response = self.model.invoke(prompt).content
        return response.strip()

    def __check_result(self, result: Dict[str, Any]) -> bool:
        """
        Performs a search to determine the reputability of a result journal..

        Args:
            result: The result to check.

        Returns:
            True if the journal is reputable or if it couldn't determine a
            reputability score, false otherwise.

        """
        journal_name = result.get("journal_ref")
        if journal_name is None:
            logger.debug(
                f"Result {result.get('title')} has no associated "
                f"journal, not evaluating reputation."
            )
            return not self.__exclude_non_published
        journal_name = self.__clean_journal_name(journal_name)

        # Check the database first.
        with self.__db_session() as session:
            journal = (
                session.query(Journal).filter_by(name=journal_name).first()
            )
            if (
                journal is not None
                and (time.time() - journal.quality_analysis_time)
                < self.__quality_reanalysis_period.total_seconds()
            ):
                logger.debug(
                    f"Found existing reputation for {journal_name} in database."
                )
                return journal.quality >= self.__threshold

        # Evaluate reputation.
        try:
            quality = self.__analyze_journal_reputation(journal_name)
            # Save to the database.
            self.__add_journal_to_db(name=journal_name, quality=quality)
            return quality >= self.__threshold
        except ValueError:
            # The LLM behaved weirdly. In this case, we will just assume it's
            # okay.
            return True

    def filter_results(
        self, results: List[Dict], query: str, **kwargs
    ) -> List[Dict]:
        try:
            return list(filter(self.__check_result, results))
        except Exception:
            logger.exception("Journal quality filtering failed")
            return results
