from __future__ import annotations

from typing import Iterable
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
import time
from threading import Lock

from analogai.deepthink.application.value_objects.user_agent import UserAgent
from analogai.foundation.modules.fact.services.fact_service import FactServicePort
from analogai.deepthink.messaging.fact.enums.fact_search_mode import FactSearchMode
from analogai.deepthink.messaging.fact.utils.fact_utils import get_fact_text
from analogai.foundation.modules.fact.utils.fact_utils import collect_fact_window
from analogai.deepthink.messaging.fact.value_objects.source import FactSource, SourceType
from analogai.foundation.common.logging.app_logger import app_logger  # type: ignore[import-not-found]
from analogai.foundation.settings import config as app_config


class GraphRagSearchCommand:
    def __init__(self, fact_service: FactServicePort | None = None) -> None:
        self._fact_service = fact_service
        self._parallel_workers = max(
            0,
            int(
                app_config.get(
                    "qa.graph_search_parallel_workers",
                    app_config.get("qa.fact_search_parallel_workers", 0),
                )
            ),
        )
        self._graph_cutoff = _resolve_graph_cutoff()
        self._rag_cutoff = _resolve_rag_cutoff()
        self._lookup_lock = Lock()
        self._relaxed_used = False
        self._context_window = max(
            0,
            _coerce_int(
                app_config.get(
                    "qa.neighbouring_background_fact_max_step",
                    app_config.get(
                        "qa.neighbouring_facts_in_background",
                        app_config.get("qa.fact_context_window"),
                    ),
                ),
                2,
            ),
        )
        self._context_time_window_seconds = _resolve_context_time_window_seconds()

    def execute(
        self,
        user_agent: UserAgent,
        question_text: str,
        entities: Iterable[str] | None,
        notions: Iterable | None,
        search_mode: FactSearchMode,
        lookup: dict[str, str] | None = None,
        source_lookup: dict[str, FactSource] | None = None,
        context_lookup: dict[str, list[str]] | None = None,
        parallel_workers: int | None = None,
        search_timeout_seconds: float | None = None,
    ) -> dict[str, str]:
        if not self._fact_service:
            return {}
        self._relaxed_used = False
        fact_service = self._fact_service
        if lookup is None:
            lookup = {}
        if source_lookup is None:
            source_lookup = {}
        if context_lookup is None:
            context_lookup = {}
        text_lookup = {_normalize_fact_text(value) for value in lookup.values() if value}
        resolved_parallel_workers = (
            self._parallel_workers
            if parallel_workers is None
            else max(0, int(parallel_workers))
        )
        if resolved_parallel_workers > 1:
            self._find_parallel(
                fact_service,
                user_agent,
                question_text,
                entities,
                notions,
                lookup,
                search_mode=search_mode,
                source_lookup=source_lookup,
                context_lookup=context_lookup,
                context_window=self._context_window,
                text_lookup=text_lookup,
                parallel_workers=resolved_parallel_workers,
                search_timeout_seconds=search_timeout_seconds,
            )
        else:
            self._find_by_notions(
                fact_service,
                notions,
                lookup,
                source_lookup=source_lookup,
                context_lookup=context_lookup,
                context_window=self._context_window,
                text_lookup=text_lookup,
            )
            self._find_by_entities(
                fact_service,
                user_agent,
                entities,
                lookup,
                search_mode=search_mode,
                similarity_cutoff=self._graph_cutoff,
                source_lookup=source_lookup,
                context_lookup=context_lookup,
                context_window=self._context_window,
                text_lookup=text_lookup,
                search_timeout_seconds=search_timeout_seconds,
            )
            self._find_direct(
                fact_service,
                user_agent,
                question_text,
                lookup,
                search_mode=search_mode,
                similarity_cutoff=self._rag_cutoff,
                source_lookup=source_lookup,
                context_lookup=context_lookup,
                context_window=self._context_window,
                text_lookup=text_lookup,
                search_timeout_seconds=search_timeout_seconds,
            )
        if not lookup:
            self._run_relaxed_pass(
                fact_service,
                user_agent,
                question_text,
                entities,
                lookup,
                search_mode=search_mode,
                source_lookup=source_lookup,
                context_lookup=context_lookup,
                context_window=self._context_window,
                text_lookup=text_lookup,
                search_timeout_seconds=search_timeout_seconds,
            )
        return lookup

    def was_relaxed(self) -> bool:
        return bool(self._relaxed_used)

    def _find_parallel(
        self,
        fact_service: FactServicePort,
        user_agent: UserAgent,
        question_text: str,
        entities: Iterable[str] | None,
        notions: Iterable | None,
        lookup: dict[str, str],
        search_mode: FactSearchMode,
        source_lookup: dict[str, FactSource],
        context_lookup: dict[str, list[str]],
        context_window: int,
        text_lookup: set[str],
        parallel_workers: int,
        search_timeout_seconds: float | None,
    ) -> None:
        futures = []
        direct_futures = set()
        executor = ThreadPoolExecutor(max_workers=parallel_workers)
        try:
            for notion in notions or []:
                if notion is None:
                    continue
                notion_id = _notion_id(notion)
                if not notion_id:
                    continue
                futures.append(executor.submit(_get_facts_for_notion, fact_service, notion_id))

            for entity in entities or []:
                if not entity:
                    continue
                query = str(entity).strip()
                if not query:
                    continue
                futures.append(
                    executor.submit(
                        _run_fact_search,
                        fact_service,
                        user_agent,
                        query,
                        search_mode,
                        self._graph_cutoff,
                        search_timeout_seconds,
                    )
                )

            if question_text and question_text.strip():
                direct_future = executor.submit(
                    _run_fact_search,
                    fact_service,
                    user_agent,
                    question_text,
                    search_mode,
                    self._rag_cutoff,
                    search_timeout_seconds,
                )
                futures.append(direct_future)
                direct_futures.add(direct_future)

            overall_timeout = None
            if search_timeout_seconds is not None:
                try:
                    overall_timeout = max(0.1, float(search_timeout_seconds))
                except (TypeError, ValueError):
                    overall_timeout = None
            start_time = time.monotonic()
            added_count = 0
            try:
                for future in as_completed(futures, timeout=overall_timeout):
                    try:
                        facts = future.result()
                    except Exception as exc:
                        app_logger.debug(
                            "[GraphRagSearchCommand] search failed. reason=%s",
                            exc,
                        )
                        continue
                    if future in direct_futures:
                        if facts:
                            app_logger.debug(
                                "[GraphRagSearchCommand] RAG search results=%d",
                                len(facts or []),
                            )
                        else:
                            app_logger.debug(
                                "[GraphRagSearchCommand] RAG search returned no results",
                            )
                    with self._lookup_lock:
                        added_count += self._append_facts(
                            facts,
                            lookup,
                            source_lookup,
                            context_lookup,
                            context_window,
                            self._context_time_window_seconds,
                            text_lookup,
                        )
            except TimeoutError:
                elapsed = time.monotonic() - start_time
                if added_count:
                    app_logger.debug(
                        "[GraphRagSearchCommand] parallel search timed out after %.2fs (facts added=%d)",
                        elapsed,
                        added_count,
                    )
                else:
                    app_logger.warning(
                        "[GraphRagSearchCommand] parallel search timeout after %.2fs",
                        elapsed,
                    )
                for pending in futures:
                    if not pending.done():
                        pending.cancel()
        finally:
            try:
                executor.shutdown(wait=False, cancel_futures=True)
            except TypeError:
                executor.shutdown(wait=False)

    def _find_by_notions(
        self,
        fact_service: FactServicePort,
        notions: Iterable | None,
        lookup: dict[str, str],
        source_lookup: dict[str, FactSource],
        context_lookup: dict[str, list[str]],
        context_window: int,
        text_lookup: set[str],
    ) -> None:
        for notion in notions or []:
            if notion is None:
                continue
            notion_id = _notion_id(notion)
            if not notion_id:
                continue
            facts = _get_facts_for_notion(fact_service, notion_id)
            self._append_facts(
                facts,
                lookup,
                source_lookup,
                context_lookup,
                context_window,
                self._context_time_window_seconds,
                text_lookup,
            )

    def _find_by_entities(
        self,
        fact_service: FactServicePort,
        user_agent: UserAgent,
        entities: Iterable[str] | None,
        lookup: dict[str, str],
        search_mode: FactSearchMode,
        similarity_cutoff: float | None,
        source_lookup: dict[str, FactSource],
        context_lookup: dict[str, list[str]],
        context_window: int,
        text_lookup: set[str],
        search_timeout_seconds: float | None,
    ) -> None:
        for entity in entities or []:
            if not entity:
                continue
            query = str(entity).strip()
            if not query:
                continue
            facts = _run_fact_search(
                fact_service,
                user_agent,
                query,
                search_mode,
                similarity_cutoff,
                search_timeout_seconds,
            )
            self._append_facts(
                facts,
                lookup,
                source_lookup,
                context_lookup,
                context_window,
                self._context_time_window_seconds,
                text_lookup,
            )

    def _find_direct(
        self,
        fact_service: FactServicePort,
        user_agent: UserAgent,
        question_text: str,
        lookup: dict[str, str],
        search_mode: FactSearchMode,
        similarity_cutoff: float | None,
        source_lookup: dict[str, FactSource],
        context_lookup: dict[str, list[str]],
        context_window: int,
        text_lookup: set[str],
        search_timeout_seconds: float | None,
    ) -> None:
        if not question_text or not question_text.strip():
            return
        facts = _run_fact_search(
            fact_service,
            user_agent,
            question_text,
            search_mode,
            similarity_cutoff,
            search_timeout_seconds,
        )
        if facts:
            app_logger.debug(
                "[GraphRagSearchCommand] RAG search results=%d",
                len(facts or []),
            )
        else:
            app_logger.debug(
                "[GraphRagSearchCommand] RAG search returned no results",
            )
        self._append_facts(
            facts,
            lookup,
            source_lookup,
            context_lookup,
            context_window,
            self._context_time_window_seconds,
            text_lookup,
        )

    def _run_relaxed_pass(
        self,
        fact_service: FactServicePort,
        user_agent: UserAgent,
        question_text: str,
        entities: Iterable[str] | None,
        lookup: dict[str, str],
        search_mode: FactSearchMode,
        source_lookup: dict[str, FactSource],
        context_lookup: dict[str, list[str]],
        context_window: int,
        text_lookup: set[str],
        search_timeout_seconds: float | None,
    ) -> None:
        relaxed_graph = _relax_cutoff(self._graph_cutoff)
        relaxed_rag = _relax_cutoff(self._rag_cutoff)
        if relaxed_graph is None and relaxed_rag is None:
            return
        self._relaxed_used = True
        app_logger.debug(
            "[GraphRagSearchCommand] relaxing cutoffs graph=%s rag=%s",
            relaxed_graph,
            relaxed_rag,
        )
        self._find_by_entities(
            fact_service,
            user_agent,
            entities,
            lookup,
            search_mode=search_mode,
            similarity_cutoff=relaxed_graph,
            source_lookup=source_lookup,
            context_lookup=context_lookup,
            context_window=context_window,
            text_lookup=text_lookup,
            search_timeout_seconds=search_timeout_seconds,
        )
        self._find_direct(
            fact_service,
            user_agent,
            question_text,
            lookup,
            search_mode=search_mode,
            similarity_cutoff=relaxed_rag,
            source_lookup=source_lookup,
            context_lookup=context_lookup,
            context_window=context_window,
            text_lookup=text_lookup,
            search_timeout_seconds=search_timeout_seconds,
        )

    @staticmethod
    def _append_facts(
        facts: Iterable,
        lookup: dict[str, str],
        source_lookup: dict[str, FactSource],
        context_lookup: dict[str, list[str]],
        context_window: int,
        context_time_window_seconds: int | None,
        text_lookup: set[str],
    ) -> int:
        added = 0
        for fact in facts or []:
            text = get_fact_text(fact)
            if not text:
                continue
            cleaned = str(text).strip()
            if not cleaned:
                continue
            if _looks_like_question(cleaned):
                continue
            normalized = _normalize_fact_text(cleaned)
            if normalized in text_lookup:
                continue
            fact_key = fact.id
            if fact_key is None:
                continue
            key = str(fact_key)
            if key in lookup:
                continue
            lookup[key] = cleaned
            text_lookup.add(normalized)
            resolved_source = _resolve_fact_source(fact)
            if resolved_source is not None:
                source_lookup[key] = resolved_source
            if key not in context_lookup:
                context_lookup[key] = _collect_fact_context(
                    fact,
                    window=context_window,
                    time_window_seconds=context_time_window_seconds,
                )
            added += 1
            app_logger.debug(
                "[GraphRagSearchCommand] fact added to lookup id=%s text=%s",
                key,
                _trim_for_log(cleaned),
            )
        return added


