"""Tests for signature handling in the SendMessageView API."""

# pylint: disable=unused-argument

import uuid
from unittest.mock import MagicMock, patch

from django.test import override_settings
from django.urls import reverse

import pytest
from jmap_email import ComposeError, InvalidAddressError
from rest_framework import status
from rest_framework.test import APIClient

from core import enums, factories, models

pytestmark = pytest.mark.django_db


SCHEMA_CUSTOM_ATTRIBUTES = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://github.com/suitenumerique/messages/schemas/custom-fields/user",
    "type": "object",
    "title": "User custom fields",
    "additionalProperties": False,
    "properties": {
        "job_title": {"type": "string"},
        "department": {"type": "string"},
    },
    "required": [],
}


@pytest.fixture(name="user")
def fixture_user():
    """Create a test user with custom attributes."""
    return factories.UserFactory(
        full_name="John Doe",
        custom_attributes={
            "job_title": "Software Engineer",
            "department": "Engineering",
        },
    )


@pytest.fixture(name="mailbox")
def fixture_mailbox():
    """Create a test mailbox."""
    return factories.MailboxFactory()


@pytest.fixture(name="mailbox_access")
def fixture_mailbox_access(user, mailbox):
    """Create mailbox access for the user with SENDER role."""
    return factories.MailboxAccessFactory(
        mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.SENDER
    )


@pytest.fixture(name="thread")
def fixture_thread(mailbox):
    """Create a thread for a mailbox."""
    thread = factories.ThreadFactory()
    factories.ThreadAccessFactory(
        mailbox=mailbox,
        thread=thread,
        role=enums.ThreadAccessRoleChoices.EDITOR,
    )
    return thread


@pytest.fixture(name="draft_message")
def fixture_draft_message(thread, mailbox):
    """Create a draft message for testing."""
    sender_contact = factories.ContactFactory(mailbox=mailbox)
    return factories.MessageFactory(
        thread=thread,
        sender=sender_contact,
        is_draft=True,
        subject="Test Draft Message",
    )


@pytest.fixture(name="signature_template")
def fixture_signature_template(mailbox):
    """Create a signature template."""
    return factories.MessageTemplateFactory(
        name="Professional Signature",
        html_body="<p>Best regards,<br>{name}<br>{job_title}<br>{department}</p>",
        text_body="Best regards,\n{name}\n{job_title}\n{department}",
        type=enums.MessageTemplateTypeChoices.SIGNATURE,
        is_active=True,
        mailbox=mailbox,
    )


