import os
os.environ.setdefault("BOT_TOKEN", "dummy-token")

import asyncio
import json
import subprocess
from unittest.mock import AsyncMock
from types import SimpleNamespace

from aiogram.exceptions import TelegramBadRequest
from aiogram.types import MenuButtonCommands, ReplyKeyboardMarkup, KeyboardButton

import bot
import master
import pytest


def test_worker_menu_button_sets_commands_text():
    mock_bot = AsyncMock()
    asyncio.run(bot._ensure_worker_menu_button(mock_bot))
    mock_bot.set_chat_menu_button.assert_awaited_once()
    menu_button = mock_bot.set_chat_menu_button.await_args.kwargs["menu_button"]
    assert isinstance(menu_button, MenuButtonCommands)
    assert menu_button.text == bot.WORKER_MENU_BUTTON_TEXT


def test_worker_menu_button_handles_bad_request(caplog):
    mock_bot = AsyncMock()
    mock_bot.set_chat_menu_button.side_effect = TelegramBadRequest(method=None, message="bad request")
    with caplog.at_level("WARNING"):
        asyncio.run(bot._ensure_worker_menu_button(mock_bot))
    assert mock_bot.set_chat_menu_button.await_count == 1


def test_worker_keyboard_structure(monkeypatch):
    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", lambda: "off")
    markup = bot._build_worker_main_keyboard()
    assert isinstance(markup, ReplyKeyboardMarkup)
    assert len(markup.keyboard) == 2
    assert len(markup.keyboard[0]) == 2
    assert len(markup.keyboard[1]) == 2
    for row in markup.keyboard:
        for button in row:
            assert isinstance(button, KeyboardButton)


def test_worker_keyboard_button_text(monkeypatch):
    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", lambda: "off")
    markup = bot._build_worker_main_keyboard()
    assert markup.keyboard[0][0].text == bot.WORKER_MENU_BUTTON_TEXT
    assert markup.keyboard[0][1].text == bot.WORKER_COMMANDS_BUTTON_TEXT
    assert markup.keyboard[1][0].text == "💻 会话实况"
    assert markup.keyboard[1][1].text == bot.WORKER_PLAN_MODE_BUTTON_TEXT_OFF


def test_worker_session_live_button_opens_session_list(monkeypatch):
    """点击“会话实况”应先进入会话列表，而不是直接截取默认 tmux。"""

    async def fake_build_session_live_list_view():
        return "*会话实况*", ReplyKeyboardMarkup(keyboard=[])

    mock_answer = AsyncMock(return_value="sent")

    def should_not_capture(*_args, **_kwargs):
        raise AssertionError("进入会话实况入口时，不应直接抓取默认 tmux 输出")

    monkeypatch.setattr(bot, "_build_session_live_list_view", fake_build_session_live_list_view, raising=False)
    monkeypatch.setattr(bot, "_answer_with_markdown", mock_answer)
    monkeypatch.setattr(bot, "_capture_tmux_recent_lines", should_not_capture)

    message = _DummyMessage("💻 会话实况")
    asyncio.run(bot.on_tmux_snapshot_button(message))

    assert mock_answer.await_count == 1
    assert "会话实况" in mock_answer.await_args.args[1]


def test_worker_keyboard_resize_enabled():
    markup = bot._build_worker_main_keyboard()
    assert markup.resize_keyboard is True


def test_master_menu_button_sets_commands_text():
    mock_bot = AsyncMock()
    asyncio.run(master._ensure_master_menu_button(mock_bot))
    mock_bot.set_chat_menu_button.assert_awaited_once()
    menu_button = mock_bot.set_chat_menu_button.await_args.kwargs["menu_button"]
    assert isinstance(menu_button, MenuButtonCommands)
    assert menu_button.text == master.MASTER_MENU_BUTTON_TEXT


def test_master_menu_button_handles_bad_request(caplog):
    mock_bot = AsyncMock()
    mock_bot.set_chat_menu_button.side_effect = TelegramBadRequest(method=None, message="bad request")
    with caplog.at_level("WARNING"):
        asyncio.run(master._ensure_master_menu_button(mock_bot))
    assert mock_bot.set_chat_menu_button.await_count == 1


