"""
FML Frequency Encoder Shell - Full 9-Layer Deterministic Token Generation
HEX_ID: 0BF1DD10

Standalone version for cubesquare.

Converts any text input into a deterministic 9-layer CST token.
Same input ALWAYS produces same token. This is the foundation of
learning without training: deterministic encoding + .cst persistence = lookup.

INPUTS:
    - Text string to encode
    - Optional membrane_id for session boundary

OUTPUTS:
    - FMLToken with all 9 layers:
        L1: Basin        - Fernandez attractor from frequency (0-130)
        L2: Semantic     - Relationship class (0-4)
        L3: Morton       - Position in 48^3 manifold from frequency components
        L4: K5xK11       - Intrinsic state product (5 x 23 = 115 states)
        L5: Energy       - Information entropy (0.0-1.0)
        L6: Emotion      - Behavioral physics (154 states from 3 axes)
        L7: Wavelength   - Direct frequency mapping (380-750nm)
        L8: Polarity     - DIRECT/INVERSE from grammatical structure
        L9: Membrane     - Session/context boundary identifier

L6 EMOTION PHYSICS (from groom strand analogy):
    - Commitment (stiffness): resistance to redirection (0-4)
    - Focus (attraction): directionality of attention (0-4)
    - Stability (inverse turbulence): predictability of propagation (0-4)
    3 axes x 5 levels = 125 base + 29 compound = 154 states

DETERMINISM GUARANTEE:
    All layers are computed from measurable text properties:
    - Zipf frequency ranks (from corpus vocabulary or hash)
    - Grammatical structure ratios
    - Information entropy
    - Article/noun specificity
    No randomness. No model inference. Same input -> same 9 layers.

Hex ID Format: BBMMSSEE
    BB = Basin (00-82)
    MM = Morton low 8 bits
    SS = L6 state (00-99)
    EE = Energy + polarity encoded
"""

import hashlib
import math
import re
from collections import Counter
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Optional, Any

# Import from cubesquare core (C backend: 0.009ns Morton)
from .._core.constants import PHI, K5, K11, PRIME_131, MATRIX_SIZE, LIGHT_UNIT
from .._core import morton_encode as _core_morton_encode

# Import I-Logic
from ..i_logic.states import ILogicState


# ==============================================================================
# CONSTANTS
# ==============================================================================

# FML Wavelength bounds
LAMBDA_MIN = 380.0  # nm (violet)
LAMBDA_MAX = 750.0  # nm (red)
LAMBDA_RANGE = LAMBDA_MAX - LAMBDA_MIN

# Default vocabulary size
DEFAULT_VOCAB_SIZE = 10000

# L2 Semantic Classes
L2_GEOMETRIC = 0      # Spatial relationships ("near", "above")
L2_SEMANTIC = 1       # Definitions ("means", "is a")
L2_SPATIAL = 2        # Location ("at", "in")
L2_FUNCTIONAL = 3     # Operations ("does", "runs")
L2_PROBABILISTIC = 4  # Uncertainty ("might", "maybe")

L2_NAMES = ['GEOMETRIC', 'SEMANTIC', 'SPATIAL', 'FUNCTIONAL', 'PROBABILISTIC']

# L2 keyword triggers
L2_KEYWORDS = {
    L2_GEOMETRIC: {'near', 'above', 'below', 'beside', 'between', 'around', 'inside', 'outside', 'left', 'right'},
    L2_SEMANTIC: {'means', 'defines', 'represents', 'signifies', 'denotes', 'equals'},
    L2_SPATIAL: {'at', 'in', 'on', 'within', 'located', 'positioned', 'placed'},
    L2_FUNCTIONAL: {'does', 'runs', 'executes', 'performs', 'processes', 'handles', 'calls'},
    L2_PROBABILISTIC: {'might', 'maybe', 'perhaps', 'possibly', 'probably', 'could', 'should', 'would'},
}

# L6 Emotion: 3 axes x 5 levels = 125 base + 29 compound = 154 states
L6_AXIS_COMMITMENT = 0  # Stiffness: 0=open, 4=immovable
L6_AXIS_FOCUS = 1       # Attraction: 0=scattered, 4=laser-targeted
L6_AXIS_STABILITY = 2   # Inverse turbulence: 0=chaotic, 4=rigid

