from __future__ import annotations

import asyncio
import os
import sys
import time
from pathlib import Path
from types import SimpleNamespace

from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey
from aiogram.fsm.storage.memory import MemoryStorage

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

os.environ.setdefault("BOT_TOKEN", "TEST_TOKEN")

import bot  # noqa: E402


class DummyMessage:
    """模拟最小可用的 Telegram Message。"""

    def __init__(self, *, chat_id: int = 1, user_id: int = 1, text: str | None = None):
        self.calls: list[tuple[str, object, object, dict]] = []
        self.chat = SimpleNamespace(id=chat_id)
        self.from_user = SimpleNamespace(id=user_id, full_name="Tester")
        self.message_id = 100
        self.text = text
        self.caption = None

    async def answer(self, text: str, parse_mode=None, reply_markup=None, **kwargs):
        self.calls.append((text, parse_mode, reply_markup, kwargs))
        return SimpleNamespace(message_id=self.message_id + len(self.calls), chat=self.chat)


class DummyCallback:
    """模拟最小可用的 Telegram CallbackQuery。"""

    def __init__(self, data: str, message: DummyMessage):
        self.data = data
        self.message = message
        self.answers: list[tuple[str | None, bool]] = []
        self.from_user = SimpleNamespace(id=message.from_user.id, full_name="Tester")

    async def answer(self, text: str | None = None, show_alert: bool = False):
        self.answers.append((text, show_alert))


def _make_state(message: DummyMessage) -> tuple[FSMContext, MemoryStorage]:
    storage = MemoryStorage()
    state = FSMContext(
        storage=storage,
        key=StorageKey(bot_id=999, chat_id=message.chat.id, user_id=message.from_user.id),
    )
    return state, storage


def _parallel_context(task_id: str = "TASK_0093") -> bot.ParallelDispatchContext:
    return bot.ParallelDispatchContext(
        task_id=task_id,
        tmux_session="vibe-par-demo",
        pointer_file=Path("/tmp/demo-pointer.txt"),
        workspace_root=Path("/tmp/demo-workspace"),
    )


def test_parallel_keyboard_uses_session_scoped_callback_payload():
    """并行消息底部按钮应携带会话级 payload，避免仅靠 task_id 串到原生会话。"""

    markup = bot._build_model_quick_reply_keyboard(
        task_id="TASK_0093",
        parallel_task_title="并行任务",
        enable_parallel_actions=True,
        parallel_callback_payload="TASK_0093:deadbeef",
    )
    callback_data = [
        button.callback_data
        for row in markup.inline_keyboard
        for button in row
        if button.callback_data
    ]

    assert f"{bot.MODEL_QUICK_REPLY_ALL_TASK_PREFIX}TASK_0093:deadbeef" in callback_data
    assert f"{bot.MODEL_QUICK_REPLY_PARTIAL_TASK_PREFIX}TASK_0093:deadbeef" in callback_data
    assert f"{bot.PARALLEL_REPLY_CALLBACK_PREFIX}TASK_0093:deadbeef" in callback_data


def test_quick_reply_all_uses_parallel_binding_dispatch_context(monkeypatch, tmp_path: Path):
    """并行消息上的“全部按推荐”应严格命中并行上下文，不能回落到原生会话。"""

    origin = DummyMessage(chat_id=9, user_id=9)
    callback = DummyCallback(f"{bot.MODEL_QUICK_REPLY_ALL_TASK_PREFIX}TASK_0093:deadbeef", origin)
    binding = SimpleNamespace(
        token="deadbeef",
        task_id="TASK_0093",
        dispatch_context=_parallel_context(),
    )
    bot.PARALLEL_CALLBACK_BINDINGS = {"deadbeef": binding}

    recorded: list[object] = []

    async def fake_dispatch(chat_id: int, prompt: str, *, reply_to, ack_immediately: bool = True, dispatch_context=None, **_kwargs):
        recorded.append(dispatch_context)
        return True, tmp_path / "parallel.jsonl"

    async def fake_preview(*_args, **_kwargs):
        return None

    async def fake_ack(*_args, **_kwargs):
        return None

    async def fake_active_parallel_session(_task_id: str):
        return None

    monkeypatch.setattr(bot, "_dispatch_prompt_to_model", fake_dispatch)
    monkeypatch.setattr(bot, "_send_model_push_preview", fake_preview)
    monkeypatch.setattr(bot, "_send_session_ack", fake_ack)
    monkeypatch.setattr(bot, "_get_active_parallel_session_for_task", fake_active_parallel_session)

    asyncio.run(bot.on_model_quick_reply_all(callback))

    assert recorded and recorded[-1] is binding.dispatch_context


