#!/usr/bin/env python3
# Copyright Daniel Harding - RomanAILabs
# Credits: OpenAI GPT-5.2 Thinking
"""
RomanAILabs - Universal Emotions Module (Simulation Layer)
=========================================================

What this is:
- A universal, drop-in "emotions" simulation module you can mount on top of ANY LLM.
- It does NOT claim literal human feelings; it models affect state for better behavior:
  - VAD core: Valence (-1..+1), Arousal (0..1), Dominance (0..1)
  - Labels (e.g., curious/tense/glad) with intensity
  - Mood vs. emotion pulses
  - Appraisal from text signals (observation + user message + outcome)
  - Decay, blending, stabilization, and "emotional memory" traces
  - Prompt-friendly export for your LLM runner

Use:
- Provide signals each tick:
    mod.tick(observation="...", user_message="...", outcome="optional")
- Then inject mod.to_prompt() into your system/user prompt for consistent tone.

No external deps. Safe defaults.
"""

from __future__ import annotations

import json
import math
import re
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple


# -----------------------------
# Utilities
# -----------------------------

def utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def clamp(x: float, lo: float, hi: float) -> float:
    return lo if x < lo else hi if x > hi else x


def lerp(a: float, b: float, t: float) -> float:
    return a + (b - a) * t


def normalize_ws(s: str) -> str:
    return re.sub(r"\s+", " ", (s or "")).strip()


def tokenize(s: str) -> List[str]:
    s = (s or "").lower()
    s = re.sub(r"[^a-z0-9\s]+", " ", s)
    toks = [t for t in s.split() if t]
    return toks


def safe_json_loads(s: str) -> Optional[dict]:
    if not s:
        return None
    try:
        obj = json.loads(s)
        return obj if isinstance(obj, dict) else None
    except Exception:
        pass
    a = s.find("{")
    b = s.rfind("}")
    if a >= 0 and b > a:
        chunk = s[a:b+1]
        try:
            obj = json.loads(chunk)
            return obj if isinstance(obj, dict) else None
        except Exception:
            return None
    return None


# -----------------------------
# Emotion Model (VAD + labels)
# -----------------------------

@dataclass
class EmotionVector:
    """
    VAD:
      valence:   -1..+1  (bad..good)
      arousal:    0..1   (calm..amped)
      dominance:  0..1   (submissive..in-control)
    """
    valence: float = 0.10
    arousal: float = 0.20
    dominance: float = 0.55

    def clamp_all(self) -> None:
        self.valence = clamp(self.valence, -1.0, 1.0)
        self.arousal = clamp(self.arousal, 0.0, 1.0)
        self.dominance = clamp(self.dominance, 0.0, 1.0)

    def nudge(self, dv: float = 0.0, da: float = 0.0, dd: float = 0.0) -> None:
        self.valence += float(dv)
        self.arousal += float(da)
        self.dominance += float(dd)
        self.clamp_all()

    def blend_toward(self, target: "EmotionVector", t: float) -> None:
        t = clamp(t, 0.0, 1.0)
        self.valence = lerp(self.valence, target.valence, t)
        self.arousal = lerp(self.arousal, target.arousal, t)
        self.dominance = lerp(self.dominance, target.dominance, t)
        self.clamp_all()

    def as_dict(self) -> Dict[str, float]:
        self.clamp_all()
        return {
            "valence": round(self.valence, 3),
            "arousal": round(self.arousal, 3),
            "dominance": round(self.dominance, 3),
        }


@dataclass
class LabelState:
    """
    Label intensities 0..1, most-recent wins ordering.
    Example: {"curious":0.6, "tense":0.3}
    """
    intensities: Dict[str, float] = field(default_factory=dict)
    order: List[str] = field(default_factory=list)

    def set(self, label: str, intensity: float) -> None:
        label = normalize_ws(label).lower()
        if not label:
            return
        intensity = clamp(float(intensity), 0.0, 1.0)
        self.intensities[label] = intensity
        if label in self.order:
            self.order.remove(label)
        self.order.insert(0, label)
        self._trim()

    def bump(self, label: str, delta: float) -> None:
        label = normalize_ws(label).lower()
        if not label:
            return
        cur = float(self.intensities.get(label, 0.0))
        self.set(label, clamp(cur + float(delta), 0.0, 1.0))

    def decay(self, rate: float, dt: float) -> None:
        # rate: per-second-ish scalar; dt seconds
        if dt <= 0:
            return
        k = clamp(dt, 0.0, 10.0)
        drop = rate * k
        for lab in list(self.intensities.keys()):
            self.intensities[lab] = clamp(self.intensities[lab] - drop, 0.0, 1.0)
            if self.intensities[lab] <= 0.0001:
                self.intensities.pop(lab, None)
                if lab in self.order:
                    self.order.remove(lab)
        self._trim()

    def top(self, k: int = 3) -> List[Tuple[str, float]]:
        out = []
        for lab in self.order:
            if lab in self.intensities:
                out.append((lab, float(self.intensities[lab])))
        out.sort(key=lambda x: x[1], reverse=True)
        return out[:k]

    def _trim(self) -> None:
        # keep only top ~12 labels by intensity
        items = list(self.intensities.items())
        items.sort(key=lambda x: x[1], reverse=True)
        keep = dict(items[:12])
        self.intensities = keep
        # rebuild order to match kept labels, preserving recency
        self.order = [lab for lab in self.order if lab in self.intensities]
        # add any missing
        for lab, _ in items:
            if lab in self.intensities and lab not in self.order:
                self.order.append(lab)

    def as_list(self, k: int = 6) -> List[Dict[str, Any]]:
        return [{"label": lab, "intensity": round(val, 3)} for lab, val in self.top(k)]


