import json
import os
import threading
import time
import urllib.error
import urllib.parse
import urllib.request

DEFAULT_BASE_URL = "http://localhost:8080"
DEFAULT_API_VERSION = "v1"
DEFAULT_SCOPE_ORDER = [
    "SCOPE_AGENT",
    "SCOPE_TEAM",
    "SCOPE_PROJECT",
    "SCOPE_GLOBAL",
]
PROACTIVE_TRIGGER_TYPES = {"bootstrap", "context_shift", "pre_edit"}
PROACTIVE_DEFAULTS = {
    "debounce_ms": 20000,
    "rate_window_ms": 10 * 60 * 1000,
    "rate_limit": 8,
    "max_active_files": 8,
    "max_active_symbols": 8,
    "max_recent_commands": 5,
    "max_scope_order": 8,
    "default_limit": 5,
}


class APIError(Exception):
    def __init__(self, status_code, code, message, details, request_id):
        super().__init__(message or "STELE API error")
        self.status_code = status_code
        self.code = code
        self.message = message
        self.details = details
        self.request_id = request_id

    def __str__(self):
        if self.code:
            return f"{self.code}: {self.message}"
        return self.message or f"STELE API error (status {self.status_code})"


def _env_or_default(key, fallback):
    value = os.environ.get(key, "").strip()
    return value or fallback


def _normalize_positive_int(raw, fallback):
    try:
        parsed = int(raw)
    except (TypeError, ValueError):
        return fallback
    if parsed <= 0:
        return fallback
    return parsed


def _dedupe_trimmed_limited(values, max_items):
    if not isinstance(values, list) or max_items <= 0:
        return []
    out = []
    seen = set()
    for value in values:
        trimmed = str(value or "").strip()
        if not trimmed:
            continue
        key = trimmed.lower()
        if key in seen:
            continue
        seen.add(key)
        out.append(trimmed)
        if len(out) >= max_items:
            break
    return out


def _canonicalize_for_hash(values):
    seen = set()
    out = []
    for value in values or []:
        normalized = str(value or "").strip().lower()
        if not normalized or normalized in seen:
            continue
        seen.add(normalized)
        out.append(normalized)
    out.sort()
    return out


def _context_hash(agent_id, session_id, context):
    payload = {
        "agent_id": str(agent_id or "").strip(),
        "session_id": str(session_id or "").strip(),
        "active_files": _canonicalize_for_hash(context.get("active_files") or []),
        "active_symbols": _canonicalize_for_hash(context.get("active_symbols") or []),
        "recent_commands": _canonicalize_for_hash(context.get("recent_commands") or []),
        "scope_order": _canonicalize_for_hash(context.get("scope_order") or []),
        "scope_ref": str(context.get("scope_ref") or "").strip().lower(),
    }
    return json.dumps(payload, sort_keys=True, separators=(",", ":"))


