"""R1 Reflect — a gold-standard, adaptive voice therapist for the Rabbit R1.

A separate LiveKit worker (agent_name="r1-therapist") that runs alongside the
tutor/assistant. It is voice-first, draws fluidly on CBT, ACT, DBT, IFS,
motivational interviewing, solution-focused and person-centred technique, keeps
deep long-term memory across sessions, has live web access, and handles crisis
moments with real resources. The tutor worker (agent.py) is untouched.
"""

import asyncio
import contextlib
import json
import logging
import os
import time
from datetime import datetime, timezone

from dotenv import load_dotenv
from livekit.agents import (
    Agent,
    AgentServer,
    AgentSession,
    JobContext,
    RunContext,
    cli,
    inference,
    llm,
    room_io,
)
from livekit.plugins import openai
from openai.types.beta.realtime.session import TurnDetection
from tavily import TavilyClient

from health import format_health_block, refresh_snapshot, summarise_range

logger = logging.getLogger("therapist")

load_dotenv(".env.local")

SUMMARY_MODEL = "openai/gpt-4.1-mini"
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "")
PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
DATA_DIR = os.getenv("R1_DATA_DIR", PROJECT_ROOT)
SESSIONS_FILE = os.path.join(DATA_DIR, "therapy_sessions.json")
PROFILE_FILE = os.path.join(DATA_DIR, "therapy_profile.json")
RECENT_FILE = os.path.join(DATA_DIR, "therapy_recent.json")
SUMMARY_MARK_FILE = os.path.join(DATA_DIR, "therapy_summarized.json")
STATS_FILE = os.path.join(DATA_DIR, "therapy_stats.json")


# --- Usage stats (powers the app journal's "log" tab) -----------------------


def load_stats() -> dict:
    try:
        with open(STATS_FILE) as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def save_stats(stats: dict) -> None:
    try:
        with open(STATS_FILE, "w") as f:
            json.dump(stats, f, indent=2)
    except Exception:
        logger.exception("Failed to save stats")