def test_master_keyboard_structure():
    markup = master._build_master_main_keyboard()
    assert isinstance(markup, ReplyKeyboardMarkup)
    assert len(markup.keyboard) == 1
    assert len(markup.keyboard[0]) == 3
    for button in markup.keyboard[0]:
        assert isinstance(button, KeyboardButton)


def test_master_keyboard_button_text():
    markup = master._build_master_main_keyboard()
    assert markup.keyboard[0][0].text == master.MASTER_MENU_BUTTON_TEXT
    assert markup.keyboard[0][1].text == master.MASTER_MANAGE_BUTTON_TEXT
    assert markup.keyboard[0][2].text == master.MASTER_SETTINGS_BUTTON_TEXT


def test_master_keyboard_resize_enabled():
    markup = master._build_master_main_keyboard()
    assert markup.resize_keyboard is True


def test_master_commands_sync_calls_set_my_commands():
    mock_bot = AsyncMock()
    asyncio.run(master._ensure_master_commands(mock_bot))
    assert mock_bot.set_my_commands.await_count == 4


def test_master_commands_handles_bad_request(caplog):
    mock_bot = AsyncMock()
    mock_bot.set_my_commands.side_effect = master.TelegramBadRequest(method=None, message="bad request")
    with caplog.at_level("WARNING"):
        asyncio.run(master._ensure_master_commands(mock_bot))
    assert mock_bot.set_my_commands.await_count == 4


def test_master_broadcast_sends_to_admins_and_state(caplog):
    class DummyStateStore:
        def __init__(self):
            self.data = {"default": master.ProjectState(model="codex", status="running", chat_id=456)}

        def refresh(self):
            return

    class DummyManager:
        def __init__(self):
            self.admin_ids = {123}
            self.state_store = DummyStateStore()
            self._refreshed = False

        def refresh_state(self):
            self._refreshed = True

    manager = DummyManager()
    mock_bot = AsyncMock()

    with caplog.at_level("INFO"):
        asyncio.run(master._broadcast_master_keyboard(mock_bot, manager))
    assert manager._refreshed is True
    assert mock_bot.send_message.await_count == 0


def test_master_broadcast_handles_empty_targets(caplog):
    class DummyStateStore:
        def __init__(self):
            self.data = {}

        def refresh(self):
            return

    class DummyManager:
        def __init__(self):
            self.admin_ids = set()
            self.state_store = DummyStateStore()
            self._refreshed = False

        def refresh_state(self):
            self._refreshed = True

    manager = DummyManager()
    mock_bot = AsyncMock()
    with caplog.at_level("INFO"):
        asyncio.run(master._broadcast_master_keyboard(mock_bot, manager))
    assert manager._refreshed is True
    assert mock_bot.send_message.await_count == 0


class _DummyMessage:
    """用于模拟 master 项目按钮触发的测试消息。"""

    def __init__(self, text: str, chat_id: int = 999, message_id: int = 123) -> None:
        self.text = text
        self.message_id = message_id
        self.chat = SimpleNamespace(id=chat_id)
        self.from_user = SimpleNamespace(id=chat_id, username=None)
        self.bot = AsyncMock()
        self._answers = []

    async def answer(self, text: str, **kwargs):
        self._answers.append((text, kwargs))


class _DummyManager:
    """模拟授权通过的 master manager。"""

    def __init__(self) -> None:
        self.invocations = []

    def is_authorized(self, chat_id: int) -> bool:
        self.invocations.append(chat_id)
        return True


def test_worker_terminal_snapshot_success(monkeypatch):
    captured_lines = {}

    async def fake_resolve(entry_key: str):
        assert entry_key == "main"
        return bot.SessionLiveEntry(
            key="main",
            label="💻 主会话（vibe）",
            tmux_session=bot.TMUX_SESSION,
            kind="main",
        )

    def fake_capture(lines: int, tmux_session: str | None = None) -> str:
        captured_lines["value"] = lines
        captured_lines["tmux_session"] = tmux_session
        return "line-1\nline-2"

    monkeypatch.setattr(bot, "_resolve_session_live_entry", fake_resolve, raising=False)
    monkeypatch.setattr(bot, "_capture_tmux_recent_lines", fake_capture)
    text, markup = asyncio.run(bot._build_session_live_snapshot_view("main"))

    assert "value" in captured_lines
    assert captured_lines["value"] == bot.TMUX_SNAPSHOT_LINES
    assert captured_lines["tmux_session"] == bot.TMUX_SESSION
    assert "line-1" in text
    callback_data = [
        button.callback_data
        for row in markup.inline_keyboard
        for button in row
        if button.callback_data
    ]
    assert bot.SESSION_LIVE_REFRESH_MAIN_CALLBACK in callback_data
    assert bot.SESSION_LIVE_LIST_CALLBACK in callback_data


