"""Tests for duty_teller.handlers.group_duty_pin (sync wrappers, update_group_pin, pin_duty_cmd)."""

from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from telegram.error import BadRequest, Forbidden
from telegram.constants import ChatMemberStatus

import duty_teller.config as config
from duty_teller.handlers import group_duty_pin as mod


@pytest.fixture(autouse=True)
def no_mini_app_url():
    """Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
    with patch.object(config, "BOT_USERNAME", ""):
        yield


class TestSyncWrappers:
    """Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""

    def test_get_duty_message_text_sync(self):
        with patch.object(mod, "session_scope") as mock_scope:
            mock_session = MagicMock()
            mock_scope.return_value.__enter__.return_value = mock_session
            mock_scope.return_value.__exit__.return_value = None
            with patch.object(mod, "get_duty_message_text", return_value="Duty text"):
                result = mod._get_duty_message_text_sync("en")
        assert result == "Duty text"
        mock_scope.assert_called_once()

    def test_sync_save_pin(self):
        with patch.object(mod, "session_scope") as mock_scope:
            mock_session = MagicMock()
            mock_scope.return_value.__enter__.return_value = mock_session
            mock_scope.return_value.__exit__.return_value = None
            with patch.object(mod, "save_pin") as mock_save:
                mod._sync_save_pin(100, 42)
        mock_save.assert_called_once_with(mock_session, 100, 42)

    def test_sync_delete_pin(self):
        with patch.object(mod, "session_scope") as mock_scope:
            mock_session = MagicMock()
            mock_scope.return_value.__enter__.return_value = mock_session
            mock_scope.return_value.__exit__.return_value = None
            with patch.object(mod, "delete_pin") as mock_delete:
                mod._sync_delete_pin(200)
        mock_delete.assert_called_once_with(mock_session, 200)

    def test_sync_get_message_id(self):
        with patch.object(mod, "session_scope") as mock_scope:
            mock_session = MagicMock()
            mock_scope.return_value.__enter__.return_value = mock_session
            mock_scope.return_value.__exit__.return_value = None
            with patch.object(mod, "get_message_id", return_value=99):
                result = mod._sync_get_message_id(300)
        assert result == 99

    def test_get_all_pin_chat_ids_sync(self):
        with patch.object(mod, "session_scope") as mock_scope:
            mock_session = MagicMock()
            mock_scope.return_value.__enter__.return_value = mock_session
            mock_scope.return_value.__exit__.return_value = None
            with patch.object(mod, "get_all_pin_chat_ids", return_value=[10, 20]):
                result = mod._get_all_pin_chat_ids_sync()
        assert result == [10, 20]

    def test_get_next_shift_end_sync(self):
        """_get_next_shift_end_sync: uses session_scope and get_next_shift_end_utc."""
        with patch.object(mod, "session_scope") as mock_scope:
            mock_session = MagicMock()
            mock_scope.return_value.__enter__.return_value = mock_session
            mock_scope.return_value.__exit__.return_value = None
            when = datetime(2026, 2, 22, 9, 0, tzinfo=timezone.utc)
            with patch.object(mod, "get_next_shift_end_utc", return_value=when):
                result = mod._get_next_shift_end_sync()
        assert result == when
        mock_scope.assert_called_once()


# --- _schedule_next_update ---


def test_get_contact_button_markup_empty_username_returns_none():
    """_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
    with patch.object(config, "BOT_USERNAME", ""):
        assert mod._get_contact_button_markup("en") is None


def test_get_contact_button_markup_returns_markup_when_username_set():
    """_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
    from telegram import InlineKeyboardMarkup

    with (
        patch.object(config, "BOT_USERNAME", "MyDutyBot"),
        patch.object(config, "MINI_APP_SHORT_NAME", ""),
    ):
        with patch.object(mod, "t", return_value="View contacts"):
            result = mod._get_contact_button_markup("en")
    assert result is not None
    assert isinstance(result, InlineKeyboardMarkup)
    assert len(result.inline_keyboard) == 1
    assert len(result.inline_keyboard[0]) == 1
    btn = result.inline_keyboard[0][0]
    assert btn.text == "View contacts"
    assert btn.url.startswith("https://t.me/")
    assert "startapp=duty" in btn.url
    assert btn.url == "https://t.me/MyDutyBot?startapp=duty"