def test_quick_reply_all_parallel_payload_fails_closed_when_binding_missing(monkeypatch):
    """并行按钮若已失效，必须直接报错，不能静默回落到原生会话。"""

    origin = DummyMessage(chat_id=10, user_id=10)
    callback = DummyCallback(f"{bot.MODEL_QUICK_REPLY_ALL_TASK_PREFIX}TASK_0093:deadbeef", origin)
    bot.PARALLEL_CALLBACK_BINDINGS = {}

    async def fake_dispatch(*_args, **_kwargs):
        raise AssertionError("并行会话失效时不应继续回落到默认会话")

    async def fake_active_parallel_session(_task_id: str):
        return None

    monkeypatch.setattr(bot, "_dispatch_prompt_to_model", fake_dispatch)
    monkeypatch.setattr(bot, "_get_active_parallel_session_for_task", fake_active_parallel_session)

    asyncio.run(bot.on_model_quick_reply_all(callback))

    assert callback.answers and callback.answers[-1] == ("并行会话已失效，请在最新并行消息中重试。", True)
    assert origin.calls and "并行会话已失效" in origin.calls[-1][0]


def test_parallel_reply_callback_and_followup_use_bound_dispatch_context(monkeypatch):
    """回复按钮应优先使用按钮绑定的并行上下文，而不是再次依赖活动会话查库。"""

    origin = DummyMessage(chat_id=12, user_id=12)
    callback = DummyCallback(f"{bot.PARALLEL_REPLY_CALLBACK_PREFIX}TASK_0093:deadbeef", origin)
    binding = SimpleNamespace(
        token="deadbeef",
        task_id="TASK_0093",
        dispatch_context=_parallel_context(),
    )
    bot.PARALLEL_CALLBACK_BINDINGS = {"deadbeef": binding}

    async def fake_active_parallel_session(_task_id: str):
        return None

    recorded: list[tuple[str, object]] = []

    async def fake_handle_request_input_custom_text_message(_message):
        return False

    async def fake_handle_command_trigger_message(_message, _prompt, _state):
        return False

    async def fake_handle_prompt_dispatch(_message, prompt: str, *, dispatch_context=None):
        recorded.append((prompt, dispatch_context))

    monkeypatch.setattr(bot, "_get_active_parallel_session_for_task", fake_active_parallel_session)
    monkeypatch.setattr(bot, "_handle_request_input_custom_text_message", fake_handle_request_input_custom_text_message)
    monkeypatch.setattr(bot, "_handle_command_trigger_message", fake_handle_command_trigger_message)
    monkeypatch.setattr(bot, "_handle_prompt_dispatch", fake_handle_prompt_dispatch)

    async def _scenario() -> None:
        await bot.on_parallel_reply_callback(callback)
        message = DummyMessage(chat_id=12, user_id=12, text="继续补充")
        state, _ = _make_state(message)
        await bot.on_text(message, state)

    asyncio.run(_scenario())

    assert recorded == [("继续补充", binding.dispatch_context)]