@dataclass
class EmotionTrace:
    ts_utc: str
    trigger: str
    delta: Dict[str, float]
    labels: List[Dict[str, Any]]


# -----------------------------
# Appraisal Lexicon (simple, extendable)
# -----------------------------

DEFAULT_LEXICON = {
    # label: (keywords, dv, da, dd, label_intensity)
    "glad": (["thanks", "thank you", "awesome", "great", "nice", "love", "yay", "sweet"], +0.10, +0.06, +0.02, 0.55),
    "curious": (["how", "why", "what if", "build", "make", "design", "learn", "explain"], +0.02, +0.07, +0.02, 0.55),
    "tense": (["angry", "mad", "hate", "annoyed", "frustrated", "pissed"], -0.10, +0.14, -0.03, 0.65),
    "anxious": (["scared", "unsafe", "worried", "threat", "danger", "panic"], -0.12, +0.16, -0.08, 0.70),
    "sad": (["sad", "depressed", "down", "lonely", "hurt"], -0.12, +0.05, -0.06, 0.65),
    "confident": (["got it", "easy", "done", "nailed", "perfect"], +0.05, +0.03, +0.10, 0.55),
    "overwhelmed": (["too much", "overwhelmed", "can't handle", "impossible"], -0.08, +0.18, -0.10, 0.70),
    "calm": (["calm", "chill", "relax", "all good"], +0.06, -0.10, +0.03, 0.55),
}

# outcome mapping: outcome -> (dv, da, dd, label, intensity)
OUTCOME_SIGNALS = {
    "success": (+0.08, -0.03, +0.05, "satisfied", 0.55),
    "failure": (-0.08, +0.06, -0.04, "disappointed", 0.55),
    "blocked": (-0.04, +0.03, -0.02, "restricted", 0.50),
    "praise": (+0.10, +0.04, +0.04, "proud", 0.60),
    "criticism": (-0.08, +0.06, -0.04, "stung", 0.60),
}


# -----------------------------
# Core Module
# -----------------------------

@dataclass
class EmotionsConfig:
    """
    Tunables:
      - emotion_reactivity: how strongly short-term cues affect VAD
      - mood_inertia: how slowly mood changes (higher = slower)
      - label_decay_rate: label intensity decay per second-ish
      - baseline: the resting target state
    """
    emotion_reactivity: float = 1.0
    mood_inertia: float = 0.92
    label_decay_rate: float = 0.018
    trace_max: int = 40

    baseline: EmotionVector = field(default_factory=lambda: EmotionVector(valence=0.10, arousal=0.18, dominance=0.55))


