"""Frustration meter (voice.frustration): the simmer→overflow curve, pure logic.

What's under test, without any audio or LLM:
  1. the interface main.py depends on (level/mood_tag/attitude/exploded/reasons)
  2. ordinary varied talk stays composed forever (no drift-into-anger)
  3. repeats climb the meter and the stages progress in order, never backwards
  4. time cools it — one bad minute is forgotten, not carried for an hour
  5. appreciation vents it; overflow is EARNED (several asks), rare in a mixed
     session, and refractory right after a blow-up (one tantrum per scene)
  6. the meter reads the prosody tone note — shouting heats it faster

Run: `python -m pytest test_frustration.py`  (or standalone: `python test_frustration.py`).
"""

import os

from voice.frustration import FrustrationMeter, FrustState

_STAGE_ORDER = ["composed", "curt", "clipped", "snapping", "seething",
                "overflow"]


class Clock:
    """Deterministic monotonic time — every update passes `now` explicitly."""

    def __init__(self) -> None:
        self.t = 1000.0

    def tick(self, s: float) -> float:
        self.t += s
        return self.t


def _run(meter, clock, text, gap, **kw) -> FrustState:
    return meter.update(text, now=clock.tick(gap), **kw)


# --- 1. interface ------------------------------------------------------------

def test_interface_fields_present():
    m, c = FrustrationMeter(), Clock()
    st = _run(m, c, "hello there jarvis", 0.0)
    for f in ("level", "mood_tag", "attitude", "exploded", "reasons", "stage"):
        assert hasattr(st, f), f"FrustState must expose .{f}"
    assert isinstance(st.level, float) and isinstance(st.reasons, list)
    assert st.mood_tag in ("calm", "dry", "annoyed", "urgent")
    # positional call exactly as main.py makes it must keep working
    assert m.update("open chrome").mood_tag in ("calm", "dry", "annoyed",
                                                "urgent")


def test_kill_switch():
    os.environ["FRUSTRATION"] = "0"
    try:
        m = FrustrationMeter()
        st = m.update("same thing same thing same thing")
        assert st.level == 0.0 and st.mood_tag == "calm"
        assert st.attitude is None and not st.exploded
    finally:
        os.environ.pop("FRUSTRATION", None)


# --- 2. ordinary talk stays composed -----------------------------------------

def test_varied_chat_never_heats():
    m, c = FrustrationMeter(), Clock()
    lines = [
        "good morning jarvis how are you today",
        "what's the weather looking like",
        "remind me about the meeting with saad",
        "did the scraper finish the riyadh run",
        "play some music please",
        "what do you think of the new dashboard",
        "tell me a joke about programmers",
        "how long until the deploy is done",
    ]
    last = None
    for i in range(40):
        last = _run(m, c, lines[i % len(lines)] + f" variant {i}", 15.0)
        assert not last.exploded
    assert last.stage == "composed" and last.attitude is None
    assert last.level < 0.3


# --- 3. repeats climb, stages progress in order -------------------------------

def test_repeats_climb_and_stages_progress():
    m, c = FrustrationMeter(), Clock()
    seen: list[str] = []
    exploded_at = None
    for i in range(8):
        st = _run(m, c, "when is the bloomwell deadline", 20.0)
        seen.append(st.stage)
        if st.exploded:
            exploded_at = i
            break
    # it climbs through real stages, in order, never regressing pre-blow-up
    idx = [_STAGE_ORDER.index(s) for s in seen]
    assert idx == sorted(idx), f"stages must not regress while heating: {seen}"
    assert "curt" in seen, "the first simmer stage should be visited"
    assert exploded_at is not None, "sustained identical asks must overflow"
    assert exploded_at >= 3, f"overflow too cheap — blew on ask {exploded_at+1}"


def test_attitude_texts_differ_per_stage():
    m, c = FrustrationMeter(), Clock()
    hints: dict[str, str] = {}
    for _ in range(8):
        st = _run(m, c, "open the ultron dashboard now", 4.0)
        if st.attitude:
            hints[st.stage] = st.attitude
        if st.exploded:
            break
    assert len(hints) >= 2, "should pass through at least two simmer stages"
    texts = list(hints.values())
    assert len(set(texts)) == len(texts), "each stage needs its own voice"
    # hints carry the WHY so the brain's grief is specific, not generic
    assert any("(" in t for t in texts)


def test_third_ask_stings_more_than_second():
    m1, c1 = FrustrationMeter(), Clock()
    _run(m1, c1, "what time is the flight", 10.0)
    a = _run(m1, c1, "what time is the flight", 10.0)
    b = _run(m1, c1, "what time is the flight", 10.0)
    rise_2nd = a.level          # after the first repeat
    rise_3rd = b.level - a.level * 0.5 ** (10.0 / 100.0)
    assert rise_3rd > rise_2nd - a.level or "asked 3x" in b.reasons


# --- 4. cooling ---------------------------------------------------------------