def _looks_like_question(text: str) -> bool:
    if "?" in text:
        return True
    lowered = text.strip().lower()
    return lowered.startswith("q:") or lowered.startswith("question:")


def _run_fact_search(
    fact_service: FactServicePort,
    user_agent: UserAgent,
    query: str,
    search_mode: FactSearchMode,
    similarity_cutoff: float | None,
    timeout_seconds: float | None,
) -> list:
    if not query or not query.strip():
        return []
    if timeout_seconds is None or timeout_seconds <= 0:
        return fact_service.search_facts(
            user_id=user_agent.user_id,
            agent_id=user_agent.agent_id,
            query=query,
            top_k=app_config.get("qa.graph_search_top_k", app_config.get("qa.fact_search_top_k", 5)),
            similarity_cutoff=similarity_cutoff,
            mode=search_mode,
        )
    executor = ThreadPoolExecutor(max_workers=1)
    try:
        future = executor.submit(
            fact_service.search_facts,
            user_id=user_agent.user_id,
            agent_id=user_agent.agent_id,
            query=query,
            top_k=app_config.get("qa.graph_search_top_k", app_config.get("qa.fact_search_top_k", 5)),
            similarity_cutoff=similarity_cutoff,
            mode=search_mode,
        )
        return future.result(timeout=timeout_seconds)
    finally:
        try:
            executor.shutdown(wait=False, cancel_futures=True)
        except TypeError:
            executor.shutdown(wait=False)


