"""Built-in context strategies batch 1.

Implements the first three built-in context strategies from the spec
(§25207-25216):

1. **SimpleKeywordStrategy** (quality 0.3) — Keyword matching on fragment
   content and UKO node URIs.  Universal fallback that works without
   specialised backends.
2. **SemanticEmbeddingStrategy** (quality 0.6) — Approximate semantic
   similarity using word-overlap scoring between fragments.  In v1,
   operates on pre-fetched fragments without actual embedding backends.
3. **BreadthDepthNavigatorStrategy** (quality 0.85) — Navigates the UKO
   node hierarchy, prioritising fragments near designated focus nodes
   with higher detail depths.  Primary strategy for code projects.

All strategies implement the v1 ``ContextStrategy`` Protocol defined in
``acms_service.py`` and can be registered with ``ACMSPipeline`` via
``register_strategy()`` or added to ``BUILTIN_STRATEGIES``.

Based on ``docs/specification.md`` §25207-25216.
"""

from __future__ import annotations

import logging
import re
from collections.abc import Sequence
from typing import Any

from cleveragents.application.services.acms_service import (
    StrategyCapabilities,
    _pack_budget,
)
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
)

logger = logging.getLogger(__name__)

_WORD_RE = re.compile(r"\w+", re.UNICODE)


# ---------------------------------------------------------------------------
# 1. SimpleKeywordStrategy  (quality 0.3)
# ---------------------------------------------------------------------------


class SimpleKeywordStrategy:
    """Keyword matching on fragment content and UKO node URIs.

    Scores each fragment by the density of distinct words in its content,
    treating fragments with richer keyword coverage as more informative.
    When a query is provided via ``set_query()``, matching is narrowed to
    query keywords.  Otherwise, a word-density heuristic is used.

    This is the universal fallback strategy — it always produces results
    regardless of backend availability.

    Implements ``ContextStrategy`` protocol.

    Based on ``docs/specification.md`` §25207 — ``simple-keyword``.
    """

    def __init__(self) -> None:
        self._query: str = ""

    def set_query(self, query: str) -> None:
        """Set the query string for keyword matching (optional)."""
        self._query = query

    @property
    def name(self) -> str:
        return "simple-keyword"

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities(supports_semantic_search=False)

    def can_handle(self, request: dict[str, Any]) -> float:
        """Return 0.3 confidence — universal fallback."""
        query = str(request.get("query", "") or "")
        self._query = query
        return 0.3

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Rank fragments by keyword match count or word density."""
        if not fragments:
            return list(fragments)

        keywords = _extract_keywords(self._query) if self._query else []

        if keywords:
            # Score by keyword match count
            scored = [
                (frag, _keyword_match_score(frag, keywords)) for frag in fragments
            ]
            scored.sort(
                key=lambda pair: (pair[1], pair[0].relevance_score),
                reverse=True,
            )
            sorted_frags = [frag for frag, _ in scored]
        else:
            # No query — score by word density (unique words / token_count)
            scored_density = [(frag, _word_density(frag)) for frag in fragments]
            scored_density.sort(
                key=lambda pair: (pair[1], pair[0].relevance_score),
                reverse=True,
            )
            sorted_frags = [frag for frag, _ in scored_density]

        logger.info(
            "SimpleKeyword ranked fragments",
            extra={
                "keyword_count": len(keywords),
                "fragment_count": len(fragments),
                "has_query": bool(self._query),
            },
        )
        return _pack_budget(sorted_frags, budget)

    def explain(self) -> str:
        return (
            "Ranks fragments by keyword match count in content and UKO node "
            "URI. Falls back to word-density ordering when no query is "
            "provided. Universal fallback strategy (quality 0.3)."
        )


# ---------------------------------------------------------------------------
# 2. SemanticEmbeddingStrategy  (quality 0.6)
# ---------------------------------------------------------------------------


class SemanticEmbeddingStrategy:
    """Approximate semantic similarity via word-overlap scoring.

    In the v1 pipeline, strategies receive pre-fetched fragments rather
    than querying vector backends directly.  This strategy approximates
    embedding-based similarity by computing word-level Jaccard similarity
    between a query (set via ``set_query()`` or ``can_handle()``) and
    each fragment's content.

    Fragments with similarity below ``min_similarity`` are excluded.
    Remaining fragments are sorted by similarity (descending) and packed
    within the token budget.  When no query is available, falls back to
    relevance-based ordering.

    Implements ``ContextStrategy`` protocol.

    Based on ``docs/specification.md`` §25207 — ``semantic-embedding``.
    """

    def __init__(self, *, min_similarity: float = 0.05) -> None:
        self._min_similarity = min_similarity
        self._query: str = ""

    def set_query(self, query: str) -> None:
        """Set the query string for similarity scoring (optional)."""
        self._query = query

    @property
    def name(self) -> str:
        return "semantic-embedding"

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities(supports_semantic_search=True)

    def can_handle(self, request: dict[str, Any]) -> float:
        """Return 0.6 confidence when a query is present, else 0.1."""
        query = str(request.get("query", "") or "")
        self._query = query
        return 0.6 if query else 0.1

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Rank fragments by word-overlap similarity to query."""
        if not fragments:
            return list(fragments)

        query_words = _tokenize(self._query) if self._query else set()

        if not query_words:
            # No query — fall back to relevance ordering
            sorted_frags = sorted(
                fragments, key=lambda f: f.relevance_score, reverse=True
            )
            return _pack_budget(sorted_frags, budget)

        # Compute similarity for each fragment
        scored: list[tuple[ContextFragment, float]] = []
        for frag in fragments:
            sim = _jaccard_similarity(query_words, _tokenize(frag.content))
            if sim >= self._min_similarity:
                scored.append((frag, sim))

        scored.sort(key=lambda pair: (pair[1], pair[0].relevance_score), reverse=True)
        sorted_frags = [frag for frag, _ in scored]

        logger.info(
            "SemanticEmbedding ranked fragments",
            extra={
                "query_word_count": len(query_words),
                "eligible_count": len(sorted_frags),
                "filtered_count": len(fragments) - len(sorted_frags),
            },
        )
        return _pack_budget(sorted_frags, budget)

    def explain(self) -> str:
        return (
            "Approximates semantic similarity using word-level Jaccard "
            "similarity between query and fragment content. Filters by "
            f"minimum similarity threshold ({self._min_similarity}). "
            "Quality 0.6."
        )