def test_worker_terminal_snapshot_handles_tmux_failure(monkeypatch):
    async def fake_resolve(entry_key: str):
        assert entry_key == "main"
        return bot.SessionLiveEntry(
            key="main",
            label="💻 主会话（vibe）",
            tmux_session=bot.TMUX_SESSION,
            kind="main",
        )

    def fake_capture(_: int, tmux_session: str | None = None) -> str:
        raise subprocess.CalledProcessError(returncode=1, cmd="tmux", output="err")

    monkeypatch.setattr(bot, "_resolve_session_live_entry", fake_resolve, raising=False)
    monkeypatch.setattr(bot, "_capture_tmux_recent_lines", fake_capture)

    with pytest.raises(RuntimeError, match=bot.TMUX_SESSION):
        asyncio.run(bot._build_session_live_snapshot_view("main"))


def test_worker_terminal_snapshot_handles_tmux_timeout(monkeypatch):
    async def fake_resolve(entry_key: str):
        assert entry_key == "main"
        return bot.SessionLiveEntry(
            key="main",
            label="💻 主会话（vibe）",
            tmux_session=bot.TMUX_SESSION,
            kind="main",
        )

    def fake_capture(_: int, tmux_session: str | None = None) -> str:
        raise subprocess.TimeoutExpired(cmd="tmux", timeout=1)

    monkeypatch.setattr(bot, "_resolve_session_live_entry", fake_resolve, raising=False)
    monkeypatch.setattr(bot, "_capture_tmux_recent_lines", fake_capture)

    with pytest.raises(RuntimeError, match="超时"):
        asyncio.run(bot._build_session_live_snapshot_view("main"))


def test_worker_terminal_snapshot_resumes_watcher_when_exited(monkeypatch, tmp_path):
    chat_id = 424242
    session_path = tmp_path / "rollout-test.jsonl"
    session_path.write_text("", encoding="utf-8")
    session_key = str(session_path)

    class DoneTask:
        def done(self) -> bool:
            return True

    done_task = DoneTask()

    bot.CHAT_SESSION_MAP[chat_id] = session_key
    bot.CHAT_LAST_MESSAGE[chat_id] = {session_key: "previous"}
    bot.CHAT_WATCHERS[chat_id] = done_task

    async def fake_watch_and_notify(*args, **kwargs):
        return None

    async def fake_build_session_live_list_view():
        return "*会话实况*", ReplyKeyboardMarkup(keyboard=[])

    monkeypatch.setattr(bot, "_watch_and_notify", fake_watch_and_notify)
    monkeypatch.setattr(bot, "_build_session_live_list_view", fake_build_session_live_list_view, raising=False)
    monkeypatch.setattr(bot, "_answer_with_markdown", AsyncMock(return_value="sent"))

    message = _DummyMessage(bot.WORKER_TERMINAL_SNAPSHOT_BUTTON_TEXT, chat_id=chat_id)
    asyncio.run(bot.on_tmux_snapshot_button(message))

    assert chat_id in bot.CHAT_WATCHERS
    assert bot.CHAT_WATCHERS[chat_id] is not done_task

    bot.CHAT_WATCHERS.pop(chat_id, None)
    bot.CHAT_SESSION_MAP.pop(chat_id, None)
    bot.CHAT_LAST_MESSAGE.pop(chat_id, None)
    bot.SESSION_OFFSETS.pop(session_key, None)


def test_probe_worker_plan_mode_state_returns_off_when_no_mode_marker(monkeypatch):
    monkeypatch.setattr(bot, "tmux_bin", lambda: "tmux")
    monkeypatch.setattr(bot, "_tmux_cmd", lambda *args: list(args))
    monkeypatch.setattr(
        subprocess,
        "check_output",
        lambda *args, **kwargs: "这里没有任何模式标识",
    )

    assert bot._probe_worker_plan_mode_state() == "off"


