from __future__ import annotations

import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from memnixa.agent import AgentRuntime
from memnixa.config import AppConfig, MemoryConfig, ProviderConfig
from memnixa.context import ContextBuilder
from memnixa.context_window import estimate_prompt_tokens
from memnixa.gateway.messages import InboundMessageContext
from memnixa.models import LLMResponse, ToolCall
from memnixa.memory import SELF_MEMORY_SCOPE
from memnixa.runtime_env import RuntimeExecutionInfo
from memnixa.session import SessionStore
from memnixa.tools import build_default_tool_registry


class FakeProvider:
    def __init__(self, model_name: str, manager: "FakeProviderManager") -> None:
        self.calls = 0
        self.model_name = model_name
        self.manager = manager

    def chat(
        self, messages: list[dict], tools: list[dict] | None = None
    ) -> LLMResponse:
        self.calls += 1
        self.manager.last_messages = messages
        if self.calls == 1:
            return LLMResponse(
                content="I should inspect the workspace first.",
                tool_calls=[
                    ToolCall(id="tool-1", name="list_dir", arguments={"path": "."})
                ],
            )
        return LLMResponse(content=f"I checked the workspace with {self.model_name}.")


class FakeProviderManager:
    def __init__(self) -> None:
        self.profiles = {
            "1": ProviderConfig(
                id="1",
                preset="openai_chatgpt",
                api_key="key-1",
                model="model-one",
                label="Model One",
            ),
            "2": ProviderConfig(
                id="2",
                preset="zhipu_coding_plan",
                api_key="key-2",
                model="model-two",
                label="Model Two",
            ),
        }
        self.last_messages: list[dict] = []

    def list_profiles(self) -> list[ProviderConfig]:
        return [self.profiles["1"], self.profiles["2"]]

    def get_profile(self, provider_id: str | None = None) -> ProviderConfig:
        key = provider_id or "1"
        if key not in self.profiles:
            raise ValueError(f"unknown provider id '{key}', expected one of: 1, 2")
        return self.profiles[key]

    def describe(self, provider_id: str | None = None) -> dict[str, str]:
        profile = self.get_profile(provider_id)
        return {
            "id": profile.id,
            "preset": profile.preset,
            "label": profile.label or profile.preset,
            "model": profile.model or "(default)",
        }

    def build(self, provider_id: str | None = None) -> FakeProvider:
        profile = self.get_profile(provider_id)
        return FakeProvider(profile.model or "(default)", self)


class CompactionAwareProvider:
    def __init__(
        self,
        model_name: str,
        manager: "CompactionProviderManager",
        *,
        overflow_once: bool = False,
    ) -> None:
        self.model_name = model_name
        self.manager = manager
        self.overflow_once = overflow_once
        self.overflow_emitted = False

    def estimate_prompt_tokens(
        self, messages: list[dict], tools: list[dict] | None = None
    ) -> tuple[int, str]:
        return estimate_prompt_tokens(messages, tools), "test_estimator"

    def chat(
        self, messages: list[dict], tools: list[dict] | None = None
    ) -> LLMResponse:
        first_system = str(messages[0]["content"]) if messages else ""
        if first_system.startswith("You summarize older agent conversation history"):
            self.manager.summary_calls += 1
            return LLMResponse(
                content=(
                    "## Decisions\n"
                    "- Earlier workspace inspection was completed.\n\n"
                    "## Pending user asks\n"
                    "- Continue with the latest request."
                )
            )

        self.manager.last_messages = messages
        self.manager.regular_calls += 1
        if self.overflow_once and not self.overflow_emitted:
            self.overflow_emitted = True
            return LLMResponse(
                content="maximum context length exceeded for this model",
                finish_reason="error",
            )
        return LLMResponse(
            content=f"Compaction-safe reply from {self.model_name}.",
            usage={"prompt_tokens": 96, "completion_tokens": 12},
        )