def test_manual_task_prefix_routes_parallel_session_without_leaking_slash_prefix(monkeypatch):
    """手动输入 /TASK_xxxx 仅用于路由，真正入模文本不应保留 slash 前缀。"""

    message = DummyMessage(chat_id=15, user_id=15, text="/TASK_0093 继续补充")
    state, _ = _make_state(message)
    binding = SimpleNamespace(
        token="deadbeef",
        task_id="TASK_0093",
        dispatch_context=_parallel_context(),
    )
    bot.PARALLEL_CALLBACK_BINDINGS = {"deadbeef": binding}

    async def fake_active_parallel_session(_task_id: str):
        return SimpleNamespace(
            task_id="TASK_0093",
            tmux_session=binding.dispatch_context.tmux_session,
            pointer_file=str(binding.dispatch_context.pointer_file),
            workspace_root=str(binding.dispatch_context.workspace_root),
        )

    recorded: list[tuple[str, object]] = []

    async def fake_handle_request_input_custom_text_message(_message):
        return False

    async def fake_handle_command_trigger_message(_message, _prompt, _state):
        return False

    async def fake_handle_prompt_dispatch(_message, prompt: str, *, dispatch_context=None):
        recorded.append((prompt, dispatch_context))

    monkeypatch.setattr(bot, "_get_active_parallel_session_for_task", fake_active_parallel_session)
    monkeypatch.setattr(bot, "_handle_request_input_custom_text_message", fake_handle_request_input_custom_text_message)
    monkeypatch.setattr(bot, "_handle_command_trigger_message", fake_handle_command_trigger_message)
    monkeypatch.setattr(bot, "_handle_prompt_dispatch", fake_handle_prompt_dispatch)

    asyncio.run(bot.on_text(message, state))

    assert recorded == [("继续补充", binding.dispatch_context)]


def test_parallel_reply_cancel_restores_main_keyboard(monkeypatch):
    """回复态发送“取消”时应退出回复模式并恢复主菜单。"""

    message = DummyMessage(chat_id=18, user_id=18, text="取消")
    state, _ = _make_state(message)
    bot._set_parallel_reply_target(
        message.chat.id,
        "TASK_0115",
        dispatch_context=_parallel_context("TASK_0115"),
    )

    async def fake_handle_request_input_custom_text_message(_message):
        return False

    monkeypatch.setattr(bot, "_handle_request_input_custom_text_message", fake_handle_request_input_custom_text_message)

    asyncio.run(bot.on_text(message, state))

    assert message.calls and message.calls[-1][0] == "已取消 /TASK_0115 回复模式。"
    reply_markup = message.calls[-1][2]
    labels = [button.text for row in reply_markup.keyboard for button in row]
    assert bot.WORKER_MENU_BUTTON_TEXT in labels
    assert message.chat.id not in bot.CHAT_PARALLEL_REPLY_TARGETS