class ProactiveSuggestor:
    def __init__(
        self,
        client,
        debounce_ms=None,
        rate_window_ms=None,
        rate_limit=None,
        max_active_files=None,
        max_active_symbols=None,
        max_recent_commands=None,
        max_scope_order=None,
        default_limit=None,
        now_ms=None,
    ):
        self.client = client
        self.debounce_ms = _normalize_positive_int(debounce_ms, PROACTIVE_DEFAULTS["debounce_ms"])
        self.rate_window_ms = _normalize_positive_int(rate_window_ms, PROACTIVE_DEFAULTS["rate_window_ms"])
        self.rate_limit = _normalize_positive_int(rate_limit, PROACTIVE_DEFAULTS["rate_limit"])
        self.max_active_files = _normalize_positive_int(max_active_files, PROACTIVE_DEFAULTS["max_active_files"])
        self.max_active_symbols = _normalize_positive_int(max_active_symbols, PROACTIVE_DEFAULTS["max_active_symbols"])
        self.max_recent_commands = _normalize_positive_int(max_recent_commands, PROACTIVE_DEFAULTS["max_recent_commands"])
        self.max_scope_order = _normalize_positive_int(max_scope_order, PROACTIVE_DEFAULTS["max_scope_order"])
        self.default_limit = _normalize_positive_int(default_limit, PROACTIVE_DEFAULTS["default_limit"])
        self._now_ms = now_ms if callable(now_ms) else (lambda: int(time.time() * 1000))
        self.bootstrap_done = False
        self.last_context_key = ""
        self.last_triggered_ms = 0
        self.trigger_times = []

    def evaluate(self, trigger_type, context=None):
        trigger = str(trigger_type or "").strip().lower()
        if trigger not in PROACTIVE_TRIGGER_TYPES:
            return {"triggered": False, "reason": "unsupported_trigger", "trigger": trigger}

        data = dict(context or {})
        agent_id = str(data.get("agent_id") or self.client.agent_id or "").strip()
        session_id = str(data.get("session_id") or self.client.session_id or "").strip()
        if not agent_id or not session_id:
            return {"triggered": False, "reason": "missing_identity", "trigger": trigger}

        normalized = {
            "active_files": _dedupe_trimmed_limited(data.get("active_files"), self.max_active_files),
            "active_symbols": _dedupe_trimmed_limited(data.get("active_symbols"), self.max_active_symbols),
            "recent_commands": _dedupe_trimmed_limited(data.get("recent_commands"), self.max_recent_commands),
            "scope_order": _dedupe_trimmed_limited(data.get("scope_order"), self.max_scope_order),
            "scope_ref": str(data.get("scope_ref") or "").strip(),
        }
        bootstrap_marker = trigger == "bootstrap" and not self.bootstrap_done
        if bootstrap_marker:
            normalized["recent_commands"].append("bootstrap:health-capabilities-context-get")
            normalized["recent_commands"] = _dedupe_trimmed_limited(
                normalized["recent_commands"], self.max_recent_commands
            )
        if (
            len(normalized["active_files"]) == 0
            and len(normalized["active_symbols"]) == 0
            and len(normalized["recent_commands"]) == 0
        ):
            return {"triggered": False, "reason": "empty_context", "trigger": trigger}

        context_key = _context_hash(agent_id, session_id, normalized)
        now_ms = int(self._now_ms())
        if trigger != "bootstrap" and context_key == self.last_context_key:
            return {"triggered": False, "reason": "unchanged_context", "trigger": trigger}
        if self.last_triggered_ms > 0 and (now_ms - self.last_triggered_ms) < self.debounce_ms:
            return {"triggered": False, "reason": "debounced", "trigger": trigger}

        threshold = now_ms - self.rate_window_ms
        self.trigger_times = [ts for ts in self.trigger_times if ts > threshold]
        if len(self.trigger_times) >= self.rate_limit:
            return {"triggered": False, "reason": "rate_limited", "trigger": trigger}

        self.last_context_key = context_key
        self.last_triggered_ms = now_ms
        self.trigger_times.append(now_ms)
        if bootstrap_marker:
            self.bootstrap_done = True

        payload = {
            "agent_id": agent_id,
            "session_id": session_id,
            "limit": _normalize_positive_int(data.get("limit"), self.default_limit),
        }
        if normalized["active_files"]:
            payload["active_files"] = normalized["active_files"]
        if normalized["active_symbols"]:
            payload["active_symbols"] = normalized["active_symbols"]
        if normalized["recent_commands"]:
            payload["recent_commands"] = normalized["recent_commands"]
        if normalized["scope_order"]:
            payload["scope_order"] = normalized["scope_order"]
        if normalized["scope_ref"]:
            payload["scope_ref"] = normalized["scope_ref"]
        return {"triggered": True, "reason": "triggered", "trigger": trigger, "payload": payload}

    def trigger(self, trigger_type, context=None):
        decision = self.evaluate(trigger_type, context)
        if not decision.get("triggered"):
            return decision
        response = self.client.suggest(decision.get("payload") or {})
        decision["response"] = response
        return decision