class CompactionProviderManager(FakeProviderManager):
    def __init__(self, *, overflow_once: bool = False) -> None:
        super().__init__()
        self.last_messages: list[dict] = []
        self.regular_calls = 0
        self.summary_calls = 0
        self.provider = CompactionAwareProvider(
            "model-one",
            self,
            overflow_once=overflow_once,
        )

    def build(self, provider_id: str | None = None) -> CompactionAwareProvider:
        _ = self.get_profile(provider_id)
        return self.provider


class MemoryExtractorProvider:
    def __init__(self, content: str, *, raises_timeout: bool = False) -> None:
        self.content = content
        self.model_name = "memory-extractor-8b"
        self.calls = 0
        self.last_messages: list[dict] = []
        self.raises_timeout = raises_timeout
        self.timeout = 120

    def chat(
        self, messages: list[dict], tools: list[dict] | None = None
    ) -> LLMResponse:
        self.calls += 1
        self.last_messages = messages
        if self.raises_timeout:
            raise TimeoutError("memory extractor timed out")
        return LLMResponse(content=self.content)


class MemoryAwareProviderManager(FakeProviderManager):
    def __init__(self, extractor_content: str, *, raises_timeout: bool = False) -> None:
        super().__init__()
        self.profiles["8"] = ProviderConfig(
            id="8",
            preset="custom_openai_compatible",
            api_key="memory-key",
            api_base="http://localhost:11434/v1",
            model="qwen-memory-8b",
            label="Memory Extractor",
        )
        self.extractor = MemoryExtractorProvider(
            extractor_content,
            raises_timeout=raises_timeout,
        )

    def build(self, provider_id: str | None = None):
        if str(provider_id or "1") == "8":
            return self.extractor
        return super().build(provider_id)


