"""Imagination trigger — entity extraction + ComponentIndex lookup + design dispatch.

The trigger runs post-state.update() in the agent loop. For each
novel entity phrase that isn't in the ComponentIndex, it dispatches
to ImaginationDesigner (I2) for real-time entity generation.

Entity extraction uses lightweight NLP heuristics inspired by
memory/concept_extractor.py. No spaCy required — uses regex + POS
heuristics for SEM-relevant noun phrases (physical objects, creatures,
environments, weapons, vehicles, items).
"""

from __future__ import annotations

import logging
import re
import threading
from typing import TYPE_CHECKING, Any

from maxim.imagination.cache import ImaginationCache, ImaginationResult

if TYPE_CHECKING:
    from maxim.default_network.network import DefaultNetwork
    from maxim.embodiment.component_index import ComponentIndex
    from maxim.embodiment.component_registry import ComponentRegistry
    from maxim.embodiment.sem import Entity
    from maxim.imagination.designer import ImaginationDesigner
    from maxim.similarity.encoder import LinguisticEncoder
    from maxim.tools.registry import ToolRegistry

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Entity noun-phrase extraction
# ---------------------------------------------------------------------------

# Words that look entity-like but are NOT SEM-relevant (abstract concepts,
# furniture, clothing, body parts, etc.)
_STOP_WORDS = frozenset(
    {
        # Abstract / meta
        "thing",
        "something",
        "nothing",
        "everything",
        "anything",
        "way",
        "time",
        "place",
        "world",
        "story",
        "plan",
        "idea",
        "thought",
        "feeling",
        "sense",
        "moment",
        "reason",
        "question",
        "answer",
        "problem",
        "solution",
        "situation",
        "action",
        "option",
        "choice",
        "decision",
        "result",
        "effect",
        "level",
        "type",
        "kind",
        "sort",
        "part",
        "side",
        "end",
        "point",
        "fact",
        "case",
        "area",
        "turn",
        "step",
        # Body parts (not SEM entities)
        "hand",
        "hands",
        "arm",
        "arms",
        "leg",
        "legs",
        "head",
        "eye",
        "eyes",
        "face",
        "foot",
        "feet",
        "finger",
        "fingers",
        "body",
        "back",
        "shoulder",
        "shoulders",
        # Clothing (not SEM entities)
        "shirt",
        "pants",
        "boots",
        "cloak",
        "robe",
        "hat",
        "hood",
        "gloves",
        "belt",
        # Common furniture (not SEM-relevant unless environmental)
        "chair",
        "table",
        "bed",
        "bench",
        # Pronouns / determiners
        "you",
        "your",
        "it",
        "its",
        "this",
        "that",
        "one",
        "ones",
        "self",
    }
)

# SEM-relevant categories — phrases containing these words are entity candidates
_ENTITY_INDICATORS = frozenset(
    {
        # Creatures
        "wolf",
        "dragon",
        "spider",
        "snake",
        "bear",
        "rat",
        "bat",
        "goblin",
        "orc",
        "troll",
        "zombie",
        "skeleton",
        "ghost",
        "demon",
        "elemental",
        "golem",
        "beast",
        "creature",
        "monster",
        "animal",
        "insect",
        "drone",
        "robot",
        "android",
        "cyborg",
        "dog",
        "hound",
        "guard",
        "sentinel",
        # Weapons
        "sword",
        "axe",
        "bow",
        "staff",
        "dagger",
        "spear",
        "mace",
        "hammer",
        "blade",
        "crossbow",
        "gun",
        "rifle",
        "pistol",
        "laser",
        "cannon",
        "turret",
        # Items
        "potion",
        "scroll",
        "amulet",
        "ring",
        "gem",
        "crystal",
        "key",
        "flask",
        "vial",
        "lantern",
        "torch",
        "device",
        "gadget",
        "terminal",
        "console",
        "laptop",
        "chip",
        # Vehicles
        "cart",
        "wagon",
        "ship",
        "boat",
        "speeder",
        "hover",
        "vehicle",
        "bike",
        "motorcycle",
        "car",
        # Environmental features
        "door",
        "gate",
        "bridge",
        "lever",
        "switch",
        "trap",
        "pit",
        "altar",
        "shrine",
        "fountain",
        "well",
        "chest",
        "crate",
        "barrel",
        "pillar",
        "statue",
        "portal",
        "pedestal",
        "mechanism",
        "lock",
        "panel",
        # NPCs
        "merchant",
        "blacksmith",
        "healer",
        "priest",
        "mage",
        "wizard",
        "warrior",
        "knight",
        "thief",
        "rogue",
        "assassin",
        "archer",
        "captain",
        "commander",
        "king",
        "queen",
        "villager",
        "innkeeper",
        "bartender",
    }
)