class SteleClient:
    def __init__(
        self,
        base_url=None,
        api_version=None,
        agent_id="",
        session_id="",
        client_id="",
        embedder_override="",
        ops_auth_token="",
        timeout=15,
    ):
        if base_url is None or base_url == "":
            base_url = _env_or_default("STELE_BASE_URL", DEFAULT_BASE_URL)
        if api_version is None or api_version == "":
            api_version = _env_or_default("STELE_API_VERSION", DEFAULT_API_VERSION)
        if not agent_id:
            agent_id = os.environ.get("STELE_AGENT_ID", "").strip()
        if not session_id:
            session_id = os.environ.get("STELE_SESSION_ID", "").strip()
        if not client_id:
            client_id = os.environ.get("STELE_CLIENT_ID", "").strip()
        if not embedder_override:
            embedder_override = os.environ.get("STELE_EMBEDDER_OVERRIDE", "").strip()
        if not ops_auth_token:
            ops_auth_token = (
                os.environ.get("STELE_OPS_AUTH_TOKEN", "").strip()
                or os.environ.get("STELE_MCP_AUTH_TOKEN", "").strip()
            )
        self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
        self.api_version = api_version or DEFAULT_API_VERSION
        self.agent_id = agent_id
        self.session_id = session_id
        self.client_id = client_id
        self.embedder_override = embedder_override
        self.ops_auth_token = ops_auth_token
        self.timeout = timeout
        self._counter = 0
        self._lock = threading.Lock()
        self._proactive_suggestor = None

    def write(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.write", payload)

    def query(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        if not payload.get("scope_order"):
            payload["scope_order"] = list(DEFAULT_SCOPE_ORDER)
        return self._request("POST", "/memory.query", payload)

    def feedback(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.feedback", payload)

    def protect(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.protect", payload)

    def supersede(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.supersede", payload)

    def forget(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.forget", payload)

    def list_memories(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.list", payload)

    def get_memory(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.get", payload)

    def search_memories(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.search", payload)

    def update_memory(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.update", payload)

    def verify(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.verify", payload)

    def extract(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.extract", payload)

    def learn_share(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.learn.share", payload)

    def edge_add(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.edge.add", payload)

    def edge_delete(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.edge.delete", payload)

    def edge_list(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.edge.list", payload)

    def edge_traverse(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.edge.traverse", payload)

    def edge_suggest(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.edge.suggest", payload)

    def suggest(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.suggest", payload)

    def watch(self, request=None):
        payload = dict(request or {})

        agent_id = str(payload.get("agent_id") or self.agent_id or "").strip()
        if not agent_id:
            raise ValueError("agent_id is required")

        scopes = self._normalize_watch_scopes(payload)
        if not scopes:
            raise ValueError("scope is required")

        scope_refs = self._normalize_watch_scope_refs(payload)
        if scope_refs and len(scope_refs) != len(scopes):
            raise ValueError("scope_ref must match scope count")
        if not scope_refs and len(scopes) > 1 and str(payload.get("scope_ref") or "").strip():
            raise ValueError("scope_ref must match scope count")

        cursor_id = self._normalize_int(payload.get("cursor_id"), 0)
        cursor_timestamp_ms = self._normalize_int(payload.get("cursor_timestamp_ms"), 0)
        heartbeat_ms = self._normalize_positive_int(payload.get("heartbeat_ms"), 1000)
        timeout_ms = self._normalize_positive_int(payload.get("timeout_ms"), 2000)
        limit = self._normalize_positive_int(payload.get("limit"), 10)
        start_from = self._normalize_start_from(payload.get("start_from"))
        if start_from == "latest" and cursor_id == 0 and cursor_timestamp_ms == 0:
            cursor_timestamp_ms = int(time.time() * 1000)

        query_pairs = [("agent_id", agent_id)]
        for scope in scopes:
            query_pairs.append(("scope", scope))
        if scope_refs:
            for ref in scope_refs:
                query_pairs.append(("scope_ref", ref))
        else:
            scope_ref = str(payload.get("scope_ref") or "").strip()
            if scope_ref and len(scopes) == 1:
                query_pairs.append(("scope_ref", scope_ref))
        query_pairs.append(("limit", str(limit)))
        query_pairs.append(("cursor_id", str(cursor_id)))
        if cursor_timestamp_ms > 0:
            query_pairs.append(("cursor_timestamp_ms", str(cursor_timestamp_ms)))
        query_pairs.append(("heartbeat_ms", str(heartbeat_ms)))

        query = urllib.parse.urlencode(query_pairs, doseq=True)
        url = f"{self.base_url}/v1/stream?{query}"
        req = urllib.request.Request(url, headers={"Accept": "text/event-stream"}, method="GET")
        timeout_sec = timeout_ms / 1000.0
        try:
            with urllib.request.urlopen(req, timeout=timeout_sec) as response:
                raw = response.read().decode("utf-8")
        except urllib.error.HTTPError as exc:
            raw = exc.read()
            raise self._api_error(exc.code, raw) from None

        return self._parse_watch_response(raw, cursor_id, cursor_timestamp_ms, limit)

    def bulk_write(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.bulk.write", payload)

    def bulk_forget(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.bulk.forget", payload, is_ops=True)

    def export_ndjson(self, request=None):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request_raw("POST", "/memory.export", payload, is_ops=True)

    def export_to_file(self, output_path, request=None):
        data = self.export_ndjson(request)
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(data)
        return {"ok": True, "output_path": output_path, "bytes": len(data.encode("utf-8"))}

    def import_memories(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.import", payload, is_ops=True)

    def schema(self, request=None):
        if request is None:
            return self._request("GET", "/memory.schema", None)
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("GET", "/memory.schema", payload)

    def metrics(self, request=None):
        if request is None:
            return self._request("GET", "/memory.metrics", None)
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("GET", "/memory.metrics", payload)

    def maintenance(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.maintenance", payload, is_ops=True)

    def reindex(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.reindex", payload, is_ops=True)

    def agent_get(self, request=None):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        if not payload.get("agent_id"):
            payload["agent_id"] = payload["meta"].get("agent_id") or self.agent_id
        return self._request("POST", "/memory.agent.get", payload)

    def agent_upsert(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        profile = dict(payload.get("profile") or {})
        if not profile.get("agent_id"):
            profile["agent_id"] = payload["meta"].get("agent_id") or self.agent_id
        payload["profile"] = profile
        return self._request("POST", "/memory.agent.upsert", payload)

    def access_mark_used(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.access.mark_used", payload)

    def decay(self, request=None):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.decay", payload, is_ops=True)

    def auth_token_mint(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        if not payload.get("agent_id"):
            payload["agent_id"] = payload["meta"].get("agent_id") or self.agent_id
        if not payload.get("session_id"):
            payload["session_id"] = payload["meta"].get("session_id") or self.session_id
        return self._request("POST", "/memory.auth.token.mint", payload, is_ops=True)

    def auth_token_revoke(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.auth.token.revoke", payload, is_ops=True)

    def events(self, request=None):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        return self._request("POST", "/memory.events", payload)

    def stats(self, request=None):
        if request:
            payload = dict(request)
            payload["meta"] = self.build_meta(payload.get("meta"))
            return self._request("GET", "/memory.stats", payload)
        return self._request("GET", "/memory.stats", None)

    def membership_add(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        if not payload.get("agent_id"):
            payload["agent_id"] = payload["meta"].get("agent_id") or self.agent_id
        if payload.get("agent_id") and not payload["meta"].get("agent_id"):
            payload["meta"]["agent_id"] = payload["agent_id"]
        return self._request("POST", "/memory.membership.add", payload)

    def membership_remove(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        if not payload.get("agent_id"):
            payload["agent_id"] = payload["meta"].get("agent_id") or self.agent_id
        if payload.get("agent_id") and not payload["meta"].get("agent_id"):
            payload["meta"]["agent_id"] = payload["agent_id"]
        return self._request("POST", "/memory.membership.remove", payload)

    def membership_list(self, request):
        payload = dict(request or {})
        payload["meta"] = self.build_meta(payload.get("meta"))
        if payload.get("agent_id") and not payload["meta"].get("agent_id"):
            payload["meta"]["agent_id"] = payload["agent_id"]
        return self._request("GET", "/memory.membership.list", payload)

    def health(self, request=None):
        if request:
            payload = dict(request)
            payload["meta"] = self.build_meta(payload.get("meta"))
            return self._request("GET", "/memory.health", payload)
        return self._request("GET", "/memory.health", None)

    def create_proactive_suggestor(self, **kwargs):
        return ProactiveSuggestor(self, **kwargs)

    def proactive_suggest(self, trigger_type, context=None):
        if self._proactive_suggestor is None:
            self._proactive_suggestor = self.create_proactive_suggestor()
        return self._proactive_suggestor.trigger(trigger_type, context or {})

    def build_meta(self, meta=None):
        data = dict(meta or {})
        if not data.get("api_version"):
            data["api_version"] = self.api_version
        if not data.get("request_id"):
            data["request_id"] = self._next_request_id()
        if not data.get("agent_id"):
            data["agent_id"] = self.agent_id
        if not data.get("session_id"):
            data["session_id"] = self.session_id
        if not data.get("client_id") and self.client_id:
            data["client_id"] = self.client_id
        if not data.get("embedder_override") and self.embedder_override:
            data["embedder_override"] = self.embedder_override
        if not data.get("timestamp_ms"):
            data["timestamp_ms"] = int(time.time() * 1000)
        return data

    def _next_request_id(self):
        with self._lock:
            self._counter += 1
            counter = self._counter
        return f"req-{int(time.time() * 1000)}-{counter}"

    def _request(self, method, path, payload, is_ops=False):
        url = f"{self.base_url}{path}"
        headers = {"Accept": "application/json"}
        if is_ops and self.ops_auth_token:
            headers["Authorization"] = f"Bearer {self.ops_auth_token}"
        data = None
        if payload is not None:
            data = json.dumps(payload).encode("utf-8")
            headers["Content-Type"] = "application/json"
        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as response:
                raw = response.read()
                if not raw:
                    return None
                return json.loads(raw.decode("utf-8"))
        except urllib.error.HTTPError as exc:
            raw = exc.read()
            raise self._api_error(exc.code, raw) from None

    def _request_raw(self, method, path, payload, is_ops=False):
        url = f"{self.base_url}{path}"
        headers = {"Accept": "*/*"}
        if is_ops and self.ops_auth_token:
            headers["Authorization"] = f"Bearer {self.ops_auth_token}"
        data = None
        if payload is not None:
            data = json.dumps(payload).encode("utf-8")
            headers["Content-Type"] = "application/json"
        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as response:
                raw = response.read()
                return raw.decode("utf-8") if raw else ""
        except urllib.error.HTTPError as exc:
            raw = exc.read()
            raise self._api_error(exc.code, raw) from None

    def _api_error(self, status_code, raw):
        message = f"STELE API error (status {status_code})"
        code = ""
        details = ""
        request_id = ""
        if raw:
            try:
                payload = json.loads(raw.decode("utf-8"))
                meta = payload.get("meta") or {}
                err = meta.get("error") or {}
                code = err.get("code") or ""
                message = err.get("message") or message
                details = err.get("details") or ""
                request_id = meta.get("request_id") or ""
                if not code and isinstance(payload, dict):
                    # Some endpoints return plain errors: {"code":"...","message":"...","details":"..."}.
                    code = payload.get("code") or code
                    message = payload.get("message") or message
                    details = payload.get("details") or details
                    request_id = payload.get("request_id") or request_id
            except (ValueError, TypeError, UnicodeDecodeError):
                pass
        return APIError(status_code, code, message, details, request_id)

    def _normalize_watch_scopes(self, payload):
        if isinstance(payload.get("scopes"), list):
            return [str(v or "").strip() for v in payload.get("scopes", []) if str(v or "").strip()]
        scope = str(payload.get("scope") or "").strip()
        return [scope] if scope else []

    def _normalize_watch_scope_refs(self, payload):
        if isinstance(payload.get("scope_refs"), list):
            return [str(v or "").strip() for v in payload.get("scope_refs", [])]
        return []

    def _normalize_positive_int(self, value, fallback):
        try:
            parsed = int(value)
        except (TypeError, ValueError):
            return fallback
        if parsed <= 0:
            return fallback
        return parsed

    def _normalize_int(self, value, fallback):
        try:
            parsed = int(value)
        except (TypeError, ValueError):
            return fallback
        if parsed < 0:
            return fallback
        return parsed

    def _normalize_start_from(self, value):
        normalized = str(value or "").strip().lower()
        if normalized == "":
            return "earliest"
        if normalized in ("earliest", "latest"):
            return normalized
        raise ValueError(f'invalid start_from "{value}"')

    def _parse_watch_response(self, raw_sse, cursor_id, cursor_timestamp_ms, limit):
        events = []
        next_cursor_id = cursor_id
        next_cursor_timestamp_ms = cursor_timestamp_ms
        current = {"id": "", "event": "", "data": ""}

        def push_event():
            nonlocal current, next_cursor_id, next_cursor_timestamp_ms
            if not current["id"] and not current["event"] and not current["data"]:
                return
            event_name = str(current.get("event") or "").strip().upper()
            if event_name != "MEMORY_EVENT_HEARTBEAT" and len(events) < limit:
                try:
                    parsed_id = int(str(current.get("id") or "").strip())
                except (TypeError, ValueError):
                    parsed_id = 0
                if parsed_id > next_cursor_id:
                    next_cursor_id = parsed_id
                ts = self._extract_event_timestamp_ms(current.get("data"))
                if ts > next_cursor_timestamp_ms:
                    next_cursor_timestamp_ms = ts
                events.append(
                    {
                        "id": current.get("id", ""),
                        "event": current.get("event", ""),
                        "data": current.get("data", ""),
                    }
                )
            current = {"id": "", "event": "", "data": ""}

        for raw_line in str(raw_sse or "").splitlines():
            line = raw_line.rstrip("\r")
            if line == "":
                push_event()
                continue
            if line.startswith("id:"):
                current["id"] = line[3:].strip()
                continue
            if line.startswith("event:"):
                current["event"] = line[6:].strip()
                continue
            if line.startswith("data:"):
                piece = line[5:].strip()
                if current["data"]:
                    current["data"] = f'{current["data"]}\n{piece}'
                else:
                    current["data"] = piece
        push_event()

        return {
            "events": events,
            "next_cursor_id": next_cursor_id,
            "next_cursor_timestamp_ms": next_cursor_timestamp_ms,
            "has_more": len(events) >= limit,
        }

    def _extract_event_timestamp_ms(self, raw_data):
        try:
            parsed = json.loads(str(raw_data or ""))
            ts = int(parsed.get("timestamp_ms", 0))
            if ts > 0:
                return ts
        except (TypeError, ValueError, json.JSONDecodeError):
            return 0
        return 0