def test_parallel_dispatch_does_not_override_primary_session_binding(monkeypatch, tmp_path: Path):
    """向并行会话发消息时，不应覆盖 chat 当前原生会话，也不应取消原生 watcher。"""

    primary_session = tmp_path / "primary.jsonl"
    parallel_session = tmp_path / "parallel.jsonl"
    pointer = tmp_path / "parallel-pointer.txt"
    primary_session.write_text("", encoding="utf-8")
    parallel_session.write_text("", encoding="utf-8")
    pointer.write_text(str(parallel_session), encoding="utf-8")

    chat_id = 77
    bot.CHAT_SESSION_MAP[chat_id] = str(primary_session)

    class ActiveTask:
        def __init__(self) -> None:
            self.cancelled = False

        def done(self) -> bool:
            return False

        def cancel(self) -> None:
            self.cancelled = True

    active_watcher = ActiveTask()
    bot.CHAT_WATCHERS[chat_id] = active_watcher

    def fake_tmux_send_line(_session: str, _prompt: str) -> None:
        return None

    async def fake_deliver(_chat_id: int, _session_path: Path, **_kwargs) -> bool:
        return False

    async def fake_interrupt_long_poll(_chat_id: int) -> None:
        return None

    async def fake_parallel_watch(*_args, **_kwargs):
        return None

    created_tasks: list[object] = []

    class DummyTask:
        def done(self) -> bool:
            return False

        def cancel(self) -> None:
            return None

    def fake_create_task(coro):
        created_tasks.append(coro)
        return DummyTask()

    monkeypatch.setattr(bot, "tmux_send_line", fake_tmux_send_line)
    monkeypatch.setattr(bot, "_deliver_pending_messages", fake_deliver)
    monkeypatch.setattr(bot, "_interrupt_long_poll", fake_interrupt_long_poll)
    monkeypatch.setattr(bot, "_watch_parallel_and_notify", fake_parallel_watch)
    monkeypatch.setattr(asyncio, "create_task", fake_create_task)

    async def _scenario() -> None:
        ok, session_path = await bot._dispatch_prompt_to_model(
            chat_id,
            "待决策项全部按模型推荐",
            reply_to=None,
            ack_immediately=False,
            dispatch_context=bot.ParallelDispatchContext(
                task_id="TASK_0093",
                tmux_session="vibe-par-demo",
                pointer_file=pointer,
                workspace_root=tmp_path / "workspace",
            ),
        )
        assert ok is True
        assert session_path == parallel_session

    asyncio.run(_scenario())

    assert bot.CHAT_SESSION_MAP[chat_id] == str(primary_session)
    assert active_watcher.cancelled is False
    for coro in created_tasks:
        try:
            coro.close()  # type: ignore[attr-defined]
        except Exception:
            pass


def test_get_active_parallel_session_marks_stale_session_closed(monkeypatch):
    """活动并行会话若 tmux 已消失，应自动降级为 closed 并清理内存绑定。"""

    task_id = "TASK_0115"
    session = SimpleNamespace(
        task_id=task_id,
        status="running",
        tmux_session="vibe-par-hyphamall-task_0115",
        pointer_file="/tmp/parallel-pointer.txt",
        workspace_root="/tmp/parallel-workspace",
    )

    updates: list[tuple[str, dict]] = []

    async def fake_get_session(_task_id: str):
        return session

    async def fake_update_status(_task_id: str, **kwargs):
        updates.append((_task_id, kwargs))

    watcher_cancelled = {"value": False}

    class DummyTask:
        def done(self) -> bool:
            return False

        def cancel(self) -> None:
            watcher_cancelled["value"] = True

    bot.PARALLEL_TASK_SESSION_MAP[task_id] = "session-key"
    bot.PARALLEL_SESSION_CONTEXTS["session-key"] = _parallel_context(task_id)
    bot.PARALLEL_CALLBACK_BINDINGS = {"deadbeef": SimpleNamespace(task_id=task_id)}
    bot.PARALLEL_TASK_WATCHERS[task_id] = DummyTask()

    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "get_session", fake_get_session)
    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "update_status", fake_update_status)
    monkeypatch.setattr(
        bot,
        "_parallel_session_runtime_issue",
        lambda _session: "tmux 会话 vibe-par-hyphamall-task_0115 不存在",
    )

    result = asyncio.run(bot._get_active_parallel_session_for_task(task_id))

    assert result is None
    assert updates and updates[-1][0] == task_id
    assert updates[-1][1]["status"] == "closed"
    assert "tmux 会话" in updates[-1][1]["last_error"]
    assert task_id not in bot.PARALLEL_TASK_SESSION_MAP
    assert "session-key" not in bot.PARALLEL_SESSION_CONTEXTS
    assert watcher_cancelled["value"] is True
    assert not bot.PARALLEL_CALLBACK_BINDINGS