def _get_facts_for_notion(fact_service: FactServicePort, notion_id: str) -> list:
    if not notion_id:
        return []
    try:
        return fact_service.get_facts_for_notion(notion_id)
    except Exception as exc:
        app_logger.debug(
            "[GraphRagSearchCommand] notion lookup failed notion_id=%s reason=%s",
            notion_id,
            exc,
        )
        return []


def _get_facts_for_statement(fact_service: FactServicePort, statement_id: str) -> list:
    if not statement_id:
        return []
    try:
        return fact_service.get_facts_for_statement(statement_id)
    except Exception as exc:
        app_logger.debug(
            "[GraphRagSearchCommand] statement lookup failed statement_id=%s reason=%s",
            statement_id,
            exc,
        )
        return []


def _get_facts_for_belief(fact_service: FactServicePort, belief_id: str) -> list:
    if not belief_id:
        return []
    try:
        return fact_service.get_facts_for_belief(belief_id)
    except Exception as exc:
        app_logger.debug(
            "[GraphRagSearchCommand] belief lookup failed belief_id=%s reason=%s",
            belief_id,
            exc,
        )
        return []


def _get_facts_for_hypothetical(fact_service: FactServicePort, statement_id: str) -> list:
    if not statement_id:
        return []
    try:
        return fact_service.get_facts_for_hypothetical_statement(statement_id)
    except Exception as exc:
        app_logger.debug(
            "[GraphRagSearchCommand] hypothetical lookup failed statement_id=%s reason=%s",
            statement_id,
            exc,
        )
        return []