# Grammatical markers for L6 computation
DEFINITE_ARTICLES = {'the', 'this', 'that', 'these', 'those'}
INDEFINITE_ARTICLES = {'a', 'an', 'some', 'any', 'few', 'several'}
SPECIFIC_MARKERS = {'specifically', 'exactly', 'precisely', 'particular'}
GENERIC_MARKERS = {'something', 'anything', 'somewhere', 'somehow', 'whatever'}
DECLARATIVE_MARKERS = {'is', 'are', 'was', 'were', 'will', 'must', 'shall'}
INTERROGATIVE_MARKERS = {'?', 'might', 'maybe', 'perhaps', 'could', 'would', 'should'}

# L8 Polarity markers
NEGATION_MARKERS = {'not', "n't", 'no', 'never', 'none', 'neither', 'nor', 'without'}

# K5 and K11 for L4
K5_VAL = 5
K11_VAL = 23  # K11 range: -11 to +11


# ==============================================================================
# DATA STRUCTURES
# ==============================================================================

@dataclass
class FMLToken:
    """
    Complete 9-layer CST token from FML frequency encoding.

    Deterministic: same input text always produces same token.
    """
    # Input
    text: str

    # L1: Basin (Fernandez attractor)
    l1_basin: int = 0

    # L2: Semantic class
    l2_semantic: int = 0
    l2_semantic_name: str = ""

    # L3: Morton position in 48^3
    l3_x: int = 0
    l3_y: int = 0
    l3_z: int = 0
    l3_morton: int = 0

    # L4: K5xK11 product
    l4_k5: int = 0
    l4_k11: int = 0
    l4_state: str = ""

    # L5: Energy (information entropy)
    l5_energy: float = 0.0

    # L6: Emotion (154 states)
    l6_state: int = 0
    l6_commitment: int = 0  # 0-4
    l6_focus: int = 0       # 0-4
    l6_stability: int = 0   # 0-4

    # L7: Wavelength (nm)
    l7_wavelength: float = 0.0

    # L8: Polarity
    l8_polarity: str = "DIRECT"
    l8_ilogic: complex = field(default=ILogicState.DIRECT)

    # L9: Membrane (session boundary)
    l9_membrane: int = 0

    # =========================================================================
    # WEIGHTS — mass from WHAT + WHERE (deterministic from L1-L9)
    # =========================================================================
    mass: float = 0.0                   # K11 * (1 + k11_level/11)
    content_mass: float = 0.0           # energy × transmission × K11
    energy_equivalent: float = 0.0      # mass × c² (CST c² = PHI×K11×131)
    escape_velocity: float = 0.0        # √(2GM) at unit radius
    kinetic_energy: float = 0.0         # ½mv²
    orbital_period: float = 0.0         # 2π√(1/GM)
    phi_norm: float = 0.0              # distance_from_center / (MATRIX×PHI)
    em_coupling: float = 0.0           # K5 × K11 × phi_norm
    transmission_ratio: float = 0.5    # |cos(2π × basin/131)| membrane T
    reflection_ratio: float = 0.5      # 1 - transmission
    membrane_action: str = "REFLECT"   # TRANSMIT or REFLECT
    mass_classification: str = "LIGHT" # LIGHT/STANDARD/HEAVY/MASSIVE

    # =========================================================================
    # L10: AGENT IDENTITY — self-reference (exterior begins)
    # =========================================================================
    l10_self_id: str = ""              # hex_id IS the self-reference
    l10_content_hash: str = ""         # md5(hex_id)[:8] — fingerprint
    l10_knows_self: bool = True        # every P knows itself
    l10_self_depth: int = 0            # self-reference recursion depth

    # =========================================================================
    # L11: COMMUNICATION — cross-reference channels
    # =========================================================================
    l11_channels: Tuple[str, ...] = ("basin", "layer", "field")
    l11_peer_basins: Tuple[int, ...] = ()  # adjacent basins in same layer
    l11_peer_count: int = 0            # how many peers in same layer

    # =========================================================================
    # L12: UNIFIED FIELD — the between-space
    # =========================================================================
    l12_uif_strength: float = 0.0     # coupling strength at this position
    l12_field_gradient: Tuple[float, float, float] = (0.0, 0.0, 0.0)
    l12_k5_coupling: float = 0.0      # K5 constant at this position
    l12_k11_coupling: float = 0.0     # K11 constant at this position
    l12_unified_state: str = "COHERENT"

    # Derived
    hex_id: str = ""

    def to_dict(self) -> Dict:
        return {
            'text': self.text,
            'hex_id': self.hex_id,
            'interior': {
                'L1_basin': self.l1_basin,
                'L2_semantic': {'class': self.l2_semantic, 'name': self.l2_semantic_name},
                'L3_morton': {'x': self.l3_x, 'y': self.l3_y, 'z': self.l3_z, 'morton': self.l3_morton},
                'L4_k5k11': {'k5': self.l4_k5, 'k11': self.l4_k11, 'state': self.l4_state},
                'L5_energy': round(self.l5_energy, 4),
                'L6_emotion': {
                    'state': self.l6_state,
                    'commitment': self.l6_commitment,
                    'focus': self.l6_focus,
                    'stability': self.l6_stability,
                },
                'L7_wavelength': round(self.l7_wavelength, 2),
                'L8_polarity': self.l8_polarity,
                'L9_membrane': self.l9_membrane,
            },
            'weights': {
                'mass': round(self.mass, 6),
                'content_mass': round(self.content_mass, 6),
                'energy_equivalent': round(self.energy_equivalent, 4),
                'escape_velocity': round(self.escape_velocity, 6),
                'kinetic_energy': round(self.kinetic_energy, 6),
                'orbital_period': round(self.orbital_period, 4),
                'phi_norm': round(self.phi_norm, 6),
                'em_coupling': round(self.em_coupling, 6),
                'transmission_ratio': round(self.transmission_ratio, 4),
                'reflection_ratio': round(self.reflection_ratio, 4),
                'membrane_action': self.membrane_action,
                'mass_classification': self.mass_classification,
            },
            'exterior': {
                'L10_agent_identity': {
                    'self_id': self.l10_self_id,
                    'content_hash': self.l10_content_hash,
                    'knows_self': self.l10_knows_self,
                    'self_depth': self.l10_self_depth,
                },
                'L11_communication': {
                    'channels': list(self.l11_channels),
                    'peer_basins': list(self.l11_peer_basins),
                    'peer_count': self.l11_peer_count,
                },
                'L12_unified_field': {
                    'uif_strength': round(self.l12_uif_strength, 6),
                    'field_gradient': tuple(round(g, 4) for g in self.l12_field_gradient),
                    'k5_coupling': round(self.l12_k5_coupling, 6),
                    'k11_coupling': round(self.l12_k11_coupling, 6),
                    'unified_state': self.l12_unified_state,
                },
            },
        }

    def to_compact(self) -> str:
        """Compact string representation for logging."""
        return (f"[{self.hex_id}] L1={self.l1_basin} L2={self.l2_semantic_name} "
                f"L3=({self.l3_x},{self.l3_y},{self.l3_z}) L4={self.l4_state} "
                f"L5={self.l5_energy:.2f} L6={self.l6_state} L7={self.l7_wavelength:.0f}nm "
                f"L8={self.l8_polarity} L9={self.l9_membrane} "
                f"m={self.mass:.3f} cm={self.content_mass:.3f} "
                f"T={self.membrane_action} "
                f"L10={self.l10_self_id[:8] if self.l10_self_id else '?'} "
                f"L11={self.l11_peer_count}peers "
                f"L12=UIF:{self.l12_uif_strength:.3f}")