class TestSendMessageAPIView:
    """Test SendMessageAPIView."""

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_api_send_message_with_signature_placeholder(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        signature_template,
    ):
        """Test sending a message with signature via FK relationship."""
        # Set the signature on the draft message
        draft_message.signature = signature_template
        draft_message.save()

        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the send_message_task
        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            mock_task_instance = MagicMock()
            mock_task_instance.id = "task-123"
            mock_task.delay.return_value = mock_task_instance

            # Send request
            response = client.post(
                reverse("send-message"),
                {
                    "messageId": str(draft_message.id),
                    "senderId": str(mailbox.id),
                    "textBody": "Hello world!",
                    "htmlBody": "<p>Hello world!</p>",
                },
            )

            assert response.status_code == status.HTTP_200_OK
            assert uuid.UUID(
                response.data["task_id"]
            )  # view-generated id, dispatched on commit

            message = models.Message.objects.get(id=draft_message.id)
            content = message.blob.get_content().decode()
            # Drop quoted-printable soft line breaks before searching, since
            # stdlib may fold long HTML lines with =\r\n.
            unfolded = content.replace("=\r\n", "")
            assert "Hello world!" in unfolded
            assert (
                "Best regards,<br>John Doe<br>Software Engineer<br>Engineering"
                in unfolded
            )

    def test_api_send_message_without_signature_placeholder(
        self, user, mailbox_access, mailbox, draft_message
    ):
        """Test sending a message without signature placeholder."""
        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the prepare_outbound_message function
        with patch("core.api.viewsets.send.prepare_outbound_message") as mock_prepare:
            mock_prepare.return_value = True

            # Mock the send_message_task
            with patch("core.api.viewsets.send.send_message_task") as mock_task:
                mock_task_instance = MagicMock()
                mock_task_instance.id = "task-123"
                mock_task.delay.return_value = mock_task_instance

                # Send request
                response = client.post(
                    reverse("send-message"),
                    {
                        "messageId": str(draft_message.id),
                        "senderId": str(mailbox.id),
                        "textBody": "Hello world!",
                        "htmlBody": "<p>Hello world!</p>",
                    },
                )

                assert response.status_code == status.HTTP_200_OK
                assert uuid.UUID(
                    response.data["task_id"]
                )  # view-generated id, dispatched on commit

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_api_send_message_with_text_body_only(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        signature_template,
    ):
        """Test sending a message with text body only and signature via FK."""
        # Set the signature on the draft message
        draft_message.signature = signature_template
        draft_message.save()

        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the send_message_task
        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            mock_task_instance = MagicMock()
            mock_task_instance.id = "task-123"
            mock_task.delay.return_value = mock_task_instance

            # Send request with text body only
            response = client.post(
                reverse("send-message"),
                {
                    "messageId": str(draft_message.id),
                    "senderId": str(mailbox.id),
                    "textBody": "Hello world!",
                },
            )

            assert response.status_code == status.HTTP_200_OK
            assert uuid.UUID(
                response.data["task_id"]
            )  # view-generated id, dispatched on commit

            message = models.Message.objects.get(id=draft_message.id)
            content = message.blob.get_content().decode()
            assert "Hello world!" in content
            assert (
                "Best regards,\r\nJohn Doe\r\nSoftware Engineer\r\nEngineering"
                in content
            )

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_api_send_message_with_html_body_only(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        signature_template,
    ):
        """Test sending a message with HTML body only and signature via FK."""
        # Set the signature on the draft message
        draft_message.signature = signature_template
        draft_message.save()

        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the send_message_task
        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            mock_task_instance = MagicMock()
            mock_task_instance.id = "task-123"
            mock_task.delay.return_value = mock_task_instance

            # Send request with HTML body only
            response = client.post(
                reverse("send-message"),
                {
                    "messageId": str(draft_message.id),
                    "senderId": str(mailbox.id),
                    "htmlBody": "<p>Hello world!</p>",
                },
            )

            assert response.status_code == status.HTTP_200_OK
            assert uuid.UUID(
                response.data["task_id"]
            )  # view-generated id, dispatched on commit

            message = models.Message.objects.get(id=draft_message.id)
            content = message.blob.get_content().decode()
            # Drop quoted-printable soft line breaks before searching, since
            # stdlib may fold long HTML lines with =\r\n.
            unfolded = content.replace("=\r\n", "")
            assert "Hello world!" in unfolded
            assert (
                "Best regards,<br>John Doe<br>Software Engineer<br>Engineering"
                in unfolded
            )

    @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_CUSTOM_ATTRIBUTES)
    def test_api_send_message_with_signature_and_reply(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        signature_template,
    ):
        """Test sending a message with signature via FK and reply content."""
        # Create a parent message for reply
        parent_message = factories.MessageFactory(
            thread=draft_message.thread,
            sender=draft_message.sender,
            subject="Re: Original Subject",
        )
        draft_message.parent = parent_message
        draft_message.signature = signature_template
        draft_message.save()

        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the send_message_task
        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            mock_task_instance = MagicMock()
            mock_task_instance.id = "task-123"
            mock_task.delay.return_value = mock_task_instance

            # Send request
            response = client.post(
                reverse("send-message"),
                {
                    "messageId": str(draft_message.id),
                    "senderId": str(mailbox.id),
                    "textBody": "This is a reply",
                    "htmlBody": "<p>This is a reply</p>",
                },
            )

            assert response.status_code == status.HTTP_200_OK
            assert uuid.UUID(
                response.data["task_id"]
            )  # view-generated id, dispatched on commit

            message = models.Message.objects.get(id=draft_message.id)
            content = message.blob.get_content().decode()
            assert "This is a reply" in content
            assert (
                "Best regards,\r\nJohn Doe\r\nSoftware Engineer\r\nEngineering"
                in content
            )

    def test_api_send_message_with_archive_true(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        signature_template,
        django_capture_on_commit_callbacks,
    ):
        """Test sending a message with archive=True passes the parameter to the task."""
        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the send_message_task
        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            # Send request with HTML body only. The delivery task is dispatched
            # via transaction.on_commit, so capture and run the callbacks.
            with django_capture_on_commit_callbacks(execute=True):
                response = client.post(
                    reverse("send-message"),
                    format="json",
                    data={
                        "messageId": str(draft_message.id),
                        "senderId": str(mailbox.id),
                        "htmlBody": "<p>Hello world!</p>",
                        "archive": True,
                    },
                )

            assert response.status_code == status.HTTP_200_OK
            assert uuid.UUID(
                response.data["task_id"]
            )  # view-generated id, dispatched on commit

            mock_task.apply_async.assert_called_once_with(
                args=[str(draft_message.id)],
                kwargs={"must_archive": True},
                task_id=response.data["task_id"],
            )

    def test_api_send_message_with_archive_false(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        django_capture_on_commit_callbacks,
    ):
        """Test sending a message with archive=False passes the parameter to the task."""
        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the send_message_task
        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            # Send request with archive=False
            with django_capture_on_commit_callbacks(execute=True):
                response = client.post(
                    reverse("send-message"),
                    format="json",
                    data={
                        "messageId": str(draft_message.id),
                        "senderId": str(mailbox.id),
                        "textBody": "Hello world!",
                        "htmlBody": "<p>Hello world!</p>",
                        "archive": False,
                    },
                )

            assert response.status_code == status.HTTP_200_OK
            assert uuid.UUID(
                response.data["task_id"]
            )  # view-generated id, dispatched on commit

            # Verify the task was dispatched with must_archive=False
            mock_task.apply_async.assert_called_once_with(
                args=[str(draft_message.id)],
                kwargs={"must_archive": False},
                task_id=response.data["task_id"],
            )

    def test_api_send_message_without_archive_parameter(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        django_capture_on_commit_callbacks,
    ):
        """Test sending a message without archive parameter defaults to False."""
        # Authenticate user
        client = APIClient()
        client.force_authenticate(user=user)

        # Mock the send_message_task
        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            # Send request without archive parameter
            with django_capture_on_commit_callbacks(execute=True):
                response = client.post(
                    reverse("send-message"),
                    format="json",
                    data={
                        "messageId": str(draft_message.id),
                        "senderId": str(mailbox.id),
                        "textBody": "Hello world!",
                        "htmlBody": "<p>Hello world!</p>",
                    },
                )

            assert response.status_code == status.HTTP_200_OK
            assert uuid.UUID(
                response.data["task_id"]
            )  # view-generated id, dispatched on commit

            # Verify the task was dispatched with must_archive=False (default)
            mock_task.apply_async.assert_called_once_with(
                args=[str(draft_message.id)],
                kwargs={"must_archive": False},
                task_id=response.data["task_id"],
            )


