"""Depth/Breadth Projection System and Skeleton Context Propagation.

Implements the projection system described in ``docs/specification.md``
§ Core Concepts > Depth/Breadth Projection (lines 25265-25340) and the
context inheritance mechanism from § Architecture > ACMS > Context
Inheritance Mechanism (lines 43067-43128).

Components
----------
- ``ProjectionSpec`` — Frozen Pydantic model capturing a projection request.
- ``ProjectedNode`` — Frozen Pydantic model: a UKO node materialized at a
  resolved detail depth and distance from focus.
- ``DepthBreadthProjector`` — Stateless projector: BFS over a UKO graph
  adjacency list, applying depth gradient to produce fragments at varying
  detail levels.
- ``PlanContextInheritance`` — Service that computes a child plan's
  inherited context request from the parent plan's assembled context,
  using the SkeletonCompressor for budget-controlled skeleton injection.
"""

from __future__ import annotations

import math
from collections import deque
from collections.abc import Mapping, Sequence
from typing import Any, cast

import structlog
from pydantic import BaseModel, ConfigDict, Field, field_validator

from cleveragents.application.services.acms_skeleton_compressor import (
    resolve_configured_skeleton_compressor,
)
from cleveragents.domain.models.acms.crp import (
    ContextFragment as ProjectionContextFragment,
)
from cleveragents.domain.models.acms.crp import (
    ContextRequest,
    DetailLevelMap,
    FragmentProvenance,
)
from cleveragents.domain.models.core.uko import UKONode

logger = structlog.get_logger()


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

DEFAULT_SKELETON_RATIO: float = 0.15
"""Default fraction of child's token budget reserved for parent skeleton."""

EDGE_RELATIONS: tuple[str, ...] = (
    "uko:references",
    "uko:dependsOn",
    "uko:contains",
)
"""UKO relation types traversed during breadth expansion."""


# ---------------------------------------------------------------------------
# ProjectionSpec — request description
# ---------------------------------------------------------------------------


class ProjectionSpec(BaseModel, frozen=True):
    """Captures a depth/breadth projection request.

    A ``ProjectionSpec`` is the input to :class:`DepthBreadthProjector`:
    it says *what* to focus on, how far to reach, and how much detail
    to include.

    Spec reference: ``docs/specification.md`` lines 25265-25270.

    Attributes:
        focus: URIs of entities at distance 0 (the focal items).
        breadth: Maximum hop count outward from focus (0 = focus only).
        depth: Target detail level (integer or named level string).
        depth_gradient: When ``True``, detail decreases with distance.
        domain: UKO domain namespace (e.g., ``"uko-code:"``).
    """

    focus: tuple[str, ...] = Field(
        ...,
        min_length=1,
        description="URIs of focal entities (at least one required)",
    )
    breadth: int = Field(
        default=2,
        ge=0,
        description="Maximum relationship hops from focus",
    )
    depth: int | str = Field(
        default=3,
        description=(
            "Target detail level — raw integer or named level "
            "resolved via the active DetailLevelMap"
        ),
    )
    depth_gradient: bool = Field(
        default=True,
        description="When True, detail decreases with distance from focus",
    )
    domain: str = Field(
        default="uko-code:",
        min_length=1,
        description="UKO domain namespace for DetailLevelMap resolution",
    )

    @field_validator("depth")
    @classmethod
    def _validate_depth(
        cls: type[ProjectionSpec],
        v: int | str,
    ) -> int | str:
        if isinstance(v, int) and v < 0:
            raise ValueError("depth must be non-negative when integer")
        return v

    model_config = ConfigDict(str_strip_whitespace=True)


# ---------------------------------------------------------------------------
# ProjectedNode — materialized result
# ---------------------------------------------------------------------------


class ProjectedNode(BaseModel, frozen=True):
    """A UKO node materialized at a specific detail depth and distance.

    Produced by :class:`DepthBreadthProjector`. Each projected node
    carries the resolved integer depth (after gradient) and the hop
    distance from the closest focus item.

    Attributes:
        uri: The UKO node URI.
        distance: Hop count from the nearest focus item.
        resolved_depth: Integer depth after gradient/clamping.
        label: Human-readable label (from :class:`UKONode`).
    """

    uri: str = Field(..., min_length=1)
    distance: int = Field(..., ge=0)
    resolved_depth: int = Field(..., ge=0)
    label: str = ""

    model_config = ConfigDict(str_strip_whitespace=True)