def _notion_id(notion) -> str | None:
    if notion is None:
        return None
    return str(notion.id)


def _normalize_fact_text(text: str) -> str:
    return " ".join(text.split()).strip().casefold()


def _resolve_fact_source(fact) -> FactSource | None:
    if fact is None:
        return None
    source = getattr(fact, "source", None)
    if source is not None:
        return source
    source_id = getattr(fact, "source_id", None)
    if not source_id:
        return None
    raw = str(source_id)
    if not raw:
        return None
    if ":" in raw:
        type_text, value = raw.split(":", 1)
    else:
        type_text, value = raw, None
    type_text = type_text.strip()
    value = value.strip() if value else None
    try:
        source_type = SourceType(type_text)
    except Exception:
        try:
            source_type = SourceType[type_text.upper()]
        except Exception:
            source_type = SourceType.OTHER
    return FactSource(source_type, value)


def _trim_for_log(text: str, max_chars: int = 240) -> str:
    if text is None:
        return ""
    cleaned = " ".join(str(text).split())
    if len(cleaned) <= max_chars:
        return cleaned
    return f"{cleaned[: max_chars - 1]}…"


def _collect_fact_context(fact, window: int, time_window_seconds: int | None) -> list[str]:
    if fact is None:
        return []
    window_facts = collect_fact_window(
        fact,
        window=max(0, int(window)),
        time_window_seconds=time_window_seconds,
    )
    context_lines: list[str] = []
    for item in window_facts:
        text = get_fact_text(item)
        cleaned = str(text or "").strip()
        if not cleaned:
            continue
        if _looks_like_question(cleaned):
            continue
        context_lines.append(cleaned)
    return context_lines