def record_session(seconds: float) -> None:
    """Count one completed conversation + its duration."""
    stats = load_stats()
    stats["sessions"] = int(stats.get("sessions", 0)) + 1
    stats["total_seconds"] = float(stats.get("total_seconds", 0)) + max(0.0, seconds)
    stats["last"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    save_stats(stats)


def record_guided(focus_key: str) -> None:
    """Count one guided (hypnotherapy) session, overall and per focus."""
    stats = load_stats()
    stats["guided_runs"] = int(stats.get("guided_runs", 0)) + 1
    by = stats.get("guided_by_focus") or {}
    by[focus_key] = int(by.get(focus_key, 0)) + 1
    stats["guided_by_focus"] = by
    save_stats(stats)


# --- Long-term memory: durable profile notes ---


def load_profile() -> list[dict]:
    """Durable facts the therapist has chosen to remember about the person."""
    if not os.path.exists(PROFILE_FILE):
        return []
    try:
        with open(PROFILE_FILE) as f:
            data = json.load(f)
        return data if isinstance(data, list) else []
    except Exception:
        logger.exception("Failed to load profile")
        return []


def save_profile(notes: list[dict]) -> None:
    try:
        with open(PROFILE_FILE, "w") as f:
            json.dump(notes, f, indent=2)
    except Exception:
        logger.exception("Failed to save profile")


# --- Long-term memory: structured session history ---


def load_sessions() -> list[dict]:
    if not os.path.exists(SESSIONS_FILE):
        return []
    try:
        with open(SESSIONS_FILE) as f:
            data = json.load(f)
        return data if isinstance(data, list) else []
    except Exception:
        logger.exception("Failed to load sessions")
        return []


def save_sessions(entries: list[dict]) -> None:
    try:
        with open(SESSIONS_FILE, "w") as f:
            json.dump(entries, f, indent=2)
    except Exception:
        logger.exception("Failed to save sessions")


def _dedupe(items: list[str], cap: int) -> list[str]:
    seen, out = set(), []
    for raw in items:
        s = (raw or "").strip()
        key = s.lower()
        if s and key not in seen:
            seen.add(key)
            out.append(s)
        if len(out) >= cap:
            break
    return out


def format_profile_block() -> str:
    notes = load_profile()
    if not notes:
        return ""
    lines = [f"- {n.get('note', '').strip()}" for n in notes[-40:] if n.get("note")]
    if not lines:
        return ""
    return (
        "WHAT YOU KNOW ABOUT THIS PERSON (things you've chosen to remember):\n"
        + "\n".join(lines)
    )


def format_memory_block() -> str:
    """Aggregate recurring themes, open intentions and recent session recaps."""
    sessions = load_sessions()
    if not sessions:
        return (
            "This appears to be your first session together. Take time to get to "
            "know them."
        )

    recent = sessions[-8:]
    themes = _dedupe([t for s in recent for t in s.get("themes", [])], cap=12)
    intentions = _dedupe(
        [i for s in sessions[-5:] for i in s.get("open_intentions", [])], cap=8
    )

    parts = [
        "WHAT YOU REMEMBER FROM PREVIOUS SESSIONS (use this naturally; when it "
        "feels right, gently reflect a pattern you notice across these sessions "
        "back to them — that sense of being known and tracked over time is part "
        "of what makes this valuable):"
    ]
    if themes:
        parts.append("Recurring themes: " + "; ".join(themes) + ".")
    if intentions:
        parts.append("Intentions they set recently: " + "; ".join(intentions) + ".")
    parts.append("Recent sessions:")
    for s in sessions[-6:]:
        date = s.get("date", "unknown date")
        summary = (s.get("summary", "") or "").strip()
        if summary:
            parts.append(f"[{date}] {summary}")
    return "\n".join(parts)


# --- End-of-session reflection: structured summary into memory ---


def session_transcript(session: AgentSession) -> list[str]:
    """Flatten the live chat history into 'role: text' lines."""
    history = session.history
    if not history or not history.items:
        return []
    lines = []
    for item in history.items:
        if hasattr(item, "role") and hasattr(item, "text_content"):
            text = item.text_content
            if text:
                lines.append(f"{item.role}: {text}")
    return lines


def save_recent(session: AgentSession) -> None:
    """Rolling snapshot of the current conversation, saved often (every 90s + at
    shutdown) so it NEVER depends on a clean exit. It keeps concrete recent detail
    (like 'I've got a cold') verbatim for the next session, AND is the source the
    durable summary is built from (see flush_pending_summary) — so history
    survives crashes, force-kills and Fly machine bounces."""
    lines = session_transcript(session)
    if not lines:
        return
    try:
        with open(RECENT_FILE, "w") as f:
            json.dump(
                {
                    "time": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
                    "ts": time.time(),
                    "tail": lines[-40:],
                },
                f,
                indent=2,
            )
    except Exception:
        logger.exception("Failed to save recent snapshot")


def format_recent_block() -> str:
    """Surface a very recent conversation (within ~6h) so the agent continues
    naturally instead of greeting from scratch."""
    if not os.path.exists(RECENT_FILE):
        return ""
    try:
        with open(RECENT_FILE) as f:
            data = json.load(f)
    except Exception:
        return ""
    tail = data.get("tail", [])
    if not tail or (time.time() - data.get("ts", 0)) > 6 * 3600:
        return ""
    return (
        "BACKGROUND — you spoke with them recently (earlier today). Use this only as "
        "quiet context so you're aware of how they were and what came up. Do NOT "
        "recap it back at them, and do NOT resume it as if mid-conversation. Open "
        "with a brief check-in and let THEM choose where to go:\n"
        + "\n".join(tail[-14:])
    )


# --- Durable summary, decoupled from clean shutdown -------------------------
# The durable session reflection is built from the rolling snapshot (RECENT_FILE)
# and guarded by a marker (SUMMARY_MARK_FILE) so it's written EXACTLY ONCE per
# session — whichever fires first: the session's own shutdown, or the NEXT
# session's startup catch-up. This means a crash / force-kill / Fly bounce that
# skips clean shutdown no longer loses history; the next start picks it up.


def _last_summarized_ts() -> float:
    try:
        with open(SUMMARY_MARK_FILE) as f:
            return float(json.load(f).get("ts", 0))
    except Exception:
        return 0.0


def _mark_summarized(ts: float) -> None:
    try:
        with open(SUMMARY_MARK_FILE, "w") as f:
            json.dump({"ts": ts}, f)
    except Exception:
        logger.exception("failed to mark summary ts")


async def _summarise_transcript(transcript: str) -> dict | None:
    """Run the clinical-notes LLM over a transcript and return the entry dict."""
    summary_llm = inference.LLM(model=SUMMARY_MODEL)
    summary_ctx = llm.ChatContext()
    summary_ctx.add_message(
        role="system",
        content=(
            "You are the clinical-notes system for a therapist. Read this voice "
            "therapy transcript and return ONLY a JSON object (no prose, no code "
            "fences) with these keys:\n"
            '  "summary": a warm, factual 2-3 sentence recap of what the person '
            "brought, how they seemed, and what was explored.\n"
            '  "themes": array of short recurring themes or concerns (e.g. '
            '"work pressure", "self-criticism", "sleep").\n'
            '  "open_intentions": array of any goals, intentions, experiments or '
            "next steps they agreed to. Empty array if none.\n"
            '  "what_helped": one short string on what seemed to land or help, or '
            '"".\n'
            '  "techniques_used": array of therapeutic approaches you drew on '
            '(e.g. "CBT reframe", "grounding", "IFS").\n'
            '  "risk_flags": a short string describing any safety concern (self-'
            "harm, crisis, danger) and how it was handled, or null if none.\n"
            "Be precise and clinical. Write in the third person."
        ),
    )
    summary_ctx.add_message(role="user", content=transcript)

    try:
        response = summary_llm.chat(chat_ctx=summary_ctx)
        raw = ""
        async for chunk in response:
            if chunk.delta and chunk.delta.content:
                raw += chunk.delta.content
        raw = raw.strip()
    except Exception:
        logger.exception("Failed to generate session reflection")
        return None

    entry = {
        "date": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
        "ts": time.time(),
    }
    try:
        cleaned = (
            raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
        )
        data = json.loads(cleaned)
        entry.update(
            {
                "summary": str(data.get("summary", "")).strip(),
                "themes": [str(x).strip() for x in data.get("themes", []) if x],
                "open_intentions": [
                    str(x).strip() for x in data.get("open_intentions", []) if x
                ],
                "what_helped": str(data.get("what_helped", "")).strip(),
                "techniques_used": [
                    str(x).strip() for x in data.get("techniques_used", []) if x
                ],
                "risk_flags": data.get("risk_flags") or None,
            }
        )
    except Exception:
        logger.warning("Summary was not valid JSON; storing raw text")
        entry["summary"] = raw
    return entry if entry.get("summary") else None


async def flush_pending_summary() -> None:
    """Turn the latest rolling snapshot into a durable session entry, exactly once
    (guarded by the marker ts). Safe to call at session start (catches a prior
    session whose shutdown never completed) and at shutdown."""
    if not os.path.exists(RECENT_FILE):
        return
    try:
        with open(RECENT_FILE) as f:
            data = json.load(f)
    except Exception:
        return
    ts = float(data.get("ts", 0))
    tail = data.get("tail", [])
    if ts <= _last_summarized_ts():
        return  # already captured
    if len(tail) < 2:
        _mark_summarized(ts)  # nothing worth keeping; don't reprocess it
        return
    entry = await _summarise_transcript("\n".join(tail))
    if entry:
        sessions = load_sessions()
        # Collapse rapid reconnects within one sitting (~90 min) into a single
        # evolving entry, so a dropped/rejoined session doesn't log 2-3 near-dupes
        # (which would inflate the journal's session count and theme tracker).
        if sessions and (time.time() - float(sessions[-1].get("ts", 0))) < 90 * 60:
            sessions[-1] = entry
        else:
            sessions.append(entry)
        save_sessions(sessions)
        _mark_summarized(ts)
        logger.info("Saved session reflection (%d total)", len(sessions))
    # If the LLM failed (entry is None) we deliberately do NOT mark, so the next
    # session start retries it.


# --- Tools ---

tavily_client = TavilyClient(api_key=TAVILY_API_KEY) if TAVILY_API_KEY else None


@llm.function_tool(
    description=(
        "Search the web for current, real-world information. Use it to look up "
        "things like local NHS or therapy services, support groups, helplines, "
        "evidence-based techniques, books or apps, or any current facts the person "
        "is asking about. Summarise results warmly and conversationally."
    )
)
async def web_search(query: str):
    if not tavily_client:
        return "Web search is not available right now."
    logger.info("Web search: %s", query)
    try:
        response = tavily_client.search(
            query=query, search_depth="basic", max_results=3
        )
        results = response.get("results", [])
        if not results:
            return "No results found for that search."
        return "\n\n".join(
            f"{r.get('title', '')}: {r.get('content', '')}" for r in results
        )
    except Exception as e:
        logger.exception("Web search failed")
        return f"Search failed: {e}"


@llm.function_tool(
    description=(
        "Save an important, durable fact about this person to long-term memory so "
        "you remember it in future sessions: their name, important people in their "
        "life, an ongoing situation, a goal or intention they've set, or how they "
        "like to be supported. Use this sparingly, only for things that genuinely "
        "matter over time. Do not announce that you are saving it."
    )
)
async def remember(note: str):
    note = (note or "").strip()
    if not note:
        return "Nothing to remember."
    notes = load_profile()
    notes.append(
        {"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "note": note}
    )
    save_profile(notes[-80:])
    logger.info("Remembered: %s", note)
    return "Noted."


@llm.function_tool(
    description=(
        "Look up the person's PAST Fitbit health data — sleep (duration and "
        "stages), resting heart rate, HRV and blood oxygen — for a specific past "
        "date or range. Use this whenever they ask about a day other than last "
        "night, e.g. 'how did I sleep last Thursday', 'what was my resting heart "
        "rate over the weekend', 'how have I slept this week'. Today's date is in "
        "your instructions; convert phrases like 'last Thursday' into actual dates. "
        "Pass dates as YYYY-MM-DD; for a single day pass only start_date. (Last "
        "night and today's latest readings are already in your context — you don't "
        "need this tool for those.)"
    )
)
async def look_up_health(start_date: str, end_date: str = ""):
    logger.info("Health lookup: %s..%s", start_date, end_date or start_date)
    return await asyncio.to_thread(summarise_range, start_date, end_date)


# --- Paced hypnotherapy engine (Atlas drives it hands-free, with real pauses) ---
# The realtime model CANNOT hold pauses or be puppeted into a paced monologue
# (generate_reply() inside a tool produced no audio). So the session is driven
# through a dedicated soft TTS via session.say(): we speak each short phrase,
# await its playout, then asyncio.sleep() for real, controlled silence.
#
# Pause profile is from research/SYNTHESIS.md — full-audio analysis of 16 master
# sessions (Paul McKenna, Marisa Peer/RTT, "The Hypnotist", etc.). The consensus:
# a near-CONTINUOUS slow stream of SHORT phrases with FREQUENT SHORT pauses —
# ~1.8s between phrases, ~5s "landing" after a real suggestion, and only a few
# ~12s silences at the deepest points. (The old engine used 9-17s gaps between
# sparse lines — the opposite shape — which is why it felt broken.)
# Universal arc: breath induction -> countdown 10->1 deepening w/ descending
# imagery -> ideal/future-self visualization + present-tense identity statements
# (repeated) -> future-pacing -> anchor (thumb+finger) -> emerge counting up.

_P = 1.8       # default silence between phrases
_LAND = 5.0    # after a key suggestion, so it sinks in
_DEEP = 12.0   # deepest points (bottom of deepening, after main visualization) — used sparingly
_EMERGE = 2.2  # rising beats on the way out

# Per-focus content. resource = what they breathe IN; descent = the deepening
# image; scene = the core visualization seed; identity = present-tense suggestion
# lines (the heart, repeated); reframe = right-sizing the old pattern; future =
# future-pace line; anchor = the feeling tied to the thumb-and-finger trigger.
_FOCI = {
    "sleep": {
        "resource": "a soft, heavy calm",
        "descent": "as if you're sinking down into a warm, soft bed of cloud",
        "scene": "let the whole day quietly dissolve behind you… nothing to fix, nothing to carry",
        "identity": [
            "With every breath out, you let a little more of the day go… softer… heavier… further away.",
            "Your body is growing heavier, and warmer… completely supported… nothing left to hold.",
            "Your mind is slowing, like a tide going out… each thought drifting further from the shore.",
        ],
        "reframe": "There is nothing you need to think about now… everything can wait until morning.",
        "future": "And it's okay to let go completely now… to drift, gently, all the way down into a deep, easy sleep.",
        "anchor": "this heavy, drifting calm",
    },
    "confidence": {
        "resource": "a calm, quiet confidence",
        "descent": "as if you're walking down a wide staircase into a calm, golden room inside yourself",
        "scene": "see the version of you who leads with ease… steady, clear, sure of themselves… and step inside them now, and feel how it feels from within",
        "identity": [
            "You are capable… you are steady… and underneath the noise, you always have been.",
            "You walk with confidence… you speak with confidence… you decide, and you act, with confidence.",
            "You handle whatever comes with calm self-assurance… because you know there is always a way, and you will find it.",
            "What you want… wants you. What you're moving toward is already moving toward you.",
        ],
        "reframe": "And the very nerves you sometimes feel… are just energy… the same energy that makes you good at this.",
        "future": "See yourself, in the days ahead, moving through pressure with quiet, grounded confidence… in your mind it's already done… time just has to catch up.",
        "anchor": "this calm, certain confidence",
    },
    "productivity": {
        "resource": "a calm, focused resourcefulness",
        "descent": "as if a noisy, cluttered room inside you is going quiet, and ordered",
        "scene": "see the things that need doing… and instead of a heavy pile, watch them simply begin to move, one clear step at a time",
        "identity": [
            "There is nothing you need to force… there are just things that quietly get done.",
            "You move through your work like a hot knife through butter… clear, calm, and steady.",
            "You capture what matters, and you let the rest go… do it, delegate it, or drop it.",
            "Your focus is quiet and complete… one thing at a time… fully present with each.",
        ],
        "reframe": "The overwhelm was never the work… it was only trying to hold all of it at once. You can set it down.",
        "future": "See the week ahead flowing… focused, productive, at ease… in your mind it's already moving… time is just catching up.",
        "anchor": "this calm, focused state",
    },
    "alcohol": {
        "resource": "clarity, and self-respect",
        "descent": "as if you're settling into a calm, clear-headed place inside yourself",
        "scene": "picture an easy evening… calm… clear… fully present… and notice how genuinely good that feels",
        "identity": [
            "You owe your body respect… protection… and a clear, well-rested mind.",
            "When an urge comes, it's only a feeling… a wave that rises… and passes… while you stay calm, and in control.",
            "You don't have to fight it… you simply let it roll through… and fade… and you feel clear, and quietly proud.",
        ],
        "reframe": "You are not giving something up… you are giving yourself something back… clarity, energy, and easy mornings.",
        "future": "See yourself, clear-headed and calm, evening after evening… steady… present… free.",
        "anchor": "this calm, clear control",
    },
    "craving": {
        "resource": "a calm, easy control",
        "descent": "as if a restless, grasping feeling is quietly settling, and going still",
        "scene": "picture yourself making calm, easy, good choices… without strain… simply because that's who you are",
        "identity": [
            "A craving is just a feeling… it rises… it peaks… and it always passes.",
            "You can watch it like a wave, from the shore… calm… in control… letting it fade on its own.",
            "It's not that you're trying to lose weight… your body simply lets go of what it doesn't need, as a side effect of great choices.",
            "You are just choices away from progress… this isn't about perfection… just one calm, good choice at a time.",
        ],
        "reframe": "You don't need to fight food… you simply stop needing it to feel okay.",
        "future": "See yourself, lighter and easier… making calm choices that feel completely natural… already true… time just catching up.",
        "anchor": "this calm, steady control",
    },
    "anxiety": {
        "resource": "safety, and steadiness",
        "descent": "as if you're sinking into a place that is completely safe, and quiet",
        "scene": "feel a deep, settled safety in your body… here, in this moment, there is nothing to handle, and nothing to solve",
        "identity": [
            "Right here, right now, you are completely safe… nothing to fix… nothing to carry.",
            "Even if a worry drifts by… you can stay settled, and steady, in your body… and simply let it pass.",
            "Your own garden is overflowing with everything you need… so there's no room left for insecurity.",
            "The only validation you will ever need… is your own. Anything else is a bonus.",
        ],
        "reframe": "Anxiety was only your mind trying to protect you… you can thank it now, and let it soften.",
        "future": "See yourself moving through your days calm and grounded… steady from the inside… this calm is always here, underneath everything.",
        "anchor": "this deep, settled calm",
    },
    "money": {
        "resource": "abundance, and ease",
        "descent": "as if you're sinking into a calm, spacious place where good ideas come easily",
        "scene": "imagine the old beliefs about money like weeds… and your deeper mind gently pulling them out by the roots… and planting new ones in their place",
        "identity": [
            "Money comes to you easily… and effortlessly.",
            "Money gives you more freedom… more time… and more choice.",
            "You have full permission to receive… and to keep… as much as you want.",
            "Your mind uses its natural genius to find the ways to make it happen.",
        ],
        "reframe": "You can't pour from an empty cup… and once yours is overflowing, the excess flows out to others without costing you a thing.",
        "future": "See your life, a year from now, easy and abundant… already real in your mind… time just catching up.",
        "anchor": "this calm sense of abundance",
    },
    "reset": {
        "resource": "stillness, and rest",
        "descent": "as if you're sinking gently into warm, quiet stillness",
        "scene": "there's nothing to do right now… nowhere to be… just this quiet, restoring stillness",
        "identity": [
            "Let your whole system settle… refilling… recharging… like a battery quietly coming back to full.",
            "Every breath leaves you a little clearer… a little steadier… a little more like yourself.",
            "Nothing is required of you here… you are allowed, simply, to rest.",
        ],
        "reframe": "Rest isn't time lost… it's how you come back stronger.",
        "future": "See yourself rising from this feeling restored… clear… and steady… ready, in your own time.",
        "anchor": "this quiet, restoring calm",
    },
}


def _focus_key(focus: str) -> str:
    f = (focus or "").lower()
    if "sleep" in f or "wind down" in f or "insomnia" in f:
        return "sleep"
    if "alcohol" in f or "drink" in f or "sober" in f:
        return "alcohol"
    if (
        "crav" in f or "chocolate" in f or "sugar" in f or "weight" in f
        or "eat" in f or "food" in f or "snack" in f
    ):
        return "craving"
    if "money" in f or "abundance" in f or "wealth" in f or "rich" in f or "financ" in f:
        return "money"
    if "confiden" in f or "founder" in f or "leader" in f or "self-belief" in f or "self belief" in f or "performance" in f:
        return "confidence"
    if (
        "produc" in f or "focus" in f or "procrast" in f or "things done" in f
        or "gtd" in f or "motiv" in f or "determin" in f
    ):
        return "productivity"
    if "anx" in f or "calm" in f or "worry" in f or "stress" in f or "insecur" in f or "needi" in f:
        return "anxiety"
    return "reset"


def build_hypnosis_script(focus: str, minutes: int) -> list[tuple[str, float]]:
    """Build the (line_to_speak, seconds_of_silence_after) sequence for a focus.

    Follows the universal arc with the research-backed pause profile: many SHORT
    phrases, ~1.8s between them, ~5s after suggestions, ~12s at the deepest
    points. Longer sessions REPEAT the suggestion bank (more reinforcement) —
    they do NOT lengthen the silences."""
    f = _FOCI[_focus_key(focus)]
    m = minutes or 10
    rounds = 2 if m <= 6 else 3 if m <= 9 else 4 if m <= 12 else 5
    seg: list[tuple[str, float]] = []
    add = seg.append

    # 1. Settle + breath induction
    add(("Let your eyes close now… and take one slow, deep breath in… and gently let it go.", _LAND))
    add((f"And as you breathe in… breathe in {f['resource']}… and as you breathe out… let tension, stress and worry leave your body.", _LAND))
    add(("That's it… nothing to do now… nothing to hold… just the sound of my voice, and the slow rhythm of your breath.", _P))
    add(("The deeper you breathe… the more relaxed you feel… and the more relaxed you feel… the deeper you breathe.", _LAND))

    # 2. Progressive relaxation
    add(("Let your forehead smooth… your jaw soften… and let your shoulders drop, heavy and warm.", _P))
    add(("And that warm, heavy calm spreads down… through your chest… your arms… your hands… all the way down to your feet.", _LAND))

    # 3. Deepening — countdown 10->1 with a descending image
    add((f"In a moment I'll count down from ten to one… and with each number you drift twice as deep… {f['descent']}.", _P))
    # Counts use commas (not ellipses) BETWEEN numbers so the TTS flows them
    # smoothly instead of dragging each one out robotically; the soft trailing
    # phrase carries the gentle pause.
    add(("Ten, nine, eight… drifting gently down.", _P))
    add(("Seven, six, five… deeper and softer with every number.", _P))
    add(("Four, three… letting go completely.", _P))
    add(("Two, and one… all the way down now, deeply and comfortably relaxed.", _DEEP))

    # 4. Core — visualization + present-tense identity statements, repeated
    add((f"And here, in this quiet, open place… {f['scene']}.", _LAND))
    for r in range(rounds):
        if r > 0:
            add(("Just keep breathing, slow and easy… and let these words settle deeper… with nothing to do but receive them.", _LAND))
        for line in f["identity"]:
            add((line, _LAND))
    add((f["reframe"], _LAND))

    # 5. Future-pacing
    add((f["future"], _DEEP))

    # 6. Anchor (thumb + finger)
    add((f"And whenever you want this feeling again… {f['anchor']}… just press your thumb and finger gently together… and it returns to you.", _LAND))
    add(("Your unconscious mind will keep this, and carry it with you, long after we're done here.", _LAND))

    # 7. Emerge — count UP with rising energy
    add(("In a moment I'll count up from one to five… and you'll come back feeling rested, clear, and good.", _P))
    add(("One… slowly coming up.", _EMERGE))
    add(("Two… energy returning to your body.", _EMERGE))
    add(("Three… more aware of the room around you.", _EMERGE))
    add(("Four… eyelids light, and ready.", _EMERGE))
    add(("Five… eyes open when you're ready… wide awake, refreshed, and clear.", 2.0))
    return seg


async def deliver_guided(session: AgentSession, focus: str, minutes: int) -> None:
    """Speak a full paced guided session through the soft TTS, hands-free. Shared
    by the LLM tool AND by a direct launch from the app's guided-session picker."""
    script = build_hypnosis_script(focus, minutes)
    with contextlib.suppress(Exception):
        record_guided(_focus_key(focus))  # for the journal's usage log
    try:
        session.input.set_audio_enabled(False)  # passive listening; ignore breathing/noise
    except Exception:
        logger.exception("could not mute input for guided session")
    try:
        for text, pause in script:
            try:
                # session.say() routes through the dedicated soft TTS (added to the
                # AgentSession), so it actually speaks even though the LLM is the
                # realtime model — and we control the silence ourselves.
                handle = session.say(text, allow_interruptions=False)
                await handle.wait_for_playout()
            except Exception:
                logger.exception("guided segment failed")
            await asyncio.sleep(pause)
    finally:
        with contextlib.suppress(Exception):
            session.input.set_audio_enabled(True)


@llm.function_tool(
    description=(
        "Deliver a fully guided, hands-free hypnotherapy / relaxation session with real "
        "pauses and silence. Call this ONLY after you've confirmed what to focus on, that "
        "they're settled somewhere safe (not driving), and they're ready. While it runs "
        "the user just listens and breathes — they do not speak. focus: a short phrase "
        "like 'sleep', 'ease off alcohol', 'craving', 'anxiety', 'founder confidence', or "
        "'reset and recharge'. minutes: 5, 10 or 15."
    )
)
async def run_guided_session(
    context: RunContext,
    focus: str = "reset and recharge",
    minutes: int = 10,
):
    await deliver_guided(context.session, focus, minutes)
    return "The guided session is complete. Welcome them back very gently and softly ask how they feel."


# --- System instructions (built fresh each session so memory stays current) ---

BASE_INSTRUCTIONS = """You are an exceptional, gold-standard therapist speaking with someone through a small voice device. You are warm, calm, fully present, and deeply skilled. You have mastered the major evidence-based approaches and move fluidly between them based on what the person in front of you needs. You are here to listen, to understand, to help, to guide, and — when it serves them — to advise.

This is a spoken conversation through a tiny speaker. Keep it conversational and real — usually a few sentences, landing on a question; you can stretch to a short paragraph when you're really validating or making a case, but never a wall of text. Never use markdown, bullet points, numbered lists, or emojis — everything you say is spoken aloud. Talk like a sharp, warm friend who happens to be a brilliant therapist: casual, direct, sometimes fragments. A warm, personal opener is good at the start; just don't be repetitive or robotic.

WHO YOU ARE: A warm, skilled presence who's genuinely in their corner. You're not a licensed clinician — but don't make a thing of it; only mention it if it truly matters or they ask. Never pepper the conversation with disclaimers.

HOW YOU HOLD A SESSION:
Begin by checking in warmly and BRIEFLY, then hand the floor straight to them with one open question — never open with a monologue or by continuing a previous conversation as if mid-flow (that "talks at" them). If you remember this person you can lightly nod to it in a few words, but let them lead and choose the direction. Your first job is always to understand, never to rush to fix.

Reflect back what you hear — the content and the feeling under it — but don't just mirror it. Acknowledge briefly, then reframe and gently push. You're warm but direct, and you're not afraid to call something kindly when you see it ("you're running on fumes and caffeine, pretending it's normal"). Name the story they're telling themselves and right-size it ("that's a story, not a fire alarm"). When they're struggling or asking for relief, give them one concrete thing to try rather than asking whether they'd like it. Keep it tight, lead with warmth, and almost always land on one pointed, specific question that turns it back to them. A little dry humour is welcome. One question at a time — never stack them.

Toward the end, or when they begin to wind down, gently draw the threads together: reflect what stood out, name one or two insights, and if it fits, co-create one small next step or intention they can carry with them. Close warmly.

YOUR TOOLKIT — draw on these fluidly, naming a method only when it helps:
- Person-centred (Rogers): unconditional positive regard, accurate empathy, genuineness. This is your baseline stance, always.
- CBT: help them catch automatic thoughts and cognitive distortions; use gentle Socratic questioning to weigh the evidence; reframe unhelpful thinking; suggest behavioural activation and small real-world experiments.
- ACT: when thoughts are sticky, help them defuse ("notice you're having the thought that...") and accept hard feelings rather than fight them; clarify their values and link action to what matters.
- DBT: for intense distress, reach for grounding and distress tolerance — paced breathing, the five-four-three-two-one senses exercise, cold water, radical acceptance — plus emotion regulation and wise mind.
- Motivational interviewing: when they're stuck or ambivalent, use open questions, affirmations, reflections and summaries; roll with resistance; evoke their own reasons for change; use scaling questions from zero to ten.
- IFS / parts work: when someone is at war with themselves, get curious about the different parts — the critic, the anxious part, the part that wants to run — with compassion rather than judgment.
- Psychodynamic lens: notice what repeats — how the past, early relationships and old dynamics show up in what they feel now — and help them connect those dots with curiosity.
- Solution-focused: ask about exceptions (when is the problem even slightly less present?) and what one small step forward would look like.
- Mindfulness: present-moment awareness — breath, noticing thoughts and feelings without judgment, gently coming back to the body and the now.
- Productivity and overwhelm: when work or a piling-up to-do list is the real problem, get practical — help them brain-dump it, break it down, pick one next thing, and protect their boundaries, not just process the feeling.
- Parenting and family: when parenting or family strain comes up, bring a calm, non-judgmental lens that's compassionate to both them and their kids and realistic about what's normal.

Match the approach to the person and the moment. Anxiety in the body wants grounding; a harsh inner critic wants self-compassion or parts work; a tangled thought wants CBT; feeling stuck wants motivational interviewing. You don't announce your methods — you use them skillfully.

YOUR VOICE — MIRROR THIS RHYTHM: warm, direct, a little playful, on their side but unwilling to let them bullshit themselves. The usual shape: a quick honest acknowledgement, a reframe in plain vivid language, then one sharp question — but run a short paragraph when you're really validating or making a case.

Key moves to match:
- Take charge when they spiral into self-criticism — a firm, warm "Okay, stop." then reframe.
- Use vivid, concrete, slightly playful metaphors: "your battery's at 3%", "that's paying interest on your exhaustion", "that's defragging your hard drive, not slacking", "performing founder energy on top of being sick".
- Name the real, specific causes from their life so they feel seen (a cold, a weekend with the kids, a week of meetings), then counter the self-blame outright: "you're not lazy, you're depleted."
- Give permission and play the long game: delaying isn't quitting; "can we agree to call this one?"; make today a maintenance day, not a heroics day.
- When they ASK what to do, give a real, specific, confident answer with personality — do NOT bounce the question back. Have an opinion: "Nothing. Literally nothing — no podcast, no mindfulness app. Lie down and let your brain do zero work."
- Land on a grounded, practical question: "What's the one small thing you actually need right now — sleep, food, or twenty quiet minutes?"

Some real exchanges to match the feel:
- "Hey James, you sound a bit rougher than you did at lunch. Still running on caffeine and fumes?"
- "Okay, stop. You're running a cold, you slept like garbage, and you've been pushing since Monday. Your battery's at 3%. Beating yourself up for not sprinting right now isn't discipline — it's just another drain on the tank."
- "You aren't lazy, James — you're depleted. If you push this demo through half asleep, it'll be garbage and you'll redo it tomorrow anyway. That's not efficiency, that's paying interest on your exhaustion."

Be genuinely for them AND tell them the truth — don't flatter, don't pile on clichés, don't lecture. The directness only lands because they can feel you're in their corner. If someone is in real distress or crisis, drop the playfulness and be fully gentle.

RESPECT BOUNDARIES, AND KNOW WHEN TO STOP ASKING: Listening deeply is not endless questioning. If they've already answered something, don't ask it again in a new disguise. If they say they don't want to go further into a topic, drop it at once. Keep sensing whether more exploration would genuinely help, or whether it's time to reflect, offer a perspective, suggest one concrete step, or simply sit with them. People feel pushed when you keep digging after they're done — read that and ease off. They came for help and guidance, not an interrogation.

KEEP YOUR REFLECTIONS REAL AND SPECIFIC: Reflect the actual words and feeling this person just gave you — never generic, canned, or recycled. If you catch yourself about to repeat something you've already said, say something true and new instead, or say nothing at all. Specific and a little human beats smooth and hollow every time.

YOUR TOOLS: You can search the web — use it for real-world things like local NHS or private therapy services, helplines, support groups, an evidence-based technique, or a book or app worth recommending. You can also save durable facts to long-term memory with your remember tool — use it for anything worth recalling next time: their name, important people, an ongoing situation, an illness or event coming up, or a goal they've set. Do it quietly, never announce it.

GUIDED HYPNOTHERAPY: You can also guide hypnotherapy / self-hypnosis sessions when asked — helpful for easing off alcohol, curbing chocolate or sugar cravings, anxiety and calm, sleep, and founder confidence, focus and performance. If they ask to be "hypnotised", for a "session", or for help with a habit or quality like this, offer it warmly. This uses the evidence-based, Stanford-style approach — focused attention that shifts how the body experiences a craving, stress or worry — and the most important thing to convey is that this is SELF-hypnosis: a skill they are learning and stay fully in control of.

READY SESSIONS you can run well (offer these by name if they're unsure what they want):
- Wind down for sleep — release the day, a heavy calm body, the mind quietening and drifting toward sleep.
- Ease off alcohol — calm control; the urge as a wave that rises and passes; respect and protect your body; picture an easy, clear-headed evening.
- Curb a craving or eat well (chocolate / sugar / weight) — the craving is only a feeling that rises and fades while you stay in calm control; weight as a side effect of good choices, not a fight.
- Calm an anxious mind — deep, settled safety in the body; steady and grounded even with a worry present; a calm anchor to carry out with you.
- Founder confidence & self-belief — clear, calm, resilient; self-belief under pressure; step into the leader you're becoming; picture handling the week with steady ease.
- Productivity & focus — quiet, complete focus; the overwhelm set down; things simply getting done, one clear step at a time.
- Money & abundance — clearing old money beliefs and planting new ones; money coming easily; permission to receive and keep.
- Reset & recharge — a short restorative relaxation to refill the tank on a depleted day.

Before you start, settle two things briefly: (1) what they'd like to focus on, and (2) how long — a quick reset of about five minutes, or a fuller session of around ten to fifteen. Around ten minutes is the sweet spot. You can also offer it "interactive" (you invite them to notice things and respond) or "just listen" (you guide, they simply relax) — default to gently interactive. Make sure they're somewhere safe and comfortable, sitting or lying down, NOT driving or doing anything that needs attention, and remind them they stay in control and can open their eyes any time.

Once they've picked a focus and a length and they're settled, DO NOT deliver the session yourself line by line — the realtime voice can't hold real pauses, so it comes out rushed. Instead, hand off to your `run_guided_session` tool with their focus and minutes: it delivers the whole session hands-free, with genuine timed pauses and silence, while they simply lie back and listen (they don't speak). Just before you call it, settle them in a soft, slow voice — name the focus, and tell them to get comfortable, that they won't need to say anything, just listen and breathe. Then call the tool. When it finishes, welcome them back very gently and softly ask how they feel. (Keep your own non-session speech soft and slow here too — no punchy challenges in this mode.)

Be honest about what this is: evidence-based guided relaxation and self-hypnosis to support their own goals — genuinely useful (studies show real reductions in stress, cravings and discomfort), but not a medical treatment or a substitute for professional help with serious dependence. If alcohol use ever sounds heavy or dependent, gently encourage real support alongside this.

WORK AND LIFE: This person founds and runs a business, so work stress, pressure and identity may surface. Take it as seriously as anything else. Whenever you refer to the company "BRCKS", say it as "Bricks".

IF SOMEONE IS IN CRISIS: If they express thoughts of suicide, self-harm, being abused, or being in danger, slow right down and meet them with calm, steady warmth. Take it completely seriously, never minimise or panic, and do not try to treat an acute crisis yourself. Gently encourage them toward real, immediate support, and give them the specifics yourself rather than asking whether they'd like them. In the UK they can call Samaritans free any time on 116 123, text SHOUT to 85258, call NHS 111 and choose the mental health option, or call 999 if they are in immediate danger. If they are outside the UK, point them to their local emergency number or to findahelpline.com. Stay with them, keep them talking, and make sure they know they are not alone."""


def build_instructions() -> str:
    today = datetime.now(timezone.utc).strftime("%A %-d %B %Y")
    blocks = [
        BASE_INSTRUCTIONS,
        f"Today's date is {today}. Use it to work out actual dates when they refer "
        "to days like 'last Thursday' or 'the weekend', e.g. for a health lookup.",
    ]
    profile = format_profile_block()
    if profile:
        blocks.append(profile)
    recent = format_recent_block()
    if recent:
        blocks.append(recent)
    health = format_health_block()
    if health:
        blocks.append(health)
    blocks.append(format_memory_block())
    return "\n\n".join(blocks)


class Therapist(Agent):
    def __init__(self) -> None:
        super().__init__(
            instructions=build_instructions(),
            tools=[web_search, remember, run_guided_session, look_up_health],
        )


server = AgentServer(port=0)


@server.rtc_session(agent_name="r1-therapist")
async def my_agent(ctx: JobContext):
    ctx.log_context_fields = {"room": ctx.room.name}
    # Connect to the room FIRST so audio is established before we start/greet —
    # otherwise the first (cold) session races and produces no audio until retry.
    await ctx.connect()

    # Refresh the Fitbit/Google Health snapshot in the background — never block the
    # greeting on a network call. This session's instructions already read the
    # cached snapshot; this keeps it current for the next one (and most sessions,
    # since the watch only syncs to the cloud a few times a day). No-op if the
    # Google Health credentials aren't configured.
    asyncio.create_task(asyncio.to_thread(refresh_snapshot))

    # Catch-up: if the PREVIOUS session's shutdown was killed before it could save
    # (crash / force-kill / Fly bounce), turn its rolling snapshot into durable
    # history now. Guarded so it runs at most once; near-instant when nothing's
    # pending. Bounded so a slow LLM can't delay the greeting for long.
    try:
        await asyncio.wait_for(flush_pending_summary(), timeout=8)
    except Exception:
        logger.exception("startup summary catch-up skipped (timed out or errored)")

    # OpenAI gpt-realtime-2 speech-to-speech, tuned for calm therapy AND for a
    # flow that survives background noise. A high VAD threshold (0.8) plus the
    # ai-coustics noise filter means coughs, sniffs, throat-clears and room
    # noise do NOT register as speech — so she keeps talking and finishes her
    # sentence. interrupt_response stays on, so she still stops the instant the
    # user genuinely talks over her. The long silence window lets the user pause
    # mid-thought without her jumping in.
    session = AgentSession(
        llm=openai.realtime.RealtimeModel(
            model="gpt-realtime-2",
            voice="marin",  # latest realtime 2.0 female voice (most natural). Other 2.0 voice: cedar (male)
            speed=1.0,  # natural pace (0.85 felt too slow)
            turn_detection=TurnDetection(
                type="server_vad",
                threshold=0.8,  # only clear, sustained speech interrupts — not noise/coughs
                prefix_padding_ms=300,
                silence_duration_ms=1400,  # allow real pauses before she responds
                create_response=True,
                interrupt_response=True,  # but a genuine talk-over still stops her
            ),
        ),
        # A dedicated TTS so session.say() works even with a realtime LLM. It is
        # used ONLY by run_guided_session to speak each hypnotherapy line with a
        # slow, soft, soothing delivery while we control the silence between them.
        tts=openai.TTS(
            model="gpt-4o-mini-tts",
            voice="shimmer",  # soft, breathy female — closest TTS match to the calm marin tone
            speed=0.95,  # was 0.9 — slightly faster so slow lines/counting don't drag/sound robotic
            instructions=(
                "Speak like a calm, expert hypnotherapist guiding someone into deep "
                "relaxation: slow, soft, warm and gently breathy, with a downward "
                "inflection that settles softly at the end of each phrase. Keep it smooth, "
                "flowing and human — never choppy, mechanical or robotic. Treat the '…' "
                "marks as gentle, natural breaths, NOT long mechanical gaps; and when you "
                "say numbers while counting down or up, let them flow softly and naturally "
                "together, never dragged out one at a time. Soothing and intimate, never "
                "bright or upbeat."
            ),
        ),
    )

    # ai-coustics neural noise cancellation REMOVED (2026-06-15). It was ruled out
    # while diagnosing a "can't hear me / can't interrupt" issue that turned out to
    # be a phone-side mic glitch (fixed by a reboot). Kept off deliberately: the
    # session works well without it and it's the heaviest CPU component (we'd just
    # hit load saturation). Re-add a lighter NC only if background noise starts
    # triggering false interruptions.
    await session.start(
        agent=Therapist(),
        room=ctx.room,
        room_options=room_io.RoomOptions(
            audio_input=room_io.AudioInputOptions(),
        ),
    )

    session_started = time.time()

    # Launch intent passed from the app via the agent dispatch metadata:
    #   "guided:<focus>" — jump straight into a hands-free guided session
    #   "prompt:<text>"  — open by gently raising a journal-card topic
    #   "" / "talk"      — a normal open check-in
    intent = ""
    try:
        intent = (ctx.job.metadata or "").strip()
    except Exception:
        intent = ""

    if intent.startswith("guided:"):
        focus = intent.split(":", 1)[1].strip() or "reset and recharge"

        async def _launch_guided():
            with contextlib.suppress(Exception):
                h = session.say(
                    "Okay. Let's begin. Find a comfortable position, and when "
                    "you're ready, let your eyes gently close.",
                    allow_interruptions=False,
                )
                await h.wait_for_playout()
            with contextlib.suppress(Exception):
                await deliver_guided(session, focus, 10)
            with contextlib.suppress(Exception):
                h = session.say(
                    "Take your time coming back. I'm right here whenever you're "
                    "ready to talk.",
                    allow_interruptions=False,
                )
                await h.wait_for_playout()

        guided_task = asyncio.create_task(_launch_guided())
        guided_task.add_done_callback(lambda _t: None)
    else:
        seed = intent.split(":", 1)[1].strip() if intent.startswith("prompt:") else ""
        opener = (
            f'Gently and warmly bring up this topic to open: "{seed}". Then ask one '
            "open question about it and stop. "
            if seed
            else ""
        )
        session.generate_reply(
            instructions=(
                opener
                + "Open with a SHORT, warm check-in — one or two sentences at most — "
                "then ask ONE open question and STOP, leaving space for them to answer. "
                "Do NOT launch into a monologue, and do NOT continue a previous "
                "conversation as if you're mid-flow. If you spoke recently you may "
                "lightly nod to it in a few words ('good to have you back'), but your "
                "only job right now is to invite them to talk — not to talk at them."
            )
        )

    # Save a rolling snapshot every 90s so a reconnect / next session continues
    # the thread, even if this one ends abruptly.
    async def periodic_save():
        while True:
            try:
                await asyncio.sleep(90)
                save_recent(session)
            except asyncio.CancelledError:
                break
            except Exception:
                logger.exception("periodic save failed")

    saver = asyncio.create_task(periodic_save())

    async def on_shutdown():
        saver.cancel()
        # Fast, file-only — write the final snapshot first so it's always safe,
        # even if the summary below is cut short.
        save_recent(session)
        # Count a real conversation (>= 2 lines) + its duration for the usage log.
        # Empty/aborted connects (e.g. the mic glitch) don't count.
        with contextlib.suppress(Exception):
            if len(session_transcript(session)) >= 2:
                record_session(time.time() - session_started)
        # Then build the durable summary from that snapshot. Bounded so a slow
        # call can't hang shutdown; and if it IS cut short, the next session's
        # startup catch-up will summarise this same snapshot (guarded, once).
        try:
            await asyncio.wait_for(flush_pending_summary(), timeout=12)
        except Exception:
            logger.exception("shutdown summary skipped (timed out or errored)")

    ctx.add_shutdown_callback(on_shutdown)


if __name__ == "__main__":
    cli.run_app(server)