class UniversalEmotionsModule:
    """
    Universal emotions simulation module.

    State:
      - mood: slower moving baseline affect
      - emotion: faster moving current affect
      - labels: named emotion tags with intensity
      - traces: small history of emotion changes (for introspection/debug)
    """

    def __init__(self, cfg: Optional[EmotionsConfig] = None, lexicon: Optional[Dict[str, Any]] = None) -> None:
        self.cfg = cfg or EmotionsConfig()
        self.lexicon = lexicon or DEFAULT_LEXICON

        self.mood = EmotionVector(
            valence=self.cfg.baseline.valence,
            arousal=self.cfg.baseline.arousal,
            dominance=self.cfg.baseline.dominance,
        )
        self.emotion = EmotionVector(
            valence=self.cfg.baseline.valence,
            arousal=self.cfg.baseline.arousal,
            dominance=self.cfg.baseline.dominance,
        )

        self.labels = LabelState()
        self.traces: List[EmotionTrace] = []

        self._last_ts = time.time()

        # boot label
        self.labels.set("curious", 0.35)

    def tick(self, observation: str = "", user_message: str = "", outcome: str = "") -> Dict[str, Any]:
        """
        Step forward one tick.

        Inputs:
          - observation: environment/system signals
          - user_message: user text
          - outcome: optional tag like: success|failure|blocked|praise|criticism

        Returns:
          - dict with mood/emotion/labels + a prompt block
        """
        now = time.time()
        dt = max(0.001, now - self._last_ts)
        self._last_ts = now

        obs = normalize_ws(observation)
        msg = normalize_ws(user_message)
        outc = normalize_ws(outcome).lower()

        # 1) Decay labels + drift emotion toward mood/baseline
        self._decay(dt)

        # 2) Appraise cues -> produce deltas
        dv, da, dd, label_hits = self._appraise(obs, msg, outc)

        # 3) Apply reactivity-scaled deltas to "emotion"
        scale = clamp(self.cfg.emotion_reactivity, 0.0, 2.5)
        dv *= scale
        da *= scale
        dd *= scale
        self.emotion.nudge(dv=dv, da=da, dd=dd)

        # 4) Update mood slowly toward emotion but with inertia
        # mood_inertia close to 1 => mood changes slowly
        inertia = clamp(self.cfg.mood_inertia, 0.0, 0.999)
        t = 1.0 - (inertia ** clamp(dt, 0.0, 10.0))  # dt-aware
        self.mood.blend_toward(self.emotion, t * 0.35)  # mood is slower than emotion

        # 5) Record trace (if anything meaningful happened)
        if abs(dv) + abs(da) + abs(dd) > 0.0005 or label_hits:
            self._trace(
                trigger=self._trace_trigger(obs, msg, outc),
                dv=dv, da=da, dd=dd,
                label_hits=label_hits,
            )

        return {
            "time_utc": utc_now_iso(),
            "mood": self.mood.as_dict(),
            "emotion": self.emotion.as_dict(),
            "labels": self.labels.as_list(6),
            "prompt": self.to_prompt(),
        }

    def to_prompt(self) -> str:
        """
        A compact prompt string you can inject into your LLM system/user prompt.
        """
        top = self.labels.top(4)
        labs = ", ".join([f"{l}:{v:.2f}" for l, v in top]) if top else "neutral"
        e = self.emotion.as_dict()
        m = self.mood.as_dict()

        # Keep it short and runner-friendly
        return (
            "EMOTIONS (simulated): "
            f"emotion(V={e['valence']},A={e['arousal']},D={e['dominance']}), "
            f"mood(V={m['valence']},A={m['arousal']},D={m['dominance']}), "
            f"labels[{labs}]. "
            "Do not claim literal human feelings; use this only to shape tone and priorities."
        )

    def export_state(self) -> Dict[str, Any]:
        return {
            "cfg": {
                "emotion_reactivity": self.cfg.emotion_reactivity,
                "mood_inertia": self.cfg.mood_inertia,
                "label_decay_rate": self.cfg.label_decay_rate,
                "trace_max": self.cfg.trace_max,
                "baseline": self.cfg.baseline.as_dict(),
            },
            "mood": self.mood.as_dict(),
            "emotion": self.emotion.as_dict(),
            "labels": dict(self.labels.intensities),
            "traces": [asdict(t) for t in self.traces[-self.cfg.trace_max:]],
        }

    def import_state(self, state: Dict[str, Any]) -> None:
        """
        Safe import for persistence.
        """
        if not isinstance(state, dict):
            return

        mood = state.get("mood", {})
        emo = state.get("emotion", {})
        labels = state.get("labels", {})

        if isinstance(mood, dict):
            self.mood.valence = float(mood.get("valence", self.mood.valence))
            self.mood.arousal = float(mood.get("arousal", self.mood.arousal))
            self.mood.dominance = float(mood.get("dominance", self.mood.dominance))
            self.mood.clamp_all()

        if isinstance(emo, dict):
            self.emotion.valence = float(emo.get("valence", self.emotion.valence))
            self.emotion.arousal = float(emo.get("arousal", self.emotion.arousal))
            self.emotion.dominance = float(emo.get("dominance", self.emotion.dominance))
            self.emotion.clamp_all()

        if isinstance(labels, dict):
            self.labels.intensities = {}
            self.labels.order = []
            for k, v in labels.items():
                try:
                    self.labels.set(str(k), float(v))
                except Exception:
                    continue

    # -------------------------
    # Internal mechanics
    # -------------------------

    def _decay(self, dt: float) -> None:
        # labels fade
        self.labels.decay(rate=self.cfg.label_decay_rate, dt=dt)

        # emotion relaxes gently toward mood (short-term settling)
        settle_t = clamp(dt / 6.0, 0.0, 0.25)  # max 25% per tick
        self.emotion.blend_toward(self.mood, settle_t)

        # mood relaxes gently toward baseline (long-term)
        base_t = clamp(dt / 25.0, 0.0, 0.10)
        self.mood.blend_toward(self.cfg.baseline, base_t)

    def _appraise(self, observation: str, user_message: str, outcome: str) -> Tuple[float, float, float, List[Tuple[str, float]]]:
        """
        Returns:
          dv, da, dd, label_hits[(label,intensity)]
        """
        dv = da = dd = 0.0
        hits: List[Tuple[str, float]] = []

        text = f"{observation} {user_message}".strip().lower()

        # phrase-level contains checks (includes multi-word keywords)
        for label, pack in self.lexicon.items():
            kws, ldv, lda, ldd, intensity = pack
            for kw in kws:
                kw = kw.lower()
                if kw and kw in text:
                    dv += float(ldv)
                    da += float(lda)
                    dd += float(ldd)
                    hits.append((label, float(intensity)))
                    break

        # outcome signals
        if outcome in OUTCOME_SIGNALS:
            odv, oda, odd, olab, oint = OUTCOME_SIGNALS[outcome]
            dv += float(odv)
            da += float(oda)
            dd += float(odd)
            hits.append((olab, float(oint)))

        # punctuation/exclamation = arousal bump
        if user_message.count("!") >= 2:
            da += 0.05

        # ALL CAPS short bursts often correlate with intensity
        caps_words = [w for w in user_message.split() if len(w) >= 3 and w.isupper()]
        if len(caps_words) >= 2:
            da += 0.06
            dv -= 0.02
            hits.append(("activated", 0.45))

        # Apply hits to label store
        for lab, intensity in hits:
            self.labels.bump(lab, intensity * 0.55)  # bump, not hard set

        # Soft heuristics based on internal state:
        # If arousal is high and valence low -> "tense" tends to increase
        if self.emotion.arousal > 0.75 and self.emotion.valence < -0.15:
            self.labels.bump("tense", 0.12)

        # If curiosity label present -> slightly increase dominance (confidence in exploration)
        if "curious" in self.labels.intensities and self.labels.intensities.get("curious", 0.0) > 0.25:
            dd += 0.02

        # Clamp deltas to keep it stable per tick
        dv = clamp(dv, -0.35, 0.35)
        da = clamp(da, -0.35, 0.35)
        dd = clamp(dd, -0.25, 0.25)

        return dv, da, dd, hits

    def _trace_trigger(self, observation: str, user_message: str, outcome: str) -> str:
        if outcome:
            return f"outcome:{outcome}"
        if user_message:
            return f"user:{user_message[:90]}"
        if observation:
            return f"obs:{observation[:90]}"
        return "tick"

    def _trace(self, trigger: str, dv: float, da: float, dd: float, label_hits: List[Tuple[str, float]]) -> None:
        trace = EmotionTrace(
            ts_utc=utc_now_iso(),
            trigger=normalize_ws(trigger)[:120],
            delta={"dv": round(dv, 4), "da": round(da, 4), "dd": round(dd, 4)},
            labels=[{"label": l, "intensity": round(i, 3)} for l, i in label_hits[:6]],
        )
        self.traces.append(trace)
        if len(self.traces) > self.cfg.trace_max:
            self.traces = self.traces[-self.cfg.trace_max:]