def test_parallel_session_runtime_issue_detects_shell_pane(monkeypatch, tmp_path: Path):
    """并行 tmux 虽存在，但 pane 当前进程仍是 shell 时，应视为未就绪/已失活。"""

    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir(parents=True, exist_ok=True)
    pointer_file = tmp_path / "pointer.txt"
    pointer_file.write_text("placeholder", encoding="utf-8")
    session = SimpleNamespace(
        task_id="TASK_0115",
        status="running",
        tmux_session="vibe-par-hyphamall-task_0115",
        pointer_file=str(pointer_file),
        workspace_root=str(workspace_root),
    )

    monkeypatch.setattr(bot, "_parallel_tmux_session_exists", lambda _session_name: True)
    monkeypatch.setattr(bot, "_get_tmux_pane_current_command", lambda _session_name: "zsh", raising=False)

    issue = bot._parallel_session_runtime_issue(session)

    assert issue is not None
    assert "zsh" in issue


def test_delete_parallel_session_workspace_allows_cleanup_for_closed_session(monkeypatch, tmp_path: Path):
    """删除并行目录时应允许清理 closed/stale 会话，而不是只能清理 active 会话。"""

    task_id = "TASK_0115"
    runtime_root = tmp_path / task_id
    workspace_root = runtime_root / "workspace"
    workspace_root.mkdir(parents=True, exist_ok=True)
    session = SimpleNamespace(
        task_id=task_id,
        status="closed",
        tmux_session="vibe-par-hyphamall-task_0115",
        pointer_file=str(runtime_root / "_runtime" / "current_session.txt"),
        workspace_root=str(workspace_root),
    )

    deleted_calls: list[dict] = []
    updates: list[tuple[str, dict]] = []

    async def fake_get_session(_task_id: str):
        return session

    async def fake_update_status(_task_id: str, **kwargs):
        updates.append((_task_id, kwargs))

    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "get_session", fake_get_session)
    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "update_status", fake_update_status)
    monkeypatch.setattr(
        bot,
        "delete_parallel_workspace",
        lambda **kwargs: deleted_calls.append(kwargs),
    )
    monkeypatch.setattr(bot, "_parallel_runtime_root", lambda _task_id: runtime_root)
    bot.PARALLEL_TASK_SESSION_MAP[task_id] = "session-key"
    bot.PARALLEL_SESSION_CONTEXTS["session-key"] = _parallel_context(task_id)
    bot.PARALLEL_CALLBACK_BINDINGS = {"deadbeef": SimpleNamespace(task_id=task_id)}

    asyncio.run(bot._delete_parallel_session_workspace(task_id))

    assert deleted_calls, "应调用删除目录"
    assert updates and updates[-1][1]["status"] == "deleted"
    assert task_id not in bot.PARALLEL_TASK_SESSION_MAP
    assert "session-key" not in bot.PARALLEL_SESSION_CONTEXTS