class TestSendMessageSecurity:
    """Security regressions for SendMessageView."""

    def test_cannot_send_as_mailbox_user_only_views(
        self, user, mailbox, draft_message, django_capture_on_commit_callbacks
    ):
        """A VIEWER on the sender mailbox cannot send as it by leaning on a
        SENDER role held on a *different* mailbox that shares the thread.

        Setup:
        - ``mailbox`` (the senderId, "B"): the draft lives here, user is VIEWER.
        - ``other_mailbox`` ("A"): user is SENDER, and it also has EDITOR
          ThreadAccess on the same thread.

        The old object-level check passed as long as the user could SEND
        through *any* EDITOR mailbox on the thread — so A's SENDER role would
        wrongly authorise sending as B. The fix re-checks the role on the
        specific senderId, so this must be 403.
        """
        # User is only a VIEWER on the sender mailbox B.
        factories.MailboxAccessFactory(
            mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.VIEWER
        )

        # User is SENDER on a different mailbox A that also edits the thread.
        other_mailbox = factories.MailboxFactory()
        factories.MailboxAccessFactory(
            mailbox=other_mailbox, user=user, role=enums.MailboxRoleChoices.SENDER
        )
        factories.ThreadAccessFactory(
            mailbox=other_mailbox,
            thread=draft_message.thread,
            role=enums.ThreadAccessRoleChoices.EDITOR,
        )

        client = APIClient()
        client.force_authenticate(user=user)

        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            with django_capture_on_commit_callbacks(execute=True):
                response = client.post(
                    reverse("send-message"),
                    format="json",
                    data={
                        "messageId": str(draft_message.id),
                        "senderId": str(mailbox.id),  # send AS B
                        "textBody": "Hello world!",
                    },
                )

            assert response.status_code == status.HTTP_403_FORBIDDEN
            # Nothing was dispatched.
            mock_task.apply_async.assert_not_called()
            mock_task.delay.assert_not_called()

    def test_send_task_dispatched_only_after_commit(
        self,
        user,
        mailbox_access,
        mailbox,
        draft_message,
        django_capture_on_commit_callbacks,
    ):
        """The delivery task is registered on transaction.on_commit, not fired
        inline — so a rolled-back send never leaks a task to the broker.
        """
        client = APIClient()
        client.force_authenticate(user=user)

        with patch("core.api.viewsets.send.send_message_task") as mock_task:
            # execute=False: capture the on_commit callbacks without running them.
            with django_capture_on_commit_callbacks(execute=False) as callbacks:
                response = client.post(
                    reverse("send-message"),
                    format="json",
                    data={
                        "messageId": str(draft_message.id),
                        "senderId": str(mailbox.id),
                        "textBody": "Hello world!",
                    },
                )

                assert response.status_code == status.HTTP_200_OK
                # Inside the request/transaction the task must NOT be dispatched.
                mock_task.apply_async.assert_not_called()

            # The send dispatch was deferred to commit (other unrelated
            # on_commit hooks, e.g. search reindex, may also be present).
            send_callbacks = [
                cb
                for cb in callbacks
                if "SendMessageView" in getattr(cb, "__qualname__", "")
            ]
            assert len(send_callbacks) == 1


