"""Jarvis gateway — FastAPI app. Everything runs over one WebSocket.

Endpoints:
  GET  /health   liveness probe (never loads the TTS model).
  WS   /ws       the whole protocol (see APP_PLAN.md §3).

Per-connection flow:
  1. First client frame MUST be ``hello`` with the correct ``auth`` — else the
     socket is closed. ``mode`` is voice | chat_voice | chat.
  2. On ``utterance`` the brain streams the reply; each finished sentence goes
     out as a ``sentence`` frame immediately, and in voice modes its WAV audio
     streams right after (``audio_start`` / binary / ``audio_end``).
  3. ``barge_in`` cancels the in-flight turn cleanly, then sends ``turn_end``.
  4. After each exchange a passive memory save fires (fire-and-forget).

Heavy things (the TTS model) are lazy: importing this module and hitting
/health never loads them, so a chat-only session pays nothing for voice.
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import math
import os
import time
import uuid

from fastapi import FastAPI, WebSocket, WebSocketDisconnect

import brain
import device_tools
import persona
import phone_store
import scheduler
import tools
import tts

VOICE_MODES = {"voice", "chat_voice"}
VALID_MODES = {"voice", "chat_voice", "chat"}
MAX_TURNS = 40  # per-connection history cap (~40 user turns kept)

# Phase-2 phone-power tuning (env-overridable).
DEVICE_TIMEOUT = float(os.environ.get("DEVICE_ACTION_TIMEOUT", "25"))
LOC_DEBOUNCE_SEC = float(os.environ.get("LOC_DEBOUNCE_SEC", "120"))
LOC_DEBOUNCE_M = float(os.environ.get("LOC_DEBOUNCE_M", "120"))
KNOWN_PLACE_M = float(os.environ.get("KNOWN_PLACE_M", "150"))

logger = logging.getLogger("jarvis.gateway")


# ---------------------------------------------------------------------------
# Live-session registry (so the routine scheduler / proactive events can find a
# connected phone to speak through) + the routine scheduler singleton.
# Keyed by the session's user/group; a personal instance has one group but the
# structure supports many.
# ---------------------------------------------------------------------------
SESSIONS: dict[str, set["Session"]] = {}
_SCHED: scheduler.RoutineScheduler | None = None


def register_session(sess: "Session") -> None:
    SESSIONS.setdefault(sess.user, set()).add(sess)


def unregister_session(sess: "Session") -> None:
    peers = SESSIONS.get(sess.user)
    if peers:
        peers.discard(sess)
        if not peers:
            SESSIONS.pop(sess.user, None)


def live_sessions(user: str) -> set["Session"]:
    return set(SESSIONS.get(user, ()))


def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
    """Great-circle distance in metres between two GPS points."""
    r = 6371000.0
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlmb = math.radians(lng2 - lng1)
    a = (math.sin(dphi / 2) ** 2
         + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2)
    return 2 * r * math.asin(math.sqrt(a))


# ---------------------------------------------------------------------------
# Routine dispatch — fired by the scheduler (time) or geofence match (place).
# Builds the spoken content server-side, then pushes a proactive turn if a phone
# is connected; otherwise logs it (FCM push is a later phase).
# ---------------------------------------------------------------------------
async def _build_brief(kind: str) -> str:
    """Assemble a morning-brief body from live data (tasks for v1)."""
    if kind == "tasks":
        try:
            return await tools._tasks_list({})
        except Exception:  # noqa: BLE001
            return "No open tasks."
    return ""


async def _routine_injection(routine: dict) -> str:
    """The synthetic instruction fed to the brain when a routine fires so Jarvis
    delivers it in persona."""
    action = routine.get("action") or {}
    atype = action.get("type")
    if atype == "speak_brief":
        body = await _build_brief(action.get("brief") or "tasks")
        return ("[ROUTINE BRIEF] It's time for Ahmed's scheduled brief "
                f"('{routine.get('name')}'). Deliver it warmly and briefly in "
                "your own voice. Here is the material:\n" + body)
    if atype == "say_text":
        return ("[ROUTINE] Your scheduled routine "
                f"('{routine.get('name')}') has fired. Say this to Ahmed, in "
                f"your own voice: {action.get('text', '')}")
    # run
    return ("[ROUTINE] Your scheduled routine "
            f"('{routine.get('name')}') has fired. Carry out this instruction "
            f"for Ahmed now: {action.get('text', '')}")


async def fire_routine(routine: dict) -> None:
    """Deliver a fired routine to a connected phone, or log it if none is."""
    user = routine.get("group") or tools._group()
    sessions = live_sessions(user)
    injected = await _routine_injection(routine)
    if sessions:
        await next(iter(sessions))._deliver_proactive(injected)
    else:
        # TODO(fcm): no live WS session — push an FCM notification so the phone
        # wakes, connects, and speaks the brief. For now it is only logged.
        logger.info("ROUTINE name=%s fired but no live session; "
                    "TODO(fcm) push", routine.get("name"))


async def _reschedule() -> None:
    if _SCHED is not None:
        await _SCHED.reload()


@contextlib.asynccontextmanager
async def _lifespan(app: FastAPI):
    global _SCHED
    _SCHED = scheduler.RoutineScheduler(
        dispatch=fire_routine,
        list_routines=lambda: phone_store.list_items(phone_store.KIND_ROUTINE))
    tools.set_routine_hook(_reschedule)
    try:
        await _SCHED.start()
    except Exception as e:  # noqa: BLE001 — a scheduler hiccup must not block boot
        logger.warning("routine scheduler failed to start: %s", str(e)[:160])
    try:
        yield
    finally:
        if _SCHED is not None:
            await _SCHED.shutdown()


app = FastAPI(title="Jarvis Gateway", lifespan=_lifespan)


# ---------------------------------------------------------------------------
# Per-turn latency instrumentation. STT→first-sentence isn't visible here (the
# phone does STT), so we log what the server CAN see: utterance received →
# first LLM token → first sentence complete → first audio chunk sent → turn end,
# plus which lane (fast|full) served. Lines are single-line, grep-friendly, and
# always start with "TIMING".
# ---------------------------------------------------------------------------
class _TurnTimer:
    __slots__ = ("id", "t0")

    def __init__(self, turn_id: str) -> None:
        self.id = turn_id
        self.t0 = time.monotonic()

    def ms(self) -> int:
        return int((time.monotonic() - self.t0) * 1000)


def _tlog(turn: _TurnTimer, event: str, **kv: object) -> None:
    extra = "".join(f" {k}={v}" for k, v in kv.items())
    logger.info("TIMING turn=%s ms=%d event=%s%s", turn.id, turn.ms(),
                event, extra)


# ---------------------------------------------------------------------------
# Protocol frame builders — pure, so they're unit-testable without a socket.
# ---------------------------------------------------------------------------
def f_ready(session_id: str) -> dict:
    return {"type": "ready", "session": session_id}


def f_ack() -> dict:
    return {"type": "ack"}


def f_sentence(seq: int, text: str, emotion: str | None) -> dict:
    return {"type": "sentence", "seq": seq, "text": text, "emotion": emotion}


def f_audio_start(seq: int) -> dict:
    return {"type": "audio_start", "seq": seq}


def f_audio_end(seq: int) -> dict:
    return {"type": "audio_end", "seq": seq}


def f_turn_end() -> dict:
    return {"type": "turn_end"}


def f_action(action_id: str, action: str, params: dict) -> dict:
    """A device-action request sent to the phone (see APP_PLAN.md §3b). The phone
    replies with an ``action_result`` carrying the same id."""
    return {"type": "action", "id": action_id, "action": action,
            "params": params}


def f_error(message: str) -> dict:
    return {"type": "error", "message": message}


def f_pong() -> dict:
    return {"type": "pong"}


def _auth_ok(auth: str | None) -> bool:
    """hello-frame gate. If GATEWAY_KEY is unset we allow (local dev)."""
    key = os.environ.get("GATEWAY_KEY", "")
    if not key:
        return True
    return auth == key


@app.get("/health")
async def health() -> dict:
    return {
        "status": "ok",
        "service": "jarvis-gateway",
        "model": os.environ.get("MODEL", "deepseek-v4-pro"),
        "fast_model": os.environ.get("FAST_MODEL", "deepseek-v4-flash"),
        "fast_chat": os.environ.get("FAST_CHAT", "1") != "0",
        "voice_dir_configured": bool(os.environ.get("VOICE_DIR")),
    }


class Session:
    """One WebSocket connection: auth gate, history, brain, turn lifecycle."""

    def __init__(self, ws: WebSocket) -> None:
        self.ws = ws
        self.mode = "voice"
        self.session_id = uuid.uuid4().hex[:12]
        self.user = tools._group()          # namespace this phone belongs to
        self.history: list[dict] = []
        self.system_msg: dict = {"role": "system", "content": ""}
        self.fast_system_msg: dict = {"role": "system", "content": ""}
        self.adapter: brain.LLMAdapter | None = None
        self.fast_adapter: brain.LLMAdapter | None = None
        self.brain: brain.Brain | None = None
        self.turn_task: asyncio.Task | None = None
        self._turn_no = 0
        self._send_lock = asyncio.Lock()
        # device-tool bridge: action id -> Future awaiting the phone's result
        self._pending: dict[str, asyncio.Future] = {}
        # new-location debounce: (lat, lng, monotonic_ts) of the last prompt
        self._last_loc_prompt: tuple[float, float, float] | None = None

    # -- socket I/O (locked so a turn's frames never interleave a pong) -----
    async def _send(self, frame: dict) -> None:
        async with self._send_lock:
            await self.ws.send_json(frame)

    async def _send_bytes(self, data: bytes) -> None:
        async with self._send_lock:
            await self.ws.send_bytes(data)

    async def _recv(self) -> dict | None:
        """Receive one JSON frame. Returns None on disconnect, {} on bad JSON."""
        txt = await self.ws.receive_text()  # raises WebSocketDisconnect on close
        try:
            msg = json.loads(txt)
            return msg if isinstance(msg, dict) else {}
        except ValueError:
            await self._send(f_error("invalid JSON"))
            return {}

    # -- lifecycle ----------------------------------------------------------
    async def run(self) -> None:
        await self.ws.accept()
        try:
            hello = await self._recv()
            if not hello or hello.get("type") != "hello" \
                    or not _auth_ok(hello.get("auth")):
                await self._send(f_error("unauthorized: hello with valid auth "
                                         "required as the first frame"))
                await self.ws.close(code=4401)
                return
            mode = hello.get("mode")
            if mode in VALID_MODES:
                self.mode = mode
            user = str(hello.get("user") or "").strip()
            if user:
                self.user = user
            await self._init_brain()
            register_session(self)
            await self._send(f_ready(self.session_id))

            while True:
                msg = await self._recv()
                if msg is None:
                    break
                await self._handle(msg)
        except WebSocketDisconnect:
            pass
        except Exception as e:  # noqa: BLE001 — never let the loop crash silently
            try:
                await self._send(f_error(f"fatal: {str(e)[:200]}"))
            except Exception:  # noqa: BLE001
                pass
        finally:
            unregister_session(self)
            await self._cancel_turn()
            self._fail_pending("connection closed")
            for ad in (self.adapter, self.fast_adapter):
                if ad is not None:
                    try:
                        await ad.aclose()
                    except Exception:  # noqa: BLE001
                        pass

    def _fail_pending(self, reason: str) -> None:
        """Resolve any outstanding device-action futures so a closing socket
        never leaves a tool call awaiting forever."""
        for fut in list(self._pending.values()):
            if not fut.done():
                fut.set_result({"ok": False, "error": reason})
        self._pending.clear()

    async def _init_brain(self) -> None:
        # session-start context; both calls swallow errors -> "" if the memory
        # service is down, so a session still boots.
        mem_index = await tools.memory_index_text()
        values = await tools.values_text()
        system_prompt = persona.build_system_prompt(
            memory_index=mem_index, values=values)
        self.system_msg = {"role": "system", "content": system_prompt}
        self.adapter = brain.DeepSeekAdapter()
        # The brain sees server-side tools AND phone (device) tools; the
        # session dispatcher routes device tools over the WS bridge.
        all_specs = tools.TOOL_SPECS + device_tools.DEVICE_TOOL_SPECS
        self.brain = brain.Brain(self.adapter, all_specs, self._dispatch_tool)
        # Fast lane: a cheap tool-less model with a compact butler persona. Its
        # system prompt is built ONCE here (no per-turn file reads) so the hot
        # path stays lean. FAST_MODEL overrides the model; FAST_CHAT=0 disables.
        fast_model = os.environ.get("FAST_MODEL", "deepseek-v4-flash")
        self.fast_adapter = brain.DeepSeekAdapter(model=fast_model)
        self.fast_system_msg = {"role": "system",
                                "content": persona.fast_system_prompt()}

    def _fast_enabled(self) -> bool:
        """Fast lane is on unless FAST_CHAT=0 (or it never initialised)."""
        return (os.environ.get("FAST_CHAT", "1") != "0"
                and self.fast_adapter is not None)

    async def _handle(self, msg: dict) -> None:
        t = msg.get("type")
        if t == "ping":
            await self._send(f_pong())
        elif t == "set_mode":
            mode = msg.get("mode")
            if mode in VALID_MODES:
                self.mode = mode
        elif t == "utterance":
            # a fresh utterance supersedes any still-running turn (no turn_end;
            # the new turn owns the floor now)
            await self._cancel_turn()
            self.turn_task = asyncio.create_task(self._run_turn(msg))
        elif t == "barge_in":
            await self._cancel_turn(send_turn_end=True)
        elif t == "action_result":
            self._resolve_action(msg)
        elif t == "event":
            await self._handle_event(msg)
        elif t == "sms_in":
            await self._handle_sms(msg)
        # hello (repeat) / unknown types: ignored

    async def _cancel_turn(self, send_turn_end: bool = False) -> None:
        task = self.turn_task
        self.turn_task = None
        if task is not None and not task.done():
            task.cancel()
            try:
                await task
            except (asyncio.CancelledError, Exception):  # noqa: BLE001
                pass
            if send_turn_end:
                await self._send(f_turn_end())

    # -- one turn -----------------------------------------------------------
    def _compose_user(self, msg: dict) -> str:
        text = str(msg.get("text", "")).strip()
        if str(msg.get("source", "voice")) == "typed":
            text = "[TYPED] " + text
        tone = str(msg.get("tone", "")).strip()
        if tone:
            if not tone.startswith("("):
                tone = f"(voice: {tone})"
            text = f"{text}  {tone}"
        return text

    async def _run_turn(self, msg: dict) -> None:
        self._turn_no += 1
        turn = _TurnTimer(f"{self.session_id}.{self._turn_no}")
        raw_text = str(msg.get("text", "")).strip()
        try:
            if not raw_text:
                await self._send(f_turn_end())
                return
            user_content = self._compose_user(msg)
            voice = self.mode in VOICE_MODES
            _tlog(turn, "utterance_received", mode=self.mode,
                  chars=len(raw_text), voice=int(voice))
            # Two-lane routing: the cheap fast lane answers banter/simple turns;
            # a <<ACT>> sentinel escalates silently to the full tool-brain. One
            # shared history — whichever lane completes commits the turn.
            served_fast = False
            if self._fast_enabled():
                served_fast = await self._serve_fast(user_content, voice, turn)
            if not served_fast:
                await self._serve_full(user_content, voice, turn)
            _tlog(turn, "turn_end", lane=("fast" if served_fast else "full"))
            await self._send(f_turn_end())
            # fire-and-forget passive memory save
            asyncio.create_task(tools.passive_remember(raw_text))
        except asyncio.CancelledError:
            # barge-in / supersede: _cancel_turn handles any turn_end
            raise
        except Exception as e:  # noqa: BLE001 — surface, never crash the socket
            await self._send(f_error(str(e)[:300]))
            await self._send(f_turn_end())

    async def _serve_fast(self, user_content: str, voice: bool,
                          turn: _TurnTimer) -> bool:
        """Run the FAST lane. Returns True if it served the turn (spoke + shared
        history committed), False to escalate (nothing spoken, history left
        untouched so the full brain owns the turn)."""
        assert self.fast_adapter is not None
        lane = brain.FastLane(self.fast_adapter, self.fast_system_msg["content"])
        agen = lane.run(self.history, user_content,
                        on_token=lambda: _tlog(turn, "first_token", lane="fast"))
        seq = 0
        final_text = ""
        got_sentence = False
        got_audio = False
        try:
            async for kind, payload in agen:
                if kind == "handoff":
                    _tlog(turn, "escalate", **{"from": "fast", "to": "full"})
                    return False
                if kind == "final":
                    final_text = payload
                    continue
                # kind == "say"
                clean, emotion = tts.emotion_label(payload)
                if not clean.strip():
                    continue
                seq += 1
                if not got_sentence:
                    got_sentence = True
                    _tlog(turn, "first_sentence", lane="fast")
                await self._send(f_sentence(seq, clean, emotion))
                if voice and await self._synth_and_send(seq, payload):
                    if not got_audio:
                        got_audio = True
                        _tlog(turn, "first_audio", lane="fast")
        finally:
            await agen.aclose()
        if seq == 0:
            return False  # nothing sayable slipped through -> escalate
        # commit the shared history: the user turn + the fast assistant turn
        # (the bare sentinel is NEVER recorded — final_text has it stripped).
        self.history = self._trim(
            self.history
            + [{"role": "user", "content": user_content},
               {"role": "assistant", "content": final_text or " "}])
        _tlog(turn, "served", lane="fast", sentences=seq)
        return True

    async def _serve_full(self, user_content: str, voice: bool,
                          turn: _TurnTimer) -> None:
        """Run the FULL tool-calling brain and commit the shared history."""
        assert self.brain is not None
        messages = ([self.system_msg] + self.history
                    + [{"role": "user", "content": user_content}])
        seq = 0
        got_sentence = False
        got_audio = False
        async for raw in self.brain.run(
                messages,
                on_token=lambda: _tlog(turn, "first_token", lane="full")):
            clean, emotion = tts.emotion_label(raw)
            if not clean.strip():
                continue
            seq += 1
            if not got_sentence:
                got_sentence = True
                _tlog(turn, "first_sentence", lane="full")
            await self._send(f_sentence(seq, clean, emotion))
            if voice and await self._synth_and_send(seq, raw):
                if not got_audio:
                    got_audio = True
                    _tlog(turn, "first_audio", lane="full")
        # commit history (drop the system message at [0]), capped
        self.history = self._trim(messages[1:])
        _tlog(turn, "served", lane="full", sentences=seq)

    async def _synth_and_send(self, seq: int, raw_sentence: str) -> bool:
        """Synthesize + stream one sentence's audio. TTS runs OFF the event loop
        (threadpool) so a ~40-200ms synth never blocks the socket. Returns True
        if audio was sent, False if voice was unavailable/empty (chat goes on)."""
        try:
            loop = asyncio.get_running_loop()
            wav = await loop.run_in_executor(None, _tts_wav, raw_sentence)
        except Exception as e:  # noqa: BLE001 — chat continues even if voice fails
            await self._send(f_error(f"tts unavailable: {str(e)[:160]}"))
            return False
        if not wav:
            return False
        await self._send(f_audio_start(seq))
        await self._send_bytes(wav)
        await self._send(f_audio_end(seq))
        return True

    # -- tool dispatch: server-side tools vs phone (device) tools -----------
    async def _dispatch_tool(self, name: str, args: dict) -> str:
        """The brain's tool executor. Device tools go over the WS bridge to the
        phone; everything else runs server-side in ``tools.execute``."""
        if name in device_tools.DEVICE_TOOL_NAMES:
            return await self._dispatch_device(name, args)
        return await tools.execute(name, args)

    async def _dispatch_device(self, name: str, args: dict) -> str:
        """Resolve any saved state, emit the WS ``action`` frame, await the
        phone's result, and hand the brain a short spoken-friendly summary. A
        timeout / no-client yields a tool error, never a hang."""
        args = args or {}
        # navigate: resolve a saved place label to an address/coords first
        if name == "navigate":
            dest = str(args.get("destination", "")).strip()
            if not dest:
                return "navigate failed: no destination."
            params: dict = {"destination": dest}
            place = await phone_store.get_item(phone_store.KIND_PLACE, dest)
            if place:
                for k in ("address", "lat", "lng"):
                    if k in place:
                        params[k] = place[k]
                params["label"] = place.get("label", dest)
            res = await self._device_call("navigate", params)
            return self._device_reply(res, f"Navigating to {dest}.")
        # call_contact: resolve the number privately; NEVER log/echo it
        if name == "call_contact":
            who = str(args.get("name", "")).strip()
            contact = await phone_store.get_item(phone_store.KIND_CONTACT, who)
            if not contact or not str(contact.get("number", "")).strip():
                return (f"No number saved for '{who}'. Ask Ahmed for it and I'll "
                        "save it with save_contact.")
            res = await self._device_call(
                "call", {"name": contact.get("name", who),
                         "number": contact["number"]})
            # the reply intentionally omits the number
            return self._device_reply(res, f"Calling {contact.get('name', who)}.")
        # get_location: pass through, and label it if it's a known place
        if name == "get_location":
            res = await self._device_call("get_location", {})
            if not res.get("ok"):
                return self._device_reply(res, "")
            data = res.get("data") or {}
            try:
                lat, lng = float(data["lat"]), float(data["lng"])
            except (KeyError, TypeError, ValueError):
                return "Got a location fix, but no usable coordinates."
            label = await self._match_place(lat, lng)
            if label:
                return f"He's at {label}."
            return f"Current location: {lat:.5f}, {lng:.5f}."
        # the plain pass-through device tools
        simple = {
            "play_music": ("play_music", {"query": str(args.get("query", ""))},
                           f"Playing {args.get('query', '')}."),
            "take_screenshot": ("take_screenshot", {}, "Screenshot taken."),
            "open_camera": ("open_camera",
                            {"mode": str(args.get("mode") or "photo")},
                            "Opening the camera."),
            "open_app": ("open_app", {"target": str(args.get("target", ""))},
                         f"Opening {args.get('target', '')}."),
            "open_url": ("open_url", {"url": str(args.get("url", ""))},
                         "Opening the link."),
            "set_timer": ("set_timer",
                          {"seconds": int(args.get("seconds") or 0),
                           "label": str(args.get("label") or "")},
                          "Timer set."),
        }
        if name in simple:
            action, params, ok_msg = simple[name]
            res = await self._device_call(action, params)
            return self._device_reply(res, ok_msg)
        return f"unknown device tool '{name}'"

    @staticmethod
    def _device_reply(res: dict, ok_msg: str) -> str:
        """Turn a phone action_result into a compact line for the brain."""
        if res.get("ok"):
            data = res.get("data")
            if isinstance(data, dict) and data.get("message"):
                return str(data["message"])
            return ok_msg or "Done."
        err = str(res.get("error") or "no response")
        if err == "timeout":
            return "Your phone didn't respond in time, sir."
        return f"Couldn't do that on the phone ({err})."

    async def _device_call(self, action: str, params: dict,
                           timeout: float = DEVICE_TIMEOUT) -> dict:
        """Emit an ``action`` frame and await the matching ``action_result``.
        Returns the result dict; on timeout/closed socket returns an error dict."""
        aid = uuid.uuid4().hex[:12]
        loop = asyncio.get_running_loop()
        fut: asyncio.Future = loop.create_future()
        self._pending[aid] = fut
        try:
            await self._send(f_action(aid, action, params))
            res = await asyncio.wait_for(fut, timeout)
            return res if isinstance(res, dict) else {"ok": False,
                                                      "error": "bad result"}
        except asyncio.TimeoutError:
            return {"ok": False, "error": "timeout"}
        finally:
            self._pending.pop(aid, None)

    def _resolve_action(self, msg: dict) -> None:
        """Deliver a phone ``action_result`` to whoever awaits its id."""
        aid = str(msg.get("id", ""))
        fut = self._pending.get(aid)
        if fut is not None and not fut.done():
            fut.set_result({"ok": bool(msg.get("ok")),
                            "data": msg.get("data"),
                            "error": msg.get("error")})

    # -- proactive turns (server initiates, no user utterance) --------------
    async def _deliver_proactive(self, injected: str) -> None:
        """Start a proactive spoken turn (Jarvis speaks first). Skipped if a turn
        is already in flight so an event never talks over a live exchange."""
        if self.turn_task is not None and not self.turn_task.done():
            logger.info("proactive skipped (turn in flight): %.40s", injected)
            return
        self.turn_task = asyncio.create_task(self._proactive_turn(injected))

    async def _proactive_turn(self, injected: str) -> None:
        self._turn_no += 1
        turn = _TurnTimer(f"{self.session_id}.p{self._turn_no}")
        try:
            voice = self.mode in VOICE_MODES
            _tlog(turn, "proactive_start")
            await self._serve_full(injected, voice, turn)
            _tlog(turn, "turn_end", lane="proactive")
            await self._send(f_turn_end())
        except asyncio.CancelledError:
            raise
        except Exception as e:  # noqa: BLE001
            await self._send(f_error(str(e)[:300]))
            await self._send(f_turn_end())

    # -- inbound phone events -----------------------------------------------
    async def _handle_event(self, msg: dict) -> None:
        if msg.get("kind") != "new_location":
            return
        coords = msg.get("coords") or {}
        try:
            lat, lng = float(coords.get("lat")), float(coords.get("lng"))
        except (TypeError, ValueError):
            return
        place_label = str(msg.get("place") or "").strip()
        if place_label:
            # a geofence ENTER naming a known place — fire its place routines,
            # don't ask where he is (we already know).
            await self._fire_place_routines(place_label)
            return
        # unknown spot: skip if it's really a known place, or debounced wobble
        if await self._match_place(lat, lng):
            return
        if not self._should_prompt_location(lat, lng):
            return
        self._last_loc_prompt = (lat, lng, time.monotonic())
        injected = (
            "[LOCATION EVENT] Ahmed has arrived somewhere you don't have saved "
            f"(GPS {lat:.5f}, {lng:.5f}). Greet him briefly and ask where he is, "
            "so you can save it with save_place (pass these coordinates as "
            "'lat,lng') once he tells you. Keep the numbers to yourself.")
        await self._deliver_proactive(injected)

    async def _handle_sms(self, msg: dict) -> None:
        sender = str(msg.get("sender", "")).strip()
        body = str(msg.get("body", "")).strip()
        if not sender:
            return
        try:
            watch = await phone_store.list_items(phone_store.KIND_SMS_WATCH)
        except Exception:  # noqa: BLE001
            return
        senders = [str(w.get("sender", "")) for w in watch]
        if not phone_store.sms_matches(sender, senders):
            return  # not on the watch-list — ignore silently
        injected = (
            f"[SMS EVENT] A text just arrived from \"{sender}\": \"{body}\". "
            "Summarise it for Ahmed in a sentence and flag anything that needs "
            "action. If it contains a one-time code or OTP, read that out.")
        await self._deliver_proactive(injected)

    def _should_prompt_location(self, lat: float, lng: float) -> bool:
        """Debounce: suppress a new-location prompt if we recently prompted at a
        point this close (GPS wobble), otherwise allow it."""
        if self._last_loc_prompt is None:
            return True
        plat, plng, pts = self._last_loc_prompt
        if (time.monotonic() - pts < LOC_DEBOUNCE_SEC
                and _haversine_m(lat, lng, plat, plng) < LOC_DEBOUNCE_M):
            return False
        return True

    async def _match_place(self, lat: float, lng: float) -> str | None:
        """Return a saved place's label if these coords are within
        ``KNOWN_PLACE_M`` of it, else None."""
        try:
            places = await phone_store.list_items(phone_store.KIND_PLACE)
        except Exception:  # noqa: BLE001
            return None
        for p in places:
            if "lat" in p and "lng" in p:
                try:
                    d = _haversine_m(lat, lng, float(p["lat"]), float(p["lng"]))
                except (TypeError, ValueError):
                    continue
                if d < KNOWN_PLACE_M:
                    return str(p.get("label") or "")
        return None

    async def _fire_place_routines(self, label: str) -> None:
        """Fire any place/both routines whose trigger place matches ``label``."""
        try:
            routines = await phone_store.list_items(phone_store.KIND_ROUTINE)
        except Exception:  # noqa: BLE001
            return
        want = label.strip().lower()
        for r in routines:
            trig = r.get("trigger") or {}
            if trig.get("type") in ("place", "both") \
                    and str(trig.get("place", "")).strip().lower() == want:
                await fire_routine(r)

    def _trim(self, msgs: list[dict]) -> list[dict]:
        """Keep the last MAX_TURNS user-turns, cutting only at a user boundary
        so an assistant tool-call and its tool results are never orphaned."""
        user_idx = [i for i, m in enumerate(msgs) if m.get("role") == "user"]
        if len(user_idx) <= MAX_TURNS:
            return msgs
        return msgs[user_idx[len(user_idx) - MAX_TURNS]:]


def _tts_wav(raw_sentence: str) -> bytes:
    """Blocking synth (runs in a threadpool). Lazy-loads the model on first call."""
    return tts.get_tts().synth_wav(raw_sentence)


@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket) -> None:
    await Session(ws).run()


if __name__ == "__main__":  # local dev: python main.py
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0",
                port=int(os.environ.get("PORT", "8000")))