def test_probe_worker_plan_mode_state_ignores_non_status_plan_mode_phrase(monkeypatch):
    monkeypatch.setattr(bot, "tmux_bin", lambda: "tmux")
    monkeypatch.setattr(bot, "_tmux_cmd", lambda *args: list(args))
    monkeypatch.setattr(
        subprocess,
        "check_output",
        lambda *args, **kwargs: "请点击按钮：No, stay in Plan mode\n这里是普通会话内容",
    )

    assert bot._probe_worker_plan_mode_state() == "off"


def test_probe_worker_plan_mode_state_detects_status_line_plan_mode(monkeypatch):
    monkeypatch.setattr(bot, "tmux_bin", lambda: "tmux")
    monkeypatch.setattr(bot, "_tmux_cmd", lambda *args: list(args))
    monkeypatch.setattr(
        subprocess,
        "check_output",
        lambda *args, **kwargs: "普通日志行\nmodel · xhigh · ~/hypha/mall · 80% · Plan mode\n",
    )

    assert bot._probe_worker_plan_mode_state() == "on"


def test_probe_worker_plan_mode_state_returns_unknown_on_tmux_error(monkeypatch):
    monkeypatch.setattr(bot, "tmux_bin", lambda: "tmux")
    monkeypatch.setattr(bot, "_tmux_cmd", lambda *args: list(args))

    def fake_check_output(*args, **kwargs):
        raise subprocess.CalledProcessError(returncode=1, cmd="tmux")

    monkeypatch.setattr(subprocess, "check_output", fake_check_output)

    assert bot._probe_worker_plan_mode_state() == "unknown"


def test_worker_main_keyboard_uses_cached_plan_mode_when_refresh_disabled(monkeypatch):
    bot.WORKER_PLAN_MODE_STATE_CACHE.clear()
    bot._set_worker_plan_mode_state_cache("on")

    def fail_probe():
        raise AssertionError("不应触发探测")

    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", fail_probe)
    markup = bot._build_worker_main_keyboard(refresh_plan_mode_state=False)
    assert markup.keyboard[1][1].text == bot.WORKER_PLAN_MODE_BUTTON_TEXT_ON


def test_worker_main_keyboard_force_probe_even_with_explicit_state(monkeypatch):
    bot.WORKER_PLAN_MODE_STATE_CACHE.clear()
    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", lambda: "off")

    markup = bot._build_worker_main_keyboard(plan_mode_state="on")
    assert markup.keyboard[1][1].text == bot.WORKER_PLAN_MODE_BUTTON_TEXT_OFF
    assert bot._get_worker_plan_mode_state_cache() == "off"


def test_refresh_worker_plan_mode_state_cache_updates_cache(monkeypatch):
    bot.WORKER_PLAN_MODE_STATE_CACHE.clear()
    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", lambda: "off")
    refreshed = bot._refresh_worker_plan_mode_state_cache(force_probe=True)
    assert refreshed == "off"
    assert bot._get_worker_plan_mode_state_cache() == "off"


def test_refresh_worker_plan_mode_state_after_toggle_retries_until_changed(monkeypatch):
    states = iter(["off", "off", "on"])

    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_STABILIZE_SECONDS", 0.0)
    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_RETRY_ROUNDS", 2)
    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_RETRY_GAP_SECONDS", 0.0)
    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", lambda: next(states))

    refreshed = asyncio.run(bot._refresh_worker_plan_mode_state_after_toggle_async(before_state="off"))
    assert refreshed == "on"


def test_worker_plan_mode_button_toggles_and_refreshes_keyboard(monkeypatch):
    states = iter(["off", "on"])
    sent_keys = []

    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_STABILIZE_SECONDS", 0.0)
    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_RETRY_ROUNDS", 0)
    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_RETRY_GAP_SECONDS", 0.0)
    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", lambda: next(states))
    monkeypatch.setattr(
        bot,
        "tmux_send_key",
        lambda session, key: sent_keys.append((session, key)),
    )

    message = _DummyMessage(bot.WORKER_PLAN_MODE_BUTTON_TEXT_OFF)
    asyncio.run(bot.on_worker_plan_mode_button(message))

    assert sent_keys == [(bot.TMUX_SESSION, bot.WORKER_PLAN_MODE_TOGGLE_KEY)]
    assert len(message._answers) == 1
    text, kwargs = message._answers[0]
    assert "当前 PLAN MODE：ON" in text
    reply_markup = kwargs.get("reply_markup")
    assert isinstance(reply_markup, ReplyKeyboardMarkup)
    assert reply_markup.keyboard[1][1].text == bot.WORKER_PLAN_MODE_BUTTON_TEXT_ON