def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
    """_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
    from telegram import InlineKeyboardMarkup

    with (
        patch.object(config, "BOT_USERNAME", "MyDutyBot"),
        patch.object(config, "MINI_APP_SHORT_NAME", "DutyApp"),
    ):
        with patch.object(mod, "t", return_value="View contacts"):
            result = mod._get_contact_button_markup("en")
    assert result is not None
    assert isinstance(result, InlineKeyboardMarkup)
    btn = result.inline_keyboard[0][0]
    assert btn.url == "https://t.me/MyDutyBot/DutyApp?startapp=duty"


@pytest.mark.asyncio
async def test_schedule_next_update_job_queue_none_returns_early():
    """_schedule_next_update: job_queue is None -> log and return, no run_once."""
    application = MagicMock()
    application.job_queue = None
    with patch.object(mod, "logger") as mock_logger:
        await mod._schedule_next_update(application, 123, datetime.now(timezone.utc))
    mock_logger.warning.assert_called_once()
    assert "Job queue not available" in mock_logger.warning.call_args[0][0]


@pytest.mark.asyncio
async def test_schedule_next_update_when_utc_not_none_runs_once_with_delay():
    """_schedule_next_update: when_utc set -> remove old jobs, run_once with delay."""
    application = MagicMock()
    job_queue = MagicMock()
    job_queue.get_jobs_by_name = MagicMock(return_value=[])
    job_queue.run_once = MagicMock()
    application.job_queue = job_queue
    when_utc = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=1)

    await mod._schedule_next_update(application, 456, when_utc)

    job_queue.get_jobs_by_name.assert_called_once_with("duty_pin_456")
    job_queue.run_once.assert_called_once()
    call_kw = job_queue.run_once.call_args[1]
    assert call_kw["data"] == {"chat_id": 456}
    assert call_kw["name"] == "duty_pin_456"
    assert call_kw["when"].total_seconds() >= 1


@pytest.mark.asyncio
async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
    """_schedule_next_update: when_utc None -> run_once with RETRY_WHEN_NO_DUTY_MINUTES."""
    application = MagicMock()
    job_queue = MagicMock()
    job_queue.get_jobs_by_name = MagicMock(return_value=[])
    job_queue.run_once = MagicMock()
    application.job_queue = job_queue

    await mod._schedule_next_update(application, 789, None)

    job_queue.run_once.assert_called_once()
    call_kw = job_queue.run_once.call_args[1]
    assert call_kw["when"] == timedelta(minutes=mod.RETRY_WHEN_NO_DUTY_MINUTES)
    assert call_kw["data"] == {"chat_id": 789}
    assert call_kw["name"] == "duty_pin_789"


@pytest.mark.asyncio
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
    """update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
    new_msg = MagicMock()
    new_msg.message_id = 999
    context = MagicMock()
    context.job = MagicMock()
    context.job.data = {"chat_id": 123}
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.unpin_chat_message = AsyncMock()
    context.bot.pin_chat_message = AsyncMock()
    context.bot.delete_message = AsyncMock()
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch.object(config, "DUTY_PIN_NOTIFY", True):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod,
                "_sync_get_pin_refresh_data",
                return_value=(1, "Current duty", None),
            ):
                with patch.object(mod, "_schedule_next_update", AsyncMock()):
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        await mod.update_group_pin(context)
    context.bot.send_message.assert_called_once_with(
        chat_id=123, text="Current duty", reply_markup=None
    )
    context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=123, message_id=999, disable_notification=False
    )
    mock_save.assert_called_once_with(123, 999)
    context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)