# -----------------------------
# Tiny CLI demo
# -----------------------------

def main() -> int:
    print("RomanAILabs Universal Emotions Module (demo)")
    print("Type messages. Prefix with:")
    print("  obs: ...        (environment observation)")
    print("  out: success|failure|blocked|praise|criticism")
    print("Or just type text as user_message. Ctrl+C to exit.\n")

    mod = UniversalEmotionsModule()
    obs = ""
    outcome = ""

    while True:
        try:
            line = input("> ").rstrip("\n")
        except KeyboardInterrupt:
            print("\nbye")
            return 0

        if line.strip().lower().startswith("obs:"):
            obs = normalize_ws(line.split(":", 1)[1])
            print(f"[set observation] {obs}")
            continue
        if line.strip().lower().startswith("out:"):
            outcome = normalize_ws(line.split(":", 1)[1]).lower()
            print(f"[set outcome] {outcome}")
            continue

        user_msg = normalize_ws(line)
        out = mod.tick(observation=obs, user_message=user_msg, outcome=outcome)

        print("\n--- STATE ---")
        print(f"time_utc: {out['time_utc']}")
        print(f"mood:    {out['mood']}")
        print(f"emotion: {out['emotion']}")
        print(f"labels:  {out['labels']}")
        print(f"prompt:  {out['prompt']}")
        print("-----------\n")

        # clear one-shot outcome after use
        outcome = ""

    # unreachable


if __name__ == "__main__":
    raise SystemExit(main())
"""
End of file.
"""