# ---------------------------------------------------------------------------
# DepthBreadthProjector — stateless graph projector
# ---------------------------------------------------------------------------


class DepthBreadthProjector:
    """BFS projector over a UKO graph with depth gradient.

    Given an adjacency list of UKO edges and a :class:`ProjectionSpec`,
    the projector performs a breadth-first traversal from the focus
    items, collecting reachable nodes at each distance up to
    ``spec.breadth``.  When ``spec.depth_gradient`` is enabled the
    resolved detail level *decreases* linearly with distance:

    .. math::

        resolved = base - \\lfloor base \\cdot (distance / breadth) \\rfloor

    where ``base`` is the resolved base depth at distance 0.

    The projector is **stateless** — it carries no mutable state and
    can be reused across calls.

    Spec reference: ``docs/specification.md`` lines 25265-25340.
    """

    def __init__(
        self, *, detail_maps: Mapping[str, DetailLevelMap] | None = None
    ) -> None:
        self._detail_maps: dict[str, DetailLevelMap] = dict(detail_maps or {})
        self._logger = logger.bind(component="DepthBreadthProjector")

    @property
    def detail_maps(self) -> Mapping[str, DetailLevelMap]:
        """Return registered detail level maps."""
        return self._detail_maps

    def register_detail_map(self, domain: str, detail_map: DetailLevelMap) -> None:
        """Register a :class:`DetailLevelMap` for a UKO domain."""
        self._detail_maps[domain] = detail_map

    def project(
        self,
        spec: ProjectionSpec,
        adjacency: Mapping[str, Sequence[str]],
        node_index: Mapping[str, UKONode] | None = None,
    ) -> list[ProjectedNode]:
        """Perform depth/breadth projection.

        Args:
            spec: The projection request.
            adjacency: Maps each UKO URI to its neighbors (bidirectional
                edges from ``uko:references``, ``uko:dependsOn``,
                ``uko:contains``).
            node_index: Optional URI→UKONode lookup for labels.

        Returns:
            List of :class:`ProjectedNode` sorted by distance then URI.
        """
        base_depth = self._resolve_base_depth(spec)

        # BFS from focus items
        visited: dict[str, int] = {}  # uri → min distance
        queue: deque[tuple[str, int]] = deque()

        for uri in spec.focus:
            if uri not in visited:
                visited[uri] = 0
                queue.append((uri, 0))

        while queue:
            current, dist = queue.popleft()
            if dist >= spec.breadth:
                continue
            for neighbor in adjacency.get(current, ()):
                if neighbor not in visited:
                    visited[neighbor] = dist + 1
                    queue.append((neighbor, dist + 1))

        # Build projected nodes
        nodes: list[ProjectedNode] = []
        for uri, distance in visited.items():
            resolved = self._apply_gradient(
                base_depth=base_depth,
                distance=distance,
                max_distance=spec.breadth,
                gradient_enabled=spec.depth_gradient,
            )
            label = ""
            if node_index and uri in node_index:
                label = node_index[uri].label
            nodes.append(
                ProjectedNode(
                    uri=uri,
                    distance=distance,
                    resolved_depth=resolved,
                    label=label,
                )
            )

        nodes.sort(key=lambda n: (n.distance, n.uri))

        self._logger.debug(
            "Projection complete",
            focus_count=len(spec.focus),
            breadth=spec.breadth,
            base_depth=base_depth,
            projected_nodes=len(nodes),
        )
        return nodes

    def project_to_fragments(
        self,
        spec: ProjectionSpec,
        adjacency: Mapping[str, Sequence[str]],
        node_index: Mapping[str, UKONode] | None = None,
        *,
        token_estimator: _TokenEstimator | None = None,
    ) -> list[ProjectionContextFragment]:
        """Project and convert to :class:`ContextFragment` instances.

        Convenience wrapper over :meth:`project` that produces fragments
        suitable for direct injection into the ACMS pipeline.

        Args:
            spec: Projection request.
            adjacency: Graph adjacency list.
            node_index: Optional URI→UKONode lookup.
            token_estimator: Optional callable ``(uri, depth) -> int``
                that estimates token cost.  Defaults to depth + 1.

        Returns:
            List of :class:`ContextFragment` sorted by distance then URI.
        """
        projected = self.project(spec, adjacency, node_index)
        estimator = token_estimator or _default_token_estimator
        fragments: list[ProjectionContextFragment] = []
        for node in projected:
            tokens = estimator(node.uri, node.resolved_depth)
            relevance = self._distance_relevance(node.distance, spec.breadth)
            fragments.append(
                ProjectionContextFragment(
                    uko_node=node.uri,
                    content=f"{node.label or node.uri} [depth={node.resolved_depth}]",
                    detail_depth=node.resolved_depth,
                    token_count=tokens,
                    relevance_score=relevance,
                    provenance=FragmentProvenance(resource_uri=node.uri),
                    metadata={
                        "distance": node.distance,
                        "projected": True,
                        "base_depth": self._resolve_base_depth(spec),
                    },
                )
            )
        return fragments

    # -- internal helpers --------------------------------------------------

    def _resolve_base_depth(self, spec: ProjectionSpec) -> int:
        """Resolve the base depth from the spec, using DetailLevelMap if available."""
        detail_map = self._detail_maps.get(spec.domain)
        if detail_map is not None:
            return detail_map.resolve(spec.depth)
        if isinstance(spec.depth, int):
            return spec.depth
        msg = (
            f"Cannot resolve named depth {spec.depth!r}: "
            f"no DetailLevelMap registered for domain {spec.domain!r}"
        )
        raise ValueError(msg)

    @staticmethod
    def _apply_gradient(
        *,
        base_depth: int,
        distance: int,
        max_distance: int,
        gradient_enabled: bool,
    ) -> int:
        """Compute the resolved depth at a given distance.

        When gradient is disabled, every node gets ``base_depth``.
        When enabled, depth decreases linearly from ``base_depth``
        at distance 0 to 0 at ``max_distance``.  This matches the
        spec examples (lines 25276-25304).
        """
        if not gradient_enabled or max_distance == 0 or distance == 0:
            return base_depth
        reduction = math.floor(base_depth * (distance / max_distance))
        return max(0, base_depth - reduction)

    @staticmethod
    def _distance_relevance(distance: int, max_distance: int) -> float:
        """Compute a 0.0-1.0 relevance score inversely proportional to distance."""
        if max_distance == 0:
            return 1.0
        return round(max(0.0, 1.0 - distance / (max_distance + 1)), 4)