def _resolve_context_time_window_seconds() -> int | None:
    value = _coerce_int(app_config.get("qa.fact_context_time_window_seconds"), 300)
    if value <= 0:
        return None
    return int(value)


def _resolve_graph_cutoff() -> float | None:
    return _coerce_float(
        app_config.get(
            "vector_search.graph_similarity_cutoff",
            app_config.get("vector_search.similarity_cutoff"),
        )
    )


def _resolve_rag_cutoff() -> float | None:
    return _coerce_float(
        app_config.get(
            "vector_search.rag_similarity_cutoff",
            app_config.get(
                "vector_search.fact_similarity_cutoff",
                app_config.get("vector_search.similarity_cutoff"),
            ),
        )
    )


def _coerce_float(value: object | None) -> float | None:
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _coerce_int(value: object | None, default: int) -> int:
    if value is None:
        return int(default)
    if isinstance(value, bool):
        return int(default)
    if isinstance(value, int):
        return int(value)
    if isinstance(value, float) and value.is_integer():
        return int(value)
    text = str(value).strip()
    if text.startswith(("+", "-")):
        sign = text[0]
        numeric = text[1:]
    else:
        sign = ""
        numeric = text
    if numeric.isdigit():
        return int(f"{sign}{numeric}")
    return int(default)


def _relax_cutoff(value: float | None, step: float | None = None) -> float | None:
    if not bool(app_config.get("vector_search.relax_enabled", True)):
        return None
    if value is None:
        return None
    try:
        if step is not None:
            resolved_ratio = 1.0 - float(step)
        else:
            resolved_ratio = _coerce_float(app_config.get("vector_search.relax_ratio"))
            if resolved_ratio is None:
                resolved_step = _coerce_float(app_config.get("vector_search.relax_step"))
                if resolved_step is None:
                    resolved_step = 0.1
                resolved_ratio = max(0.0, 1.0 - float(resolved_step))
        resolved_ratio = max(0.0, min(1.0, float(resolved_ratio)))
        return max(0.0, float(value) * resolved_ratio)
    except (TypeError, ValueError):
        return None


# Backward compatibility
FactRetrievalCommand = GraphRagSearchCommand