def test_worker_plan_mode_button_unknown_still_attempts_toggle(monkeypatch):
    states = iter(["unknown", "unknown", "unknown"])
    sent_keys = []

    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_STABILIZE_SECONDS", 0.0)
    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_RETRY_ROUNDS", 0)
    monkeypatch.setattr(bot, "WORKER_PLAN_MODE_TOGGLE_RETRY_GAP_SECONDS", 0.0)
    monkeypatch.setattr(bot, "_probe_worker_plan_mode_state", lambda: next(states))
    monkeypatch.setattr(
        bot,
        "tmux_send_key",
        lambda session, key: sent_keys.append((session, key)),
    )

    message = _DummyMessage(bot.WORKER_PLAN_MODE_BUTTON_TEXT_UNKNOWN)
    asyncio.run(bot.on_worker_plan_mode_button(message))

    assert sent_keys == [(bot.TMUX_SESSION, bot.WORKER_PLAN_MODE_TOGGLE_KEY)]
    assert len(message._answers) == 1
    text, kwargs = message._answers[0]
    assert "当前 PLAN MODE：?" in text
    reply_markup = kwargs.get("reply_markup")
    assert isinstance(reply_markup, ReplyKeyboardMarkup)
    assert reply_markup.keyboard[1][1].text == bot.WORKER_PLAN_MODE_BUTTON_TEXT_UNKNOWN


def test_master_projects_button_accepts_legacy_text(monkeypatch):
    dummy_manager = _DummyManager()

    async def fake_ensure_manager():
        return dummy_manager

    send_calls = []

    async def fake_send(bot, chat_id, manager, reply_to_message_id=None):
        send_calls.append((chat_id, reply_to_message_id))

    monkeypatch.setattr(master, "_ensure_manager", fake_ensure_manager)
    monkeypatch.setattr(master, "_send_projects_overview_to_chat", fake_send)

    message = _DummyMessage("📂 Projects")
    asyncio.run(master.on_master_projects_button(message))

    assert len(message._answers) == 1
    _, kwargs = message._answers[0]
    assert isinstance(kwargs["reply_markup"], ReplyKeyboardMarkup)
    assert kwargs["reply_markup"].keyboard[0][0].text == master.MASTER_MENU_BUTTON_TEXT
    assert kwargs["reply_markup"].keyboard[0][1].text == master.MASTER_MANAGE_BUTTON_TEXT
    assert send_calls == [(message.chat.id, None)]


def test_master_projects_button_uses_new_text_without_refresh(monkeypatch):
    dummy_manager = _DummyManager()

    async def fake_ensure_manager():
        return dummy_manager

    send_calls = []

    async def fake_send(bot, chat_id, manager, reply_to_message_id=None):
        send_calls.append((chat_id, reply_to_message_id))

    monkeypatch.setattr(master, "_ensure_manager", fake_ensure_manager)
    monkeypatch.setattr(master, "_send_projects_overview_to_chat", fake_send)

    message = _DummyMessage(master.MASTER_MENU_BUTTON_TEXT)
    asyncio.run(master.on_master_projects_button(message))

    assert message._answers == []
    assert send_calls == [(message.chat.id, message.message_id)]


