"""
aiel_sdk.memory.remote
=====================

Remote-backed persistence for conversation memory, runtime state, checkpoints,
and long-term recall.

This module provides :class:`~aiel_sdk.memory.remote.RemoteSaver`, a production
implementation of :class:`~aiel_sdk.memory.saver.Saver` backed by the AIEL Memory API.

What it stores
--------------
1) Thread messages (short-term context)
   Append/read ordered :class:`~aiel_sdk.memory.types.ThreadMessage` entries for a thread.

2) Thread state (mutable working state)
   Read/patch a JSON-like dict representing workflow/runtime state for a thread.

3) Checkpoints (resumable snapshots)
   Store/read point-in-time snapshots of state + metadata for recovery and resume.

4) Recall (long-term memory)
   Store/search/delete key/value items under a hierarchical ``namespace`` (list[str]).

Key identifiers
---------------
thread_key:
    Stable external identifier you choose for a conversation/session. Keep it deterministic,
    unique for the session, and safe to reuse across calls.
    Examples: ``"user:42/session:abc"`` or ``"jira:SUP-001"``.

thread_id:
    Backend-generated internal identifier returned by the API. You do not provide it.
    The SDK never requires a ``thread_id`` as input.

Normalization
-------------
``thread_key`` is normalized via :func:`~aiel_sdk.memory.thread_key.normalize_thread_key`
before being sent to the API (e.g., trimming/validation).

Typical usage (matches TextIntegration-style tests)
---------------------------------------------------
    from typing import Any
    from aiel_sdk import AielClient
    from aiel_sdk.memory import RemoteSaver, ThreadMessage, RecallQuery

    # Configure your client (token/base_url typically come from your own context loader)
    client = AielClient(base_url=BASE_URL, api_key=TOKEN)

    saver = RemoteSaver(
        client,
        workspace_id=WORKSPACE_ID,
        project_id=PROJECT_ID,
    )

    thread_key = "jira:SUP-001"

    # 1) Append messages (short-term memory)
    saver.thread_write(
        thread_key=thread_key,
        messages=[ThreadMessage(role="human", content="Hello!")],
        ttl_seconds=3600,
    )

    # 2) Read messages
    thread = saver.thread_read(thread_key=thread_key, limit=50)

    # 3) Patch/read structured state
    saver.state_patch(thread_key=thread_key, patch={"stage": "triage_done"}, ttl_seconds=3600)
    state = saver.state_read(thread_key=thread_key)

    # 4) Write a checkpoint for recovery/resume
    cp = saver.checkpoint_write(
        thread_key=thread_key,
        state=state.state,
        metadata={"node": "resume_agent"},
        ttl_seconds=86400,
    )

    # 5) Long-term recall (namespaced key/value)
    saver.recall_put(
        namespace=["tenant", "t1", "user", "u1"],
        key="preferred_number",
        value={"value": "+12368586928"},
        tags={"type": "preference"},
        ttl_seconds=31536000,
    )

    recall = saver.recall_get(namespace=["tenant", "t1", "user", "u1"], key="preferred_number")

    results = saver.recall_search(
        query=RecallQuery(
            namespace=["tenant", "t1", "user", "u1"],
            query={},   # backend-defined filter object
            limit=20,
        )
    )

    # Optional: forget (delete) selected recall items (selector structure is backend-defined)
    saver.recall_forget(
        namespace=["tenant", "t1", "user", "u1"],
        selector={"keys": ["preferred_number"]},
    )

Input expectations (SDK-level)
------------------------------
thread_key:
    - required, non-empty after normalization

ThreadMessage:
    - role should be one of: "human", "ai", "system", "tool"
    - content should be non-empty for meaningful history

namespace (recall):
    - must be ``list[str]`` (not a single string)

limit:
    - always pass a bounded limit for reads/searches to control payload size

Errors and reliability
----------------------
- Local validation issues typically raise ValueError (e.g., invalid/empty thread_key).
- Remote API/network failures surface as SDK client errors/exceptions.
- For production observability, enable request correlation IDs in your client stack
  and include them in logs/traces where possible.

See also
--------
- :class:`~aiel_sdk.memory.saver.Saver`
- :class:`~aiel_sdk.memory.types.ThreadMessage`
- :class:`~aiel_sdk.memory.types.ThreadState`
- :class:`~aiel_sdk.memory.types.Checkpoint`
- :class:`~aiel_sdk.memory.types.RecallItem`
- :class:`~aiel_sdk.memory.types.RecallQuery`
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional, Sequence

from ..client import AielClient

from .saver import Saver
from .types import Checkpoint, RecallItem,RecallResult, RecallQuery, Thread, ThreadMessage, ThreadScope, ThreadState
from .thread_key import normalize_thread_key


def _parse_messages(raw_messages: List[Dict[str, Any]] | None) -> List[ThreadMessage]:
    out: List[ThreadMessage] = []
    for m in (raw_messages or []):
        if not isinstance(m, dict):
            continue
        role = str(m.get("role") or "human")
        if role not in ("human", "ai", "system", "tool"):
            role = "human"
        out.append(
            ThreadMessage(
                role=role,  # type: ignore[arg-type]
                content=str(m.get("content") or ""),
                ts=m.get("ts"),
                meta=dict(m.get("meta") or {}),
            )
        )
    return out


class RemoteSaver(Saver):
    """
    RemoteSaver
    ==========
    AIEL Memory API.

    This saver persists and retrieves four kinds of data:

    1) Thread messages (short-term context)
       - append/read ordered messages for a conversation

    2) Thread state (mutable working state)
       - patch/read a structured JSON-like object associated with the thread

    3) Checkpoints (resumable snapshots)
       - write/read a point-in-time snapshot of state + metadata

    4) Recall (long-term memory)
       - store/search key/value items under a hierarchical namespace

    Remote binding
    --------------
    RemoteSaver is bound to a *workspace* and *project* at construction time.
    Every operation targets:

    - workspace_id: the workspace boundary for the Memory API
    - project_id: the project boundary for the Memory API

    The backend derives/returns an internal ``thread_id`` when you read/write,
    while you provide a stable external ``thread_key``.

    Key concepts
    ------------
    thread_key:
        Stable external identifier for a conversation/session.
        Must be deterministic and safe to reuse across calls.
        Examples: ``"user:42/session:abc"`` or ``"jira:SUP-001"``.

    thread_id:
        Backend-derived internal ID returned by the Memory API.

    scope:
        Optional :class:`~aiel_sdk.memory.types.ThreadScope` describing visibility boundaries
        for a write/patch. When omitted, defaults to ``ThreadScope()``.

    ttl_seconds:
        Optional time-to-live for persisted data. When provided, it should be a reasonable
        bounded value to avoid unbounded storage. Exact enforcement depends on backend policy.

    Normalization
    -------------
   ``thread_key`` is normalized via :func:`~aiel_sdk.memory.thread_key.normalize_thread_key`
    before being sent to the API (e.g., trimming/validation).

    ``thread_id`` is always generated by the backend. The user only needs to provide
    a stable ``thread_key``; the SDK never requires a ``thread_id`` as input.

    Quick start
    -----------
        from aiel_sdk import AielClient
        from aiel_sdk.memory import RemoteSaver, ThreadMessage, RecallQuery

        client = AielClient(base_url="https://api.example.com", api_key="TOKEN")
        saver = RemoteSaver(client, workspace_id="ws_123", project_id="proj_456")

        # Messages
        saver.thread_write(
            thread_key="user:42/session:abc",
            messages=[ThreadMessage(role="human", content="Hello!")],
            ttl_seconds=3600,
        )
        thread = saver.thread_read(thread_key="user:42/session:abc", limit=50)

        # State
        saver.state_patch(thread_key="user:42/session:abc", patch={"step": "triage"})
        state = saver.state_read(thread_key="user:42/session:abc")

        # Checkpoint
        cp = saver.checkpoint_write(
            thread_key="user:42/session:abc",
            state=state.state,
            metadata={"source": "agent"},
        )

        # Recall
        saver.recall_put(
            namespace=["users", "42"],
            key="preferences",
            value={"tone": "concise"},
            tags={"app": "support"},
        )
        item = saver.recall_get(namespace=["users", "42"], key="preferences")
        items = saver.recall_search(query=RecallQuery(namespace=["users", "42"], limit=20))

    Notes
    -----
    - API/network failures surface via SDK client exceptions (e.g., integration/API errors).
    - Input validation may raise ValueError (e.g., malformed/empty thread_key).
    - Always pass bounded limits for reads/searches to control payload size.
    """

    def __init__(self, client: AielClient, *, workspace_id: str, project_id: str) -> None:
        """
        Create a RemoteSaver bound to a specific workspace/project.

        Args:
            client:
                Initialized :class:`~aiel_sdk.client.AielClient` with auth configured.
            workspace_id:
                Workspace identifier for Memory API requests.
            project_id:
                Project identifier for Memory API requests.
        """
        self._c = client
        self._ws = workspace_id
        self._proj = project_id

    # ---- Thread ----
    def thread_read(self, *, thread_key: str, limit: int = 50) -> Thread:
        """
        Read messages for a thread (short-term memory).

        Args:
            thread_key:
                Stable external thread identifier (normalized before request).
            limit:
                Max number of most recent messages to return (backend-enforced ordering).

        Returns:
            Thread:
                Contains ``thread_key``, backend ``thread_id``, parsed ``messages``, and ``updated_at``.
        """
        k = normalize_thread_key(thread_key)
        out = self._c.memory.thread_get_messages(self._ws, self._proj, thread_key=k, limit=limit)
        return Thread(thread_key=k, thread_id=out.thread_id, messages=_parse_messages(out.messages), updated_at=out.updated_at)

    def thread_write(
        self,
        *,
        thread_key: str,
        messages: Sequence[ThreadMessage],
        scope: ThreadScope | None = None,
        ttl_seconds: int | None = None,
    ) -> Thread:
        """
        Append messages to a thread (short-term memory).

        This is typically used to persist chat history or tool traces.

        Args:
            thread_key:
                Stable external thread identifier (normalized before request).
            messages:
                Sequence of :class:`~aiel_sdk.memory.types.ThreadMessage` to append.
            scope:
                Optional :class:`~aiel_sdk.memory.types.ThreadScope` controlling visibility.
                Defaults to ``ThreadScope()``.
            ttl_seconds:
                Optional TTL applied to this write, if supported by backend.

        Returns:
            Thread:
                Updated thread with messages as returned by the backend (may include server timestamps).
        """
        k = normalize_thread_key(thread_key)
        payload = {
            "scope": (scope or ThreadScope()).to_json(),
            "messages": [m.to_json() for m in messages],
            "ttl_seconds": ttl_seconds,
        }
        out = self._c.memory.thread_append_messages(self._ws, self._proj, thread_key=k, payload=payload)
        return Thread(thread_key=k, thread_id=out.thread_id, messages=_parse_messages(out.messages), updated_at=out.updated_at)

    # ---- State ----
    def state_read(self, *, thread_key: str) -> ThreadState:
        """
        Read the structured state object for a thread.

        Args:
            thread_key:
                Stable external thread identifier (normalized before request).

        Returns:
            ThreadState:
                Contains ``thread_key``, backend ``thread_id``, ``state`` (dict), and ``updated_at``.
        """
        k = normalize_thread_key(thread_key)
        out = self._c.memory.thread_get_state(self._ws, self._proj, thread_key=k)
        return ThreadState(thread_key=k, thread_id=out.thread_id, state=dict(out.state or {}), updated_at=out.updated_at)

    def state_patch(
        self,
        *,
        thread_key: str,
        patch: Dict[str, Any],
        scope: ThreadScope | None = None,
        ttl_seconds: int | None = None,
    ) -> ThreadState:
        """
        Patch (merge/update) the structured state for a thread.

        Intended for mutable workflow/runtime state (e.g., stage transitions, extracted entities,
        agent routing decisions). The backend defines the patch semantics; this method sends a
        JSON patch-like dict.

        Args:
            thread_key:
                Stable external thread identifier (normalized before request).
            patch:
                Dict of fields to update. Empty dict is allowed but typically not useful.
            scope:
                Optional :class:`~aiel_sdk.memory.types.ThreadScope` controlling visibility.
                Defaults to ``ThreadScope()``.
            ttl_seconds:
                Optional TTL applied to this patch, if supported by backend.

        Returns:
            ThreadState:
                Updated thread state returned by the backend.
        """
        k = normalize_thread_key(thread_key)
        payload = {
            "scope": (scope or ThreadScope()).to_json(),
            "patch": patch or {},
            "ttl_seconds": ttl_seconds,
        }
        out = self._c.memory.thread_patch_state(self._ws, self._proj, thread_key=k, payload=payload)
        return ThreadState(thread_key=k, thread_id=out.thread_id, state=dict(out.state or {}), updated_at=out.updated_at)

    # ---- Checkpoints ----
    def checkpoint_read_latest(self, *, thread_key: str) -> Optional[Checkpoint]:
        """
            Read the latest checkpoint for a thread, if any exists.

            If the backend returns "not found" (or empty body), this method returns None.

            Args:
                thread_key:
                    Stable external thread identifier (normalized before request).

            Returns:
                Checkpoint | None:
                    Latest checkpoint snapshot, or None when no checkpoint exists.
        """
        k = normalize_thread_key(thread_key)
        raw = self._c.memory.checkpoint_get_latest(self._ws, self._proj, thread_key=k) or {}
        # If your endpoint returns 404/no-body, MemoryService should handle that.
        if not raw:
            return None
        # If you switched MemoryService to return CheckpointOut, then 'raw' is a model.
        if hasattr(raw, "model_dump"):
            d = raw.model_dump()
        else:
            d = dict(raw)
        return Checkpoint(
            thread_key=k,
            thread_id=d.get("thread_id"),
            checkpoint_id=d.get("checkpoint_id"),
            state=dict(d.get("state") or {}),
            metadata=dict(d.get("metadata") or {}),
            created_at=d.get("created_at"),
        )

    def checkpoint_write(
        self,
        *,
        thread_key: str,
        state: Dict[str, Any],
        metadata: Dict[str, Any] | None = None,
        scope: ThreadScope | None = None,
        ttl_seconds: int | None = None,
    ) -> Checkpoint:
        """
        Write a checkpoint snapshot for recovery/resume.

        Use checkpoints at key milestones so an agent/workflow can resume after failure
        without replaying the full conversation.

        Args:
            thread_key:
                Stable external thread identifier (normalized before request).
            state:
                Full structured state snapshot to persist.
            metadata:
                Optional dict of checkpoint metadata (e.g., node name, source agent, version).
            scope:
                Optional :class:`~aiel_sdk.memory.types.ThreadScope` controlling visibility.
                Defaults to ``ThreadScope()``.
            ttl_seconds:
                Optional TTL applied to this checkpoint, if supported by backend.

        Returns:
            Checkpoint:
                Persisted checkpoint as returned by the backend (includes checkpoint_id when available).
        """
        k = normalize_thread_key(thread_key)
        payload = {
            "scope": (scope or ThreadScope()).to_json(),
            "state": state or {},
            "metadata": metadata or {},
            "ttl_seconds": ttl_seconds,
        }
        raw = self._c.memory.checkpoint_put(self._ws, self._proj, thread_key=k, payload=payload) or {}
        if hasattr(raw, "model_dump"):
            d = raw.model_dump()
        else:
            d = dict(raw)
        return Checkpoint(
            thread_key=k,
            thread_id=d.get("thread_id"),
            checkpoint_id=d.get("checkpoint_id"),
            state=dict(d.get("state") or state or {}),
            metadata=dict(d.get("metadata") or metadata or {}),
            created_at=d.get("created_at"),
        )

    # ---- Recall ----
    def recall_put(
        self,
        *,
        namespace: List[str],
        key: str,
        value: Dict[str, Any],
        tags: Dict[str, Any] | None = None,
        ttl_seconds: int | None = None,
    ) -> RecallResult:
        """
        Store/overwrite a long-term recall item.

        Recall is a namespaced key/value store intended for durable preferences, profiles,
        and other long-lived facts.

        Args:
            namespace:
                Hierarchical namespace as ``list[str]`` (example: ``["users", "42"]``).
            key:
                Item key within the namespace (example: ``"preferences"`` or ``"profile:v2"``).
            value:
                JSON-like dict payload to store.
            tags:
                Optional tags for filtering/analytics (backend-dependent).
            ttl_seconds:
                Optional TTL applied to this recall item, if supported by backend.

        Returns:
             RecallResult
                ok: boolean
                id: stringe
        """
        return self._c.memory.recall_put(
            self._ws,
            self._proj,
            payload={"namespace": namespace, "key": key, "value": value or {}, "tags": tags or {}, "ttl_seconds": ttl_seconds},
        )

    def recall_get(self, *, namespace: List[str], key: str) -> Optional[RecallItem]:
        """
        Read a recall item by namespace + key.

        Backend implementations vary: some return a "found" flag wrapper, others return
        the item directly. This SDK method returns a result object when data is present.

        Args:
            namespace:
                Hierarchical namespace as ``list[str]``.
            key:
                Recall key.

        Returns:
            RecallResult | None:
                - None when the backend returns no body / not found.
                - Otherwise a :class:`~aiel_sdk.memory.types.RecallResult` with ``found`` and ``item``.
        """
        raw = self._c.memory.recall_get(self._ws, self._proj, payload={"namespace": namespace, "key": key}) or {}
        if not raw:
            return None
        return RecallResult(
            found=raw.get("found") or False,
            item=dict(raw.get("item") or {})
        )

    def recall_search(self, *, query: RecallQuery) -> List[RecallItem]:
        """
        Search recall items within a namespace.

        Args:
            query:
                :class:`~aiel_sdk.memory.types.RecallQuery` containing:
                - namespace: list[str]
                - query: dict filter (backend-defined)
                - limit: bounded max results

        Returns:
            list[RecallItem]:
                Parsed recall items. Returns an empty list if the backend response is malformed
                or contains no items.
        """
        raw = self._c.memory.recall_search(
            self._ws,
            self._proj,
            payload={"namespace": query.namespace, "query": query.query or {}, "limit": query.limit},
        ) or {}
        items = raw.get("items") if isinstance(raw, dict) else None
        if not isinstance(items, list):
            return []
        out: List[RecallItem] = []
        for it in items:
            if not isinstance(it, dict):
                continue
            out.append(
                RecallItem(
                    namespace=list(it.get("namespace") or query.namespace),
                    key=str(it.get("key") or ""),
                    value=dict(it.get("value") or {}),
                    tags=dict(it.get("tags") or {}),
                    updated_at=it.get("updated_at"),
                )
            )
        return out

    def recall_forget(self, *, namespace: List[str], selector: Dict[str, Any]) -> None:
        """
        Delete recall items within a namespace matching a selector.

        The selector structure is backend-defined (e.g., by keys, tags, predicates).
        For safety, prefer selectors that explicitly list keys to delete.

        Args:
            namespace:
                Hierarchical namespace as ``list[str]``.
            selector:
                Backend-defined selection criteria (example: ``{"keys": ["preferences"]}``).

        Returns:
            None
        """
        return self._c.memory.recall_forget(self._ws, self._proj, payload={"namespace": namespace, "selector": selector or {}})