def test_time_cools_it_down():
    m, c = FrustrationMeter(), Clock()
    for _ in range(3):
        st = _run(m, c, "check the scraper status", 5.0)
        st = _run(m, c, "check the scraper status", 5.0)
    assert st.level > 0.4, "sanity: the pestering actually heated it"
    calm = _run(m, c, "good morning jarvis lovely day", 600.0)
    assert calm.level < 0.2 and calm.stage == "composed"
    assert calm.attitude is None, "ten quiet minutes must fully forgive"


def test_appreciation_vents():
    m, c = FrustrationMeter(), Clock()
    for _ in range(2):
        st = _run(m, c, "restart the dashboard service", 8.0)
        st = _run(m, c, "restart the dashboard service", 8.0)
    hot = st.level
    assert hot > 0.35
    st = _run(m, c, "thanks jarvis, brilliant work as always", 10.0)
    assert st.level < hot - 0.2, "a thank-you should visibly vent the tension"
    assert "appreciated" in st.reasons


# --- 5. overflow: earned, rare, refractory -------------------------------------

def test_overflow_needs_a_hot_reason():
    # drift + menial streaks alone must never detonate, no matter how long
    m, c = FrustrationMeter(), Clock()
    verbs = ["open", "close", "play", "pause", "mute", "check", "run", "stop"]
    for i in range(120):
        st = _run(m, c, f"{verbs[i % len(verbs)]} thing{i}", 7.0)
        assert not st.exploded, "barked orders alone must never cause a tantrum"
    assert st.level <= 0.97


def test_overflow_rare_in_mixed_session():
    m, c = FrustrationMeter(), Clock()
    blows = 0
    chat = [
        "how is the riyadh scrape going",
        "open chrome",
        "what should i eat tonight",
        "send the report to elie",
        "what do you think about the new voice",
        "play some jazz",
    ]
    for i in range(60):
        text = chat[i % len(chat)]
        gap = 12.0 + (i % 5) * 4
        if i in (20, 21, 40, 41):        # a couple of honest repeat moments
            text, gap = "did the email go out", 8.0
        st = _run(m, c, text + ("" if i in (20, 21, 40, 41) else f" {i}"), gap)
        blows += st.exploded
    assert blows == 0, f"a normal hour of use must not include {blows} tantrums"


def test_refractory_one_tantrum_per_scene():
    m, c = FrustrationMeter(), Clock()
    blows = 0
    for _ in range(12):                   # relentless hammering, 4s apart
        st = _run(m, c, "why is it not working", 4.0)
        blows += st.exploded
    assert blows == 1, f"kept hammering inside the cooldown: {blows} blow-ups"
    assert st.level <= 0.97, "post-blow-up pestering parks at seething, capped"


def test_reset_after_blowup_is_calm_side():
    m, c = FrustrationMeter(), Clock()
    st = None
    for _ in range(8):
        st = _run(m, c, "answer me right now", 4.0)
        if st.exploded:
            break
    assert st is not None and st.exploded
    assert st.mood_tag == "urgent" and st.attitude is None
    assert st.level < 0.3, "catharsis: the blow-up resets below the curt line"


# --- 6. tone: shouting heats it faster ------------------------------------------

def test_yelling_heats_faster():
    quiet, cq = FrustrationMeter(), Clock()
    shouty, cs = FrustrationMeter(), Clock()
    for _ in range(3):
        sq = _run(quiet, cq, "fix the build already", 10.0)
        ss = _run(shouty, cs, "fix the build already", 10.0, tone="loud, fast")
    assert ss.level > sq.level + 0.15, "being shouted at must heat it faster"
    assert "yelling" in ss.reasons


def test_voice_note_in_text_is_read_and_stripped():
    m, c = FrustrationMeter(), Clock()
    st = _run(m, c, "come on answer me\n\n(voice: loud, urgent — read his "
                    "tone; don't parrot it)", 10.0)
    assert "yelling" in st.reasons
    # the note's words must not pollute the repeat check next turn
    st2 = _run(m, c, "read his tone loud urgent parrot", 10.0)
    assert "near-exact repeat" not in st2.reasons


def test_laughter_vents():
    grim, cg = FrustrationMeter(), Clock()
    jolly, cj = FrustrationMeter(), Clock()
    for _ in range(2):
        g = _run(grim, cg, "do the thing again", 8.0)
        j = _run(jolly, cj, "do the thing again", 8.0, tone="laughing")
    assert j.level < g.level, "laughing along must cool the meter"


if __name__ == "__main__":  # standalone runner (repo convention: python test_x.py)
    import traceback
    fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
    fails = 0
    for fn in fns:
        try:
            fn()
            print(f"PASS  {fn.__name__}")
        except Exception:  # noqa: BLE001
            fails += 1
            print(f"FAIL  {fn.__name__}")
            traceback.print_exc()
    print(f"\n{len(fns) - fails}/{len(fns)} passed")
    raise SystemExit(1 if fails else 0)