@pytest.mark.asyncio
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
    """update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
    new_msg = MagicMock()
    new_msg.message_id = 999
    context = MagicMock()
    context.job = MagicMock()
    context.job.data = {"chat_id": 123}
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.unpin_chat_message = AsyncMock()
    context.bot.pin_chat_message = AsyncMock()
    context.bot.delete_message = AsyncMock(
        side_effect=BadRequest("Message to delete not found")
    )
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch.object(config, "DUTY_PIN_NOTIFY", True):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod,
                "_sync_get_pin_refresh_data",
                return_value=(1, "Current duty", None),
            ):
                with patch.object(
                    mod, "_schedule_next_update", AsyncMock()
                ) as mock_schedule:
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        with patch.object(mod, "logger") as mock_logger:
                            await mod.update_group_pin(context)
    mock_save.assert_called_once_with(123, 999)
    mock_schedule.assert_called_once_with(context.application, 123, None)
    mock_logger.warning.assert_called_once()
    assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]


@pytest.mark.asyncio
async def test_update_group_pin_no_message_id_skips():
    """update_group_pin: when no pin record (message_id None), does not send_message."""
    context = MagicMock()
    context.job = MagicMock()
    context.job.data = {"chat_id": 456}
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock()

    with patch.object(mod, "_sync_is_trusted", return_value=True):
        with patch.object(
            mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
        ):
            await mod.update_group_pin(context)
    context.bot.send_message.assert_not_called()


@pytest.mark.asyncio
async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called():
    """update_group_pin: send_message raises BadRequest -> no unpin/pin/save, _schedule_next_update still called."""
    context = MagicMock()
    context.job = MagicMock()
    context.job.data = {"chat_id": 111}
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
    context.bot.unpin_chat_message = AsyncMock()
    context.bot.pin_chat_message = AsyncMock()
    context.application = MagicMock()

    with patch.object(mod, "_sync_is_trusted", return_value=True):
        with patch.object(
            mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
        ):
            with patch.object(
                mod, "_schedule_next_update", AsyncMock()
            ) as mock_schedule:
                await mod.update_group_pin(context)
    context.bot.unpin_chat_message.assert_not_called()
    context.bot.pin_chat_message.assert_not_called()
    mock_schedule.assert_called_once_with(context.application, 111, None)


@pytest.mark.asyncio
async def test_update_group_pin_unpin_raises_pin_succeeds_saves_and_schedules():
    """update_group_pin: send_message ok, unpin raises (e.g. no pinned message), pin succeeds -> save_pin and schedule called."""
    new_msg = MagicMock()
    new_msg.message_id = 888
    context = MagicMock()
    context.job = MagicMock()
    context.job.data = {"chat_id": 222}
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.unpin_chat_message = AsyncMock(
        side_effect=BadRequest("Chat has no pinned message")
    )
    context.bot.pin_chat_message = AsyncMock()
    context.bot.delete_message = AsyncMock()
    context.application = MagicMock()

    with patch.object(config, "DUTY_PIN_NOTIFY", True):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
            ):
                with patch.object(
                    mod, "_schedule_next_update", AsyncMock()
                ) as mock_schedule:
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        await mod.update_group_pin(context)
    context.bot.send_message.assert_called_once_with(
        chat_id=222, text="Text", reply_markup=None
    )
    context.bot.unpin_chat_message.assert_called_once_with(chat_id=222)
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=222, message_id=888, disable_notification=False
    )
    mock_save.assert_called_once_with(222, 888)
    mock_schedule.assert_called_once_with(context.application, 222, None)


@pytest.mark.asyncio
async def test_update_group_pin_pin_raises_no_save_still_schedules_next():
    """update_group_pin: send_message ok, unpin ok, pin raises -> no _sync_save_pin, schedule still called, log."""
    new_msg = MagicMock()
    new_msg.message_id = 888
    context = MagicMock()
    context.job = MagicMock()
    context.job.data = {"chat_id": 222}
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.unpin_chat_message = AsyncMock()
    context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
    context.application = MagicMock()

    with patch.object(config, "DUTY_PIN_NOTIFY", True):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
            ):
                with patch.object(
                    mod, "_schedule_next_update", AsyncMock()
                ) as mock_schedule:
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        with patch.object(mod, "logger") as mock_logger:
                            await mod.update_group_pin(context)
    context.bot.send_message.assert_called_once_with(
        chat_id=222, text="Text", reply_markup=None
    )
    mock_save.assert_not_called()
    mock_logger.warning.assert_called_once()
    assert "Pin after refresh failed" in mock_logger.warning.call_args[0][0]
    mock_schedule.assert_called_once_with(context.application, 222, None)


@pytest.mark.asyncio
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
    """update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
    new_msg = MagicMock()
    new_msg.message_id = 777
    context = MagicMock()
    context.job = MagicMock()
    context.job.data = {"chat_id": 333}
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.unpin_chat_message = AsyncMock()
    context.bot.pin_chat_message = AsyncMock()
    context.bot.delete_message = AsyncMock()
    context.application = MagicMock()

    with patch.object(config, "DUTY_PIN_NOTIFY", False):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
            ):
                with patch.object(
                    mod, "_schedule_next_update", AsyncMock()
                ) as mock_schedule:
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        await mod.update_group_pin(context)
    context.bot.send_message.assert_called_once_with(
        chat_id=333, text="Text", reply_markup=None
    )
    context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=333, message_id=777, disable_notification=True
    )
    mock_save.assert_called_once_with(333, 777)
    context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
    mock_schedule.assert_called_once_with(context.application, 333, None)