# ---------------------------------------------------------------------------
# Token estimation
# ---------------------------------------------------------------------------

_TokenEstimator = Any  # callable[[str, int], int]


def _default_token_estimator(_uri: str, depth: int) -> int:
    """Placeholder estimator: tokens = depth + 1."""
    return depth + 1


# ---------------------------------------------------------------------------
# PlanContextInheritance — skeleton context propagation
# ---------------------------------------------------------------------------


class InheritanceConfig(BaseModel, frozen=True):
    """Configuration for :class:`PlanContextInheritance`.

    Attributes:
        skeleton_ratio: Fraction of child's token budget reserved
            for the parent skeleton (default 0.2, per spec §43082).
        min_skeleton_tokens: Minimum tokens for a skeleton to be
            useful.  Below this, no skeleton is injected.
        max_detail_increase: Maximum detail level increase per
            depth-in-tree delta.
    """

    skeleton_ratio: float = Field(
        default=DEFAULT_SKELETON_RATIO,
        ge=0.0,
        le=1.0,
    )
    min_skeleton_tokens: int = Field(default=32, ge=0)
    max_detail_increase: int = Field(default=3, ge=0)

    model_config = ConfigDict(str_strip_whitespace=True)


class ChildContextResult(BaseModel, frozen=True):
    """Result of a child context inheritance computation.

    Contains the :class:`ContextRequest` for the child plan plus
    skeleton fragments and metadata from the parent context.

    Attributes:
        request: The child plan's context request.
        skeleton_fragments: Compressed parent fragments for injection.
        parent_context_hash: Hash of the parent's assembled context.
        skeleton_budget: Tokens reserved for the skeleton.
        depth_delta: Depth-in-tree difference between child and parent.
    """

    request: ContextRequest
    skeleton_fragments: tuple[ProjectionContextFragment, ...] = ()
    parent_context_hash: str = ""
    skeleton_budget: int = 0
    depth_delta: int = 0

    model_config = ConfigDict(str_strip_whitespace=True)