def test_delete_parallel_session_workspace_cleans_session_scoped_runtime_state(monkeypatch, tmp_path: Path):
    """删除并行目录时，应同步清理会话级缓存状态，避免残留到 vibego 重启前。"""

    task_id = "TASK_0117"
    session_key = str(tmp_path / "runtime" / "session.jsonl")
    runtime_root = tmp_path / task_id
    workspace_root = runtime_root / "workspace"
    workspace_root.mkdir(parents=True, exist_ok=True)
    session = SimpleNamespace(
        task_id=task_id,
        status="committed",
        tmux_session="vibe-par-hyphamall-task_0117",
        pointer_file=str(runtime_root / "_runtime" / "current_session.txt"),
        workspace_root=str(workspace_root),
    )

    async def fake_get_session(_task_id: str):
        return session

    async def fake_update_status(_task_id: str, **_kwargs):
        return None

    async def fake_cleanup(path: Path, *, scope: str, owner_key: str):
        return None

    bot.PARALLEL_TASK_SESSION_MAP[task_id] = session_key
    bot.PARALLEL_SESSION_CONTEXTS[session_key] = _parallel_context(task_id)
    bot.PARALLEL_SESSION_TASK_BINDINGS[session_key] = task_id
    bot.PARALLEL_CALLBACK_BINDINGS = {
        "deadbeef": SimpleNamespace(task_id=task_id, session_key=session_key),
    }
    bot.SESSION_OFFSETS[session_key] = 12
    bot.PENDING_SUMMARIES[session_key] = bot.PendingSummary(
        task_id=task_id,
        request_id="req-1",
        actor="Tester",
        session_key=session_key,
        session_path=Path(session_key),
        created_at=time.monotonic(),
    )
    bot.CHAT_LAST_MESSAGE[1] = {session_key: "历史消息"}
    bot.CHAT_DELIVERED_HASHES[1] = {session_key: {"hash-1"}}
    bot.CHAT_DELIVERED_OFFSETS[1] = {session_key: {1}}
    bot.CHAT_PARALLEL_REPLY_TARGETS[1] = {
        "task_id": task_id,
        "dispatch_context": _parallel_context(task_id),
        "token": "deadbeef",
        "expires_at": time.time() + 600,
    }

    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "get_session", fake_get_session)
    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "update_status", fake_update_status)
    monkeypatch.setattr(bot, "_cleanup_codex_trusted_project_path", fake_cleanup)
    monkeypatch.setattr(bot, "delete_parallel_workspace", lambda **_kwargs: None)
    monkeypatch.setattr(bot, "_parallel_runtime_root", lambda _task_id: runtime_root)

    asyncio.run(bot._delete_parallel_session_workspace(task_id))

    assert session_key not in bot.SESSION_OFFSETS
    assert session_key not in bot.PENDING_SUMMARIES
    assert bot.CHAT_LAST_MESSAGE.get(1, {}).get(session_key) is None
    assert bot.CHAT_DELIVERED_HASHES.get(1, {}).get(session_key) is None
    assert bot.CHAT_DELIVERED_OFFSETS.get(1, {}).get(session_key) is None
    assert 1 not in bot.CHAT_PARALLEL_REPLY_TARGETS
    assert "deadbeef" not in bot.PARALLEL_CALLBACK_BINDINGS
    assert session_key not in bot.PARALLEL_SESSION_TASK_BINDINGS


def test_delete_parallel_session_workspace_cleans_parallel_workspace_trust(monkeypatch, tmp_path: Path):
    """删除并行目录时，应同步清理该 workspace 的 Codex trusted 条目。"""

    task_id = "TASK_0116"
    runtime_root = tmp_path / task_id
    workspace_root = runtime_root / "workspace"
    workspace_root.mkdir(parents=True, exist_ok=True)
    session = SimpleNamespace(
        task_id=task_id,
        status="closed",
        tmux_session="vibe-par-hyphamall-task_0116",
        pointer_file=str(runtime_root / "_runtime" / "current_session.txt"),
        workspace_root=str(workspace_root),
    )

    cleanup_calls: list[tuple[str, str, str]] = []

    async def fake_get_session(_task_id: str):
        return session

    async def fake_update_status(_task_id: str, **_kwargs):
        return None

    async def fake_cleanup(path: Path, *, scope: str, owner_key: str):
        cleanup_calls.append((str(path), scope, owner_key))

    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "get_session", fake_get_session)
    monkeypatch.setattr(bot.PARALLEL_SESSION_STORE, "update_status", fake_update_status)
    monkeypatch.setattr(bot, "_cleanup_codex_trusted_project_path", fake_cleanup)
    monkeypatch.setattr(bot, "delete_parallel_workspace", lambda **_kwargs: None)
    monkeypatch.setattr(bot, "_parallel_runtime_root", lambda _task_id: runtime_root)

    asyncio.run(bot._delete_parallel_session_workspace(task_id))

    assert cleanup_calls == [(str(workspace_root), "parallel_workspace", task_id)]