@pytest.mark.asyncio
async def test_pin_duty_cmd_group_only_reply():
    """pin_duty_cmd in private chat -> reply group_only."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "private"
    update.effective_chat.id = 1
    update.effective_user = MagicMock()
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
            mock_t.return_value = "Group only"
            await mod.pin_duty_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Group only")
    mock_t.assert_called_with("en", "pin_duty.group_only")


@pytest.mark.asyncio
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
    """pin_duty_cmd in group with existing pin record -> pin, schedule next update, reply pinned."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.pin_chat_message = AsyncMock()
    context.application = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(mod, "_sync_get_message_id", return_value=5):
                with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
                    with patch.object(
                        mod, "_schedule_next_update", AsyncMock()
                    ) as mock_schedule:
                        with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                            mock_t.return_value = "Pinned"
                            await mod.pin_duty_cmd(update, context)
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=100, message_id=5, disable_notification=True
    )
    mock_schedule.assert_called_once_with(context.application, 100, None)
    update.message.reply_text.assert_called_once_with("Pinned")


@pytest.mark.asyncio
async def test_pin_duty_cmd_untrusted_group_rejects():
    """pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=False):
            with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                mock_t.return_value = "Not authorized"
                await mod.pin_duty_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Not authorized")
    mock_t.assert_called_with("en", "group.not_trusted")
    context.bot.send_message.assert_not_called()


@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
    """pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()
    context.bot = MagicMock()
    new_msg = MagicMock()
    new_msg.message_id = 42
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.pin_chat_message = AsyncMock()
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(mod, "_sync_get_message_id", return_value=None):
                with patch.object(
                    mod, "_get_duty_message_text_sync", return_value="Duty text"
                ):
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        with patch.object(
                            mod, "_get_next_shift_end_sync", return_value=None
                        ):
                            with patch.object(
                                mod, "_schedule_next_update", AsyncMock()
                            ):
                                with patch(
                                    "duty_teller.handlers.group_duty_pin.t"
                                ) as mock_t:
                                    mock_t.return_value = "Pinned"
                                    await mod.pin_duty_cmd(update, context)
    context.bot.send_message.assert_called_once_with(
        chat_id=100, text="Duty text", reply_markup=None
    )
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=100, message_id=42, disable_notification=True
    )
    mock_save.assert_called_once_with(100, 42)
    update.message.reply_text.assert_called_once_with("Pinned")
    mock_t.assert_called_with("en", "pin_duty.pinned")


@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
    """pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
    context.application = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(mod, "_sync_get_message_id", return_value=None):
                with patch.object(
                    mod, "_get_duty_message_text_sync", return_value="Duty"
                ):
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        with patch.object(
                            mod, "_schedule_next_update", AsyncMock()
                        ) as mock_schedule:
                            with patch(
                                "duty_teller.handlers.group_duty_pin.t"
                            ) as mock_t:
                                mock_t.return_value = "Failed"
                                await mod.pin_duty_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Failed")
    mock_t.assert_called_with("en", "pin_duty.failed")
    mock_save.assert_not_called()
    mock_schedule.assert_not_called()


@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin():
    """pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()
    context.bot = MagicMock()
    new_msg = MagicMock()
    new_msg.message_id = 43
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(mod, "_sync_get_message_id", return_value=None):
                with patch.object(
                    mod, "_get_duty_message_text_sync", return_value="Duty"
                ):
                    with patch.object(mod, "_sync_save_pin") as mock_save:
                        with patch.object(
                            mod, "_get_next_shift_end_sync", return_value=None
                        ):
                            with patch.object(
                                mod, "_schedule_next_update", AsyncMock()
                            ):
                                with patch(
                                    "duty_teller.handlers.group_duty_pin.t"
                                ) as mock_t:
                                    mock_t.return_value = "Make me admin to pin"
                                    await mod.pin_duty_cmd(update, context)
    context.bot.send_message.assert_called_once_with(
        chat_id=100, text="Duty", reply_markup=None
    )
    mock_save.assert_called_once_with(100, 43)
    update.message.reply_text.assert_called_once_with("Make me admin to pin")
    mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")