# Sentence-level patterns that introduce entities: "you see X", "there is X",
# "a X stands", "X appears", "X blocks", etc.
# Use word boundary or common prepositions/conjunctions as terminators to
# avoid swallowing the rest of the sentence.
_INTRO_PATTERNS = [
    re.compile(
        r"(?:you\s+(?:see|notice|spot|observe|find|discover))\s+(?:a|an|the|some)\s+"
        r"(.+?)(?:\s+(?:and|on|in|at|near|by|from|with|behind|inside|lying|resting|sitting)\b|\.|,|;|$)",
        re.I,
    ),
    re.compile(
        r"(?:there\s+(?:is|are|stands?|sits?))\s+(?:a|an|the|some)\s+"
        r"(.+?)(?:\s+(?:and|on|in|at|near|by|from|with|behind|inside)\b|\.|,|;|$)",
        re.I,
    ),
    re.compile(r"(?:a|an|the)\s+(.+?)\s+(?:appears?|emerges?|blocks?|guards?|lurks?|waits?)", re.I),
    re.compile(r"(?:a|an|the)\s+(.+?)\s+(?:lies|rests?|hangs?|leans?|stands?)\s", re.I),
]


def extract_entity_phrases(text: str) -> list[str]:
    """Extract SEM-relevant entity noun phrases from percept text.

    Returns deduplicated, normalized phrases in order of first occurrence.
    Filters out abstract concepts, body parts, clothing — only returns
    physical objects, creatures, weapons, items, environmental features,
    vehicles, and NPCs that could be SEM entities.

    Args:
        text: Raw percept text (narration, scene description, etc.)

    Returns:
        List of candidate entity phrases (lowercase, stripped).
    """
    if not text or not text.strip():
        return []

    seen: set[str] = set()
    seen_heads: set[str] = set()
    results: list[str] = []

    def _maybe_add(phrase: str) -> None:
        """Validate and add a candidate phrase."""
        phrase = phrase.strip().lower()
        # Remove leading determiners
        phrase = re.sub(r"^(?:a|an|the|some)\s+", "", phrase)
        # Remove trailing punctuation
        phrase = re.sub(r"[.,;:!?]+$", "", phrase).strip()
        if not phrase or len(phrase) < 3:
            return
        words = phrase.split()
        if len(words) > 4 or len(words) == 0:
            return

        # Stem the head noun (avoid stripping "s" from words ending in "ss")
        raw_head = words[-1]
        head_noun = raw_head.rstrip("s") if not raw_head.endswith("ss") else raw_head
        if head_noun in _STOP_WORDS or phrase in _STOP_WORDS:
            return

        # Check if any word is an entity indicator
        has_indicator = any(
            (w.rstrip("s") if not w.endswith("ss") else w) in _ENTITY_INDICATORS or w in _ENTITY_INDICATORS
            for w in words
        )

        if not has_indicator:
            return

        # Deduplicate on head noun — "wolf", "large wolf", "the wolf" collapse
        if head_noun in seen_heads:
            return
        seen_heads.add(head_noun)

        if phrase not in seen:
            seen.add(phrase)
            results.append(phrase)

    # Strategy 1: Sentence-level intro patterns (highest confidence)
    for pat in _INTRO_PATTERNS:
        for m in pat.finditer(text):
            candidate = m.group(1).strip()
            # May contain multiple entities joined by "and"
            for part in re.split(r"\s+and\s+", candidate):
                _maybe_add(part)

    # Strategy 2: Scan for entity indicator words with preceding adjectives.
    # For each word that matches an entity indicator, collect up to 3
    # preceding adjectives to form a phrase.
    words = re.findall(r"[a-z]+-?[a-z]*", text.lower())
    for i, word in enumerate(words):
        stem = word.rstrip("s") if not word.endswith("ss") else word
        if stem not in _ENTITY_INDICATORS and word not in _ENTITY_INDICATORS:
            continue
        # Collect up to 3 preceding words as adjectives
        start = max(0, i - 3)
        candidate_words = words[start : i + 1]
        # Filter out stop words and determiners from the front
        while candidate_words and candidate_words[0] in (
            "a",
            "an",
            "the",
            "some",
            "this",
            "that",
            "its",
            "your",
            "my",
        ):
            candidate_words = candidate_words[1:]
        if candidate_words:
            _maybe_add(" ".join(candidate_words))

    return results


