"""ArticulationRealizerV2 — deterministic template-based realization.

Converts an ArticulationTarget (ordered rhetorical steps from the graph
planner) into a RealizedPlan: an ordered sequence of surface fragments
joined into a single deterministic surface string.

Design constraints:
  - No LLM fallback
  - No broad grammar engine
  - Deterministic: same ArticulationTarget → same RealizedPlan, always
  - Composable: does not replace the existing realize() path yet
"""

from __future__ import annotations

from dataclasses import dataclass

from core.physics.energy import EnergyClass
from generate.graph_planner import (
    ArticulationStep,
    ArticulationTarget,
    PropositionGraph,
    RhetoricalMove,
)
from generate.intent import IntentTag
from generate.semantic_templates import render_semantic
from generate.templates import render_step


_ENERGY_SURFACE_PREFIX: dict[EnergyClass, str] = {
    EnergyClass.E0: "From memory: ",
    EnergyClass.E1: "I seem to recall: ",
    EnergyClass.E2: "I recall: ",
    EnergyClass.E3: "",
    EnergyClass.E4: "",
}


def energy_modulated_surface(base_surface: str, energy_class: EnergyClass) -> str:
    """Prepend energy-class framing per ADR-0006 §Integration Points."""
    prefix = _ENERGY_SURFACE_PREFIX.get(energy_class, "")
    if not prefix or not base_surface:
        return base_surface
    return prefix + base_surface


@dataclass(frozen=True, slots=True)
class RealizedFragment:
    node_id: str
    move: RhetoricalMove
    surface: str

    def as_dict(self) -> dict[str, str]:
        return {
            "node_id": self.node_id,
            "move": self.move.value,
            "surface": self.surface,
        }


def _capitalize_sentence(s: str) -> str:
    """Capitalize the first alphabetic character of a sentence.

    Skips leading whitespace/punctuation so fragments that start with
    discourse markers ("next, knowledge…") still emit a capital first
    letter ("Next, knowledge…") at the sentence boundary.  Leaves the
    rest of the string untouched — proper nouns and embedded all-caps
    tokens are preserved.
    """
    if not s:
        return s
    for i, ch in enumerate(s):
        if ch.isalpha():
            return s[:i] + ch.upper() + s[i + 1:]
    return s


def _join_as_paragraph(fragments: list["RealizedFragment"]) -> str:
    """Join fragments into a paragraph with sentence-initial capitalization.

    Each fragment becomes one sentence; sentence-initial letters are
    capitalized; the paragraph ends with a single terminal period.
    """
    if not fragments:
        return ""
    pieces: list[str] = []
    for f in fragments:
        s = f.surface.strip()
        if not s:
            continue
        s = _capitalize_sentence(s)
        pieces.append(s)
    joined = ". ".join(pieces)
    if joined and not joined.endswith("."):
        joined += "."
    return joined


@dataclass(frozen=True, slots=True)
class RealizedPlan:
    fragments: tuple[RealizedFragment, ...]
    surface: str

    def as_dict(self) -> dict[str, object]:
        return {
            "fragments": tuple(f.as_dict() for f in self.fragments),
            "surface": self.surface,
        }


def realize_semantic(
    target: ArticulationTarget,
    graph: PropositionGraph | None = None,
) -> RealizedPlan:
    """Realize using intent-aware semantic templates.

    Uses the source intent to select a template that produces structurally
    better surfaces (e.g. "X is defined as Y" for definition intents)
    rather than the generic rhetorical-move templates.

    Returns an empty RealizedPlan for empty/None targets so the caller
    can fall back to the older articulation path.
    """
    if target is None or not target.steps:
        return RealizedPlan(fragments=(), surface="")

    intent = target.source_intent
    fragments: list[RealizedFragment] = []
    # Comb pass 2026-05-21 — O(1) object-slot lookup per step.
    node_objs = _build_node_map(graph)

    if intent is IntentTag.COMPARISON and len(target.steps) >= 2:
        step_a = target.steps[0]
        step_b = target.steps[1]
        obj_a = node_objs.get(step_a.node_id, "...")
        secondary = step_b.subject if step_b.subject != step_a.subject else obj_a
        surface = render_semantic(
            intent=intent,
            subject=step_a.subject,
            predicate=step_a.predicate,
            obj=obj_a,
            secondary=secondary,
        )
        fragments.append(RealizedFragment(
            node_id=step_a.node_id,
            move=RhetoricalMove.CONTRAST,
            surface=surface,
        ))
    else:
        for step in target.steps:
            obj = node_objs.get(step.node_id, "...")
            surface = render_semantic(
                intent=intent,
                subject=step.subject,
                predicate=step.predicate,
                obj=obj,
            )
            move = step.move
            if move is RhetoricalMove.ASSERT and intent is IntentTag.CORRECTION:
                move = RhetoricalMove.CORRECT
            fragments.append(RealizedFragment(
                node_id=step.node_id,
                move=move,
                surface=surface,
            ))

    joined = _join_as_paragraph(fragments)
    return RealizedPlan(fragments=tuple(fragments), surface=joined)


