from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from types import SimpleNamespace
from unittest.mock import patch
from django.utils import timezone

from basic_info.models import Employee, EmployeeStatusEnum, EmployeeType, Merchant, MerchantTypeEnum
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
from mission.services import (
    cancel_mission,
    create_mission,
    create_mission_reply,
    reject_reply,
    reopen_mission,
    set_mission_participants,
    set_mission_urgent,
    update_mission,
)
from mission.signals import (
    mission_cancelled,
    mission_completed,
    mission_created,
    mission_reopened,
    mission_replied,
    mission_reply_rejected,
)
from mission.payload_processors import _split_structured_description
from api_v2.views.mission import MissionWriteSerializer
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute
from notifier.services import dispatch_notification_event


class MissionModelTestCase(TestCase):
    def setUp(self):
        self.merchant = Merchant.objects.create(name="测试商户", type=MerchantTypeEnum.STORE)
        self.other_merchant = Merchant.objects.create(name="其他商户", type=MerchantTypeEnum.STORE)
        self.default_category = MissionCategory.objects.create(merchant=self.merchant, name="通用")
        self.custom_category = MissionCategory.objects.create(merchant=self.merchant, name="售后")
        self.other_category = MissionCategory.objects.create(merchant=self.other_merchant, name="通用")
        self.printer_type = EmployeeType.objects.create(merchant=self.merchant, title="打纸")
        self.empty_type = EmployeeType.objects.create(merchant=self.merchant, title="空职位")
        self.other_type = EmployeeType.objects.create(merchant=self.other_merchant, title="外部职位")
        self.creator = Employee.objects.create(merchant=self.merchant, name="创建者")
        self.responder = Employee.objects.create(merchant=self.merchant, name="回应者")
        self.position_employee = Employee.objects.create(
            merchant=self.merchant,
            name="职位参与者",
            position=self.printer_type,
        )
        self.inactive_position_employee = Employee.objects.create(
            merchant=self.merchant,
            name="离职职位参与者",
            position=self.printer_type,
            status=EmployeeStatusEnum.INACTIVE,
        )
        self.other_employee = Employee.objects.create(merchant=self.other_merchant, name="外部员工")
        self.mission = Mission.objects.create(
            merchant=self.merchant,
            category=self.default_category,
            description="测试任务",
            creator=self.creator,
        )

    def test_participant_helpers(self):
        MissionParticipant.objects.create(
            merchant=self.merchant,
            mission=self.mission,
            employee=self.responder,
        )

        self.assertEqual(self.mission.get_participants().count(), 1)
        self.assertEqual(
            self.mission.filter_participants(employee=self.responder).first().employee,
            self.responder,
        )

    def test_create_ending_reply_marks_mission_completed(self):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="完成任务",
            ends_task=True,
        )

        self.mission.refresh_from_db()
        reply.refresh_from_db()
        self.assertTrue(reply.ends_task)
        self.assertEqual(reply.merchant, self.merchant)
        self.assertTrue(self.mission.is_completed)
        self.assertTrue(self.mission.has_ending_reply)
        self.assertFalse(self.mission.can_reply)

    def test_reopen_mission_rejects_ending_reply(self):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="完成任务",
            ends_task=True,
        )

        reopen_mission(mission=self.mission, reopened_by=self.creator)

        self.mission.refresh_from_db()
        reply.refresh_from_db()
        self.assertFalse(self.mission.is_completed)
        self.assertFalse(reply.ends_task)
        self.assertTrue(reply.is_rejected)
        self.assertEqual(reply.rejected_by, self.creator)
        self.assertIsNotNone(reply.rejected_at)
        self.assertTrue(self.mission.can_reply)

    def test_reopen_cancelled_mission_is_rejected(self):
        create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="完成任务",
            ends_task=True,
        )
        self.mission.is_cancelled = True
        self.mission.save(update_fields=["is_cancelled", "updated_at"])

        with self.assertRaises(ValueError):
            reopen_mission(mission=self.mission, reopened_by=self.creator)

    def test_cross_merchant_responder_is_rejected(self):
        with self.assertRaises(ValueError):
            create_mission_reply(
                mission=self.mission,
                responder=self.other_employee,
                content="跨商户回应",
            )

    def test_create_reply_rejects_empty_responder(self):
        with self.assertRaises(ValueError):
            create_mission_reply(
                mission=self.mission,
                responder=None,
                content="无回应者",
            )

    def test_reject_reply_marks_reply_rejected_and_reopens_mission(self):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="完成任务",
            ends_task=True,
        )

        reject_reply(reply=reply, rejected_by=self.creator)

        self.mission.refresh_from_db()
        reply.refresh_from_db()
        self.assertFalse(self.mission.is_completed)
        self.assertFalse(reply.ends_task)
        self.assertTrue(reply.is_rejected)
        self.assertEqual(reply.rejected_by, self.creator)
        self.assertIsNotNone(reply.rejected_at)

    def test_cancel_mission_records_employee_and_time(self):
        cancel_mission(mission=self.mission, cancelled_by=self.creator)

        self.mission.refresh_from_db()
        self.assertTrue(self.mission.is_cancelled)
        self.assertEqual(self.mission.cancelled_by, self.creator)
        self.assertIsNotNone(self.mission.cancelled_at)

    def test_cross_merchant_cancel_is_rejected(self):
        with self.assertRaises(ValueError):
            cancel_mission(mission=self.mission, cancelled_by=self.other_employee)

    def test_create_mission_sets_defaults_and_participants(self):
        mission = create_mission(
            creator=self.creator,
            description="service 创建任务",
            participant_ids=[self.responder.id, self.responder.id],
        )

        self.assertEqual(mission.merchant, self.merchant)
        self.assertEqual(mission.category, self.default_category)
        self.assertFalse(mission.is_urgent)
        self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.responder.id])

    def test_create_mission_adds_active_employees_from_employee_types(self):
        mission = create_mission(
            creator=self.creator,
            description="按职位添加参与者",
            participant_ids=[self.responder.id, self.position_employee.id],
            employee_type_ids=[self.printer_type.id, self.empty_type.id],
        )

        self.assertEqual(
            set(mission.participants.values_list("employee_id", flat=True)),
            {self.responder.id, self.position_employee.id},
        )

    def test_mission_write_serializer_accepts_employee_type_ids(self):
        serializer = MissionWriteSerializer(
            data={
                "description": "接口创建任务",
                "employee_type_ids": [self.printer_type.id],
            },
            is_create=True,
            context={"employee": self.creator},
        )

        self.assertTrue(serializer.is_valid(), serializer.errors)
        self.assertEqual(serializer.validated_data["employee_type_ids"], [self.printer_type.id])

    def test_create_mission_supports_unreplied_notification_fields(self):
        mission = create_mission(
            creator=self.creator,
            description="带未回复提醒的任务",
            notify_if_unreplied=True,
            unreplied_notify_interval_minutes=15,
        )

        self.assertTrue(mission.notify_if_unreplied)
        self.assertEqual(mission.unreplied_notify_interval_minutes, 15)
        self.assertEqual(mission.unreplied_notify_max_count, 5)
        self.assertEqual(mission.unreplied_notify_sent_count, 0)
        self.assertIsNone(mission.unreplied_last_notified_at)

    def test_create_mission_requires_interval_when_unreplied_notification_enabled(self):
        with self.assertRaisesMessage(ValueError, "开启未回复提醒时必须设置提醒间隔"):
            create_mission(
                creator=self.creator,
                description="缺少提醒间隔",
                notify_if_unreplied=True,
            )

    def test_create_mission_rejects_empty_creator(self):
        with self.assertRaises(ValueError):
            create_mission(creator=None, description="无创建人")

    def test_create_mission_rejects_partial_content_object(self):
        with self.assertRaises(ValueError):
            create_mission(
                creator=self.creator,
                description="缺少 content_id",
                content_type=ContentType.objects.get_for_model(Mission),
            )

    def test_create_mission_rejects_missing_content_object(self):
        with self.assertRaises(ValueError):
            create_mission(
                creator=self.creator,
                description="不存在的关联对象",
                content_type=ContentType.objects.get_for_model(Mission),
                content_id=999999,
            )

    def test_create_mission_rejects_cross_merchant_content_object(self):
        other_mission = Mission.objects.create(
            merchant=self.other_merchant,
            category=self.other_category,
            creator=self.other_employee,
            description="其他商户任务",
        )

        with self.assertRaises(ValueError):
            create_mission(
                creator=self.creator,
                description="跨商户关联对象",
                content_type=ContentType.objects.get_for_model(Mission),
                content_id=other_mission.id,
            )

    def test_update_mission_updates_content_object_and_participants(self):
        related = Mission.objects.create(
            merchant=self.merchant,
            category=self.default_category,
            creator=self.creator,
            description="同商户关联对象",
        )

        update_mission(
            mission=self.mission,
            updated_by=self.creator,
            description="更新描述",
            category=self.custom_category,
            content_type=ContentType.objects.get_for_model(Mission),
            content_id=related.id,
            update_content_object=True,
            participant_ids=[self.responder.id],
        )

        self.mission.refresh_from_db()
        self.assertEqual(self.mission.description, "更新描述")
        self.assertEqual(self.mission.category, self.custom_category)
        self.assertEqual(self.mission.content_id, related.id)
        self.assertEqual(list(self.mission.participants.values_list("employee_id", flat=True)), [self.responder.id])

    def test_update_mission_sets_participants_from_employee_types(self):
        MissionParticipant.objects.create(
            merchant=self.merchant,
            mission=self.mission,
            employee=self.responder,
        )

        update_mission(
            mission=self.mission,
            updated_by=self.creator,
            employee_type_ids=[self.printer_type.id],
        )

        self.assertEqual(
            list(self.mission.participants.values_list("employee_id", flat=True)),
            [self.position_employee.id],
        )

    def test_update_mission_updates_unreplied_notification_fields(self):
        update_mission(
            mission=self.mission,
            updated_by=self.creator,
            notify_if_unreplied=True,
            unreplied_notify_interval_minutes=20,
            unreplied_notify_max_count=8,
        )

        self.mission.refresh_from_db()
        self.assertTrue(self.mission.notify_if_unreplied)
        self.assertEqual(self.mission.unreplied_notify_interval_minutes, 20)
        self.assertEqual(self.mission.unreplied_notify_max_count, 8)

    def test_create_mission_rejects_cross_merchant_category(self):
        with self.assertRaises(ValueError):
            create_mission(
                creator=self.creator,
                description="跨商户分类",
                category=self.other_category,
            )

    def test_update_mission_rejects_cross_merchant_employee(self):
        with self.assertRaises(ValueError):
            update_mission(
                mission=self.mission,
                updated_by=self.other_employee,
                description="非法更新",
            )

    def test_set_participants_rejects_cross_merchant_employee(self):
        with self.assertRaises(ValueError):
            set_mission_participants(
                mission=self.mission,
                participant_ids=[self.other_employee.id],
            )

    def test_set_participants_rejects_cross_merchant_employee_type(self):
        with self.assertRaises(ValueError):
            set_mission_participants(
                mission=self.mission,
                employee_type_ids=[self.other_type.id],
            )

    def test_update_mission_rejects_cross_merchant_category(self):
        with self.assertRaises(ValueError):
            update_mission(
                mission=self.mission,
                updated_by=self.creator,
                category=self.other_category,
            )

    def test_create_reply_rejects_after_ending_reply(self):
        create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="结束任务",
            ends_task=True,
        )

        with self.assertRaises(ValueError):
            create_mission_reply(
                mission=self.mission,
                responder=self.responder,
                content="后续回应",
            )

    def test_reopen_uncompleted_mission_is_rejected(self):
        with self.assertRaises(ValueError):
            reopen_mission(mission=self.mission, reopened_by=self.creator)

    def test_reopen_completed_mission_without_ending_reply_is_rejected(self):
        self.mission.is_completed = True
        self.mission.save(update_fields=["is_completed", "updated_at"])

        with self.assertRaises(ValueError):
            reopen_mission(mission=self.mission, reopened_by=self.creator)

    def test_reopen_cross_merchant_employee_is_rejected(self):
        create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="结束任务",
            ends_task=True,
        )

        with self.assertRaises(ValueError):
            reopen_mission(mission=self.mission, reopened_by=self.other_employee)

    def test_reject_reply_rejects_cancelled_mission(self):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="普通回应",
        )
        self.mission.is_cancelled = True
        self.mission.save(update_fields=["is_cancelled", "updated_at"])

        with self.assertRaises(ValueError):
            reject_reply(reply=reply, rejected_by=self.creator)

    def test_reject_reply_rejects_already_rejected_reply(self):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="普通回应",
        )

        reject_reply(reply=reply, rejected_by=self.creator)

        with self.assertRaises(ValueError):
            reject_reply(reply=reply, rejected_by=self.creator)

    def test_reject_reply_rejects_cross_merchant_employee(self):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="普通回应",
        )

        with self.assertRaises(ValueError):
            reject_reply(reply=reply, rejected_by=self.other_employee)

    def test_reject_non_ending_reply_keeps_completed_state(self):
        self.mission.is_completed = True
        self.mission.save(update_fields=["is_completed", "updated_at"])
        reply = MissionReply.objects.create(
            merchant=self.merchant,
            mission=self.mission,
            responder=self.responder,
            content="普通回应",
            ends_task=False,
        )

        reject_reply(reply=reply, rejected_by=self.creator)

        self.mission.refresh_from_db()
        self.assertTrue(self.mission.is_completed)

    def test_create_reply_resets_unreplied_notification_state(self):
        self.mission.notify_if_unreplied = True
        self.mission.unreplied_notify_interval_minutes = 10
        self.mission.unreplied_notify_sent_count = 3
        self.mission.unreplied_last_notified_at = timezone.now()
        self.mission.save(
            update_fields=[
                "notify_if_unreplied",
                "unreplied_notify_interval_minutes",
                "unreplied_notify_sent_count",
                "unreplied_last_notified_at",
                "updated_at",
            ]
        )

        create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="收到，处理中",
        )

        self.mission.refresh_from_db()
        self.assertEqual(self.mission.unreplied_notify_sent_count, 0)
        self.assertIsNone(self.mission.unreplied_last_notified_at)

    def test_reject_reply_without_other_effective_reply_resets_unreplied_notification_state(self):
        self.mission.notify_if_unreplied = True
        self.mission.unreplied_notify_interval_minutes = 10
        self.mission.unreplied_notify_sent_count = 2
        self.mission.unreplied_last_notified_at = timezone.now()
        self.mission.save(
            update_fields=[
                "notify_if_unreplied",
                "unreplied_notify_interval_minutes",
                "unreplied_notify_sent_count",
                "unreplied_last_notified_at",
                "updated_at",
            ]
        )
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="普通回应",
        )

        reject_reply(reply=reply, rejected_by=self.creator)

        self.mission.refresh_from_db()
        self.assertEqual(self.mission.unreplied_notify_sent_count, 0)
        self.assertIsNone(self.mission.unreplied_last_notified_at)

    def test_cancel_already_cancelled_mission_is_rejected(self):
        cancel_mission(mission=self.mission, cancelled_by=self.creator)

        with self.assertRaises(ValueError):
            cancel_mission(mission=self.mission, cancelled_by=self.creator)

    def test_set_urgent_updates_status_and_rejects_cross_merchant_employee(self):
        set_mission_urgent(mission=self.mission, updated_by=self.creator, is_urgent=True)

        self.mission.refresh_from_db()
        self.assertTrue(self.mission.is_urgent)

        with self.assertRaises(ValueError):
            set_mission_urgent(mission=self.mission, updated_by=self.other_employee, is_urgent=False)

    def test_create_mission_emits_mission_created_signal(self):
        received = []

        def receiver(sender, instance, created_by=None, **kwargs):
            received.append((sender, instance.id, created_by.id))

        mission_created.connect(receiver, sender=Mission, dispatch_uid="test_mission_created")
        try:
            with self.captureOnCommitCallbacks(execute=True):
                mission = create_mission(creator=self.creator, description="触发创建信号")
        finally:
            mission_created.disconnect(sender=Mission, dispatch_uid="test_mission_created")

        self.assertEqual(received, [(Mission, mission.id, self.creator.id)])

    def test_create_reply_emits_replied_and_completed_signals(self):
        received = []

        def on_replied(sender, instance, mission=None, responder=None, **kwargs):
            received.append(("replied", sender, instance.id, mission.id, responder.id))

        def on_completed(sender, instance, completed_by=None, reply=None, **kwargs):
            received.append(("completed", sender, instance.id, completed_by.id, reply.id))

        mission_replied.connect(on_replied, sender=MissionReply, dispatch_uid="test_mission_replied")
        mission_completed.connect(on_completed, sender=Mission, dispatch_uid="test_mission_completed")
        try:
            with self.captureOnCommitCallbacks(execute=True):
                reply = create_mission_reply(
                    mission=self.mission,
                    responder=self.responder,
                    content="完成并触发信号",
                    ends_task=True,
                )
        finally:
            mission_replied.disconnect(sender=MissionReply, dispatch_uid="test_mission_replied")
            mission_completed.disconnect(sender=Mission, dispatch_uid="test_mission_completed")

        self.assertIn(("completed", Mission, self.mission.id, self.responder.id, reply.id), received)
        self.assertIn(("replied", MissionReply, reply.id, self.mission.id, self.responder.id), received)

    def test_reject_reply_emits_reply_rejected_signal(self):
        received = []
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="待撤销回应",
        )

        def receiver(sender, instance, mission=None, rejected_by=None, reason=None, **kwargs):
            received.append((sender, instance.id, mission.id, rejected_by.id, reason))

        mission_reply_rejected.connect(receiver, sender=MissionReply, dispatch_uid="test_reply_rejected")
        try:
            with self.captureOnCommitCallbacks(execute=True):
                reject_reply(reply=reply, rejected_by=self.creator)
        finally:
            mission_reply_rejected.disconnect(sender=MissionReply, dispatch_uid="test_reply_rejected")

        self.assertEqual(received, [(MissionReply, reply.id, self.mission.id, self.creator.id, "reject_reply")])

    def test_reopen_emits_reopened_and_reply_rejected_signals(self):
        received = []
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="结束并等待 reopen",
            ends_task=True,
        )

        def on_reply_rejected(sender, instance, mission=None, rejected_by=None, reason=None, **kwargs):
            received.append(("reply_rejected", sender, instance.id, mission.id, rejected_by.id, reason))

        def on_reopened(sender, instance, reopened_by=None, rejected_reply_ids=None, **kwargs):
            received.append(("reopened", sender, instance.id, reopened_by.id, rejected_reply_ids))

        mission_reply_rejected.connect(on_reply_rejected, sender=MissionReply, dispatch_uid="test_reopen_reply_rejected")
        mission_reopened.connect(on_reopened, sender=Mission, dispatch_uid="test_mission_reopened")
        try:
            with self.captureOnCommitCallbacks(execute=True):
                reopen_mission(mission=self.mission, reopened_by=self.creator)
        finally:
            mission_reply_rejected.disconnect(sender=MissionReply, dispatch_uid="test_reopen_reply_rejected")
            mission_reopened.disconnect(sender=Mission, dispatch_uid="test_mission_reopened")

        self.assertIn(("reply_rejected", MissionReply, reply.id, self.mission.id, self.creator.id, "reopen"), received)
        self.assertIn(("reopened", Mission, self.mission.id, self.creator.id, [reply.id]), received)

    def test_cancel_mission_emits_cancelled_signal(self):
        received = []

        def receiver(sender, instance, cancelled_by=None, **kwargs):
            received.append((sender, instance.id, cancelled_by.id))

        mission_cancelled.connect(receiver, sender=Mission, dispatch_uid="test_mission_cancelled")
        try:
            with self.captureOnCommitCallbacks(execute=True):
                cancel_mission(mission=self.mission, cancelled_by=self.creator)
        finally:
            mission_cancelled.disconnect(sender=Mission, dispatch_uid="test_mission_cancelled")

        self.assertEqual(received, [(Mission, self.mission.id, self.creator.id)])

    def test_split_structured_description_v1(self):
        title, body, url, image_url = _split_structured_description(
            "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n手机端链接: https://example.com/detail"
        )

        self.assertEqual(title, "标题一\n标题二")
        self.assertEqual(body, "正文一\n正文二")
        self.assertEqual(url, "https://example.com/detail")
        self.assertEqual(image_url, "https://images.yuwen.cloud/abc.jpg")

    def test_split_structured_description_v1_without_blank_line_uses_title_only(self):
        title, body, url, image_url = _split_structured_description(
            "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n手机端链接: https://example.com/detail"
        )

        self.assertEqual(title, "标题一\n标题二")
        self.assertEqual(body, "")
        self.assertEqual(url, "https://example.com/detail")
        self.assertEqual(image_url, "https://images.yuwen.cloud/abc.jpg")

    @patch("mission.handlers.enqueue_notification_event")
    def test_mission_created_handler_enqueues_notifier_event(self, mock_enqueue):
        with self.captureOnCommitCallbacks(execute=True):
            mission = create_mission(creator=self.creator, description="触发任务创建通知")

        mock_enqueue.assert_called_once()
        self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.created")
        self.assertEqual(mock_enqueue.call_args.kwargs["merchant_id"], self.merchant.id)
        self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], mission.id)

    @patch("mission.tasks.notify_mission_speech.delay")
    @patch("mission.handlers.enqueue_notification_event")
    def test_mission_created_speech_requires_category_enabled(self, mock_enqueue, mock_speech_delay):
        with self.captureOnCommitCallbacks(execute=True):
            create_mission(creator=self.creator, description="不播报任务")

        mock_enqueue.assert_called_once()
        mock_speech_delay.assert_not_called()

    @patch("mission.tasks.notify_mission_speech.delay")
    @patch("mission.handlers.enqueue_notification_event")
    def test_mission_created_speech_enqueues_when_category_enabled(self, mock_enqueue, mock_speech_delay):
        self.default_category.speech_enabled = True
        self.default_category.save(update_fields=["speech_enabled", "updated_at"])

        with self.captureOnCommitCallbacks(execute=True):
            mission = create_mission(creator=self.creator, description="需要播报任务")

        mock_enqueue.assert_called_once()
        mock_speech_delay.assert_called_once()
        self.assertIn(str(mission.description), mock_speech_delay.call_args.kwargs["text"])

    @patch("mission.handlers.enqueue_notification_event")
    def test_mission_created_handler_applies_category_payload_processor(self, mock_enqueue):
        self.default_category.payload_processor = "structured_description_v1"
        self.default_category.save(update_fields=["payload_processor", "updated_at"])

        with self.captureOnCommitCallbacks(execute=True):
            mission = create_mission(
                creator=self.creator,
                description=(
                    "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n"
                    "手机端链接: https://example.com/detail"
                ),
            )

        payload = mock_enqueue.call_args.kwargs["payload"]
        self.assertEqual(payload["mission_id"], mission.id)
        self.assertEqual(payload["payload_processor"], "structured_description_v1")
        self.assertEqual(payload["parsed_description_title"], "标题一\n标题二")
        self.assertEqual(payload["parsed_description_body"], "正文一\n正文二")
        self.assertEqual(payload["parsed_description_url"], "https://example.com/detail")
        self.assertEqual(payload["parsed_description_image_url"], "https://images.yuwen.cloud/abc.jpg")

    @patch("mission.handlers.enqueue_notification_event")
    def test_create_ending_reply_handler_enqueues_replied_and_completed_notifications(self, mock_enqueue):
        with self.captureOnCommitCallbacks(execute=True):
            reply = create_mission_reply(
                mission=self.mission,
                responder=self.responder,
                content="完成任务并通知",
                ends_task=True,
            )

        self.assertEqual(mock_enqueue.call_count, 2)
        event_keys = {call.kwargs["event_key"] for call in mock_enqueue.call_args_list}
        self.assertEqual(event_keys, {"mission.replied", "mission.completed"})
        payloads = [call.kwargs["payload"] for call in mock_enqueue.call_args_list]
        self.assertTrue(any(payload.get("reply_id") == reply.id for payload in payloads))

    @patch("mission.handlers.enqueue_notification_event")
    def test_reject_reply_handler_enqueues_notification(self, mock_enqueue):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="待撤销回应",
        )

        with self.captureOnCommitCallbacks(execute=True):
            reject_reply(reply=reply, rejected_by=self.creator)

        mock_enqueue.assert_called_once()
        self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.reply_rejected")
        self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["reply_id"], reply.id)

    @patch("mission.handlers.enqueue_notification_event")
    def test_reopen_mission_handler_enqueues_rejected_and_reopened_notifications(self, mock_enqueue):
        reply = create_mission_reply(
            mission=self.mission,
            responder=self.responder,
            content="结束任务",
            ends_task=True,
        )

        with self.captureOnCommitCallbacks(execute=True):
            reopen_mission(mission=self.mission, reopened_by=self.creator)

        self.assertEqual(mock_enqueue.call_count, 2)
        event_keys = {call.kwargs["event_key"] for call in mock_enqueue.call_args_list}
        self.assertEqual(event_keys, {"mission.reply_rejected", "mission.reopened"})
        self.assertTrue(
            any(reply.id in call.kwargs["payload"].get("rejected_reply_ids", []) for call in mock_enqueue.call_args_list)
        )

    @patch("mission.handlers.enqueue_notification_event")
    def test_cancel_mission_handler_enqueues_notification(self, mock_enqueue):
        with self.captureOnCommitCallbacks(execute=True):
            cancel_mission(mission=self.mission, cancelled_by=self.creator)

        mock_enqueue.assert_called_once()
        self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.cancelled")
        self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], self.mission.id)

    @patch("notifier.backends.send_message_api_news_to_agents")
    @patch("notifier.tasks.dispatch_notification_event_task.delay")
    def test_mission_created_can_flow_to_message_api_news_with_structured_description_template(
        self,
        mock_delay,
        mock_send_news,
    ):
        self.default_category.payload_processor = "structured_description_v1"
        self.default_category.save(update_fields=["payload_processor", "updated_at"])
        notifier = Notifier.objects.create(
            merchant=self.merchant,
            name="结构化描述图文通知",
            channel=NotifierChannelEnum.MESSAGE_API,
            template_key="mission_structured_description_news",
            config={
                "agent_ids": [1000007],
                "image_url": "https://cdn.example.com/covers/mission-news.png",
            },
        )
        NotifierRoute.objects.create(
            merchant=self.merchant,
            notifier=notifier,
            event_key=NotificationEventKeyEnum.MISSION_CREATED,
            mission_category=self.default_category,
        )
        mock_send_news.return_value = [
            {
                "agent_id": 1000007,
                "ok": True,
                "response": {"errcode": 0, "errmsg": "ok"},
                "error": None,
            }
        ]

        def inline_delay(*, event_key, merchant_id, payload):
            dispatch_notification_event(
                event_key=event_key,
                merchant_id=merchant_id,
                payload=payload,
            )
            return SimpleNamespace(id="task-inline-1")

        mock_delay.side_effect = inline_delay

        with self.captureOnCommitCallbacks(execute=True):
            create_mission(
                creator=self.creator,
                category=self.default_category,
                description=(
                    "系统首行\n标题一\n款式图:https://images.yuwen.cloud/abc.jpg\n标题二\n\n正文一\n正文二\n"
                    "手机端链接: https://example.com/detail"
                ),
            )

        mock_send_news.assert_called_once()
        self.assertEqual(mock_send_news.call_args.kwargs["agent_ids"], [1000007])
        self.assertEqual(mock_send_news.call_args.kwargs["title"], "标题一\n标题二")
        self.assertEqual(mock_send_news.call_args.kwargs["description"], "正文一\n正文二")
        self.assertEqual(mock_send_news.call_args.kwargs["url"], "https://example.com/detail")
        self.assertEqual(
            mock_send_news.call_args.kwargs["image_url"],
            "https://images.yuwen.cloud/abc.jpg",
        )