# ---------------------------------------------------------------------------
# 3. BreadthDepthNavigatorStrategy  (quality 0.85)
# ---------------------------------------------------------------------------


class BreadthDepthNavigatorStrategy:
    """Navigate UKO node hierarchy with breadth-first + depth-first phases.

    This strategy treats fragment UKO node URIs as a hierarchical
    namespace.  Given focus nodes (set via ``set_focus()`` or
    ``can_handle()``), it:

    1. **Breadth phase**: Discovers fragments at related nodes (shared
       URI prefix within ``max_hops`` path segments).
    2. **Depth phase**: Prefers higher ``detail_depth`` for fragments
       closer to focus nodes.

    The combined score prioritises proximity (breadth, weight 0.6) and
    detail (depth, weight 0.3), with a small relevance contribution
    (weight 0.1).  This makes it the primary strategy for code projects
    where UKO nodes represent a file/module hierarchy.

    Implements ``ContextStrategy`` protocol.

    Based on ``docs/specification.md`` §25207 — ``breadth-depth-navigator``.
    """

    def __init__(self, *, max_hops: int = 4) -> None:
        self._max_hops = max_hops
        self._focus: list[str] = []

    def set_focus(self, focus: list[str]) -> None:
        """Set the focus nodes for graph navigation (optional)."""
        self._focus = list(focus)

    @property
    def name(self) -> str:
        return "breadth-depth-navigator"

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities(supports_graph_navigation=True)

    def can_handle(self, request: dict[str, Any]) -> float:
        """Return 0.85 when focus nodes are present, else 0.2."""
        focus = request.get("focus", [])
        if isinstance(focus, str):
            self._focus = [focus] if focus else []
        else:
            self._focus = list(focus) if focus else []
        return 0.85 if self._focus else 0.2

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        """Rank fragments by breadth proximity and depth detail."""
        if not fragments:
            return list(fragments)

        if not self._focus:
            # No focus — fall back to depth-weighted relevance
            sorted_frags = sorted(
                fragments,
                key=lambda f: (f.detail_depth, f.relevance_score),
                reverse=True,
            )
            return _pack_budget(sorted_frags, budget)

        # Score each fragment by proximity to focus nodes and detail depth
        scored: list[tuple[ContextFragment, float]] = []
        for frag in fragments:
            proximity = _max_proximity(frag.uko_node, self._focus, self._max_hops)
            # Normalise depth contribution: depth/9 gives 0.0-1.0
            depth_score = frag.detail_depth / 9.0
            # Combined: proximity dominates (0.6), depth adds (0.3),
            # relevance contributes (0.1)
            combined = proximity * 0.6 + depth_score * 0.3 + frag.relevance_score * 0.1
            scored.append((frag, combined))

        scored.sort(key=lambda pair: pair[1], reverse=True)
        sorted_frags = [frag for frag, _ in scored]

        logger.info(
            "BreadthDepthNavigator ranked fragments",
            extra={
                "focus_count": len(self._focus),
                "max_hops": self._max_hops,
                "fragment_count": len(fragments),
            },
        )
        return _pack_budget(sorted_frags, budget)

    def explain(self) -> str:
        return (
            "Navigates the UKO node hierarchy with breadth-first discovery "
            "and depth-first detail retrieval. Prioritises fragments near "
            f"focus nodes (max {self._max_hops} hops) with higher detail "
            "depth. Primary strategy for code projects (quality 0.85)."
        )


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _extract_keywords(query: str) -> list[str]:
    """Extract lowercase keywords from a query string."""
    return _WORD_RE.findall(query.lower())