@pytest.mark.asyncio
async def test_pin_duty_cmd_pin_raises_replies_failed():
    """pin_duty_cmd: pin_chat_message raises -> reply pin_duty.failed."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.pin_chat_message = AsyncMock(
        side_effect=BadRequest("Not enough rights")
    )

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(mod, "_sync_get_message_id", return_value=5):
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "Failed to pin"
                    await mod.pin_duty_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Failed to pin")
    mock_t.assert_called_with("en", "pin_duty.failed")


# --- refresh_pin_cmd ---


@pytest.mark.asyncio
async def test_refresh_pin_cmd_private_replies_group_only():
    """refresh_pin_cmd in private chat -> reply refresh_pin.group_only."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "private"
    update.effective_chat.id = 1
    update.effective_user = MagicMock()
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
            mock_t.return_value = "Group only"
            await mod.refresh_pin_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Group only")
    mock_t.assert_called_with("en", "refresh_pin.group_only")


@pytest.mark.asyncio
async def test_refresh_pin_cmd_group_updated_replies_updated():
    """refresh_pin_cmd in group with pin record -> _refresh_pin_for_chat returns updated -> reply refresh_pin.updated."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
            ):
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "Updated"
                    await mod.refresh_pin_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Updated")
    mock_t.assert_called_with("en", "refresh_pin.updated")


@pytest.mark.asyncio
async def test_refresh_pin_cmd_group_no_message_replies_no_message():
    """refresh_pin_cmd: no pin record -> _refresh_pin_for_chat returns no_message -> reply refresh_pin.no_message."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
            ):
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "No message"
                    await mod.refresh_pin_cmd(update, context)
    update.message.reply_text.assert_called_once_with("No message")
    mock_t.assert_called_with("en", "refresh_pin.no_message")


@pytest.mark.asyncio
async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
    """refresh_pin_cmd: _refresh_pin_for_chat returns failed (e.g. BadRequest) -> reply refresh_pin.failed."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
            ):
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "Failed"
                    await mod.refresh_pin_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Failed")
    mock_t.assert_called_with("en", "refresh_pin.failed")


@pytest.mark.asyncio
async def test_refresh_pin_cmd_untrusted_group_rejects():
    """refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=False):
            with patch.object(
                mod, "_refresh_pin_for_chat", AsyncMock()
            ) as mock_refresh:
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "Not authorized"
                    await mod.refresh_pin_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Not authorized")
    mock_t.assert_called_with("en", "group.not_trusted")
    mock_refresh.assert_not_called()


# --- my_chat_member_handler ---


def _make_my_chat_member_update(
    old_status,
    new_status,
    chat_id=100,
    chat_type="group",
    bot_id=999,
    from_user_id=1,
):
    """Build Update with my_chat_member for group add/remove."""
    update = MagicMock()
    update.effective_user = MagicMock()
    update.effective_user.id = from_user_id
    update.effective_chat = MagicMock()
    update.effective_chat.id = chat_id
    update.effective_chat.type = chat_type
    update.my_chat_member = MagicMock()
    update.my_chat_member.old_chat_member = MagicMock()
    update.my_chat_member.old_chat_member.status = old_status
    update.my_chat_member.new_chat_member = MagicMock()
    update.my_chat_member.new_chat_member.status = new_status
    update.my_chat_member.new_chat_member.user = MagicMock()
    update.my_chat_member.new_chat_member.user.id = bot_id
    return update


@pytest.mark.asyncio
async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
    """my_chat_member_handler: bot added to group -> send_message, pin, save_pin, schedule."""
    update = _make_my_chat_member_update(
        old_status=ChatMemberStatus.LEFT,
        new_status=ChatMemberStatus.ADMINISTRATOR,
        chat_id=200,
        bot_id=999,
    )
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.id = 999
    context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
    context.bot.pin_chat_message = AsyncMock()
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_get_duty_message_text_sync", return_value="Duty text"
            ):
                with patch.object(mod, "_sync_save_pin"):
                    with patch.object(
                        mod, "_get_next_shift_end_sync", return_value=None
                    ):
                        with patch.object(mod, "_schedule_next_update", AsyncMock()):
                            await mod.my_chat_member_handler(update, context)
    context.bot.send_message.assert_called_once_with(
        chat_id=200, text="Duty text", reply_markup=None
    )
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=200, message_id=42, disable_notification=True
    )