class PlanContextInheritance:
    """Compute child plan context from parent assembled context.

    Implements the ``PlanContextInheritance.compute_child_context()``
    pattern from ``docs/specification.md`` lines 43069-43101.

    The service:
    1. Extracts the child's focus from the parent plan's decisions.
    2. Computes depth/breadth adjustments based on tree depth delta.
    3. Builds a skeleton from parent context using the pipeline's
       :class:`SkeletonCompressor`.
    4. Returns a :class:`ChildContextResult` containing the request
       and skeleton data.
    """

    def __init__(
        self,
        *,
        config: InheritanceConfig | None = None,
        skeleton_compressor: _SkeletonCompressorLike | None = None,
    ) -> None:
        self._config = config or InheritanceConfig()
        self._compressor = (
            skeleton_compressor or resolve_configured_skeleton_compressor()
        )
        self._logger = logger.bind(component="PlanContextInheritance")

    @property
    def config(self) -> InheritanceConfig:
        """Return the inheritance configuration."""
        return self._config

    def compute_child_context(
        self,
        *,
        parent_context: _AssembledContextLike,
        child_focus: Sequence[str],
        child_token_budget: int,
        parent_depth_in_tree: int = 0,
        child_depth_in_tree: int = 1,
    ) -> ChildContextResult:
        """Build the child plan's initial context request.

        Args:
            parent_context: The parent's :class:`AssembledContext`.
            child_focus: URIs the child plan focuses on.
            child_token_budget: Total token budget for the child.
            parent_depth_in_tree: Parent's depth in the plan tree.
            child_depth_in_tree: Child's depth in the plan tree.

        Returns:
            A :class:`ChildContextResult` containing the request and
            skeleton fragments.
        """
        depth_delta = child_depth_in_tree - parent_depth_in_tree
        if depth_delta < 0:
            msg = "child_depth_in_tree must be >= parent_depth_in_tree"
            raise ValueError(msg)

        # 1. Compute child detail = parent average + delta (clamped)
        avg_detail = self._avg_detail(parent_context)
        child_detail = min(
            avg_detail + depth_delta * self._config.max_detail_increase,
            9,  # Absolute max detail (FULL_SOURCE)
        )

        # 2. Compute child breadth = narrower than parent
        parent_breadth = getattr(parent_context, "avg_breadth", 2)
        child_breadth = max(1, parent_breadth - depth_delta)

        # 3. Skeleton budget
        skeleton_budget = int(child_token_budget * self._config.skeleton_ratio)
        skeleton_fragments: tuple[ProjectionContextFragment, ...] = ()

        if skeleton_budget >= self._config.min_skeleton_tokens and self._compressor:
            parent_fragments = self._prioritize_parent_fragments(
                tuple(parent_context.fragments),
                child_focus,
            )
            skeleton_fragments = self._compressor.compress(
                cast(Any, parent_fragments),
                skeleton_budget,
            )
            self._logger.info(
                "Skeleton injected",
                skeleton_fragments=len(skeleton_fragments),
                skeleton_budget=skeleton_budget,
                parent_fragments=len(parent_fragments),
            )

        # 4. Build inherited context request
        context_hash = getattr(parent_context, "context_hash", "")

        request = ContextRequest(
            focus=list(child_focus),
            breadth=child_breadth,
            depth=child_detail,
            depth_gradient=True,
            purpose=f"Inherited context from parent (hash={context_hash})",
        )

        return ChildContextResult(
            request=request,
            skeleton_fragments=skeleton_fragments,
            parent_context_hash=context_hash,
            skeleton_budget=skeleton_budget,
            depth_delta=depth_delta,
        )

    @staticmethod
    def _avg_detail(context: _AssembledContextLike) -> int:
        """Compute average detail depth of assembled fragments."""
        fragments = context.fragments
        if not fragments:
            return 0
        total = sum(getattr(f, "detail_depth", 0) for f in fragments)
        return total // len(fragments)

    @staticmethod
    def _prioritize_parent_fragments(
        fragments: tuple[ProjectionContextFragment, ...],
        child_focus: Sequence[str],
    ) -> tuple[ProjectionContextFragment, ...]:
        """Boost fragments whose provenance is closest to the child focus."""

        if not fragments or not child_focus:
            return fragments
        prioritized: list[ProjectionContextFragment] = []
        for fragment in fragments:
            proximity = PlanContextInheritance._focus_proximity(fragment, child_focus)
            adjusted_score = min(
                1.0,
                fragment.relevance_score * 0.5 + proximity * 0.5,
            )
            if adjusted_score == fragment.relevance_score:
                prioritized.append(fragment)
                continue
            prioritized.append(
                fragment.model_copy(update={"relevance_score": adjusted_score})
            )
        return tuple(prioritized)

    @staticmethod
    def _focus_proximity(
        fragment: ProjectionContextFragment,
        child_focus: Sequence[str],
    ) -> float:
        """Return a 0..1 score for how near a fragment is to the child focus."""

        fragment_paths = [fragment.uko_node]
        resource_uri = fragment.provenance.resource_uri
        if resource_uri and resource_uri != fragment.uko_node:
            fragment_paths.append(resource_uri)
        best = 0.0
        for fragment_path in fragment_paths:
            fragment_segments = _uri_segments(fragment_path)
            if not fragment_segments:
                continue
            for focus in child_focus:
                focus_segments = _uri_segments(focus)
                shared = 0
                for left, right in zip(fragment_segments, focus_segments, strict=False):
                    if left != right:
                        break
                    shared += 1
                if not focus_segments:
                    continue
                best = max(
                    best,
                    shared / max(len(fragment_segments), len(focus_segments)),
                )
        return best

    @staticmethod
    def extract_child_focus(
        parent_decisions: Sequence[str],
        fallback_focus: Sequence[str] | None = None,
    ) -> list[str]:
        """Extract child focus from parent plan decisions.

        In the absence of a full decision model, this method accepts
        a list of URIs from the parent's decisions and returns them
        as the child's focus.  A fallback list is used if decisions
        are empty.
        """
        if parent_decisions:
            return list(parent_decisions)
        return list(fallback_focus or [])