# ==============================================================================
# FML FREQUENCY ENCODER
# ==============================================================================

class FMLEncoder:
    """
    Encodes text into deterministic 9-layer CST tokens.

    Uses frequency decomposition for all layer computations:
    - Zipf ranks from corpus vocabulary or hash-based fallback
    - Grammatical structure ratios
    - Information entropy

    No randomness. Same input always produces same token.

    Usage:
        encoder = FMLEncoder()
        token = encoder.encode("the healer agent is stuck")
        print(token.hex_id)     # -> deterministic hex
        print(token.l1_basin)   # -> basin from average wavelength
        print(token.l8_polarity) # -> DIRECT or INVERSE
    """

    def __init__(self, vocab_size: int = DEFAULT_VOCAB_SIZE,
                 term_ranks: Dict[str, int] = None,
                 membrane_id: int = 0):
        """
        Initialize encoder.

        Args:
            vocab_size: Size of vocabulary for frequency normalization
            term_ranks: Optional dict mapping terms to frequency ranks
            membrane_id: L9 membrane identifier for this session
        """
        self.vocab_size = vocab_size
        self.term_ranks = term_ranks or {}
        self.membrane_id = membrane_id

    def _tokenize(self, text: str) -> List[str]:
        """Tokenize text into words."""
        return re.findall(r"[a-zA-Z]+(?:'[a-zA-Z]+)?|[?!.,;:]", text.lower())

    def _extract_terms(self, text: str) -> List[str]:
        """Extract meaningful terms (words only)."""
        return re.findall(r'[a-zA-Z]+', text.lower())

    def _get_term_rank(self, term: str) -> int:
        """Get Zipf rank for a term."""
        if term in self.term_ranks:
            return self.term_ranks[term]
        # Unknown term: hash to consistent pseudo-rank (treat as rare)
        hash_val = int(hashlib.md5(term.encode()).hexdigest()[:8], 16)
        return (hash_val % (self.vocab_size // 2)) + (self.vocab_size // 2)

    def _frequency_to_wavelength(self, rank: int) -> float:
        """Convert frequency rank to wavelength."""
        normalized_rank = min(1.0, max(0.0, rank / self.vocab_size))
        return LAMBDA_MIN + (normalized_rank * LAMBDA_RANGE)

    def _wavelength_to_basin(self, wavelength: float) -> int:
        """Convert wavelength to basin number."""
        wavelength = max(LAMBDA_MIN, min(LAMBDA_MAX, wavelength))
        normalized = (wavelength - LAMBDA_MIN) / LAMBDA_RANGE
        basin = int(normalized * (PRIME_131 - 1))
        return max(0, min(PRIME_131 - 1, basin))

    # ========================================================================
    # LAYER COMPUTATIONS
    # ========================================================================

    def _compute_l1_basin(self, terms: List[str]) -> Tuple[int, float]:
        """
        L1: Basin from frequency-derived wavelength.

        Returns (basin, avg_wavelength)
        """
        if not terms:
            return 65, (LAMBDA_MIN + LAMBDA_MAX) / 2  # Center basin

        wavelengths = []
        for term in terms:
            rank = self._get_term_rank(term)
            wl = self._frequency_to_wavelength(rank)
            wavelengths.append(wl)

        avg_wl = sum(wavelengths) / len(wavelengths)
        basin = self._wavelength_to_basin(avg_wl)
        return basin, avg_wl

    def _compute_l2_semantic(self, tokens: List[str]) -> int:
        """
        L2: Semantic class from grammatical structure.

        Returns class 0-4.
        """
        token_set = set(tokens)

        # Check each class's keywords
        scores = [0, 0, 0, 0, 0]
        for l2_class, keywords in L2_KEYWORDS.items():
            scores[l2_class] = len(token_set & keywords)

        # If tie or no matches, default to FUNCTIONAL (most common for commands)
        if max(scores) == 0:
            return L2_FUNCTIONAL

        return scores.index(max(scores))

    def _compute_l3_morton(self, terms: List[str], wavelength: float) -> Tuple[int, int, int, int]:
        """
        L3: Morton position from frequency components.

        X: Average wavelength position (spectral)
        Y: Term frequency variance (information spread)
        Z: Grammatical depth (sentence complexity)

        Returns (x, y, z, morton)
        """
        # X: Wavelength -> position in spectrum
        x_norm = (wavelength - LAMBDA_MIN) / LAMBDA_RANGE
        x = int(x_norm * (MATRIX_SIZE - 1))
        x = max(0, min(MATRIX_SIZE - 1, x))

        # Y: Frequency variance -> spread across vocabulary
        if len(terms) > 1:
            ranks = [self._get_term_rank(t) for t in terms]
            mean_rank = sum(ranks) / len(ranks)
            variance = sum((r - mean_rank) ** 2 for r in ranks) / len(ranks)
            y_norm = min(1.0, math.sqrt(variance) / (self.vocab_size / 4))
        else:
            y_norm = 0.5
        y = int(y_norm * (MATRIX_SIZE - 1))
        y = max(0, min(MATRIX_SIZE - 1, y))

        # Z: Term count as proxy for complexity
        z_norm = min(1.0, len(terms) / 20)  # Normalize: 20 terms = max depth
        z = int(z_norm * (MATRIX_SIZE - 1))
        z = max(0, min(MATRIX_SIZE - 1, z))

        # Morton encoding (interleaved bits)
        morton = self._morton_encode(x, y, z)

        return x, y, z, morton

    def _morton_encode(self, x: int, y: int, z: int) -> int:
        """Encode (x,y,z) to Morton/Z-order code. Uses C backend."""
        return _core_morton_encode(x, y, z)

    def _compute_l4_k5k11(self, x: int, y: int, z: int) -> Tuple[int, int, str]:
        """
        L4: K5xK11 from position.

        K5 = (x + y + z) % 5 -> [0, 4]
        K11 = (x + y + z) % 23 - 11 -> [-11, +11]
        State from K11 mod 4

        Returns (k5, k11, state_name)
        """
        total = x + y + z
        k5 = total % K5_VAL
        k11 = (total % K11_VAL) - 11

        # State from K11 mod 4
        k11_mod4 = ((k11 % 4) + 4) % 4
        states = ['ABSORBED', 'DIRECT', 'INVERSE', 'SPIRAL']
        state = states[k11_mod4]

        return k5, k11, state

    def _compute_l5_energy(self, text: str, terms: List[str]) -> float:
        """
        L5: Energy from information entropy.

        Higher entropy = more information = higher energy.
        """
        if not text:
            return 0.0

        # Shannon entropy of characters
        char_counts = Counter(text.lower())
        total = len(text)
        entropy = 0.0
        for count in char_counts.values():
            if count > 0:
                p = count / total
                entropy -= p * math.log2(p)

        # Normalize to 0-1 (max entropy for 26 chars ~ 4.7)
        max_entropy = math.log2(min(len(char_counts), 26))
        if max_entropy > 0:
            normalized = entropy / max_entropy
        else:
            normalized = 0.0

        # Boost for term specificity (rare terms = more information)
        if terms:
            avg_rank = sum(self._get_term_rank(t) for t in terms) / len(terms)
            rank_boost = avg_rank / self.vocab_size
            normalized = min(1.0, normalized * 0.7 + rank_boost * 0.3)

        return normalized

    def _compute_l6_emotion(self, tokens: List[str], text: str) -> Tuple[int, int, int, int]:
        """
        L6: Emotion as behavioral physics (154 states).

        Three axes measured from text structure:
        - Commitment: definite vs indefinite articles
        - Focus: specific vs generic nouns
        - Stability: declarative vs interrogative

        Returns (state, commitment, focus, stability)
        """
        token_set = set(tokens)

        # Axis 1: Commitment (stiffness)
        definite_count = len(token_set & DEFINITE_ARTICLES)
        indefinite_count = len(token_set & INDEFINITE_ARTICLES)
        if definite_count + indefinite_count > 0:
            commitment_ratio = definite_count / (definite_count + indefinite_count)
        else:
            commitment_ratio = 0.5  # Neutral
        commitment = int(commitment_ratio * 4)  # 0-4

        # Axis 2: Focus (attraction)
        specific_count = len(token_set & SPECIFIC_MARKERS)
        generic_count = len(token_set & GENERIC_MARKERS)
        # Also count hex IDs and numbers as specific
        hex_count = len(re.findall(r'[0-9a-fA-F]{6,}', text))
        specific_count += hex_count
        if specific_count + generic_count > 0:
            focus_ratio = specific_count / (specific_count + generic_count + 1)
        else:
            # Check for named entities (capitalized words)
            caps = len(re.findall(r'\b[A-Z][a-z]+\b', text))
            focus_ratio = min(1.0, caps / 5) if caps else 0.5
        focus = int(focus_ratio * 4)  # 0-4

        # Axis 3: Stability (inverse turbulence)
        declarative_count = len(token_set & DECLARATIVE_MARKERS)
        interrogative_count = len(token_set & INTERROGATIVE_MARKERS)
        if '?' in text:
            interrogative_count += 1
        if declarative_count + interrogative_count > 0:
            stability_ratio = declarative_count / (declarative_count + interrogative_count)
        else:
            stability_ratio = 0.5  # Neutral
        stability = int(stability_ratio * 4)  # 0-4

        # Encode to state 0-154
        # Base: commitment*25 + focus*5 + stability = 0-124
        # Compound states: 125-153 for extremes
        state = commitment * 25 + focus * 5 + stability

        # Detect compound states (multiple axes at extreme)
        extremes = (commitment in (0, 4)) + (focus in (0, 4)) + (stability in (0, 4))
        if extremes >= 2:
            # Shift to compound range
            state = 125 + (commitment // 2) * 9 + (focus // 2) * 3 + (stability // 2)
            state = min(153, state)

        return state, commitment, focus, stability

    def _compute_l7_wavelength(self, avg_wavelength: float) -> float:
        """L7: Direct wavelength from frequency."""
        return avg_wavelength

    def _compute_l8_polarity(self, tokens: List[str], text: str) -> Tuple[str, complex]:
        """
        L8: Polarity from grammatical structure.

        DIRECT: Affirmative assertion
        INVERSE: Negation or reversal
        """
        token_set = set(tokens)

        # Check for negation
        has_negation = bool(token_set & NEGATION_MARKERS)

        # Check for question
        is_question = '?' in text

        if has_negation:
            return 'INVERSE', ILogicState.INVERSE
        elif is_question:
            return 'INVERSE', ILogicState.INVERSE
        else:
            return 'DIRECT', ILogicState.DIRECT

    def _compute_l9_membrane(self) -> int:
        """L9: Session/context boundary."""
        return self.membrane_id

    # ========================================================================
    # WEIGHT COMPUTATIONS — mass from position + content
    # ========================================================================

    def _compute_weights(self, token: FMLToken) -> None:
        """
        Compute all weight properties from the token's L1-L9 layers.

        Position mass: K11 * (1 + k11_level/11)  — from WHERE it lands
        Content mass:  energy × transmission × K11 — from WHAT it carries
        All derived deterministically. Same input → same weights.
        """
        # CST c² equivalent (from mass.py)
        C_SQUARED = PHI * K11 * LIGHT_UNIT

        # CST gravitational constant
        G_CST = K11 / (PHI * LIGHT_UNIT)

        # --- Position mass (from K11 level) ---
        k11_level = token.l4_k11  # already computed, range [-11, +11]
        token.mass = max(0, K11 * (1 + k11_level / 11))

        # --- Phi norm (distance from center / matrix×PHI) ---
        center = MATRIX_SIZE // 2
        dx = token.l3_x - center
        dy = token.l3_y - center
        dz = token.l3_z - center
        distance = math.sqrt(dx * dx + dy * dy + dz * dz)
        token.phi_norm = distance / (MATRIX_SIZE * PHI)

        # --- EM coupling ---
        token.em_coupling = K5 * K11 * token.phi_norm

        # --- Membrane transmission from basin coupling ---
        # φ(A) = |cos(2π × basin / 131)| — how much passes through L9
        token.transmission_ratio = abs(math.cos(2 * math.pi * token.l1_basin / LIGHT_UNIT))
        token.reflection_ratio = 1.0 - token.transmission_ratio
        token.membrane_action = "TRANSMIT" if token.transmission_ratio > 0.5 else "REFLECT"

        # --- Content mass (energy × transmission × K11) ---
        token.content_mass = token.l5_energy * token.transmission_ratio * K11

        # --- Energy equivalent (E = mc²) ---
        token.energy_equivalent = token.mass * C_SQUARED

        # --- Orbital mechanics ---
        if token.content_mass > 1e-6:
            token.orbital_period = 2 * math.pi * math.sqrt(1.0 / (G_CST * token.content_mass))
            token.escape_velocity = math.sqrt(2 * G_CST * token.content_mass)
        else:
            token.orbital_period = float('inf')
            token.escape_velocity = 0.0

        # --- Kinetic energy (T = ½mv²) ---
        token.kinetic_energy = 0.5 * token.content_mass * token.escape_velocity ** 2 if token.content_mass > 0 else 0.0

        # --- Mass classification ---
        if token.mass < K11 * 0.5:
            token.mass_classification = "LIGHT"
        elif token.mass < K11:
            token.mass_classification = "STANDARD"
        elif token.mass < K11 * 1.5:
            token.mass_classification = "HEAVY"
        else:
            token.mass_classification = "MASSIVE"

    # ========================================================================
    # L10-L12: EXTERNAL IDENTITY — self, communication, unified field
    # ========================================================================

    def _compute_exterior(self, token: FMLToken) -> None:
        """
        Compute L10-L12 external identity from hex_id and position.

        L10: Agent Identity — the hex_id IS the self-reference
        L11: Communication — peer basins in the same layer
        L12: Unified Field  — coupling strength at this position
        """
        # --- L10: Agent Identity ---
        token.l10_self_id = token.hex_id
        token.l10_content_hash = hashlib.md5(token.hex_id.encode()).hexdigest()[:8].upper()
        token.l10_knows_self = True

        # Self-reference depth: how many times hashing returns to same basin
        depth = 0
        current = token.hex_id
        seen = set()
        while current not in seen and depth < 10:
            seen.add(current)
            next_hash = hashlib.md5(current.encode()).hexdigest()[:8].upper()
            # Check if next hash maps to same basin
            try:
                next_val = int(next_hash, 16)
                if (next_val % LIGHT_UNIT) == token.l1_basin:
                    depth += 1
            except ValueError:
                pass
            current = next_hash
        token.l10_self_depth = depth

        # --- L11: Communication ---
        token.l11_channels = ("basin", "layer", "field")

        # Find peer basins: other basins in the same layer
        layer = token.l1_basin // 15 + 1
        if layer > 9:
            layer = 9
        start_basin = (layer - 1) * 15  # approximate
        end_basin = min(start_basin + 14, 130)
        peers = tuple(b for b in range(start_basin, end_basin + 1) if b != token.l1_basin)
        token.l11_peer_basins = peers
        token.l11_peer_count = len(peers)

        # --- L12: Unified Field ---
        center = MATRIX_SIZE // 2
        token.l12_field_gradient = (
            (token.l3_x - center) / MATRIX_SIZE,
            (token.l3_y - center) / MATRIX_SIZE,
            (token.l3_z - center) / MATRIX_SIZE,
        )

        # UIF strength from basin coupling: K5 × K11 × |cos(2π × basin/131)|
        token.l12_uif_strength = K5 * K11 * abs(
            math.cos(2 * math.pi * token.l1_basin / LIGHT_UNIT)
        )
        token.l12_k5_coupling = K5 * (1 + token.phi_norm * 0.1)
        token.l12_k11_coupling = K11 * (token.l1_basin / LIGHT_UNIT)

        # Unified state from polarity
        if token.l8_polarity == 'DIRECT':
            token.l12_unified_state = "COHERENT"
        else:
            token.l12_unified_state = "ACTIVE"

    def _generate_hex_id(self, token: FMLToken) -> str:
        """
        Generate deterministic hex ID from token layers.

        Format: BBMMSSEE
            BB = Basin (00-82)
            MM = Morton low 8 bits
            SS = L6 state (00-99)
            EE = Energy + polarity encoded
        """
        bb = token.l1_basin & 0xFF
        mm = token.l3_morton & 0xFF
        ss = token.l6_state & 0xFF

        # Encode energy (0-15) + polarity bit (0/1) into EE
        energy_nibble = int(token.l5_energy * 15) & 0x0F
        polarity_bit = 0 if token.l8_polarity == 'DIRECT' else 1
        ee = (energy_nibble << 4) | (polarity_bit << 3) | (token.l2_semantic & 0x07)

        return f"{bb:02X}{mm:02X}{ss:02X}{ee:02X}"

    # ========================================================================
    # MAIN ENCODE METHOD
    # ========================================================================

    def encode(self, text: str) -> FMLToken:
        """
        Encode text into a deterministic 9-layer token.

        Same input ALWAYS produces same output.

        Args:
            text: Input text to encode

        Returns:
            FMLToken with all 9 layers computed
        """
        # Tokenize
        tokens = self._tokenize(text)
        terms = self._extract_terms(text)

        # Create token
        token = FMLToken(text=text)

        # L1 + L7: Basin and wavelength (computed together)
        token.l1_basin, token.l7_wavelength = self._compute_l1_basin(terms)

        # L2: Semantic class
        token.l2_semantic = self._compute_l2_semantic(tokens)
        token.l2_semantic_name = L2_NAMES[token.l2_semantic]

        # L3: Morton position
        token.l3_x, token.l3_y, token.l3_z, token.l3_morton = self._compute_l3_morton(
            terms, token.l7_wavelength
        )

        # L4: K5xK11
        token.l4_k5, token.l4_k11, token.l4_state = self._compute_l4_k5k11(
            token.l3_x, token.l3_y, token.l3_z
        )

        # L5: Energy
        token.l5_energy = self._compute_l5_energy(text, terms)

        # L6: Emotion
        token.l6_state, token.l6_commitment, token.l6_focus, token.l6_stability = \
            self._compute_l6_emotion(tokens, text)

        # L8: Polarity
        token.l8_polarity, token.l8_ilogic = self._compute_l8_polarity(tokens, text)

        # L9: Membrane
        token.l9_membrane = self._compute_l9_membrane()

        # Generate hex ID
        token.hex_id = self._generate_hex_id(token)

        # Compute weights (mass, energy, coupling) from L1-L9
        self._compute_weights(token)

        # Compute exterior (L10-L12) from hex_id and position
        self._compute_exterior(token)

        return token

    def encode_batch(self, texts: List[str]) -> List[FMLToken]:
        """Encode multiple texts."""
        return [self.encode(t) for t in texts]


# ==============================================================================
# CONVENIENCE FUNCTIONS
# ==============================================================================

_encoder: Optional[FMLEncoder] = None


def get_encoder(membrane_id: int = 0) -> FMLEncoder:
    """Get or create singleton encoder instance."""
    global _encoder
    if _encoder is None or _encoder.membrane_id != membrane_id:
        _encoder = FMLEncoder(membrane_id=membrane_id)
    return _encoder


def encode_text(text: str, membrane_id: int = 0) -> FMLToken:
    """Convenience function to encode text."""
    return get_encoder(membrane_id).encode(text)


def encode_to_dict(text: str, membrane_id: int = 0) -> Dict:
    """Encode and return as dict."""
    return encode_text(text, membrane_id).to_dict()


def get_basin_for_text(text: str) -> int:
    """Get basin for text (L1)."""
    return encode_text(text).l1_basin


def get_wavelength_for_text(text: str) -> float:
    """Get wavelength for text (L7)."""
    return encode_text(text).l7_wavelength


def get_polarity_for_text(text: str) -> str:
    """Get polarity for text (L8)."""
    return encode_text(text).l8_polarity


# Add run method to FMLEncoder
def _fml_encoder_run(self, command: str, *args, as_json: bool = False):
    """Run a shell command (CLI interface)."""
    import json
    commands = {
        'status': lambda: {
            'hex_id': '0BF1DD10',
            'vocab_size': self.vocab_size,
            'term_ranks_count': len(self.term_ranks),
            'membrane_id': self.membrane_id,
            'l2_classes': L2_NAMES,
        },
        'encode': lambda: self.encode(args[0] if args else '').to_dict(),
        'batch': lambda: [t.to_dict() for t in self.encode_batch(list(args))],
        'compact': lambda: self.encode(args[0] if args else '').to_compact(),
        'basin': lambda: get_basin_for_text(args[0] if args else ''),
        'wavelength': lambda: get_wavelength_for_text(args[0] if args else ''),
        'polarity': lambda: get_polarity_for_text(args[0] if args else ''),
    }
    if command in commands:
        result = commands[command]()
        if as_json and isinstance(result, (dict, list)):
            return json.dumps(result, indent=2, default=str)
        return result
    return {'error': f'Unknown command: {command}', 'available': list(commands.keys())}

# Attach to class
FMLEncoder.run = _fml_encoder_run