class TestSendMessageComposeFailure:
    """A draft the composer cannot put on the wire is a 400, not a 500.

    ``prepare_outbound_message`` composes synchronously inside the request,
    so a ``ComposeError`` — a non-ASCII local part needing SMTPUTF8, a
    malformed addr-spec, an undecodable attachment — escaped as an
    unhandled exception. That is a property of the draft the user built,
    so it belongs in the 4xx family with a message they can act on.
    """

    def test_compose_error_escapes_prepare_outbound_message(
        self, user, mailbox_access, mailbox, draft_message
    ):
        """The error must cross ``prepare_outbound_message``, not be
        swallowed there.

        ``prepare_outbound_message`` wraps composition in a broad
        ``except Exception: return False`` whose ``False`` the view turns
        into a 500 — so mocking ``prepare_outbound_message`` itself cannot
        prove the 400 works. Run the real function and fail at its
        composition boundary instead.
        """
        client = APIClient()
        client.force_authenticate(user=user)

        with (
            patch("core.mda.outbound.compose_and_sign_mime") as mock_compose,
            patch("core.mda.outbound.logger") as mock_outbound_logger,
        ):
            mock_compose.side_effect = InvalidAddressError(
                "recipient needs SMTPUTF8: 'josé@example.com'"
            )
            response = client.post(
                reverse("send-message"),
                {
                    "messageId": str(draft_message.id),
                    "textBody": "hello",
                    "htmlBody": "<p>hello</p>",
                    "senderId": str(mailbox.id),
                },
                format="json",
            )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        # The broad handler in ``prepare_outbound_message`` must not have
        # caught it: its ``logger.exception`` line would carry the raw
        # addr-spec into logs/Sentry.
        assert not mock_outbound_logger.exception.called

    def test_compose_error_is_a_400(self, user, mailbox_access, mailbox, draft_message):
        client = APIClient()
        client.force_authenticate(user=user)

        with patch("core.api.viewsets.send.prepare_outbound_message") as mock_prepare:
            mock_prepare.side_effect = ComposeError("bad addr-spec")
            response = client.post(
                reverse("send-message"),
                {
                    "messageId": str(draft_message.id),
                    "textBody": "hello",
                    "htmlBody": "<p>hello</p>",
                    "senderId": str(mailbox.id),
                },
                format="json",
            )

        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_compose_error_log_does_not_carry_the_message_body(
        self, user, mailbox_access, mailbox, draft_message
    ):
        """The rejection is logged for operators; the log line must not
        become a copy of the user's content."""
        client = APIClient()
        client.force_authenticate(user=user)

        with patch("core.api.viewsets.send.prepare_outbound_message") as mock_prepare:
            mock_prepare.side_effect = ComposeError("bad addr-spec")
            with patch("core.api.viewsets.send.logger") as mock_logger:
                client.post(
                    reverse("send-message"),
                    {
                        "messageId": str(draft_message.id),
                        "textBody": "SECRET-BODY-CONTENT",
                        "htmlBody": "<p>SECRET-BODY-CONTENT</p>",
                        "senderId": str(mailbox.id),
                    },
                    format="json",
                )

        logged = " ".join(
            str(a) for call in mock_logger.info.call_args_list for a in call.args
        )
        assert mock_logger.info.called
        assert "SECRET-BODY-CONTENT" not in logged
