from typing import Dict

from loguru import logger

from ...citation_handler import CitationHandler
# LLM and search instances should be passed via constructor, not imported

# Removed get_db_setting import - using settings_snapshot instead
from ...utilities.thread_context import (
    preserve_research_context,
    get_search_context,
)
from ...utilities.threading_utils import thread_context
from ..filters.cross_engine_filter import CrossEngineFilter
from ..findings.repository import FindingsRepository
from ..parallel_search import run_parallel_searches
from ..questions.atomic_fact_question import AtomicFactQuestionGenerator
from ..questions.standard_question import StandardQuestionGenerator
from .base_strategy import BaseSearchStrategy


class SourceBasedSearchStrategy(BaseSearchStrategy):
    """
    Source-based search strategy: iterative question generation → parallel search → final synthesis.

    ## High-level flow (analyze_topic):

    1. **Iteration loop** (default 5 iterations, configurable via `search.iterations`):
       - Iteration 1: uses the original query + LLM-generated questions
       - Iterations 2+: LLM generates follow-up questions using accumulated search results
         as context (the [-N:] most recent, configurable via `search.question_context_limit`)
       - All questions for an iteration are searched in parallel via ThreadPoolExecutor
       - Results are appended to `accumulated_search_results_across_all_iterations` (local var)

    2. **Cross-engine filter** (after all iterations):
       - If enabled (default): filters/reorders/reindexes accumulated results for relevance
       - If disabled: uses all accumulated results as-is
       - Result is `final_filtered_results`

    3. **Citation & synthesis**:
       - `final_filtered_results` are extended into `self.all_links_of_system` (shared list)
       - `citation_handler.analyze_followup()` synthesizes content with citation numbers
       - Citation numbers are offset by `total_citation_count_before_this_search` so they
         continue from previous analyze_topic() calls (important for detailed reports)

    ## Key invariant — `self.all_links_of_system`:

    This list is the SAME object as `AdvancedSearchSystem.all_links_of_system` (passed by
    reference via constructor, see search_system.py:195). It is the single source of truth
    for citations in the final report.

    In detailed report mode, `IntegratedReportGenerator` calls `analyze_topic()` multiple
    times (once per subsection). Each call EXTENDS this shared list with its own results.
    Previous sections' results are never touched or re-filtered — they persist safely.

    The final report's Sources section is generated from this list at report_generator.py:494.
    """

    def __init__(
        self,
        search,
        model,
        citation_handler=None,
        include_text_content: bool = True,
        use_cross_engine_filter: bool = True,
        filter_reorder: bool = True,
        filter_reindex: bool = True,
        cross_engine_max_results: int = None,
        all_links_of_system=None,
        use_atomic_facts: bool = False,
        settings_snapshot=None,
        search_original_query: bool = True,
    ):
        """Initialize with optional dependency injection for testing.

        Args:
            search: Search engine instance (e.g., SearXNG, Serper, Tavily)
            model: LLM instance for question generation, filtering, and synthesis
            citation_handler: Handles citation formatting in synthesized content.
                If None, a default CitationHandler is created.
            include_text_content: Whether to fetch full page content (not just snippets)
            use_cross_engine_filter: Whether to apply LLM-based relevance filtering
                after all iterations. When True, the filter may reduce result count
                significantly (e.g. 60 → 10). When False, all accumulated results pass through.
            filter_reorder: Whether the cross-engine filter should reorder by relevance
            filter_reindex: Whether the cross-engine filter should reassign citation indices
            cross_engine_max_results: Max results the filter keeps (default 100)
            all_links_of_system: SHARED list with AdvancedSearchSystem — do not replace,
                only extend. This is the single source of truth for report citations.
            use_atomic_facts: Use AtomicFactQuestionGenerator instead of Standard
            settings_snapshot: Frozen settings dict, read via self.get_setting()
            search_original_query: If True, iteration 1 searches the original query
                verbatim alongside LLM-generated questions. This ensures at least one
                search uses the user's exact words (important for product names, etc.).
        """
        super().__init__(
            all_links_of_system=all_links_of_system,
            settings_snapshot=settings_snapshot,
            search_original_query=search_original_query,
        )

        # Model and search are always provided by AdvancedSearchSystem
        self.model = model
        self.search = search
        # Note: progress_callback and questions_by_iteration are already set by parent class

        self.include_text_content = include_text_content
        self.use_cross_engine_filter = use_cross_engine_filter
        self.filter_reorder = filter_reorder
        self.filter_reindex = filter_reindex

        self.cross_engine_filter = CrossEngineFilter(
            model=self.model,
            max_results=cross_engine_max_results,
            default_reorder=filter_reorder,
            default_reindex=filter_reindex,
            settings_snapshot=settings_snapshot,
        )

        # Set include_full_content on the search engine if it supports it
        if hasattr(self.search, "include_full_content"):
            self.search.include_full_content = include_text_content

        # Use provided citation_handler or create default
        self.citation_handler = citation_handler or CitationHandler(
            self.model, settings_snapshot=settings_snapshot
        )

        # Initialize question generator (atomic facts variant is experimental)
        if use_atomic_facts:
            self.question_generator = AtomicFactQuestionGenerator(self.model)
        else:
            self.question_generator = StandardQuestionGenerator(self.model)
        self.findings_repository = FindingsRepository(self.model)

    def _format_search_results_as_context(self, search_results):
        """Format search results into a text string for the question generation prompt.

        This is a PURE read-only method — it never modifies the input list or dicts.
        It only reads 'title', 'snippet', and 'link' via .get() and builds a string.

        The caller controls how many results are passed (typically sliced with
        [-question_context_limit:]). This method processes whatever it receives.
        """
        context_snippets = []

        for i, result in enumerate(search_results):
            title = result.get("title", "Untitled")
            snippet = result.get("snippet", "")
            url = result.get("link", "")

            if snippet:
                context_snippets.append(
                    f"Source {i + 1}: {title}\nURL: {url}\nSnippet: {snippet}"
                )

        return "\n\n".join(context_snippets)

    def analyze_topic(self, query: str) -> Dict:
        """
        Analyze a topic using source-based search strategy.
        """
        logger.info(f"Starting source-based research on topic: {query}")

        # LOCAL to this analyze_topic() call — accumulates search results across
        # iterations within a single search, then discarded when the call returns.
        # NOT the same as self.all_links_of_system which is SHARED with
        # AdvancedSearchSystem (same list object, passed via constructor).
        # In detailed report mode, IntegratedReportGenerator calls analyze_topic()
        # multiple times (once per subsection). self.all_links_of_system persists
        # across all those calls and accumulates sources for the final report.
        accumulated_search_results_across_all_iterations = []
        findings = []

        # Capture current length of the shared list BEFORE this search adds to it.
        # Used as the citation offset so each analyze_topic() call produces
        # continuous citation numbers (e.g. first call: [1]-[15], second: [16]-[28]).
        total_citation_count_before_this_search = len(self.all_links_of_system)

        self._update_progress(
            "Initializing source-based research",
            5,
            {
                "phase": "init",
                "strategy": "source-based",
                "include_text_content": self.include_text_content,
            },
        )

        # Check search engine
        if not self._validate_search_engine():
            return {
                "findings": [],
                "iterations": 0,
                "questions_by_iteration": {},
                "formatted_findings": "Error: Unable to conduct research without a search engine.",
                "current_knowledge": "",
                "error": "No search engine available",
            }

        # Determine number of iterations to run
        iterations_to_run = self.get_setting("search.iterations", 2)
        iterations_to_run = int(iterations_to_run)
        questions_per_iteration = self.get_setting("search.questions", 3)

        logger.info(
            f"SourceBasedStrategy configuration - iterations: {iterations_to_run}, questions_per_iteration: {questions_per_iteration}"
        )
        logger.debug(
            f"SourceBasedStrategy settings - include_text_content: {self.include_text_content}, use_cross_engine_filter: {self.use_cross_engine_filter}"
        )
        try:
            filtered_search_results = []
            total_citation_count_before_this_search = len(
                self.all_links_of_system
            )
            # Run each iteration
            for iteration in range(1, iterations_to_run + 1):
                self.check_termination()
                iteration_progress_base = 5 + (iteration - 1) * (
                    70 / iterations_to_run
                )

                # Step 1: Generate or use questions
                # Show context-aware progress message (includes iteration info)
                self._emit_question_generation_progress(
                    iteration=iteration,
                    progress_percent=iteration_progress_base + 5,
                    source_count=len(
                        accumulated_search_results_across_all_iterations
                    )
                    if iteration > 1
                    else 0,
                    query=query,
                )

                # ITERATION 1: Search the original query verbatim + LLM-generated questions.
                # The original query is included as-is (if search_original_query=True)
                # because the LLM may misinterpret ambiguous queries (e.g. "local deep
                # research" → Austin heat islands). The verbatim query ensures at least
                # one search hits the right topic.
                if iteration == 1:
                    # Check if user query is too long for direct search
                    max_query_length = self.get_setting(
                        "app.max_user_query_length", 300
                    )
                    original_search_original_query = self.search_original_query

                    if (
                        self.search_original_query
                        and len(query.strip()) > max_query_length
                    ):
                        logger.warning(
                            f"Long user query detected ({len(query.strip())} chars > {max_query_length} limit), "
                            "using LLM questions only for search"
                        )
                        self.search_original_query = False

                    # Generate questions for first iteration
                    context = (
                        f"""Iteration: {iteration} of {iterations_to_run}"""
                    )
                    questions = self.question_generator.generate_questions(
                        current_knowledge=context,
                        query=query,
                        questions_per_iteration=int(
                            self.get_setting(
                                "search.questions_per_iteration", 5
                            )  # Default to 5 if not set
                        ),
                        questions_by_iteration=self.questions_by_iteration,
                    )

                    # Include original query if enabled and not already present
                    all_questions = (
                        [query] + questions
                        if self.search_original_query and query not in questions
                        else questions
                    )

                    if not self.search_original_query:
                        logger.info(
                            "search_original_query=False - skipping original query"
                        )

                    self.questions_by_iteration[iteration] = all_questions
                    logger.info(
                        f"Using questions for iteration {iteration}: {all_questions}"
                    )

                    # Restore original search_original_query setting after first iteration
                    if (
                        original_search_original_query
                        != self.search_original_query
                    ):
                        self.search_original_query = (
                            original_search_original_query
                        )
                        logger.debug(
                            "Restored original search_original_query setting after first iteration"
                        )

                else:
                    # For subsequent iterations, generate questions based on previous search results.
                    # Uses accumulated results (not just the last iteration) so that if an iteration
                    # returns 0 results (e.g. rate-limiting), the question generator still has context.
                    # This is READ-ONLY — _format_search_results_as_context is a pure formatter
                    # that builds a string; it never modifies the input or all_links_of_system.
                    # The [-N:] slice gives the most recent results for iterative deepening.
                    question_context_limit = int(
                        self.get_setting("search.question_context_limit", 30)
                    )
                    source_context = self._format_search_results_as_context(
                        accumulated_search_results_across_all_iterations[
                            -question_context_limit:
                        ]
                    )
                    if iteration != 1:
                        context = f"""Previous search results:\n{source_context}\n\nIteration: {iteration} of {iterations_to_run}"""
                    elif iterations_to_run == 1:
                        context = ""
                    else:
                        context = (
                            f"""Iteration: {iteration} of {iterations_to_run}"""
                        )
                    # Use standard question generator with search results as context
                    questions = self.question_generator.generate_questions(
                        current_knowledge=context,
                        query=query,
                        questions_per_iteration=int(
                            self.get_setting(
                                "search.questions_per_iteration", 2
                            )
                        ),
                        questions_by_iteration=self.questions_by_iteration,
                    )

                    # Use only the new questions for this iteration's searches
                    all_questions = questions

                    # Store in questions_by_iteration
                    self.questions_by_iteration[iteration] = questions
                    logger.info(
                        f"Generated questions for iteration {iteration}: {questions}"
                    )

                # Skip if no questions (all_questions may include original query in iteration 1)
                if not all_questions:
                    logger.warning(
                        f"No questions generated for iteration {iteration}, skipping search phase"
                    )
                    continue

                # STEP 2: Run all searches in parallel for this iteration.
                # Each question becomes a separate search query executed concurrently.
                # Results are collected into iteration_search_results, then extended
                # into accumulated_search_results_across_all_iterations.
                @preserve_research_context
                def search_question(q):
                    try:
                        current_context = get_search_context()
                        result = self.search.run(
                            q, research_context=current_context
                        )
                        return {"question": q, "results": result or []}
                    except Exception:
                        logger.exception(f"Error searching for '{q}'")
                        return {
                            "question": q,
                            "results": [],
                            "error": "Search failed",
                        }

                # Run searches in parallel. thread_context (the factory, not
                # a call) is invoked once per question so each worker thread
                # gets its OWN fresh Flask app context — required so
                # current_app / g are accessible inside self.search.run, and
                # so concurrent workers never share one context instance.
                completed = run_parallel_searches(
                    all_questions,
                    search_question,
                    context_factory=thread_context,
                )
                iteration_search_dict = {}
                iteration_search_results = []
                for _, payload in completed:
                    iteration_search_dict[payload["question"]] = payload[
                        "results"
                    ]
                    iteration_search_results.extend(payload["results"])

                # Collect this iteration's results. Note: filtered_search_results is
                # reassigned each iteration — it only holds the CURRENT iteration's results.
                # The accumulated list holds ALL iterations' results.
                filtered_search_results = iteration_search_results
                accumulated_search_results_across_all_iterations.extend(
                    filtered_search_results
                )

                # Lightweight metadata finding (no actual content — synthesis happens later)
                finding = {
                    "phase": f"Iteration {iteration}",
                    "content": f"Searched with {len(all_questions)} questions, found {len(filtered_search_results)} results.",
                    "question": query,
                    "documents": [],
                }
                findings.append(finding)

            # Filter accumulated results from THIS call only. The cross-engine filter
            # never sees previous sections' results — those are already safely in
            # self.all_links_of_system from earlier analyze_topic() calls.
            # start_index ensures new results get citation numbers that continue
            # after existing ones (e.g. if all_links already has 27 items, new
            # results start at [28]).
            if self.use_cross_engine_filter:
                self._update_progress(
                    f"Filtering {len(accumulated_search_results_across_all_iterations)} results for relevance...",
                    80,
                    {"phase": "final_filtering", "type": "milestone"},
                )
                final_filtered_results = (
                    self.cross_engine_filter.filter_results(
                        accumulated_search_results_across_all_iterations,
                        query,
                        reorder=True,
                        reindex=True,
                        max_results=int(
                            self.get_setting("search.final_max_results", 100)
                        ),
                        start_index=len(self.all_links_of_system),
                    )
                )
                self._update_progress(
                    f"Filtered from {len(accumulated_search_results_across_all_iterations)} to {len(final_filtered_results)} results",
                    iteration_progress_base + 85,
                    {
                        "phase": "filtering_complete",
                        "iteration": iteration,
                        "links_count": len(self.all_links_of_system),
                    },
                )
            else:
                # Preserve all iterations' results (not just the last iteration)
                # so sources from earlier iterations are not lost.
                final_filtered_results = (
                    accumulated_search_results_across_all_iterations
                )

            # Extend the SHARED all_links_of_system with this call's results only.
            # This list is the single source of truth for citations in the final
            # report — format_links_to_markdown reads it at report_generator.py:494.
            self.all_links_of_system.extend(final_filtered_results)

            # SYNTHESIS PHASE — run after all iterations complete.
            # The citation handler receives final_filtered_results and produces
            # synthesized content with inline citation numbers like [1], [2], etc.
            # nr_of_links offsets these numbers so they continue from previous
            # analyze_topic() calls (critical for detailed report mode where
            # multiple subsections each add their own citations).
            if final_filtered_results:
                self._update_progress(
                    f"Synthesizing {len(final_filtered_results)} sources from {iterations_to_run} iterations...",
                    90,
                    {"phase": "synthesis", "type": "milestone"},
                )
            else:
                # All searches returned nothing (no matches, or engine
                # errors — see logs). The citation handler refuses to
                # invoke the LLM without sources (it would fabricate
                # citations) and returns an explicit no-sources message.
                self._update_progress(
                    "No sources found — answer cannot be generated (check logs for search errors)",
                    90,
                    {"phase": "synthesis", "type": "error"},
                )

            final_citation_result = self.citation_handler.analyze_followup(
                query,
                final_filtered_results,
                previous_knowledge="",
                nr_of_links=total_citation_count_before_this_search,
            )
            # analyze_followup calls _create_documents() which sets "index" on each
            # dict if not already present (via base_citation_handler.py:68-69).
            # Since these are the same dict objects now in all_links_of_system,
            # the indices propagate to the shared list automatically.

            # Null check — synthesis can fail on empty/malformed results
            if final_citation_result:
                synthesized_content = final_citation_result["content"]
                documents = final_citation_result.get("documents", [])
            else:
                synthesized_content = (
                    "No relevant results found in final synthesis."
                )
                documents = []

            final_finding = {
                "phase": "Final synthesis",
                "content": synthesized_content,
                "question": query,
                "search_results": self.all_links_of_system,
                "documents": documents,
            }
            findings.append(final_finding)

            self.findings_repository.add_documents(documents)
            # Transfer questions so they appear in the formatted output
            self.findings_repository.set_questions_by_iteration(
                self.questions_by_iteration
            )

            formatted_findings = (
                self.findings_repository.format_findings_to_text(
                    findings, synthesized_content
                )
            )

        except Exception as e:
            error_msg = f"Error in research process: {e!s}"
            logger.exception(error_msg)
            synthesized_content = f"Error: {e!s}"
            formatted_findings = f"Error: {e!s}"
            finding = {
                "phase": "Error",
                "content": synthesized_content,
                "question": query,
                "search_results": [],
                "documents": [],
            }
            findings.append(finding)

        # Return dict is consumed by AdvancedSearchSystem._perform_search() (search_system.py:335)
        # which merges all_links_of_system and attaches the search_system reference.
        # In quick summary mode, "current_knowledge" becomes the final output.
        # In detailed report mode, IntegratedReportGenerator uses the search_system
        # to call analyze_topic() again for each subsection, building on all_links_of_system.
        return {
            "findings": findings,
            "iterations": iterations_to_run,
            "questions_by_iteration": self.questions_by_iteration,
            "formatted_findings": formatted_findings,
            "current_knowledge": synthesized_content,
            "all_links_of_system": self.all_links_of_system,
        }
