import json
import time
from abc import ABC, abstractmethod
from datetime import datetime, UTC
from typing import Any, Dict, List, Optional

from langchain_core.language_models import BaseLLM
from loguru import logger
from tenacity import (
    RetryError,
    retry,
    retry_if_exception_type,
    stop_after_attempt,
)
from tenacity.wait import wait_base

from ..advanced_search_system.filters.base_filter import BaseFilter
from ..utilities.thread_context import set_search_context

# Lazy import for metrics to avoid database dependencies in programmatic mode
# from ..metrics.search_tracker import get_search_tracker
from .rate_limiting import RateLimitError, get_tracker


class AdaptiveWait(wait_base):
    """Custom wait strategy that uses adaptive rate limiting."""

    def __init__(self, get_wait_func):
        self.get_wait_func = get_wait_func

    def __call__(self, retry_state):
        return self.get_wait_func()


class BaseSearchEngine(ABC):
    """
    Abstract base class for search engines with two-phase retrieval capability.
    Handles common parameters and implements the two-phase search approach.
    """

    # Class attribute to indicate if this engine searches public internet sources
    # Should be overridden by subclasses - defaults to False for safety
    is_public = False

    # Class attribute to indicate if this is a generic search engine (vs specialized)
    # Generic engines are general web search (Google, Bing, etc) vs specialized (arXiv, PubMed)
    is_generic = False

    # Class attribute to indicate if this is a scientific/academic search engine
    # Scientific engines include arXiv, PubMed, Semantic Scholar, etc.
    is_scientific = False

    # Class attribute to indicate if this is a local RAG/document search engine
    # Local engines search private document collections stored locally
    is_local = False

    # Class attribute to indicate if this is a news search engine
    # News engines specialize in news articles and current events
    is_news = False

    # Class attribute to indicate if this is a code search engine
    # Code engines specialize in searching code repositories
    is_code = False

    @classmethod
    def _load_engine_class(cls, name: str, config: Dict[str, Any]):
        """
        Helper method to load an engine class dynamically.

        Args:
            name: Engine name
            config: Engine configuration dict with module_path and class_name

        Returns:
            Tuple of (success: bool, engine_class or None, error_msg or None)
        """
        import importlib

        try:
            module_path = config.get("module_path")
            class_name = config.get("class_name")

            if not module_path or not class_name:
                return (
                    False,
                    None,
                    f"Missing module_path or class_name for {name}",
                )

            # Import the module
            package = None
            if module_path.startswith("."):
                # This is a relative import
                package = "local_deep_research.web_search_engines"
            module = importlib.import_module(module_path, package=package)
            engine_class = getattr(module, class_name)

            return True, engine_class, None

        except Exception as e:
            return False, None, f"Could not load engine class for {name}: {e}"

    @classmethod
    def _check_api_key_availability(
        cls, name: str, config: Dict[str, Any]
    ) -> bool:
        """
        Helper method to check if an engine's API key is available and valid.

        Args:
            name: Engine name
            config: Engine configuration dict

        Returns:
            True if API key is not required or is available and valid
        """
        from loguru import logger

        if not config.get("requires_api_key", False):
            return True

        api_key = config.get("api_key", "").strip()

        # Check for common placeholder values
        if (
            not api_key
            or api_key in ["", "None", "PLACEHOLDER", "YOUR_API_KEY_HERE"]
            or api_key.endswith(
                "_API_KEY"
            )  # Default placeholders like BRAVE_API_KEY
            or api_key.startswith("YOUR_")
            or api_key == "null"
        ):
            logger.debug(
                f"Skipping {name} - requires API key but none configured"
            )
            return False

        return True

    def __init__(
        self,
        llm: Optional[BaseLLM] = None,
        max_filtered_results: Optional[int] = None,
        max_results: Optional[int] = 10,  # Default value if not provided
        preview_filters: List[BaseFilter] | None = None,
        content_filters: List[BaseFilter] | None = None,
        search_snippets_only: bool = True,  # New parameter with default
        settings_snapshot: Optional[Dict[str, Any]] = None,
        programmatic_mode: bool = False,
        **kwargs,
    ):
        """
        Initialize the search engine with common parameters.

        Args:
            llm: Optional language model for relevance filtering
            max_filtered_results: Maximum number of results to keep after filtering
            max_results: Maximum number of search results to return
            preview_filters: Filters that will be applied to all previews
                produced by the search engine, before relevancy checks.
            content_filters: Filters that will be applied to the full content
                produced by the search engine, after relevancy checks.
            search_snippets_only: Whether to return only snippets or full content
            settings_snapshot: Settings snapshot for configuration
            programmatic_mode: If True, disables database operations and uses memory-only tracking
            **kwargs: Additional engine-specific parameters
        """
        if max_filtered_results is None:
            max_filtered_results = 5
        if max_results is None:
            max_results = 10
        self._preview_filters: List[BaseFilter] = preview_filters
        if self._preview_filters is None:
            self._preview_filters = []
        self._content_filters: List[BaseFilter] = content_filters
        if self._content_filters is None:
            self._content_filters = []

        self.llm = llm  # LLM for relevance filtering
        self._max_filtered_results = int(
            max_filtered_results
        )  # Ensure it's an integer
        self._max_results = max(
            1, int(max_results)
        )  # Ensure it's a positive integer
        self.search_snippets_only = search_snippets_only  # Store the setting
        self.settings_snapshot = (
            settings_snapshot or {}
        )  # Store settings snapshot
        self.programmatic_mode = programmatic_mode

        # Rate limiting attributes
        self.engine_type = self.__class__.__name__
        # Create a tracker with our settings if in programmatic mode
        if self.programmatic_mode:
            from .rate_limiting.tracker import AdaptiveRateLimitTracker

            self.rate_tracker = AdaptiveRateLimitTracker(
                settings_snapshot=self.settings_snapshot,
                programmatic_mode=self.programmatic_mode,
            )
        else:
            self.rate_tracker = get_tracker()
        self._last_wait_time = (
            0.0  # Default to 0 for successful searches without rate limiting
        )
        self._last_results_count = 0

    @property
    def max_filtered_results(self) -> int:
        """Get the maximum number of filtered results."""
        return self._max_filtered_results

    @max_filtered_results.setter
    def max_filtered_results(self, value: int) -> None:
        """Set the maximum number of filtered results."""
        if value is None:
            value = 5
            logger.warning("Setting max_filtered_results to 5")
        self._max_filtered_results = int(value)

    @property
    def max_results(self) -> int:
        """Get the maximum number of search results."""
        return self._max_results

    @max_results.setter
    def max_results(self, value: int) -> None:
        """Set the maximum number of search results."""
        if value is None:
            value = 10
        self._max_results = max(1, int(value))

    def _get_adaptive_wait(self) -> float:
        """Get adaptive wait time from tracker."""
        wait_time = self.rate_tracker.get_wait_time(self.engine_type)
        self._last_wait_time = wait_time
        logger.debug(
            f"{self.engine_type} waiting {wait_time:.2f}s before retry"
        )
        return wait_time

    def _record_retry_outcome(self, retry_state) -> None:
        """Record outcome after retry completes."""
        success = (
            not retry_state.outcome.failed if retry_state.outcome else False
        )
        self.rate_tracker.record_outcome(
            self.engine_type,
            self._last_wait_time or 0,
            success,
            retry_state.attempt_number,
            error_type="RateLimitError" if not success else None,
            search_result_count=self._last_results_count if success else 0,
        )

    def run(
        self, query: str, research_context: Dict[str, Any] | None = None
    ) -> List[Dict[str, Any]]:
        """
        Run the search engine with a given query, retrieving and filtering results.
        This implements a two-phase retrieval approach:
        1. Get preview information for many results
        2. Filter the previews for relevance
        3. Get full content for only the relevant results

        Args:
            query: The search query
            research_context: Context from previous research to use.

        Returns:
            List of search results with full content (if available)
        """
        # Track search call for metrics (if available and not in programmatic mode)
        tracker = None
        if not self.programmatic_mode:
            from ..metrics.search_tracker import get_search_tracker

            # For thread-safe context propagation: if we have research_context parameter, use it
            # Otherwise, try to inherit from current thread context (normal case)
            # This allows strategies running in threads to explicitly pass context when needed
            if research_context:
                # Explicit context provided - use it and set it for this thread
                set_search_context(research_context)

            # Get tracker after context is set (either from parameter or thread)
            tracker = get_search_tracker()

        engine_name = self.__class__.__name__.replace(
            "SearchEngine", ""
        ).lower()
        start_time = time.time()

        success = True
        error_message = None
        results_count = 0

        # Define the core search function with retry logic
        if self.rate_tracker.enabled:
            # Rate limiting enabled - use retry with adaptive wait
            @retry(
                stop=stop_after_attempt(3),
                wait=AdaptiveWait(lambda: self._get_adaptive_wait()),
                retry=retry_if_exception_type((RateLimitError,)),
                after=self._record_retry_outcome,
                reraise=True,
            )
            def _run_with_retry():
                nonlocal success, error_message, results_count
                return _execute_search()
        else:
            # Rate limiting disabled - run without retry
            def _run_with_retry():
                nonlocal success, error_message, results_count
                return _execute_search()

        def _execute_search():
            nonlocal success, error_message, results_count

            try:
                # Step 1: Get preview information for items
                previews = self._get_previews(query)
                if not previews:
                    logger.info(
                        f"Search engine {self.__class__.__name__} returned no preview results for query: {query}"
                    )
                    results_count = 0
                    return []

                for preview_filter in self._preview_filters:
                    previews = preview_filter.filter_results(previews, query)

                # Step 2: Filter previews for relevance with LLM
                # Check if LLM relevance filtering should be enabled
                enable_llm_filter = getattr(
                    self, "enable_llm_relevance_filter", False
                )

                logger.info(
                    f"BaseSearchEngine: Relevance filter check - enable_llm_relevance_filter={enable_llm_filter}, has_llm={self.llm is not None}, engine_type={type(self).__name__}"
                )

                if enable_llm_filter and self.llm:
                    logger.info(
                        f"Applying LLM relevance filter to {len(previews)} previews"
                    )
                    filtered_items = self._filter_for_relevance(previews, query)
                    logger.info(
                        f"LLM filter kept {len(filtered_items)} of {len(previews)} results"
                    )
                else:
                    filtered_items = previews
                    if not enable_llm_filter:
                        logger.info(
                            f"LLM relevance filtering disabled (enable_llm_relevance_filter={enable_llm_filter}) - returning all {len(previews)} previews"
                        )
                    elif not self.llm:
                        logger.info(
                            f"No LLM available for relevance filtering - returning all {len(previews)} previews"
                        )

                # Step 3: Get full content for filtered items
                if self.search_snippets_only:
                    logger.info("Returning snippet-only results as per config")
                    results = filtered_items
                else:
                    results = self._get_full_content(filtered_items)

                for content_filter in self._content_filters:
                    results = content_filter.filter_results(results, query)

                results_count = len(results)
                self._last_results_count = results_count

                # Record success if we get here and rate limiting is enabled
                if self.rate_tracker.enabled:
                    logger.info(
                        f"Recording successful search for {self.engine_type}: wait_time={self._last_wait_time}s, results={results_count}"
                    )
                    self.rate_tracker.record_outcome(
                        self.engine_type,
                        self._last_wait_time,
                        success=True,
                        retry_count=1,  # First attempt succeeded
                        search_result_count=results_count,
                    )
                else:
                    logger.info(
                        f"Rate limiting disabled, not recording search for {self.engine_type}"
                    )

                return results

            except RateLimitError:
                # Only re-raise if rate limiting is enabled
                if self.rate_tracker.enabled:
                    raise
                else:
                    # If rate limiting is disabled, treat as regular error
                    success = False
                    error_message = "Rate limit hit but rate limiting disabled"
                    logger.warning(
                        f"Rate limit hit on {self.__class__.__name__} but rate limiting is disabled"
                    )
                    results_count = 0
                    return []
            except Exception as e:
                # Other errors - don't retry
                success = False
                error_message = str(e)
                logger.exception(
                    f"Search engine {self.__class__.__name__} failed"
                )
                results_count = 0
                return []

        try:
            return _run_with_retry()
        except RetryError as e:
            # All retries exhausted
            success = False
            error_message = f"Rate limited after all retries: {e}"
            logger.exception(
                f"{self.__class__.__name__} failed after all retries"
            )
            return []
        except Exception as e:
            success = False
            error_message = str(e)
            logger.exception(f"Search engine {self.__class__.__name__} error")
            return []
        finally:
            # Record search metrics (if tracking is available)
            if tracker is not None:
                response_time_ms = int((time.time() - start_time) * 1000)
                tracker.record_search(
                    engine_name=engine_name,
                    query=query,
                    results_count=results_count,
                    response_time_ms=response_time_ms,
                    success=success,
                    error_message=error_message,
                )

    def invoke(self, query: str) -> List[Dict[str, Any]]:
        """Compatibility method for LangChain tools"""
        return self.run(query)

    def _filter_for_relevance(
        self, previews: List[Dict[str, Any]], query: str
    ) -> List[Dict[str, Any]]:
        """
        Filter search results by relevance to the query using the LLM.

        Args:
            previews: List of preview dictionaries
            query: The original search query

        Returns:
            Filtered list of preview dictionaries
        """
        # If no LLM or too few previews, return all
        if not self.llm or len(previews) <= 1:
            return previews

        # Log the number of previews we're processing
        logger.info(f"Filtering {len(previews)} previews for relevance")

        # Create a simple context for LLM
        preview_context = []
        indices_used = []
        for i, preview in enumerate(previews):
            title = preview.get("title", "Untitled").strip()
            snippet = preview.get("snippet", "").strip()
            url = preview.get("url", "").strip()

            # Clean up snippet if too long
            if len(snippet) > 300:
                snippet = snippet[:300] + "..."

            preview_context.append(
                f"[{i}] Title: {title}\nURL: {url}\nSnippet: {snippet}"
            )
            indices_used.append(i)

        # Log the indices we're presenting to the LLM
        logger.info(
            f"Created preview context with indices 0-{len(previews) - 1}"
        )
        logger.info(
            f"First 5 indices in prompt: {indices_used[:5]}, Last 5: {indices_used[-5:] if len(indices_used) > 5 else 'N/A'}"
        )

        # Join all previews with clear separation
        preview_text = "\n\n".join(preview_context)

        # Log a sample of what we're sending to the LLM
        logger.debug(
            f"First preview in prompt: {preview_context[0] if preview_context else 'None'}"
        )
        if len(preview_context) > 1:
            logger.debug(f"Last preview in prompt: {preview_context[-1]}")

        # Set a reasonable limit on context length
        current_date = datetime.now(UTC).strftime("%Y-%m-%d")
        prompt = f"""Analyze these search results and select the most relevant ones for the query.

Query: "{query}"
Current date: {current_date}
Total results: {len(previews)}

Criteria for selection (in order of importance):
1. Direct relevance - MUST directly address the specific query topic, not just mention keywords
2. Quality - from reputable sources with substantive information about the query
3. Recency - prefer recent information when relevant

Search results to evaluate:
{preview_text}

Return a JSON array of indices (0-based) for results that are highly relevant to the query.
Valid indices are 0 to {len(previews) - 1}.
Only include results that directly help answer the specific question asked.
Be selective - it's better to return fewer high-quality results than many mediocre ones.
Maximum results to return: {self.max_filtered_results}
Example response: [4, 0, 2, 7]

Respond with ONLY the JSON array, no other text."""

        try:
            # Get LLM's evaluation
            response = self.llm.invoke(prompt)

            # Log the raw response for debugging
            logger.info(f"Raw LLM response for relevance filtering: {response}")

            # Handle different response formats
            response_text = ""
            if hasattr(response, "content"):
                response_text = response.content
            else:
                response_text = str(response)

            # Clean up response
            response_text = response_text.strip()
            logger.debug(f"Cleaned response text: {response_text}")

            # Find JSON array in response
            start_idx = response_text.find("[")
            end_idx = response_text.rfind("]")

            if start_idx >= 0 and end_idx > start_idx:
                array_text = response_text[start_idx : end_idx + 1]
                try:
                    ranked_indices = json.loads(array_text)
                    logger.info(f"LLM returned indices: {ranked_indices}")

                    # Validate that ranked_indices is a list of integers
                    if not isinstance(ranked_indices, list):
                        logger.warning(
                            "LLM response is not a list, returning empty results"
                        )
                        return []

                    if not all(isinstance(idx, int) for idx in ranked_indices):
                        logger.warning(
                            "LLM response contains non-integer indices, returning empty results"
                        )
                        return []

                    # Log analysis of the indices
                    max_index = max(ranked_indices) if ranked_indices else -1
                    min_index = min(ranked_indices) if ranked_indices else -1
                    logger.info(
                        f"Index analysis: min={min_index}, max={max_index}, "
                        f"valid_range=0-{len(previews) - 1}, count={len(ranked_indices)}"
                    )

                    # Return the results in ranked order
                    ranked_results = []
                    out_of_range = []
                    for idx in ranked_indices:
                        if 0 <= idx < len(previews):
                            ranked_results.append(previews[idx])
                        else:
                            out_of_range.append(idx)
                            logger.warning(
                                f"Index {idx} out of range (valid: 0-{len(previews) - 1}), skipping"
                            )

                    if out_of_range:
                        logger.error(
                            f"Out of range indices: {out_of_range}. "
                            f"Total previews: {len(previews)}, "
                            f"All returned indices: {ranked_indices}"
                        )

                    # Limit to max_filtered_results if specified
                    if (
                        self.max_filtered_results
                        and len(ranked_results) > self.max_filtered_results
                    ):
                        logger.info(
                            f"Limiting filtered results to top {self.max_filtered_results}"
                        )
                        return ranked_results[: self.max_filtered_results]

                    return ranked_results

                except json.JSONDecodeError as e:
                    logger.warning(
                        f"Failed to parse JSON from LLM response: {e}"
                    )
                    logger.debug(f"Problematic JSON text: {array_text}")
                    return []
            else:
                logger.warning(
                    "Could not find JSON array in response, returning original previews"
                )
                logger.debug(
                    f"Response text without JSON array: {response_text}"
                )
                return previews[: min(5, len(previews))]

        except Exception:
            logger.exception("Relevance filtering error")
            # Fall back to returning top results on error
            return previews[: min(5, len(previews))]

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

        Args:
            query: The search query

        Returns:
            List of preview dictionaries with at least 'id', 'title', and 'snippet' keys
        """
        pass

    @abstractmethod
    def _get_full_content(
        self, relevant_items: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        """
        Get full content for the relevant items.

        Args:
            relevant_items: List of relevant preview dictionaries

        Returns:
            List of result dictionaries with full content
        """
        pass