def _uri_segments(uri: str) -> list[str]:
    """Split a URI into normalized path segments for proximity checks."""

    if "://" in uri:
        uri = uri.split("://", 1)[1]
    return [segment for segment in uri.replace("\\", "/").split("/") if segment]


# ---------------------------------------------------------------------------
# Structural protocols (duck-typed, avoid circular imports)
# ---------------------------------------------------------------------------

_SkeletonCompressorLike = Any
"""Any object with ``compress(fragments, skeleton_budget) -> tuple[...]``."""

_AssembledContextLike = Any
"""Any object with ``fragments``, ``context_hash``, optionally ``avg_breadth``."""


# ---------------------------------------------------------------------------
# Built-in DetailLevelMap presets
# ---------------------------------------------------------------------------


def code_detail_map() -> DetailLevelMap:
    """Return the built-in ``uko-code:`` detail level map.

    Delegates to the authoritative domain-layer registry
    (``ontology_registry.get_domain``).  See spec §41934-41971.
    """
    from cleveragents.domain.models.acms.ontology_registry import get_domain

    return get_domain("uko-code:").detail_map


def docs_detail_map() -> DetailLevelMap:
    """Return the built-in ``uko-doc:`` detail level map.

    Delegates to the authoritative domain-layer registry
    (``ontology_registry.get_domain``).  See spec §42023-42083.
    """
    from cleveragents.domain.models.acms.ontology_registry import get_domain

    return get_domain("uko-doc:").detail_map


def database_detail_map() -> DetailLevelMap:
    """Return the built-in ``uko-data:`` detail level map.

    Delegates to the authoritative domain-layer registry
    (``ontology_registry.get_domain``).  See spec §42167-42230.
    """
    from cleveragents.domain.models.acms.ontology_registry import get_domain

    return get_domain("uko-data:").detail_map


def infra_detail_map() -> DetailLevelMap:
    """Return the built-in ``uko-infra:`` detail level map.

    Delegates to the authoritative domain-layer registry
    (``ontology_registry.get_domain``).  See spec §42297-42331.
    """
    from cleveragents.domain.models.acms.ontology_registry import get_domain

    return get_domain("uko-infra:").detail_map
