"""TencentDB Agent Memory v2 Python SDK — synchronous + asynchronous clients.

Exposes the v2 data-plane API (14 routes) over a Bearer-token authenticated
HTTP transport.
"""

from __future__ import annotations

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

from ._http import AsyncHttpStub, HttpStub, Stub
from .cos import AsyncMemoryFileReader, AsyncStsCredentialManager, MemoryFileReader, StsCredentialManager

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_V2 = "/v2"


def _strip_none(d: Dict[str, Any]) -> Dict[str, Any]:
    """Return a copy of *d* with ``None`` values removed."""
    return {k: v for k, v in d.items() if v is not None}


# ---------------------------------------------------------------------------
# Synchronous client
# ---------------------------------------------------------------------------

class MemoryClient:
    """Synchronous client for the TencentDB Agent Memory v2 data-plane API.

    Example::

        from tencentdb_agent_memory import MemoryClient

        client = MemoryClient(
            endpoint="https://memory.tencentyun.com",
            api_key="sk-xxxxxxxx",
            service_id="mem-xxxxxxxx",
        )
        result = client.add_conversation("sess-1", [
            {"role": "user", "content": "hello"},
        ])
        print(result)  # {"accepted_ids": [...], "total_count": 1}

    Parameters
    ----------
    endpoint : str
        Base URL of the memory service.
    api_key : str
        Bearer token.
    service_id : str
        Memory instance ID (sent via ``x-tdai-service-id`` header).
    timeout : float
        Request timeout in seconds.
    stub : Stub | None
        Inject a custom transport (useful for testing).
    """

    def __init__(
        self,
        endpoint: str = "",
        api_key: str = "",
        service_id: Optional[str] = None,
        *,
        timeout: float = 30,
        verify: bool = False,
        stub: Optional[Stub] = None,
    ) -> None:
        if stub is not None:
            self._stub = stub
        else:
            if not service_id:
                raise ValueError("service_id must be provided")
            self._stub = HttpStub(endpoint, api_key, service_id, timeout=timeout, verify=verify)

        # Memory file reader (lazy init on first read_file call)
        self._cos_reader: Optional[MemoryFileReader] = None
        self._sts_manager: Optional[StsCredentialManager] = None

    # -- L0 Conversation ---------------------------------------------------

    def add_conversation(
        self,
        session_id: str,
        messages: List[Dict[str, Any]],
    ) -> Dict[str, Any]:
        """``POST /conversation/add``"""
        return self._stub.post(
            f"{_V2}/conversation/add",
            {"session_id": session_id, "messages": messages},
        )

    def query_conversation(
        self,
        *,
        session_id: Optional[str] = None,
        limit: Optional[int] = None,
        offset: Optional[int] = None,
        time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        """``POST /conversation/query``"""
        return self._stub.post(
            f"{_V2}/conversation/query",
            _strip_none({
                "session_id": session_id,
                "limit": limit,
                "offset": offset,
                "time_start": time_start,
                "time_end": time_end,
            }),
        )

    def search_conversation(
        self,
        query: str,
        *,
        limit: Optional[int] = None,
        session_id: Optional[str] = None,
        time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        """``POST /conversation/search``"""
        return self._stub.post(
            f"{_V2}/conversation/search",
            _strip_none({
                "query": query,
                "limit": limit,
                "session_id": session_id,
                "time_start": time_start,
                "time_end": time_end,
            }),
        )

    def delete_conversation(
        self,
        *,
        message_ids: Optional[List[str]] = None,
        session_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """``POST /conversation/delete`` — *message_ids* 和 *session_id* 二选一。"""
        return self._stub.post(
            f"{_V2}/conversation/delete",
            _strip_none({
                "message_ids": message_ids,
                "session_id": session_id,
            }),
        )

    # -- L1 Atomic ---------------------------------------------------------

    def update_atomic(self, id: str, content: str, *, background: Optional[str] = None) -> Dict[str, Any]:
        """``POST /atomic/update``"""
        return self._stub.post(
            f"{_V2}/atomic/update",
            _strip_none({"id": id, "content": content, "background": background}),
        )

    def query_atomic(
        self,
        *,
        type: Optional[str] = None,
        limit: Optional[int] = None,
        offset: Optional[int] = None,
        time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        """``POST /atomic/query``"""
        return self._stub.post(
            f"{_V2}/atomic/query",
            _strip_none({
                "type": type,
                "limit": limit,
                "offset": offset,
                "time_start": time_start,
                "time_end": time_end,
            }),
        )

    def search_atomic(
        self,
        query: str,
        *,
        limit: Optional[int] = None,
        type: Optional[str] = None,
        time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        """``POST /atomic/search``"""
        return self._stub.post(
            f"{_V2}/atomic/search",
            _strip_none({
                "query": query,
                "limit": limit,
                "type": type,
                "time_start": time_start,
                "time_end": time_end,
            }),
        )

    def delete_atomic(self, ids: List[str]) -> Dict[str, Any]:
        """``POST /atomic/delete``"""
        return self._stub.post(f"{_V2}/atomic/delete", {"ids": ids})

    # -- L2 Scenario -------------------------------------------------------

    def list_scenarios(
        self,
        *,
        path_prefix: Optional[str] = None,
    ) -> Dict[str, Any]:
        """``POST /scenario/ls``"""
        return self._stub.post(
            f"{_V2}/scenario/ls",
            _strip_none({
                "path_prefix": path_prefix,
            }),
        )

    def read_scenario(self, path: str) -> Dict[str, Any]:
        """``POST /scenario/read``

        Returns dict with ``content``, ``created_at``, ``updated_at``.
        If the file does not exist, ``content`` will be ``None``.
        """
        return self._stub.post(f"{_V2}/scenario/read", {"path": path})

    def write_scenario(self, path: str, content: str, *, summary: Optional[str] = None) -> Dict[str, Any]:
        """``POST /scenario/write``"""
        return self._stub.post(
            f"{_V2}/scenario/write",
            _strip_none({"path": path, "content": content, "summary": summary}),
        )

    def rm_scenario(self, path: str) -> Dict[str, Any]:
        """``POST /scenario/rm``"""
        return self._stub.post(f"{_V2}/scenario/rm", {"path": path})

    # -- L3 Core -----------------------------------------------------------

    def read_core(self) -> Dict[str, Any]:
        """``POST /core/read``

        Returns dict with ``content``, ``created_at``, ``updated_at``.
        If core memory has not been generated yet, ``content`` will be ``None``.
        """
        return self._stub.post(f"{_V2}/core/read", {})

    def write_core(self, content: str) -> Dict[str, Any]:
        """``POST /core/write``"""
        return self._stub.post(f"{_V2}/core/write", {"content": content})

    # -- Offload (Ingest + Compact + Query-MMD) ----------------------------

    def offload_ingest(
        self,
        session_id: str,
        tool_pairs: List[Dict[str, Any]],
        *,
        prompt: Optional[str] = None,
        recent_messages: Optional[List[Dict[str, Any]]] = None,
    ) -> Dict[str, Any]:
        """``POST /v2/offload/ingest`` — 上报工具调用对，触发 L1 异步处理。

        可 fire-and-forget 使用（忽略返回值）。

        Parameters
        ----------
        session_id : str
            会话 ID。
        tool_pairs : list[dict]
            工具调用对列表，每个元素包含 ``tool_name``、``tool_call_id``、
            ``params``、``result``、``timestamp``，可选 ``duration_ms``。
        prompt : str, optional
            最新 user message，用于 L1.5 任务判断。
        recent_messages : list[dict], optional
            近期历史消息列表（``role`` + ``content``），辅助 L1 提取上下文。
        """
        return self._stub.post(
            f"{_V2}/offload/ingest",
            _strip_none({
                "session_id": session_id,
                "tool_pairs": tool_pairs,
                "prompt": prompt,
                "recent_messages": recent_messages,
            }),
        )

    def offload_compact(
        self,
        session_id: str,
        messages: List[Dict[str, Any]],
        ratio: float,
        total_tokens: int,
        *,
        context_window: Optional[int] = None,
        message_tokens: Optional[List[int]] = None,
    ) -> Dict[str, Any]:
        """``POST /v2/offload/compact`` — 对 messages 执行服务端上下文压缩。

        Parameters
        ----------
        session_id : str
            会话 ID。
        messages : list[dict]
            当前完整对话消息列表。
        ratio : float
            当前 token 使用比例（已用 / context_window），触发压缩策略判断。
        total_tokens : int
            当前完整上下文的总 token 数（包含 system prompt、tool schemas 等不在
            messages 中的隐性开销）。服务端用于计算 fixed overhead 和校准 token 估算。
        context_window : int, optional
            模型 context window 大小（token 数）。
        message_tokens : list[int], optional
            每条消息对应的 token 数，提供时可跳过服务端估算，提升性能。

        Returns
        -------
        dict
            ``messages``（压缩后消息列表）+ ``report``（压缩报告）。
        """
        return self._stub.post(
            f"{_V2}/offload/compact",
            _strip_none({
                "session_id": session_id,
                "messages": messages,
                "ratio": ratio,
                "total_tokens": total_tokens,
                "context_window": context_window,
                "message_tokens": message_tokens,
            }),
        )

    def offload_query_mmd(
        self,
        session_id: str,
        *,
        limit: Optional[int] = None,
    ) -> Dict[str, Any]:
        """``POST /v2/offload/query-mmd`` — 查询 session 的任务流程图（MMD 文件）。

        Parameters
        ----------
        session_id : str
            会话 ID。
        limit : int, optional
            最多返回几个 MMD 文件。``limit=1`` 时走快速路径只返回当前活跃 MMD。

        Returns
        -------
        dict
            ``mmds``（列表，每项含 ``filename``、``content``、``version``）+
            ``current_mmd``（当前活跃 MMD 文件名，无则为 ``None``）。
        """
        return self._stub.post(
            f"{_V2}/offload/query-mmd",
            _strip_none({
                "session_id": session_id,
                "limit": limit,
            }),
        )

    # -- File read (memory pipeline artifacts) -----------------------------

    def read_file(self, path: str) -> str:
        """Read a memory pipeline artifact (e.g. ``persona.md``,
        ``scene_blocks/*.md``) by relative path.

        Parameters
        ----------
        path : str
            Relative path within the memory space, e.g.
            ``"scene_blocks/cooking-recipes.md"`` or ``"persona.md"``.

        Returns
        -------
        str
            File content.

        Raises
        ------
        TDAMError
            On 404 (not found), 403 (auth failure after retry), or other errors.
        """
        if self._cos_reader is None:
            self._sts_manager = StsCredentialManager(
                endpoint=self._stub.endpoint,
                api_key=self._stub.headers["Authorization"].removeprefix("Bearer "),
                service_id=self._stub.headers["x-tdai-service-id"],
            )
            self._cos_reader = MemoryFileReader(self._sts_manager)
        return self._cos_reader.read(path)

    # -- lifecycle ---------------------------------------------------------

    def close(self) -> None:
        if self._cos_reader is not None:
            self._cos_reader.close()
        self._stub.close()

    def __enter__(self) -> "MemoryClient":
        return self

    def __exit__(self, *exc: Any) -> None:
        self.close()


# ---------------------------------------------------------------------------
# Asynchronous client
# ---------------------------------------------------------------------------

class AsyncMemoryClient:
    """Asynchronous client for the TencentDB Agent Memory v2 data-plane API.

    Same API surface as :class:`MemoryClient` but all methods are coroutines.
    """

    def __init__(
        self,
        endpoint: str = "",
        api_key: str = "",
        service_id: Optional[str] = None,
        *,
        timeout: float = 30,
        verify: bool = False,
    ) -> None:
        if not service_id:
            raise ValueError("service_id must be provided")
        self._stub = AsyncHttpStub(endpoint, api_key, service_id, timeout=timeout, verify=verify)

        # Memory file reader (lazy init)
        self._cos_reader: Optional[AsyncMemoryFileReader] = None
        self._sts_manager: Optional[AsyncStsCredentialManager] = None

    # -- L0 Conversation ---------------------------------------------------

    async def add_conversation(
        self, session_id: str, messages: List[Dict[str, Any]],
    ) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/conversation/add",
            {"session_id": session_id, "messages": messages},
        )

    async def query_conversation(
        self, *, session_id: Optional[str] = None, limit: Optional[int] = None,
        offset: Optional[int] = None, time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/conversation/query",
            _strip_none({"session_id": session_id, "limit": limit, "offset": offset,
                         "time_start": time_start, "time_end": time_end}),
        )

    async def search_conversation(
        self, query: str, *, limit: Optional[int] = None,
        session_id: Optional[str] = None, time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/conversation/search",
            _strip_none({"query": query, "limit": limit, "session_id": session_id,
                         "time_start": time_start, "time_end": time_end}),
        )

    async def delete_conversation(
        self, *, message_ids: Optional[List[str]] = None,
        session_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/conversation/delete",
            _strip_none({"message_ids": message_ids, "session_id": session_id}),
        )

    # -- L1 Atomic ---------------------------------------------------------

    async def update_atomic(self, id: str, content: str, *, background: Optional[str] = None) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/atomic/update",
            _strip_none({"id": id, "content": content, "background": background}),
        )

    async def query_atomic(
        self, *, type: Optional[str] = None, limit: Optional[int] = None,
        offset: Optional[int] = None, time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/atomic/query",
            _strip_none({"type": type, "limit": limit, "offset": offset,
                         "time_start": time_start, "time_end": time_end}),
        )

    async def search_atomic(
        self, query: str, *, limit: Optional[int] = None,
        type: Optional[str] = None, time_start: Optional[str] = None,
        time_end: Optional[str] = None,
    ) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/atomic/search",
            _strip_none({"query": query, "limit": limit, "type": type,
                         "time_start": time_start, "time_end": time_end}),
        )

    async def delete_atomic(self, ids: List[str]) -> Dict[str, Any]:
        return await self._stub.post(f"{_V2}/atomic/delete", {"ids": ids})

    # -- L2 Scenario -------------------------------------------------------

    async def list_scenarios(
        self, *, path_prefix: Optional[str] = None,
    ) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/scenario/ls",
            _strip_none({"path_prefix": path_prefix}),
        )

    async def read_scenario(self, path: str) -> Dict[str, Any]:
        """``POST /scenario/read`` — returns ``content: None`` if file does not exist."""
        return await self._stub.post(f"{_V2}/scenario/read", {"path": path})

    async def write_scenario(self, path: str, content: str, *, summary: Optional[str] = None) -> Dict[str, Any]:
        return await self._stub.post(
            f"{_V2}/scenario/write", _strip_none({"path": path, "content": content, "summary": summary}),
        )

    async def rm_scenario(self, path: str) -> Dict[str, Any]:
        return await self._stub.post(f"{_V2}/scenario/rm", {"path": path})

    # -- L3 Core -----------------------------------------------------------

    async def read_core(self) -> Dict[str, Any]:
        """``POST /core/read`` — returns ``content: None`` if not yet generated."""
        return await self._stub.post(f"{_V2}/core/read", {})

    async def write_core(self, content: str) -> Dict[str, Any]:
        return await self._stub.post(f"{_V2}/core/write", {"content": content})

    # -- Offload (Ingest + Compact + Query-MMD) ----------------------------

    async def offload_ingest(
        self,
        session_id: str,
        tool_pairs: List[Dict[str, Any]],
        *,
        prompt: Optional[str] = None,
        recent_messages: Optional[List[Dict[str, Any]]] = None,
    ) -> Dict[str, Any]:
        """``POST /v2/offload/ingest``（异步）"""
        return await self._stub.post(
            f"{_V2}/offload/ingest",
            _strip_none({
                "session_id": session_id,
                "tool_pairs": tool_pairs,
                "prompt": prompt,
                "recent_messages": recent_messages,
            }),
        )

    async def offload_compact(
        self,
        session_id: str,
        messages: List[Dict[str, Any]],
        ratio: float,
        total_tokens: int,
        *,
        context_window: Optional[int] = None,
        message_tokens: Optional[List[int]] = None,
    ) -> Dict[str, Any]:
        """``POST /v2/offload/compact``（异步）"""
        return await self._stub.post(
            f"{_V2}/offload/compact",
            _strip_none({
                "session_id": session_id,
                "messages": messages,
                "ratio": ratio,
                "total_tokens": total_tokens,
                "context_window": context_window,
                "message_tokens": message_tokens,
            }),
        )

    async def offload_query_mmd(
        self,
        session_id: str,
        *,
        limit: Optional[int] = None,
    ) -> Dict[str, Any]:
        """``POST /v2/offload/query-mmd``（异步）"""
        return await self._stub.post(
            f"{_V2}/offload/query-mmd",
            _strip_none({
                "session_id": session_id,
                "limit": limit,
            }),
        )

    # -- lifecycle ---------------------------------------------------------

    # -- File read (memory pipeline artifacts) -----------------------------

    async def read_file(self, path: str) -> str:
        """Read a memory pipeline artifact (async)."""
        if self._cos_reader is None:
            self._sts_manager = AsyncStsCredentialManager(
                endpoint=self._stub.endpoint,
                api_key=self._stub.headers["Authorization"].removeprefix("Bearer "),
                service_id=self._stub.headers["x-tdai-service-id"],
            )
            self._cos_reader = AsyncMemoryFileReader(self._sts_manager)
        return await self._cos_reader.read(path)

    # -- lifecycle ---------------------------------------------------------

    async def close(self) -> None:
        if self._cos_reader is not None:
            await self._cos_reader.close()
        await self._stub.close()

    async def __aenter__(self) -> "AsyncMemoryClient":
        return self

    async def __aexit__(self, *exc: Any) -> None:
        await self.close()