def _tokenize(text: str) -> set[str]:
    """Tokenise text into a set of lowercase words."""
    return set(_WORD_RE.findall(text.lower()))


def _keyword_match_score(frag: ContextFragment, keywords: list[str]) -> int:
    """Count how many keywords match in fragment content + uko_node."""
    text = (frag.content + " " + frag.uko_node).lower()
    return sum(1 for kw in keywords if kw in text)


def _word_density(frag: ContextFragment) -> float:
    """Compute word density: unique words / max(token_count, 1)."""
    words = _tokenize(frag.content)
    return len(words) / max(frag.token_count, 1)


def _jaccard_similarity(set_a: set[str], set_b: set[str]) -> float:
    """Compute Jaccard similarity between two word sets."""
    if not set_a or not set_b:
        return 0.0
    intersection = len(set_a & set_b)
    union = len(set_a | set_b)
    return intersection / union if union > 0 else 0.0


def _uri_segments(uri: str) -> list[str]:
    """Split a UKO URI into path segments for hierarchy comparison."""
    # Strip scheme (e.g. "project://")
    if "://" in uri:
        uri = uri.split("://", 1)[1]
    return [seg for seg in uri.replace("\\", "/").split("/") if seg]


def _max_proximity(node_uri: str, focus_nodes: list[str], max_hops: int) -> float:
    """Compute the maximum proximity of a node to any focus node.

    Proximity is based on the number of shared URI path segments.
    Returns 1.0 for exact match, decreasing toward 0.0 as distance
    increases up to ``max_hops``.
    """
    node_segs = _uri_segments(node_uri)
    best = 0.0

    for focus in focus_nodes:
        focus_segs = _uri_segments(focus)
        # Count shared prefix segments
        shared = 0
        for a, b in zip(node_segs, focus_segs, strict=False):
            if a == b:
                shared += 1
            else:
                break

        # Distance = max of the two tail lengths
        distance = max(
            len(node_segs) - shared,
            len(focus_segs) - shared,
        )

        if distance <= max_hops:
            # Proximity: 1.0 for exact match, decaying with distance
            proximity = 1.0 - (distance / (max_hops + 1))
            best = max(best, proximity)

    return best