@pytest.mark.asyncio
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
    """my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
    update = _make_my_chat_member_update(
        old_status=ChatMemberStatus.LEFT,
        new_status=ChatMemberStatus.ADMINISTRATOR,
        chat_id=200,
        bot_id=999,
    )
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.id = 999
    context.bot.send_message = AsyncMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=False):
            with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                mock_t.return_value = "Not authorized"
                await mod.my_chat_member_handler(update, context)
    context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
    mock_t.assert_called_with("en", "group.not_trusted")


@pytest.mark.asyncio
async def test_my_chat_member_handler_trusted_group_sends_duty():
    """my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
    update = _make_my_chat_member_update(
        old_status=ChatMemberStatus.LEFT,
        new_status=ChatMemberStatus.ADMINISTRATOR,
        chat_id=200,
        bot_id=999,
    )
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.id = 999
    context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
    context.bot.pin_chat_message = AsyncMock()
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(
                mod, "_get_duty_message_text_sync", return_value="Duty text"
            ):
                with patch.object(mod, "_sync_save_pin"):
                    with patch.object(
                        mod, "_get_next_shift_end_sync", return_value=None
                    ):
                        with patch.object(mod, "_schedule_next_update", AsyncMock()):
                            await mod.my_chat_member_handler(update, context)
    context.bot.send_message.assert_called_once_with(
        chat_id=200, text="Duty text", reply_markup=None
    )
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=200, message_id=42, disable_notification=True
    )


@pytest.mark.asyncio
async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
    """my_chat_member_handler: pin_chat_message raises -> send_message could_not_pin_make_admin."""
    update = _make_my_chat_member_update(
        old_status=ChatMemberStatus.LEFT,
        new_status=ChatMemberStatus.MEMBER,
        chat_id=201,
        bot_id=999,
    )
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.id = 999
    context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=43))
    context.bot.pin_chat_message = AsyncMock(side_effect=BadRequest("Cannot pin"))
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "_sync_is_trusted", return_value=True):
            with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
                with patch.object(mod, "_sync_save_pin"):
                    with patch.object(
                        mod, "_get_next_shift_end_sync", return_value=None
                    ):
                        with patch.object(mod, "_schedule_next_update", AsyncMock()):
                            with patch(
                                "duty_teller.handlers.group_duty_pin.t"
                            ) as mock_t:
                                mock_t.return_value = "Make me admin to pin"
                                await mod.my_chat_member_handler(update, context)
    assert context.bot.send_message.call_count >= 2
    pin_hint_calls = [
        c
        for c in context.bot.send_message.call_args_list
        if c[1].get("text") == "Make me admin to pin"
    ]
    assert len(pin_hint_calls) == 1


@pytest.mark.asyncio
async def test_my_chat_member_handler_bot_removed_deletes_pin_and_jobs():
    """my_chat_member_handler: bot removed -> _sync_delete_pin, remove jobs by name."""
    update = _make_my_chat_member_update(
        old_status=ChatMemberStatus.ADMINISTRATOR,
        new_status=ChatMemberStatus.LEFT,
        chat_id=202,
        bot_id=999,
    )
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.id = 999
    context.application = MagicMock()
    job_queue = MagicMock()
    mock_job = MagicMock()
    job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
    context.application.job_queue = job_queue

    with patch.object(mod, "_sync_delete_pin"):
        await mod.my_chat_member_handler(update, context)
    job_queue.get_jobs_by_name.assert_called_once_with("duty_pin_202")
    mock_job.schedule_removal.assert_called_once()


@pytest.mark.asyncio
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat_with_jitter():
    """restore_group_pin_jobs: for each chat_id calls _schedule_next_update with jitter_seconds=60."""
    application = MagicMock()
    application.job_queue = MagicMock()
    application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    application.job_queue.run_once = MagicMock()

    with patch.object(mod, "_get_all_pin_chat_ids_sync", return_value=[10, 20]):
        with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
            with patch.object(
                mod, "_schedule_next_update", AsyncMock()
            ) as mock_schedule:
                await mod.restore_group_pin_jobs(application)
    assert mock_schedule.call_count == 2
    mock_schedule.assert_any_call(application, 10, None, jitter_seconds=60.0)
    mock_schedule.assert_any_call(application, 20, None, jitter_seconds=60.0)


# --- _refresh_pin_for_chat untrusted ---