class AgentRuntimeTests(unittest.TestCase):
    def _make_runtime(self, workspace: Path) -> AgentRuntime:
        config = AppConfig(
            api_key="test-key",
            workspace=workspace,
            database_path=workspace / ".memnixa/memnixa.db",
            default_provider="1",
            max_iterations=4,
        )
        return AgentRuntime(
            config=config,
            providers=FakeProviderManager(),
            session=SessionStore(config.storage_path),
            tools=build_default_tool_registry(workspace),
            context_builder=ContextBuilder(config),
        )

    def _make_memory_runtime(
        self,
        workspace: Path,
        *,
        extractor_content: str,
        raises_timeout: bool = False,
    ) -> tuple[AgentRuntime, MemoryAwareProviderManager]:
        config = AppConfig(
            api_key="test-key",
            workspace=workspace,
            database_path=workspace / ".memnixa/memnixa.db",
            default_provider="1",
            max_iterations=4,
            memory=MemoryConfig(
                enabled=True,
                extraction_provider_id="8",
                extraction_timeout_seconds=9,
                retrieval_limit=5,
                max_injected_chars=2400,
            ),
        )
        providers = MemoryAwareProviderManager(
            extractor_content,
            raises_timeout=raises_timeout,
        )
        runtime = AgentRuntime(
            config=config,
            providers=providers,
            session=SessionStore(config.storage_path),
            tools=build_default_tool_registry(workspace),
            context_builder=ContextBuilder(config),
        )
        return runtime, providers

    def _make_compaction_runtime(
        self,
        workspace: Path,
        *,
        overflow_once: bool = False,
        context_window_tokens: int = 240,
    ) -> AgentRuntime:
        config = AppConfig(
            api_key="test-key",
            workspace=workspace,
            database_path=workspace / ".memnixa/memnixa.db",
            default_provider="1",
            max_iterations=4,
            history_limit=8,
            context_window_tokens=context_window_tokens,
            max_output_tokens=32,
            context_safety_margin_tokens=0,
            context_compaction_max_rounds=2,
        )
        provider_manager = CompactionProviderManager(overflow_once=overflow_once)
        provider_manager.profiles["1"].context_window_tokens = context_window_tokens
        provider_manager.profiles["1"].max_output_tokens = 32
        return AgentRuntime(
            config=config,
            providers=provider_manager,
            session=SessionStore(config.storage_path),
            tools=build_default_tool_registry(workspace),
            context_builder=ContextBuilder(config),
        )

    def test_run_turn_executes_tool_loop(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            (workspace / "hello.txt").write_text("hello", encoding="utf-8")
            runtime = self._make_runtime(workspace)

            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Inspect the repo",
                body_for_agent="Inspect the repo",
            )
            result = runtime.run_turn(inbound)

            self.assertEqual(result.reply, "I checked the workspace with model-one.")
            self.assertEqual(result.provider_id, "1")
            self.assertEqual(result.model_name, "model-one")
            self.assertEqual(len(result.tool_events), 1)
            self.assertEqual(result.tool_events[0]["tool_name"], "list_dir")
            history = runtime.session.load_history(limit=10, session_id="main")
            self.assertEqual(history[0]["role"], "user")
            self.assertEqual(history[-1]["role"], "assistant")

    def test_system_prompt_includes_runtime_environment_details(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime = self._make_runtime(workspace)
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Inspect runtime context",
                body_for_agent="Inspect runtime context",
                source_mode="cli_local",
                channel="cli",
                launch_cwd="/tmp/launch-here",
                entrypoint_path="/usr/local/bin/memnixa",
                python_executable="/usr/bin/python3.12",
                capabilities=("slash_commands", "model_switch"),
            )

            with patch(
                "memnixa.context.detect_runtime_execution",
                return_value=RuntimeExecutionInfo(
                    runtime_mode="development",
                    install_mode="source_tree",
                    package_root="/tmp/workspace/src/memnixa",
                    project_root="/tmp/workspace",
                    dist_info_path="/tmp/workspace/.venv/lib/python3.13/site-packages/memnixa-0.1.0.dist-info",
                ),
            ):
                runtime.run_turn(inbound)

        system_prompt = runtime.providers.last_messages[0]["content"]
        self.assertIn("## Runtime Environment", system_prompt)
        self.assertIn("Host:", system_prompt)
        self.assertIn("Python:", system_prompt)
        self.assertIn("Python Executable: /usr/bin/python3.12", system_prompt)
        self.assertIn("Entrypoint: /usr/local/bin/memnixa", system_prompt)
        self.assertIn("Runtime Mode: development", system_prompt)
        self.assertIn("Install Mode: source_tree", system_prompt)
        self.assertIn("Launch CWD: /tmp/launch-here", system_prompt)
        self.assertIn("Channel: cli", system_prompt)
        self.assertIn("Capabilities: slash_commands, model_switch", system_prompt)
        self.assertIn("Source Mode: cli_local", system_prompt)
        self.assertIn("## Actor Identity", system_prompt)
        self.assertIn("Identity Status: owner", system_prompt)
        self.assertIn("Actor Role: owner", system_prompt)
        self.assertIn("Actor User ID: owner", system_prompt)
        self.assertIn("Is Owner: true", system_prompt)
        self.assertIn("## Available Tools", system_prompt)
        self.assertEqual(
            runtime.providers.last_messages[1]["content"],
            "Inspect runtime context",
        )

    def test_session_metadata_tracks_runtime_environment(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime = self._make_runtime(workspace)
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="hello",
                body_for_agent="hello",
                source_mode="channel",
                channel="feishu",
                launch_cwd="/tmp/from-cli",
                entrypoint_path="/usr/bin/memnixa",
                python_executable="/usr/bin/python3",
                capabilities=("reply_status",),
            )

            with patch(
                "memnixa.agent.detect_runtime_execution",
                return_value=RuntimeExecutionInfo(
                    runtime_mode="installed",
                    install_mode="editable",
                    package_root="/tmp/workspace/src/memnixa",
                    project_root="/tmp/workspace",
                    dist_info_path="/tmp/tools/memnixa-0.1.0.dist-info",
                ),
            ):
                runtime.run_turn(inbound)
            state = runtime.session.get_session_state("main")

        self.assertIsNotNone(state)
        self.assertEqual(state.metadata["source_mode"], "channel")
        self.assertEqual(state.metadata["channel"], "feishu")
        self.assertEqual(state.metadata["launch_cwd"], "/tmp/from-cli")
        self.assertEqual(state.metadata["entrypoint_path"], "/usr/bin/memnixa")
        self.assertEqual(state.metadata["python_executable"], "/usr/bin/python3")
        self.assertEqual(state.metadata["runtime_mode"], "installed")
        self.assertEqual(state.metadata["install_mode"], "editable")
        self.assertEqual(state.metadata["channel_capabilities"], ["reply_status"])
        self.assertEqual(state.metadata["actor_identity_status"], "unknown")
        self.assertEqual(state.metadata["actor_role"], "unknown")
        self.assertFalse(state.metadata["actor_is_owner"])

    def test_memory_context_is_injected_and_memory_tools_are_available(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime, providers = self._make_memory_runtime(
                workspace,
                extractor_content='{"items": []}',
            )
            runtime.session.upsert_memory_item(
                scope_type="user",
                scope_id="owner",
                memory_type="preference",
                content="The owner prefers concise answers in Chinese.",
                conflict_key="language-style",
                confidence=0.9,
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="How should you reply to me?",
                body_for_agent="How should you reply to me?",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )

            runtime.run_turn(inbound)

        system_prompt = providers.last_messages[0]["content"]
        injected_memory = providers.last_messages[1]["content"]
        self.assertIn("## Recalled Memory", injected_memory)
        self.assertIn("The owner prefers concise answers in Chinese.", injected_memory)
        self.assertIn("memory_search:", system_prompt)
        self.assertIn("memory_get:", system_prompt)

    def test_self_model_memory_is_injected_across_all_contexts(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime, providers = self._make_memory_runtime(
                workspace,
                extractor_content='{"operations": []}',
            )
            runtime.session.upsert_memory_item(
                scope_type=SELF_MEMORY_SCOPE[0],
                scope_id=SELF_MEMORY_SCOPE[1],
                memory_type="self_model",
                content="Memnixa serves as the owner's coding assistant and should be explicit about its capability boundaries.",
                conflict_key="self_model.core_identity",
                confidence=0.98,
            )
            inbound = InboundMessageContext(
                session_id="other-session",
                raw_body="Who are you in this project?",
                body_for_agent="Who are you in this project?",
                actor_identity_status="unknown",
                actor_user_id="guest-user",
                actor_role="guest",
                actor_external_id="guest-user",
                actor_is_owner=False,
            )

            runtime.run_turn(inbound)

        injected_memory = providers.last_messages[1]["content"]
        self.assertIn("Memnixa serves as the owner's coding assistant", injected_memory)

    def test_skill_prompt_and_tools_are_available_when_workspace_skills_exist(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            skill_dir = workspace / "skills" / "release-checklist"
            skill_dir.mkdir(parents=True, exist_ok=True)
            (skill_dir / "SKILL.md").write_text(
                "---\n"
                "name: release-checklist\n"
                "description: Use when preparing a release checklist or release notes.\n"
                "---\n\n"
                "# Release Checklist\n"
                "Always confirm version, changelog, and tests.\n",
                encoding="utf-8",
            )
            runtime = self._make_runtime(workspace)
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Prepare a release checklist",
                body_for_agent="Prepare a release checklist",
            )

            runtime.run_turn(inbound)

        system_prompt = runtime.providers.last_messages[0]["content"]
        self.assertIn("## Skills", system_prompt)
        self.assertIn("<skill_id>release-checklist</skill_id>", system_prompt)
        self.assertIn("skill_list:", system_prompt)
        self.assertIn("skill_read:", system_prompt)

    def test_memory_extraction_persists_candidates_with_extractor_provider(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            extractor_payload = json.dumps(
                {
                    "operations": [
                        {
                            "action": "upsert",
                            "scope_type": "user",
                            "scope_id": "owner",
                            "memory_type": "constraint",
                            "content": "Do not execute sudo automatically; ask first.",
                            "conflict_key": "sudo-policy",
                            "confidence": 0.94,
                        },
                        {
                            "action": "upsert",
                            "scope_type": "agent",
                            "scope_id": str(workspace),
                            "memory_type": "project_fact",
                            "content": "Scripts should be written under /home/mcallzbl/Desktop/Scripts/ when requested.",
                            "conflict_key": "script-path",
                            "confidence": 0.88,
                        },
                    ]
                },
                ensure_ascii=False,
            )
            runtime, providers = self._make_memory_runtime(
                workspace,
                extractor_content=extractor_payload,
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Remember that you should ask before sudo, and scripts belong on my desktop scripts folder.",
                body_for_agent="Remember that you should ask before sudo, and scripts belong on my desktop scripts folder.",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )

            runtime.run_turn(inbound)
            items = runtime.session.list_memory_items(limit=10)

        self.assertEqual(providers.extractor.calls, 1)
        self.assertEqual(providers.extractor.timeout, 9)
        contents = {item.content for item in items}
        self.assertIn("Do not execute sudo automatically; ask first.", contents)
        self.assertIn(
            "Scripts should be written under /home/mcallzbl/Desktop/Scripts/ when requested.",
            contents,
        )

    def test_memory_extraction_accepts_self_model_type(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            extractor_payload = json.dumps(
                {
                    "operations": [
                        {
                            "action": "upsert",
                            "scope_type": SELF_MEMORY_SCOPE[0],
                            "scope_id": SELF_MEMORY_SCOPE[1],
                            "memory_type": "self_model",
                            "content": "Memnixa should describe itself as a coding assistant that uses tools explicitly and admits uncertainty.",
                            "conflict_key": "self_model.behavior_contract",
                            "confidence": 0.96,
                        }
                    ]
                },
                ensure_ascii=False,
            )
            runtime, _providers = self._make_memory_runtime(
                workspace,
                extractor_content=extractor_payload,
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Remember your long-term role and behavior contract.",
                body_for_agent="Remember your long-term role and behavior contract.",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )

            runtime.run_turn(inbound)
            items = runtime.session.list_memory_items(limit=None)

        self.assertEqual(len(items), 1)
        self.assertEqual(items[0].scope_type, SELF_MEMORY_SCOPE[0])
        self.assertEqual(items[0].scope_id, SELF_MEMORY_SCOPE[1])
        self.assertEqual(items[0].memory_type, "self_model")

    def test_memory_review_prompt_includes_existing_related_memories(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime, providers = self._make_memory_runtime(
                workspace,
                extractor_content='{"operations": []}',
            )
            existing = runtime.session.upsert_memory_item(
                scope_type="user",
                scope_id="owner",
                memory_type="user_profile",
                content="The owner prefers to be called mcallzbl.",
                conflict_key="user_profile.name",
                confidence=0.9,
                metadata={"source_kind": "user_directive"},
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Remember to call me mcallzbl.",
                body_for_agent="Remember to call me mcallzbl.",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )

            runtime.run_turn(inbound)

        prompt_text = providers.extractor.last_messages[1]["content"]
        self.assertIn("Existing related memories:", prompt_text)
        self.assertIn(f"\"id\": {existing.id}", prompt_text)
        self.assertIn("The owner prefers to be called mcallzbl.", prompt_text)

    def test_memory_review_updates_existing_memory_without_creating_duplicate(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime, _providers = self._make_memory_runtime(
                workspace,
                extractor_content='{"operations": []}',
            )
            existing = runtime.session.upsert_memory_item(
                scope_type="user",
                scope_id="owner",
                memory_type="user_profile",
                content="The owner prefers to be called Luna.",
                conflict_key="user_profile.name",
                confidence=0.8,
            )
            extractor_payload = json.dumps(
                {
                    "operations": [
                        {
                            "action": "upsert",
                            "existing_id": existing.id,
                            "scope_type": "user",
                            "scope_id": "owner",
                            "memory_type": "user_profile",
                            "content": "The owner prefers to be called mcallzbl.",
                            "conflict_key": "user_calling_name",
                            "confidence": 0.97,
                            "reason": "Same durable fact; update the existing preferred name memory.",
                        }
                    ]
                },
                ensure_ascii=False,
            )
            runtime, providers = self._make_memory_runtime(
                workspace,
                extractor_content=extractor_payload,
            )
            runtime.session.upsert_memory_item(
                existing_id=existing.id,
                scope_type=existing.scope_type,
                scope_id=existing.scope_id,
                memory_type=existing.memory_type,
                content=existing.content,
                conflict_key=existing.conflict_key,
                confidence=existing.confidence,
                metadata=existing.metadata,
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Remember that my preferred name is mcallzbl.",
                body_for_agent="Remember that my preferred name is mcallzbl.",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )

            result = runtime.run_turn(inbound)
            items = runtime.session.list_memory_items(limit=None)

        self.assertEqual(providers.extractor.calls, 1)
        self.assertEqual(len(items), 1)
        self.assertEqual(items[0].id, existing.id)
        self.assertEqual(items[0].content, "The owner prefers to be called mcallzbl.")
        self.assertEqual(items[0].conflict_key, "user_profile.preferred_name")
        self.assertEqual(result.runtime_events[-1]["stored_count"], 1)

    def test_memory_review_ignores_time_sensitive_news_candidate(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            extractor_payload = json.dumps(
                {
                    "operations": [
                        {
                            "action": "upsert",
                            "scope_type": "agent",
                            "scope_id": str(workspace),
                            "memory_type": "project_fact",
                            "content": "今日新闻包含国际要闻、欧盟动态、中国相关、美国内政等内容。",
                            "conflict_key": "today_news_summary",
                            "confidence": 0.96,
                            "metadata": {
                                "stability": "volatile",
                                "source_kind": "summary",
                            },
                            "reason": "This was extracted from the current turn.",
                        }
                    ]
                },
                ensure_ascii=False,
            )
            runtime, _providers = self._make_memory_runtime(
                workspace,
                extractor_content=extractor_payload,
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="总结一下今天新闻。",
                body_for_agent="总结一下今天新闻。",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )

            result = runtime.run_turn(inbound)
            items = runtime.session.list_memory_items(limit=10)

        self.assertEqual(items, [])
        self.assertEqual(result.runtime_events[-1]["stored_count"], 0)
        self.assertEqual(result.runtime_events[-1]["ignored_count"], 1)

    def test_memory_extraction_timeout_does_not_abort_main_reply(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime, providers = self._make_memory_runtime(
                workspace,
                extractor_content='{"items": []}',
                raises_timeout=True,
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Remember this preference.",
                body_for_agent="Remember this preference.",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )

            result = runtime.run_turn(inbound)
            items = runtime.session.list_memory_items(limit=10)

        self.assertEqual(result.reply, "I checked the workspace with model-one.")
        self.assertEqual(providers.extractor.calls, 1)
        self.assertEqual(items, [])

    def test_runtime_event_callback_receives_tool_and_memory_events(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime, _providers = self._make_memory_runtime(
                workspace,
                extractor_content='{"items": []}',
            )
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="Inspect the repo and remember stable facts.",
                body_for_agent="Inspect the repo and remember stable facts.",
                actor_identity_status="owner",
                actor_user_id="owner",
                actor_role="owner",
                actor_external_id="owner",
                actor_is_owner=True,
            )
            seen_events: list[dict] = []

            result = runtime.run_turn(inbound, event_callback=seen_events.append)

        self.assertGreaterEqual(len(seen_events), 3)
        self.assertEqual(seen_events[0]["type"], "tool")
        self.assertEqual(seen_events[0]["tool_name"], "list_dir")
        self.assertEqual(seen_events[1]["type"], "memory")
        self.assertEqual(seen_events[1]["phase"], "start")
        self.assertEqual(seen_events[-1]["type"], "memory")
        self.assertEqual(seen_events[-1]["phase"], "complete")
        self.assertEqual(result.runtime_events, seen_events)

    def test_session_store_can_export_all_memory_items(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime, _providers = self._make_memory_runtime(
                workspace,
                extractor_content='{"items": []}',
            )
            runtime.session.upsert_memory_item(
                scope_type="user",
                scope_id="owner",
                memory_type="preference",
                content="The owner prefers Chinese.",
                conflict_key="lang-pref",
                confidence=0.9,
            )
            runtime.session.upsert_memory_item(
                scope_type="agent",
                scope_id=str(workspace),
                memory_type="project_fact",
                content="Scripts belong under /home/mcallzbl/Desktop/Scripts/.",
                conflict_key="script-path",
                confidence=0.85,
            )

            payload = runtime.session.export_memory_items()

        self.assertEqual(payload["memory_count"], 2)
        self.assertEqual(len(payload["memory_items"]), 2)
        contents = {item["content"] for item in payload["memory_items"]}
        self.assertIn("The owner prefers Chinese.", contents)
        self.assertIn(
            "Scripts belong under /home/mcallzbl/Desktop/Scripts/.",
            contents,
        )

    def test_session_history_is_isolated_by_session_id(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime = self._make_runtime(workspace)

            runtime.session.append(
                {"role": "user", "content": "from main"},
                session_id="main",
            )
            runtime.session.append(
                {"role": "user", "content": "from feishu"},
                session_id="feishu:oc_demo",
            )

            main_history = runtime.session.load_history(limit=10, session_id="main")
            feishu_history = runtime.session.load_history(
                limit=10,
                session_id="feishu:oc_demo",
            )

        self.assertEqual(main_history[0]["content"], "from main")
        self.assertEqual(feishu_history[0]["content"], "from feishu")

    def test_models_command_lists_available_providers(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            runtime = self._make_runtime(Path(temp_dir))
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="/models",
                body_for_agent="/models",
            )

            result = runtime.run_turn(inbound)

        self.assertIn("Available models", result.reply)
        self.assertIn("1. Model One", result.reply)
        self.assertIn("2. Model Two", result.reply)
        self.assertEqual(result.provider_id, "1")

    def test_models_command_uses_chinese_when_locale_is_zh(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            runtime = self._make_runtime(Path(temp_dir))
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="/models",
                body_for_agent="/models",
            )

            with patch.dict("os.environ", {"MEMNIXA_LANG": "zh-CN"}, clear=False):
                result = runtime.run_turn(inbound)

        self.assertIn("可用模型列表", result.reply)

    def test_runtime_command_reports_detected_execution_mode(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            runtime = self._make_runtime(Path(temp_dir))
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="/runtime",
                body_for_agent="/runtime",
                source_mode="cli_local",
                channel="cli",
                launch_cwd="/tmp/launch-here",
                entrypoint_path="/usr/local/bin/memnixa",
                python_executable="/usr/bin/python3.12",
            )

            with patch(
                "memnixa.agent.detect_runtime_execution",
                return_value=RuntimeExecutionInfo(
                    runtime_mode="installed",
                    install_mode="editable",
                    package_root="/tmp/workspace/src/memnixa",
                    project_root="/tmp/workspace",
                    dist_info_path="/tmp/tools/memnixa-0.1.0.dist-info",
                ),
            ):
                result = runtime.run_turn(inbound)

        self.assertIn("Current runtime:", result.reply)
        self.assertIn("runtime_mode: installed", result.reply)
        self.assertIn("install_mode: editable", result.reply)
        self.assertIn("source_mode: cli_local", result.reply)
        self.assertIn("entrypoint_path: /usr/local/bin/memnixa", result.reply)

    def test_whoami_command_reports_current_identity(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            runtime = self._make_runtime(Path(temp_dir))
            inbound = InboundMessageContext(
                session_id="main",
                raw_body="/whoami",
                body_for_agent="/whoami",
                source_mode="cli_local",
                channel="cli",
            )

            result = runtime.run_turn(inbound)

        self.assertIn("Current identity:", result.reply)
        self.assertIn("identity_status: owner", result.reply)
        self.assertIn("actor_user_id: owner", result.reply)
        self.assertIn("actor_is_owner: true", result.reply)

    def test_use_command_switches_provider_for_session(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            runtime = self._make_runtime(Path(temp_dir))
            switch = InboundMessageContext(
                session_id="main",
                raw_body="/use 2",
                body_for_agent="/use 2",
            )
            switch_result = runtime.run_turn(switch)

            followup = InboundMessageContext(
                session_id="main",
                raw_body="continue",
                body_for_agent="continue",
            )
            result = runtime.run_turn(followup)
            state = runtime.session.get_session_state("main")

        self.assertIsNotNone(state)
        self.assertEqual(state.provider_id, "2")
        self.assertEqual(result.provider_id, "2")
        self.assertEqual(result.model_name, "model-two")
        self.assertIn("Switched to model 2", switch_result.reply)
        self.assertIn("model-two", result.reply)

    def test_run_turn_compacts_old_history_before_model_call(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime = self._make_compaction_runtime(
                workspace,
                context_window_tokens=120,
            )
            for index in range(3):
                runtime.session.append(
                    {
                        "role": "user",
                        "content": f"user-{index} " + ("A" * 720),
                    },
                    session_id="main",
                )
                runtime.session.append(
                    {
                        "role": "assistant",
                        "content": f"assistant-{index} " + ("B" * 720),
                    },
                    session_id="main",
                )

            result = runtime.run_turn(
                InboundMessageContext(
                    session_id="main",
                    raw_body="continue",
                    body_for_agent="continue",
                )
            )
            state = runtime.session.get_session_state("main")
            compactions = runtime.session.list_compactions(session_id="main")
            provider_manager = runtime.providers

        self.assertEqual(result.reply, "Compaction-safe reply from model-one.")
        self.assertEqual(len(compactions), 1)
        self.assertIsNotNone(state)
        self.assertEqual(state.metadata["context_compaction_count"], 1)
        self.assertGreater(provider_manager.summary_calls, 0)
        self.assertEqual(
            provider_manager.last_messages[1]["role"],
            "system",
        )
        self.assertIn(
            "Compacted Session Summary",
            provider_manager.last_messages[1]["content"],
        )

    def test_context_overflow_error_triggers_compaction_and_retry(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            workspace = Path(temp_dir)
            runtime = self._make_compaction_runtime(
                workspace,
                overflow_once=True,
                context_window_tokens=4000,
            )
            for index in range(3):
                runtime.session.append(
                    {
                        "role": "user",
                        "content": f"user-{index} " + ("C" * 240),
                    },
                    session_id="main",
                )
                runtime.session.append(
                    {
                        "role": "assistant",
                        "content": f"assistant-{index} " + ("D" * 240),
                    },
                    session_id="main",
                )

            result = runtime.run_turn(
                InboundMessageContext(
                    session_id="main",
                    raw_body="retry after overflow",
                    body_for_agent="retry after overflow",
                )
            )
            compactions = runtime.session.list_compactions(session_id="main")
            provider_manager = runtime.providers

        self.assertEqual(result.reply, "Compaction-safe reply from model-one.")
        self.assertEqual(len(compactions), 1)
        self.assertGreaterEqual(provider_manager.regular_calls, 2)
        self.assertEqual(provider_manager.summary_calls, 1)


if __name__ == "__main__":
    unittest.main()