@pytest.mark.parametrize(
    ("text", "expect_refresh"),
    [
        (master.MASTER_MENU_BUTTON_TEXT, False),
        (f"{master.MASTER_MENU_BUTTON_TEXT} ", False),
        ("\u200b" + master.MASTER_MENU_BUTTON_TEXT + "\u200d", False),
        ("📂项目列表", True),
        ("项目列表", True),
        (" 项目 列表 ", True),
        ("📂 Projects", True),
        ("projects", True),
        ("Project List", True),
        ("📂 PROJECTS", True),
    ],
)
def test_master_projects_button_handles_variants(monkeypatch, text, expect_refresh):
    dummy_manager = _DummyManager()

    async def fake_ensure_manager():
        return dummy_manager

    send_calls = []

    async def fake_send(bot, chat_id, manager, reply_to_message_id=None):
        send_calls.append((chat_id, reply_to_message_id))

    monkeypatch.setattr(master, "_ensure_manager", fake_ensure_manager)
    monkeypatch.setattr(master, "_send_projects_overview_to_chat", fake_send)

    message = _DummyMessage(text)
    asyncio.run(master.on_master_projects_button(message))

    assert send_calls == [
        (message.chat.id, None if expect_refresh else message.message_id)
    ]
    if expect_refresh:
        assert len(message._answers) == 1
        _, kwargs = message._answers[0]
        assert isinstance(kwargs["reply_markup"], ReplyKeyboardMarkup)
        assert kwargs["reply_markup"].keyboard[0][0].text == master.MASTER_MENU_BUTTON_TEXT
        assert kwargs["reply_markup"].keyboard[0][1].text == master.MASTER_MANAGE_BUTTON_TEXT
    else:
        assert message._answers == []


def test_worker_resolve_targets_reads_state_and_config(tmp_path, monkeypatch):
    slug = bot.PROJECT_SLUG
    state_file = tmp_path / "state.json"
    state_file.write_text(json.dumps({slug: {"chat_id": 111}}), encoding="utf-8")
    projects_file = tmp_path / "projects.json"
    projects_file.write_text(
        json.dumps([{"project_slug": slug, "bot_name": bot.PROJECT_NAME or slug, "allowed_chat_id": "222"}]),
        encoding="utf-8",
    )
    monkeypatch.setenv("STATE_FILE", str(state_file))
    monkeypatch.setenv("MASTER_PROJECTS_PATH", str(projects_file))
    monkeypatch.delenv("ALLOWED_CHAT_ID", raising=False)
    monkeypatch.delenv("WORKER_CHAT_ID", raising=False)

    targets = bot._resolve_worker_target_chat_ids()
    assert targets == [111, 222]

    monkeypatch.delenv("STATE_FILE", raising=False)
    monkeypatch.delenv("MASTER_PROJECTS_PATH", raising=False)


def test_worker_broadcast_pushes_to_targets(tmp_path, monkeypatch):
    slug = bot.PROJECT_SLUG
    state_file = tmp_path / "state.json"
    state_file.write_text(json.dumps({slug: {"chat_id": 333}}), encoding="utf-8")
    projects_file = tmp_path / "projects.json"
    projects_file.write_text(
        json.dumps([{"project_slug": slug, "bot_name": bot.PROJECT_NAME or slug, "allowed_chat_id": "444"}]),
        encoding="utf-8",
    )
    monkeypatch.setenv("STATE_FILE", str(state_file))
    monkeypatch.setenv("MASTER_PROJECTS_PATH", str(projects_file))
    monkeypatch.delenv("ALLOWED_CHAT_ID", raising=False)
    monkeypatch.delenv("WORKER_CHAT_ID", raising=False)
    fake_task_view = AsyncMock(return_value=("任务列表", None))
    monkeypatch.setattr(bot, "_build_task_list_view", fake_task_view)

    mock_bot = AsyncMock()
    asyncio.run(bot._broadcast_worker_keyboard(mock_bot))
    assert mock_bot.send_message.await_count == 2
    payload = {call.kwargs["chat_id"] for call in mock_bot.send_message.await_args_list}
    assert payload == {333, 444}

    monkeypatch.delenv("STATE_FILE", raising=False)
    monkeypatch.delenv("MASTER_PROJECTS_PATH", raising=False)


def test_worker_identity_record_updates_state(tmp_path, monkeypatch):
    slug = bot.PROJECT_SLUG
    state_file = tmp_path / "state.json"
    state_file.write_text(json.dumps({slug: {"chat_id": 555}}), encoding="utf-8")
    monkeypatch.setenv("STATE_FILE", str(state_file))
    bot._record_worker_identity("ActualTelegramBot", 123456789)
    updated = json.loads(state_file.read_text(encoding="utf-8"))
    assert updated[slug]["actual_username"] == "ActualTelegramBot"
    assert updated[slug]["telegram_user_id"] == 123456789
    monkeypatch.delenv("STATE_FILE", raising=False)