@pytest.mark.asyncio
async def test_refresh_pin_for_chat_untrusted_removes_pin():
    """_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.unpin_chat_message = AsyncMock()
    context.bot.delete_message = AsyncMock()
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    mock_job = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])

    with patch.object(mod, "_sync_is_trusted", return_value=False):
        with patch.object(mod, "_sync_get_message_id", return_value=11):
            with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
                result = await mod._refresh_pin_for_chat(context, 100)
    assert result == "untrusted"
    mock_delete_pin.assert_called_once_with(100)
    context.application.job_queue.get_jobs_by_name.assert_called_once_with(
        "duty_pin_100"
    )
    mock_job.schedule_removal.assert_called_once()
    context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
    context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)


# --- trust_group_cmd / untrust_group_cmd ---


@pytest.mark.asyncio
async def test_trust_group_cmd_non_admin_rejects():
    """trust_group_cmd: non-admin -> reply import.admin_only."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    update.effective_user.id = 111
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
            with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                mock_t.return_value = "Admin only"
                await mod.trust_group_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Admin only")
    mock_t.assert_called_with("en", "import.admin_only")


@pytest.mark.asyncio
async def test_trust_group_cmd_admin_adds_group():
    """trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    update.effective_user.id = 111
    context = MagicMock()
    context.bot = MagicMock()
    new_msg = MagicMock()
    new_msg.message_id = 50
    context.bot.send_message = AsyncMock(return_value=new_msg)
    context.bot.pin_chat_message = AsyncMock()
    context.application = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
    context.application.job_queue.run_once = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
            with patch.object(mod, "_sync_trust_group", return_value=False):
                with patch.object(mod, "_sync_get_message_id", return_value=None):
                    with patch.object(
                        mod, "_get_duty_message_text_sync", return_value="Duty text"
                    ):
                        with patch.object(mod, "_sync_save_pin") as mock_save:
                            with patch.object(
                                mod, "_get_next_shift_end_sync", return_value=None
                            ):
                                with patch.object(
                                    mod, "_schedule_next_update", AsyncMock()
                                ):
                                    with patch(
                                        "duty_teller.handlers.group_duty_pin.t"
                                    ) as mock_t:
                                        mock_t.return_value = "Added"
                                        with patch.object(
                                            config, "DUTY_PIN_NOTIFY", False
                                        ):
                                            await mod.trust_group_cmd(update, context)
    update.message.reply_text.assert_any_call("Added")
    mock_t.assert_any_call("en", "trust_group.added")
    context.bot.send_message.assert_called_once_with(
        chat_id=100, text="Duty text", reply_markup=None
    )
    context.bot.pin_chat_message.assert_called_once_with(
        chat_id=100, message_id=50, disable_notification=True
    )
    mock_save.assert_called_once_with(100, 50)


@pytest.mark.asyncio
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
    """trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    update.effective_user.id = 111
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.send_message = AsyncMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
            with patch.object(mod, "_sync_trust_group", return_value=True):
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "Already trusted"
                    await mod.trust_group_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Already trusted")
    mock_t.assert_called_with("en", "trust_group.already_trusted")
    context.bot.send_message.assert_not_called()


@pytest.mark.asyncio
async def test_untrust_group_cmd_removes_group():
    """untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    update.effective_user.id = 111
    context = MagicMock()
    context.bot = MagicMock()
    context.bot.unpin_chat_message = AsyncMock()
    context.bot.delete_message = AsyncMock()
    context.application = MagicMock()
    mock_job = MagicMock()
    context.application.job_queue = MagicMock()
    context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
            with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "Removed"
                    await mod.untrust_group_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Removed")
    mock_t.assert_called_with("en", "untrust_group.removed")
    context.application.job_queue.get_jobs_by_name.assert_called_once_with(
        "duty_pin_100"
    )
    mock_job.schedule_removal.assert_called_once()
    context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
    context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)


@pytest.mark.asyncio
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
    """untrust_group_cmd: group not in trusted list -> reply not_trusted."""
    update = MagicMock()
    update.message = MagicMock()
    update.message.reply_text = AsyncMock()
    update.effective_chat = MagicMock()
    update.effective_chat.type = "group"
    update.effective_chat.id = 100
    update.effective_user = MagicMock()
    context = MagicMock()

    with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
        with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
            with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
                with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
                    mock_t.return_value = "Not trusted"
                    await mod.untrust_group_cmd(update, context)
    update.message.reply_text.assert_called_once_with("Not trusted")
    mock_t.assert_called_with("en", "untrust_group.not_trusted")