# ---------------------------------------------------------------------------
# Imagination trigger
# ---------------------------------------------------------------------------


class ImaginationTrigger:
    """Orchestrates entity extraction → cache → index → design pipeline.

    Wired into the agent loop post-state.update(). Thread-safe.

    Args:
        component_index: Semantic component lookup.
        component_registry: For ephemeral registration.
        designer: ImaginationDesigner for LLM-based entity design.
        cache: Session-scoped imagination cache.
        tool_registry: For registering affordance tools (I3 API).
        default_network: For arousal gating (optional — if None, arousal
            gate is always open).
        imagination_threshold: Number of mentions before triggering
            imagination for a novel phrase.
        enabled: Master enable/disable switch.
    """

    def __init__(
        self,
        component_index: ComponentIndex,
        component_registry: ComponentRegistry,
        designer: ImaginationDesigner | None = None,
        cache: ImaginationCache | None = None,
        tool_registry: ToolRegistry | None = None,
        default_network: DefaultNetwork | None = None,
        encoder: LinguisticEncoder | None = None,
        *,
        agent_id: str = "",
        imagination_threshold: int = 2,
        enabled: bool = True,
    ) -> None:
        self._index = component_index
        self._registry = component_registry
        self._designer = designer
        self._cache = cache or ImaginationCache()
        self._tool_registry = tool_registry
        self._dn = default_network
        self._agent_id = agent_id
        self._threshold = imagination_threshold
        self._enabled = enabled
        self._lock = threading.RLock()
        # Per-phrase design guard — prevents concurrent LLM calls for the
        # same phrase when AUT + orchestrator process simultaneously.
        self._designing: set[str] = set()

        # Track imagined entity refs for provenance tagging at session end
        self._imagined_refs: set[str] = set()

        # Affordance substrate encoder: encodes affordance names through
        # EC → ATL → NAc eligibility trace pipeline for concept transfer.
        # Uses AffordanceDecompositionStrategy (splits on underscores),
        # separate from the percept encoder's SpaCyNounChunkStrategy.
        # Shares the same EC/ATL/NAc backing as the main encoder.
        # Uses _make_aff_encoder factory (shared with encode_entity_affordances).
        self._aff_encoder: LinguisticEncoder | None = None
        if encoder is not None:
            self._aff_encoder = _make_aff_encoder(encoder)

        # Stats
        self._phrases_extracted = 0
        self._cache_hits = 0
        self._index_hits = 0
        self._designs_attempted = 0
        self._designs_succeeded = 0

    @property
    def enabled(self) -> bool:
        return self._enabled

    @enabled.setter
    def enabled(self, value: bool) -> None:
        self._enabled = value

    @property
    def cache(self) -> ImaginationCache:
        return self._cache

    @property
    def imagined_refs(self) -> frozenset[str]:
        """Return the set of imagined entity refs created this session.

        Used at session end to retroactively tag CausalLinks and Episodes
        involving these entities with ``imagined=True`` provenance.
        """
        with self._lock:
            return frozenset(self._imagined_refs)

    def _encode_entity_affordances(self, entity: Entity) -> list[str]:
        """Encode an entity's affordance names through the substrate path.

        Decomposes each affordance identifier (e.g., ``fire_breath``) via
        ``AffordanceDecompositionStrategy`` into compound + component concepts,
        then encodes each through LinguisticEncoder → EC → ATL → NAc eligibility.

        This creates substrate nodes for concepts like "fire" and "breath"
        that are shared across entities — enabling cross-entity transfer
        through EC pattern completion.

        Called OUTSIDE the trigger's ``_lock`` to avoid cross-lock ordering
        issues with ATL's RWLock.

        Returns list of substrate node IDs created/reinforced.
        """
        if self._aff_encoder is None:
            try:
                from maxim.simulation.sim_logger import sim_log

                sim_log("SEM_TRACE", f"affordance encoding skipped: _aff_encoder is None for '{entity.name}'")
            except Exception:
                pass
            return []

        all_node_ids: list[str] = []
        _n_mods = len(entity.modulators)
        _n_affs = sum(len(m.affordances) for m in entity.modulators.values())
        try:
            from maxim.simulation.sim_logger import sim_log

            sim_log(
                "SEM_TRACE",
                f"affordance encoding: '{entity.name}' has {_n_mods} modulators, {_n_affs} affordances",
            )
        except Exception:
            pass

        for mod in entity.modulators.values():
            for aff_name in mod.affordances:
                try:
                    node_ids = self._aff_encoder.encode_decomposed(aff_name, "text", self._agent_id)
                    all_node_ids.extend(node_ids)
                except Exception as e:
                    log.debug(
                        "Imagination: affordance encoding failed for '%s': %s",
                        aff_name,
                        e,
                    )
        if all_node_ids:
            log.info(
                "Imagination: encoded %d affordance concepts for '%s' (%d nodes)",
                sum(1 for m in entity.modulators.values() for _ in m.affordances),
                entity.name,
                len(all_node_ids),
            )
        return all_node_ids

    def _ensure_entity_live(
        self,
        ref: str,
        phrase: str,
        scene_context: dict[str, Any] | None,
        scene_id: str | None,
    ) -> None:
        """Instantiate a known component as a live scene entity.

        When the ComponentIndex matches a seed component (e.g., creatures/dragon),
        it's recognized but not instantiated.  This method loads the spec,
        parses it into a live Entity, and registers it as a **scene entity**
        in the EntityMap (observable via ``sense`` / ``sense_presence``, but
        NO callable affordance tools).  The agent interacts with scene
        entities using its own body's tools, not the entity's affordances.
        """
        _entity_map = getattr(self, "_entity_map", None)
        if _entity_map is None:
            return

        # Check if already live — extract the entity name from the ref
        # (e.g., "creatures/dragon" → "dragon")
        entity_name = ref.rsplit("/", 1)[-1] if "/" in ref else ref
        if _entity_map.resolve(entity_name) is not None:
            return  # Already instantiated

        try:
            spec = self._registry.get(ref)
            entity_raw = spec.get("entity", spec)

            from maxim.embodiment.spec import _parse_entity, normalize_llm_entity_spec

            entity = _parse_entity(normalize_llm_entity_spec(entity_raw))
            _entity_map.register_scene(entity)

            # Encode affordance names through substrate path for concept transfer.
            # Runs OUTSIDE _lock — ATL has its own RWLock.
            self._encode_entity_affordances(entity)

            log.info(
                "Imagination: instantiated '%s' from seed '%s' as scene entity (observe-only)",
                entity_name,
                ref,
            )
            try:
                from maxim.simulation.sim_logger import sim_log

                sim_log(
                    "SEM_TRACE",
                    f"Scene entity '{entity_name}' instantiated from {ref} "
                    f"(observe-only — use sense/sense_presence to observe)",
                )
            except Exception:
                pass
        except Exception as e:
            log.debug("Imagination: failed to instantiate '%s' from '%s': %s", entity_name, ref, e)

    def process_percept(
        self,
        percept_text: str,
        scene_context: dict[str, Any] | None = None,
        scene_id: str | None = None,
    ) -> list[ImaginationResult]:
        """Run the full imagination pipeline on a percept text.

        1. Extract entity noun phrases.
        2. For each phrase: check cache → check index → design if novel.
        3. Register ephemeral entities + affordance tools.

        Returns list of ImaginationResults (both cached and newly resolved).
        """
        if not self._enabled:
            return []

        # Extract candidates
        phrases = extract_entity_phrases(percept_text)
        try:
            from maxim.simulation.sim_logger import sim_log

            if phrases:
                sim_log("SEM_TRACE", f"Imagination extracted {len(phrases)} phrases: {phrases[:5]}")
            else:
                sim_log("SEM_TRACE", f"Imagination: no entity phrases from '{percept_text[:80]}'")
        except Exception:
            pass
        if not phrases:
            return []

        with self._lock:
            self._phrases_extracted += len(phrases)

        results: list[ImaginationResult] = []

        for phrase in phrases:
            result = self._resolve_phrase(phrase, scene_context, scene_id)
            if result is not None:
                results.append(result)

        return results

    def _resolve_phrase(
        self,
        phrase: str,
        scene_context: dict[str, Any] | None,
        scene_id: str | None,
    ) -> ImaginationResult | None:
        """Resolve a single entity phrase through cache → index → design."""
        # 1. Check cache first (avoids repeated embedding computations)
        cached = self._cache.get(phrase)
        if cached is not None:
            with self._lock:
                self._cache_hits += 1
            try:
                from maxim.simulation.sim_logger import sim_imagination

                sim_imagination("cache_hit", phrase, result=cached.ref)
            except Exception:
                pass
            return cached

        # 2. Record mention using the head noun so variant phrases
        # ("fire-breathing dragon", "large dragon", "the dragon") all
        # accumulate under the same key.  The full phrase is still used
        # for cache lookups and designer input.
        words = phrase.strip().lower().split()
        head_noun = words[-1] if words else phrase
        count = self._cache.record_mention(head_noun)

        # 3. Check ComponentIndex for existing match
        match = self._index.find(phrase)
        if match is not None:
            # Bring the entity to life if it isn't already a live entity.
            # The ComponentIndex knows about seed components (creatures/dragon,
            # weapons/rusty_sword, etc.) but they aren't instantiated as live
            # Entities with registered affordance tools until this happens.
            self._ensure_entity_live(match.ref, phrase, scene_context, scene_id)

            result = ImaginationResult(
                phrase=phrase,
                ref=match.ref,
                imagined=False,
                score=match.score,
            )
            self._cache.put(result)
            with self._lock:
                self._index_hits += 1
            log.debug(
                "Imagination: '%s' matched existing component '%s' (score=%.2f, layer=%s)",
                phrase,
                match.ref,
                match.score,
                match.layer,
            )
            try:
                from maxim.simulation.sim_logger import sim_imagination

                sim_imagination("index_hit", phrase, result=f"{match.ref} (score={match.score:.2f})")
            except Exception:
                pass
            return result

        # 4. Below threshold → not enough mentions yet, skip
        if count < self._threshold:
            return None

        # 4b. Head-noun cache check: a variant phrase for the same head noun
        # may have already been designed and cached (e.g., "fire-breathing
        # dragon" designed, now "large dragon" hits threshold).  Check the
        # cache by head noun to avoid duplicate designs.
        head_cached = self._cache.get(head_noun)
        if head_cached is not None:
            # Cache the variant phrase as well so future lookups are instant.
            self._cache.put(
                ImaginationResult(
                    phrase=phrase,
                    ref=head_cached.ref,
                    imagined=head_cached.imagined,
                    score=head_cached.score,
                )
            )
            with self._lock:
                self._cache_hits += 1
            return head_cached

        # 5. Check DN arousal gate — only imagine during low arousal
        if not self._is_arousal_allowed():
            log.debug("Imagination: arousal gate blocked design for '%s'", phrase)
            try:
                from maxim.simulation.sim_logger import sim_imagination

                sim_imagination("gate_rejected", phrase, result="arousal too high")
            except Exception:
                pass
            return None

        # 6. Check energy budget
        if not self._is_energy_available():
            log.debug("Imagination: energy gate blocked design for '%s'", phrase)
            try:
                from maxim.simulation.sim_logger import sim_imagination

                sim_imagination("gate_rejected", phrase, result="energy critical")
            except Exception:
                pass
            return None

        # 7. Design the entity
        if self._designer is None:
            log.debug("Imagination: no designer available, skipping '%s'", phrase)
            return None

        # Per-phrase guard: use head noun to prevent concurrent/duplicate
        # design for variant phrases of the same entity.
        with self._lock:
            if head_noun in self._designing:
                return None  # Another thread/variant is already designing this
            self._designing.add(head_noun)
            self._designs_attempted += 1

        try:
            design_result = self._designer.imagine(phrase, scene_context or {})
        except Exception as e:
            log.warning("Imagination: design failed for '%s': %s", phrase, e, exc_info=True)
            return None
        finally:
            with self._lock:
                self._designing.discard(head_noun)

        if design_result is None:
            return None

        spec = design_result.spec
        ref = design_result.ref

        # 8. Register ephemeral component
        self._registry.register_ephemeral(
            ref,
            spec,
            provenance="imagined",
        )

        # 9. Add to ComponentIndex for future lookups
        synonyms = design_result.synonyms
        self._index.add(ref, spec, synonyms=synonyms)

        # 10. Register as scene entity in EntityMap (observe-only, no tools).
        # The agent perceives imagined entities through sense/sense_presence
        # and interacts using its own body's tools, not the entity's affordances.
        _entity_map = getattr(self, "_entity_map", None)
        if _entity_map is not None:
            try:
                from maxim.embodiment.spec import _parse_entity, normalize_llm_entity_spec

                entity = _parse_entity(normalize_llm_entity_spec(spec.get("entity", spec)))
                _entity_map.register_scene(entity)
                # Encode imagined entity affordances through substrate path
                self._encode_entity_affordances(entity)
                log.info(
                    "Imagination: registered imagined entity '%s' as scene entity (observe-only)",
                    ref,
                )
            except Exception as e:
                log.warning("Imagination: scene entity registration failed for '%s': %s", ref, e)

        result = ImaginationResult(
            phrase=phrase,
            ref=ref,
            imagined=True,
            score=1.0,
            spec=spec,
            validation_warnings=design_result.validation_warnings,
        )
        self._cache.put(result)
        # Also cache under head noun so variant phrases ("large dragon"
        # after "fire-breathing dragon" was designed) get a cache hit at
        # step 4b instead of triggering another design call.
        head_noun_result = ImaginationResult(
            phrase=head_noun,
            ref=ref,
            imagined=True,
            score=1.0,
        )
        self._cache.put(head_noun_result)

        with self._lock:
            self._designs_succeeded += 1
            self._imagined_refs.add(ref)

        log.info("Imagination: designed new entity '%s' from phrase '%s'", ref, phrase)
        try:
            from maxim.simulation.sim_logger import sim_imagination

            sim_imagination("design", phrase, result=ref)
        except Exception:
            pass
        return result

    def _is_arousal_allowed(self) -> bool:
        """Check DN arousal gate — imagination only during low arousal."""
        if self._dn is None:
            return True  # No DN → gate is open
        return self._dn.imagination_allowed()

    def _is_energy_available(self) -> bool:
        """Check energy budget — skip imagination if LLM energy is critical."""
        try:
            from maxim.energy.registry import get_global_registry

            registry = get_global_registry()
            if registry.is_critical_energy("llm"):
                return False
        except Exception:
            pass  # Energy system not available → allow
        return True

    def process_manifest(
        self,
        manifest_text: str,
        scene_context: dict[str, Any] | None = None,
        scene_id: str | None = None,
        *,
        max_entities: int = 8,
        max_designs: int = 5,
    ) -> list[ImaginationResult]:
        """Pre-trigger: resolve entities from a scene manifest.

        Called by the orchestrator BEFORE the first sim turn to
        pre-instantiate entities.  Unlike :meth:`process_percept`, this
        method bypasses the mention threshold, arousal gate, and energy
        gate — pre-triggering is a deliberate orchestrator action, not a
        reactive response to mid-sim percepts.

        Processes ComponentIndex hits first (instant), then budgets up
        to *max_designs* LLM design calls for truly novel entities.
        Skips any entity already live in the EntityMap (including the
        AUT's self-entity) to avoid ownership collisions.

        Args:
            manifest_text: Natural-language scene description listing
                key entities.
            scene_context: Optional context dict for the designer.
            scene_id: Optional scene identifier.
            max_entities: Hard cap on total entities resolved.
            max_designs: Max LLM design calls for novel entities.

        Returns:
            List of resolved ImaginationResults.
        """
        if not self._enabled:
            return []

        phrases = extract_entity_phrases(manifest_text)
        try:
            from maxim.simulation.sim_logger import sim_log

            sim_log(
                "SEM_TRACE",
                f"Manifest pre-trigger: {len(phrases)} phrases extracted" + (f": {phrases[:8]}" if phrases else ""),
            )
        except Exception:
            pass
        if not phrases:
            return []

        with self._lock:
            self._phrases_extracted += len(phrases)

        # Resolve the self-entity name(s) so we can skip them.
        # Store both the full name and individual words so compound names
        # like "dark_knight" match head-noun "knight" as well as the full
        # name.  This prevents a manifest phrase like "a dark knight"
        # from being registered as a scene entity when the agent's own
        # body is named "dark_knight" (executor review finding #2).
        _entity_map = getattr(self, "_entity_map", None)
        self_names: set[str] = set()
        if _entity_map is not None:
            try:
                for ent in _entity_map.list_self_entities():
                    name_lower = ent.name.lower()
                    self_names.add(name_lower)
                    # Also add individual words for compound names
                    for part in name_lower.replace("_", " ").split():
                        self_names.add(part)
            except Exception:
                pass

        # Pass 1: ComponentIndex hits (instant, no cap)
        results: list[ImaginationResult] = []
        novel_phrases: list[str] = []

        for phrase in phrases:
            if len(results) >= max_entities:
                break

            # Skip if already live (self-entity or previously instantiated)
            words = phrase.strip().lower().split()
            head_noun = words[-1] if words else phrase
            if head_noun in self_names:
                continue
            if _entity_map is not None and _entity_map.resolve(head_noun) is not None:
                continue

            # Check cache
            cached = self._cache.get(phrase)
            if cached is not None:
                results.append(cached)
                continue

            # Check ComponentIndex
            match = self._index.find(phrase)
            if match is not None:
                self._ensure_entity_live(match.ref, phrase, scene_context, scene_id)
                result = ImaginationResult(
                    phrase=phrase,
                    ref=match.ref,
                    imagined=False,
                    score=match.score,
                )
                self._cache.put(result)
                results.append(result)
                with self._lock:
                    self._index_hits += 1
                continue

            # Novel — queue for design
            novel_phrases.append(phrase)

        # Pass 2: LLM design for novel entities (capped)
        designs_done = 0
        for phrase in novel_phrases:
            if len(results) >= max_entities or designs_done >= max_designs:
                try:
                    from maxim.simulation.sim_logger import sim_log

                    sim_log(
                        "SEM_TRACE",
                        f"Manifest: skipping '{phrase}' (cap reached: "
                        f"{len(results)}/{max_entities} entities, "
                        f"{designs_done}/{max_designs} designs)",
                    )
                except Exception:
                    pass
                break

            if self._designer is None:
                break

            # Per-phrase design guard — use head noun for consistency
            # with _resolve_phrase (prevents variant duplicates).
            manifest_words = phrase.strip().lower().split()
            manifest_head = manifest_words[-1] if manifest_words else phrase
            with self._lock:
                if manifest_head in self._designing:
                    continue
                self._designing.add(manifest_head)
                self._designs_attempted += 1

            try:
                design_result = self._designer.imagine(phrase, scene_context or {})
            except Exception as e:
                log.warning("Manifest: design failed for '%s': %s", phrase, e)
                design_result = None
            finally:
                with self._lock:
                    self._designing.discard(manifest_head)

            if design_result is None:
                continue

            # Register ephemeral component
            self._registry.register_ephemeral(
                design_result.ref,
                design_result.spec,
                provenance="imagined",
            )
            self._index.add(design_result.ref, design_result.spec, synonyms=design_result.synonyms)

            # Register as scene entity
            if _entity_map is not None:
                try:
                    from maxim.embodiment.spec import _parse_entity, normalize_llm_entity_spec

                    raw_spec = design_result.spec.get("entity", design_result.spec)
                    entity = _parse_entity(normalize_llm_entity_spec(raw_spec))
                    _entity_map.register_scene(entity)
                    self._encode_entity_affordances(entity)
                except Exception as e:
                    log.warning("Manifest: scene entity registration failed for '%s': %s", design_result.ref, e)

            result = ImaginationResult(
                phrase=phrase,
                ref=design_result.ref,
                imagined=True,
                score=1.0,
                spec=design_result.spec,
                validation_warnings=design_result.validation_warnings,
            )
            self._cache.put(result)
            results.append(result)
            designs_done += 1

            with self._lock:
                self._designs_succeeded += 1
                self._imagined_refs.add(design_result.ref)

        try:
            from maxim.simulation.sim_logger import sim_log

            index_hits = sum(1 for r in results if not r.imagined)
            designed = sum(1 for r in results if r.imagined)
            sim_log(
                "SEM_TRACE",
                f"Manifest resolved {len(results)} entities ({index_hits} from index, {designed} designed)",
            )
        except Exception:
            pass

        return results

    def stats(self) -> dict[str, int]:
        """Return imagination trigger statistics."""
        with self._lock:
            return {
                "phrases_extracted": self._phrases_extracted,
                "cache_hits": self._cache_hits,
                "index_hits": self._index_hits,
                "designs_attempted": self._designs_attempted,
                "designs_succeeded": self._designs_succeeded,
                "cache_size": self._cache.size,
            }

    def clear_session(self) -> None:
        """Clear session state (called at session end)."""
        self._cache.clear()
        with self._lock:
            self._phrases_extracted = 0
            self._cache_hits = 0
            self._index_hits = 0
            self._designs_attempted = 0
            self._designs_succeeded = 0