def _build_node_map(graph: PropositionGraph | None) -> dict[str, str]:
    """Index graph nodes by node_id for O(1) ``obj`` lookup.

    Comb pass 2026-05-21 — pre-fix ``_resolve_obj`` did an O(N) linear
    scan of ``graph.nodes`` per step, so a target with S steps over an
    N-node graph cost O(S × N).  Building the map once in the realizer
    and indexing into it makes the realizer linear in (S + N) overall.
    Returns an empty mapping when the graph is None or empty.
    """
    if graph is None:
        return {}
    return {node.node_id: node.obj for node in graph.nodes}


def _resolve_obj(step: ArticulationStep, graph: PropositionGraph | None) -> str:
    """Look up the object slot from the graph node matching this step.

    Retained as the legacy single-step accessor for callers that do
    not have a node_map handy.  Hot paths in ``realize_semantic`` and
    ``realize_target`` build the map once and bypass this function.
    """
    if graph is None:
        return "..."
    for node in graph.nodes:
        if node.node_id == step.node_id:
            return node.obj
    return "..."


def realize_target(
    target: ArticulationTarget,
    graph: PropositionGraph | None = None,
) -> RealizedPlan:
    """Realize an ArticulationTarget into a deterministic surface plan.

    Handles compound constructions (conjunction, disjunction, complement,
    relative clause) by detecting graph edges and joining surfaces with
    appropriate connectors rather than sentence-level punctuation.

    Returns an empty-but-valid RealizedPlan for empty/None targets.
    """
    from generate.graph_planner import Relation

    if target is None or not target.steps:
        return RealizedPlan(fragments=(), surface="")

    edge_map: dict[str, tuple[str, Relation]] = {}
    if graph is not None:
        for edge in graph.edges:
            edge_map[edge.source] = (edge.target, edge.relation)

    step_by_id = {step.node_id: step for step in target.steps}
    # Comb pass 2026-05-21 — O(1) object-slot lookup per step.
    node_objs = _build_node_map(graph)
    visited: set[str] = set()
    fragments: list[RealizedFragment] = []

    for step in target.steps:
        if step.node_id in visited:
            continue
        visited.add(step.node_id)

        obj = node_objs.get(step.node_id, "...")
        move = step.move
        if move is RhetoricalMove.ASSERT and target.source_intent is IntentTag.CORRECTION:
            move = RhetoricalMove.CORRECT

        surface = render_step(
            move=move,
            subject=step.subject,
            predicate=step.predicate,
            obj=obj,
            negated=step.negated,
            quantifier=step.quantifier,
            tense=step.tense,
            aspect=step.aspect,
        )

        if step.node_id in edge_map:
            target_id, relation = edge_map[step.node_id]
            target_step = step_by_id.get(target_id)
            if target_step is not None and target_id not in visited:
                match relation:
                    case Relation.CONJUNCTION | Relation.DISJUNCTION | Relation.COMPLEMENT | Relation.RELATIVE:
                        visited.add(target_id)
                        target_obj = node_objs.get(target_step.node_id, "...")
                        target_surface = render_step(
                            move=RhetoricalMove.ASSERT,
                            subject=target_step.subject,
                            predicate=target_step.predicate,
                            obj=target_obj,
                            negated=target_step.negated,
                            quantifier=target_step.quantifier,
                            tense=target_step.tense,
                            aspect=target_step.aspect,
                        )
                        match relation:
                            case Relation.CONJUNCTION:
                                surface = f"{surface} and {target_surface}"
                            case Relation.DISJUNCTION:
                                surface = f"{surface} or {target_surface}"
                            case Relation.COMPLEMENT:
                                surface = f"{step.subject} {step.predicate} that {target_surface}"
                            case Relation.RELATIVE:
                                surface = f"{step.subject}, which {target_step.predicate} {target_obj}, {step.predicate} {obj}"
                    case _:
                        pass

        fragments.append(
            RealizedFragment(
                node_id=step.node_id,
                move=move,
                surface=surface,
            )
        )

    joined = _join_as_paragraph(fragments)
    return RealizedPlan(fragments=tuple(fragments), surface=joined)