# ---------------------------------------------------------------------------
# Standalone affordance encoding (for self-entity registration sites)
# ---------------------------------------------------------------------------


def _make_aff_encoder(encoder: LinguisticEncoder) -> LinguisticEncoder | None:
    """Build an affordance-specific LinguisticEncoder sharing the same backing.

    Single factory for both ImaginationTrigger.__init__ and the standalone
    encode_entity_affordances function — keeps construction in sync.
    """
    try:
        from maxim.similarity.decomposer import (
            AffordanceDecompositionStrategy,
            ConceptDecomposer,
        )
        from maxim.similarity.encoder import LinguisticEncoder as _LE

        return _LE(
            ec=encoder.ec,
            atl=encoder.atl,
            nac=encoder._nac,
            decomposer=ConceptDecomposer(
                strategy=AffordanceDecompositionStrategy(),
            ),
            config=encoder.config,
        )
    except Exception as e:
        log.debug("_make_aff_encoder: setup failed: %s", e)
        return None


def encode_entity_affordances(
    entity: Entity,
    encoder: LinguisticEncoder,
    agent_id: str = "",
) -> list[str]:
    """Encode an entity's affordance names through the substrate path.

    Standalone version for call sites that register the agent's OWN body
    (orchestrator AUT setup, embodied_runtime) — these don't go through
    ImaginationTrigger.

    Returns list of substrate node IDs created/reinforced.
    """
    aff_encoder = _make_aff_encoder(encoder)
    if aff_encoder is None:
        return []

    all_node_ids: list[str] = []
    for mod in entity.modulators.values():
        for aff_name in mod.affordances:
            try:
                node_ids = aff_encoder.encode_decomposed(aff_name, "text", agent_id)
                all_node_ids.extend(node_ids)
            except Exception as e:
                log.debug("encode_entity_affordances: failed for '%s': %s", aff_name, e)

    if all_node_ids:
        log.info(
            "Encoded %d affordance concepts for self-entity '%s' (%d nodes)",
            sum(1 for m in entity.modulators.values() for _ in m.affordances),
            entity.name,
            len(all_node_ids),
        )
    return all_node_ids
