"""Tests for widget inbound API endpoints."""

from unittest.mock import patch

from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.test import override_settings

import pytest
from rest_framework import status
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.test import APIClient

from core import factories, models
from core.api.viewsets.inbound import widget as widget_module
from core.api.viewsets.inbound.widget import WidgetAuthentication


@pytest.fixture(autouse=True)
def _clear_throttle_cache():
    """Reset throttle state between tests.

    The widget deliver endpoint is rate-limited per IP, and the test client
    always presents the same address, so DRF's throttle history would
    otherwise accumulate across tests in the in-process LocMem cache and trip
    unrelated cases. Clearing the cache keeps each test independent.
    """
    cache.clear()
    yield


@pytest.fixture(name="api_client")
def fixture_api_client():
    """Return an API client."""
    return APIClient()


@pytest.fixture(name="channel")
def fixture_channel():
    """Create a test channel with mailbox."""
    mailbox = factories.MailboxFactory()
    return factories.ChannelFactory(
        type="widget",
        mailbox=mailbox,
        settings={
            "config": {"enabled": True, "theme": "light"},
        },
    )


@pytest.fixture(name="channel_with_mailbox_contact")
def fixture_channel_with_mailbox_contact():
    """Create a test channel with mailbox."""
    contact = factories.ContactFactory(email="widget@example.com", name="Widget Sender")
    mailbox = factories.MailboxFactory(contact=contact)
    return factories.ChannelFactory(
        type="widget",
        mailbox=mailbox,
        settings={
            "config": {"enabled": True, "theme": "light"},
        },
    )


@pytest.fixture(name="channel_without_mailbox")
def fixture_channel_without_mailbox():
    """Create a test channel without mailbox."""
    return factories.ChannelFactory(
        type="widget",
        mailbox=None,
        maildomain=factories.MailDomainFactory(),
    )


@pytest.mark.django_db
def test_inbound_widget_channel_model():
    """Test the Channel model scope_level invariants.

    - mailbox AND maildomain both set → rejected (scope_level must pick one).
    - neither set → valid global channel (scope_level=global).
    """
    with pytest.raises(ValidationError):
        factories.ChannelFactory(
            mailbox=factories.MailboxFactory(),
            maildomain=factories.MailDomainFactory(),
        )

    # A channel with neither mailbox nor maildomain is now a valid global channel.
    global_channel = factories.ChannelFactory(mailbox=None, maildomain=None)
    assert global_channel.scope_level == "global"


@pytest.mark.django_db
class TestWidgetAuthentication:
    """Test the WidgetAuthentication class."""

    def test_inbound_widget_authenticate_with_valid_channel_id(self, channel):
        """Test authentication with valid channel ID."""
        auth = WidgetAuthentication()

        # Create a mock request with valid channel ID
        class MockRequest:
            """Mock request."""

            def __init__(self, channel_id):
                """Initialize the mock request."""
                self.headers = {"X-Channel-ID": str(channel_id)}
                self.META = {}  # pylint: disable=invalid-name

        request = MockRequest(channel.id)
        user, auth_data = auth.authenticate(request)

        assert user is None
        assert auth_data["channel"] == channel

    def test_inbound_widget_authenticate_with_missing_channel_id(self):
        """Test authentication fails with missing channel ID."""
        auth = WidgetAuthentication()

        class MockRequest:
            """Mock request."""

            def __init__(self):
                """Initialize the mock request."""
                self.headers = {}
                self.META = {}  # pylint: disable=invalid-name

        request = MockRequest()

        with pytest.raises(AuthenticationFailed, match="Missing channel_id"):
            auth.authenticate(request)

    def test_inbound_widget_authenticate_with_invalid_channel_id(self):
        """Test authentication fails with invalid channel ID."""
        auth = WidgetAuthentication()

        class MockRequest:
            """Mock request."""

            def __init__(self, channel_id):
                """Initialize the mock request."""
                self.headers = {"X-Channel-ID": str(channel_id)}
                self.META = {}  # pylint: disable=invalid-name

        request = MockRequest("invalid-uuid")

        with pytest.raises(ValidationError):
            auth.authenticate(request)

    def test_inbound_widget_authenticate_with_paused_channel(self, channel):
        """A paused (is_active=False) widget must not authenticate — same
        generic failure as an unknown channel id."""
        channel.is_active = False
        channel.save()
        auth = WidgetAuthentication()

        class MockRequest:
            """Mock request."""

            def __init__(self, channel_id):
                """Initialize the mock request."""
                self.headers = {"X-Channel-ID": str(channel_id)}
                self.META = {}  # pylint: disable=invalid-name

        request = MockRequest(channel.id)

        with pytest.raises(AuthenticationFailed, match="Invalid channel_id"):
            auth.authenticate(request)


@pytest.mark.django_db
class TestInboundWidgetConfig:
    """Test the config endpoint."""

    def test_inbound_widget_config_success(self, api_client, channel):
        """Test successful config retrieval."""
        response = api_client.get(
            "/api/v1.0/inbound/widget/config/",
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_200_OK
        assert response.json() == {
            "success": True,
            "config": {"enabled": True, "theme": "light"},
        }

    def test_inbound_widget_config_with_empty_settings(self, api_client):
        """Test config with empty settings."""
        channel = factories.ChannelFactory(type="widget", settings={})

        response = api_client.get(
            "/api/v1.0/inbound/widget/config/",
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_200_OK
        assert response.json() == {"success": True, "config": {}}

    def test_inbound_widget_config_without_authentication(self, api_client):
        """Test config endpoint without authentication."""
        response = api_client.get("/api/v1.0/inbound/widget/config/")

        assert response.status_code == status.HTTP_403_FORBIDDEN


@pytest.mark.django_db
class TestInboundWidgetDeliver:
    """Test the deliver endpoint."""

    @patch("core.api.viewsets.inbound.widget.deliver_inbound_message")
    def test_inbound_widget_deliver_success(
        self, mock_deliver, api_client, channel, channel_with_mailbox_contact
    ):
        """Test successful message delivery."""
        mock_deliver.return_value = True

        data = {
            "email": "sender@example.com",
            "textBody": "This is a test message from the widget.",
        }

        for _channel in [channel, channel_with_mailbox_contact]:
            response = api_client.post(
                "/api/v1.0/inbound/widget/deliver/",
                data=data,
                HTTP_X_CHANNEL_ID=str(_channel.id),
                HTTP_REFERER="https://example.com/contact",
            )

            assert response.status_code == status.HTTP_200_OK
            assert response.json() == {"success": True}

            # Verify deliver_inbound_message was called
            mock_deliver.assert_called_once()
            call_args = mock_deliver.call_args[0]
            call_kwargs = mock_deliver.call_args[1]
            assert call_kwargs["channel"] == _channel
            if _channel.mailbox.contact:
                assert call_args[0] == str(_channel.mailbox.contact.email)
            else:
                assert call_args[0] == str(_channel.mailbox)

            mock_deliver.reset_mock()

    def test_inbound_widget_deliver_missing_email(self, api_client, channel):
        """Test deliver with missing email."""
        data = {"textBody": "This is a test message."}

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json() == {"detail": "Missing email"}

    def test_inbound_widget_deliver_invalid_email(self, api_client, channel):
        """Test deliver with invalid email format."""
        data = {
            "email": "invalid-email",
            "textBody": "This is a test message.",
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json() == {"detail": "Invalid email format"}

    def test_inbound_widget_deliver_non_ascii_local_part(self, api_client, channel):
        """An RFC 6531 local part is a *valid* address, so ``parse_address``
        accepts it and the shape check above lets it through — but we emit
        7-bit SMTP and never negotiate SMTPUTF8, so the composer refuses it.
        That is a property of what this public form was given, so it must be
        a 400 like any other unusable address, not a 500."""
        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data={"email": "josé@example.com", "textBody": "hello"},
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json() == {"detail": "Invalid email format"}

    def test_compose_rejection_log_carries_only_the_domain(self, api_client, channel):
        """The rejection is logged so operators can see widgets failing,
        but the submitter's address is user data on a public endpoint —
        the local part must not land in the log. Same convention the
        send_mail command already uses.

        Asserts on the logger call rather than ``caplog``: this logger does
        not propagate to root under the project's logging config, so a
        ``caplog.text`` assertion would pass whether or not the address
        leaked.
        """
        with patch.object(widget_module, "logger") as mock_logger:
            api_client.post(
                "/api/v1.0/inbound/widget/deliver/",
                data={"email": "josé.secret@example.com", "textBody": "hello"},
                HTTP_X_CHANNEL_ID=str(channel.id),
            )

        assert mock_logger.info.called
        logged = " ".join(
            str(a) for call in mock_logger.info.call_args_list for a in call.args
        )
        assert "example.com" in logged
        assert "josé.secret" not in logged

    def test_inbound_widget_deliver_idn_domain_is_accepted(self, api_client, channel):
        """The other half: an IDN *domain* has an exact ASCII wire form, so
        ``COMPOSE_OPTIONS`` converts it and the submission goes through
        rather than being rejected alongside the case above."""
        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data={"email": "contact@exemplé.fr", "textBody": "hello"},
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_200_OK

    def test_inbound_widget_deliver_missing_message(self, api_client, channel):
        """Test deliver with missing message."""
        data = {"email": "sender@example.com"}

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json() == {"detail": "Missing message"}

    def test_inbound_widget_deliver_no_mailbox_configured(
        self, api_client, channel_without_mailbox
    ):
        """Test deliver when no mailbox is configured for the channel."""
        data = {
            "email": "sender@example.com",
            "textBody": "This is a test message.",
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel_without_mailbox.id),
        )

        assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
        assert response.json() == {"detail": "No mailbox configured for this channel"}

    @patch("core.api.viewsets.inbound.widget.deliver_inbound_message")
    def test_inbound_widget_deliver_with_custom_settings(
        self, mock_deliver, api_client
    ):
        """Test deliver with custom channel settings."""
        mock_deliver.return_value = True

        channel = factories.ChannelFactory(
            type="widget",
            mailbox=factories.MailboxFactory(),
            settings={},
        )

        data = {
            "email": "sender@example.com",
            "textBody": "Test message with custom settings.",
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
            HTTP_REFERER="https://example.com/contact",
        )

        assert response.status_code == status.HTTP_200_OK

        # Verify the parsed email structure
        call_args = mock_deliver.call_args[0]
        parsed_email = call_args[1]

        assert parsed_email["from"][0]["email"] == "sender@example.com"
        assert (
            "Test message with custom settings"
            in parsed_email["htmlBody"][0]["content"]
        )

    def test_inbound_widget_deliver_message_e2e(self, api_client):
        """Test that message is properly formatted with HTML, metadata, and tags."""

        assert models.Message.objects.count() == 0

        # Create a mailbox with labels and a channel with tags
        mailbox = factories.MailboxFactory()
        label1 = factories.LabelFactory(mailbox=mailbox, name="Widget Support")
        label2 = factories.LabelFactory(mailbox=mailbox, name="Urgent")
        channel = factories.ChannelFactory(
            type="widget",
            mailbox=mailbox,
            settings={
                "config": {"enabled": True},
                "tags": [str(label1.id), str(label2.id)],
                "subject_template": "Contact from {referer_domain}",
            },
        )

        data = {
            "email": "sender@example.com",
            "textBody": "Line 1\nLine 2\nLine 3",
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
            HTTP_REFERER="https://example.com/contact",
        )

        assert response.status_code == status.HTTP_200_OK

        assert models.Message.objects.count() == 1
        message = models.Message.objects.first()
        # Check we have a threadaccess on the right mailbox
        assert message.thread.accesses.first().mailbox == mailbox
        assert message.sender.email == "sender@example.com"
        assert message.subject == "Contact from example.com"

        # Check that channel tags were applied to the thread
        thread_label_ids = set(message.thread.labels.values_list("id", flat=True))
        assert label1.id in thread_label_ids
        assert label2.id in thread_label_ids

        authenticated_user = factories.UserFactory()
        factories.MailboxAccessFactory(
            mailbox=mailbox,
            user=authenticated_user,
            role=models.MailboxRoleChoices.VIEWER,
        )
        api_client.force_authenticate(user=authenticated_user)

        # Check the STMSG headers in the REST API
        apimsg = api_client.get(f"/api/v1.0/messages/{message.id}/")
        assert apimsg.status_code == status.HTTP_200_OK
        assert apimsg.json()["stmsg_headers"] == {
            "sender-auth": "none",
            "widget-referer": "https://example.com/contact",
        }
        # Strip trailing CRLF added by stdlib's set_content body normalization.
        assert (
            apimsg.json()["htmlBody"][0]["content"].rstrip("\r\n")
            == "Line 1<br/>Line 2<br/>Line 3"
        )
        assert (
            apimsg.json()["textBody"][0]["content"].rstrip("\r\n")
            == "Line 1\r\nLine 2\r\nLine 3"
        )

    def test_inbound_widget_deliver_message_e2e_no_referer(self, api_client, channel):
        """Test that message is well delivered without referer."""

        assert models.Message.objects.count() == 0

        data = {
            "email": "sender@example.com",
            "textBody": "Line 1\nLine 2\nLine 3",
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_200_OK

        assert models.Message.objects.count() == 1
        mailbox = channel.mailbox
        message = models.Message.objects.first()
        # Check we have a threadaccess on the right mailbox
        assert message.thread.accesses.first().mailbox == mailbox
        assert message.sender.email == "sender@example.com"
        assert message.subject == "Message from widget"

        authenticated_user = factories.UserFactory()
        factories.MailboxAccessFactory(
            mailbox=mailbox,
            user=authenticated_user,
            role=models.MailboxRoleChoices.VIEWER,
        )
        api_client.force_authenticate(user=authenticated_user)

        # Check the STMSG headers in the REST API
        apimsg = api_client.get(f"/api/v1.0/messages/{message.id}/")
        assert apimsg.status_code == status.HTTP_200_OK
        assert apimsg.json()["stmsg_headers"] == {"sender-auth": "none"}

    def test_inbound_widget_deliver_message_e2e_referer_not_url(
        self, api_client, channel
    ):
        """Test that message is well delivered with a referer that is not a URL."""

        assert models.Message.objects.count() == 0

        data = {
            "email": "sender@example.com",
            "textBody": "Line 1\nLine 2\nLine 3",
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
            HTTP_REFERER="javascript:void(0)",
        )

        assert response.status_code == status.HTTP_200_OK

        assert models.Message.objects.count() == 1
        mailbox = channel.mailbox
        message = models.Message.objects.first()
        # Check we have a threadaccess on the right mailbox
        assert message.thread.accesses.first().mailbox == mailbox
        assert message.sender.email == "sender@example.com"
        assert message.subject == "Message from widget"

        authenticated_user = factories.UserFactory()
        factories.MailboxAccessFactory(
            mailbox=mailbox,
            user=authenticated_user,
            role=models.MailboxRoleChoices.VIEWER,
        )
        api_client.force_authenticate(user=authenticated_user)

        # Check the STMSG headers in the REST API
        apimsg = api_client.get(f"/api/v1.0/messages/{message.id}/")
        assert apimsg.status_code == status.HTTP_200_OK
        assert apimsg.json()["stmsg_headers"] == {
            "sender-auth": "none",
            "widget-referer": "javascript:void(0)",
        }

    def test_inbound_widget_deliver_without_authentication(self, api_client):
        """Test deliver endpoint without authentication."""
        data = {
            "email": "sender@example.com",
            "textBody": "This is a test message.",
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
        )

        assert response.status_code == status.HTTP_403_FORBIDDEN


@pytest.mark.django_db
class TestInboundWidgetAbuse:
    """Throttling and body-size cap on the public widget deliver path."""

    @patch(
        "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True
    )
    def test_per_ip_throttle_blocks_flood(self, _mock_deliver, api_client, channel):
        """Once the per-IP rate is exhausted, further posts get 429.

        The rate is forced low so the test is deterministic and fast.
        """
        data = {"email": "sender@example.com", "textBody": "hi"}

        with patch.object(
            widget_module.WidgetIPThrottle, "get_rate", return_value="1/minute"
        ):
            first = api_client.post(
                "/api/v1.0/inbound/widget/deliver/",
                data=data,
                HTTP_X_CHANNEL_ID=str(channel.id),
            )
            second = api_client.post(
                "/api/v1.0/inbound/widget/deliver/",
                data=data,
                HTTP_X_CHANNEL_ID=str(channel.id),
            )

        assert first.status_code == status.HTTP_200_OK
        assert second.status_code == status.HTTP_429_TOO_MANY_REQUESTS
        # The throttled request must be rejected before the view runs delivery:
        # only the first (200) post reached deliver_inbound_message.
        _mock_deliver.assert_called_once()

    @patch(
        "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True
    )
    def test_per_channel_throttle_blocks_flood(
        self, _mock_deliver, api_client, channel
    ):
        """The per-channel cap trips even when the per-IP cap is generous."""
        data = {"email": "sender@example.com", "textBody": "hi"}

        with (
            patch.object(
                widget_module.WidgetChannelThrottle, "get_rate", return_value="1/minute"
            ),
            patch.object(
                widget_module.WidgetIPThrottle, "get_rate", return_value="1000/minute"
            ),
        ):
            first = api_client.post(
                "/api/v1.0/inbound/widget/deliver/",
                data=data,
                HTTP_X_CHANNEL_ID=str(channel.id),
            )
            second = api_client.post(
                "/api/v1.0/inbound/widget/deliver/",
                data=data,
                HTTP_X_CHANNEL_ID=str(channel.id),
            )

        assert first.status_code == status.HTTP_200_OK
        assert second.status_code == status.HTTP_429_TOO_MANY_REQUESTS

    @patch(
        "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True
    )
    @override_settings(MAX_INCOMING_EMAIL_SIZE=1024)
    def test_oversized_body_rejected(self, mock_deliver, api_client, channel):
        """A body over MAX_INCOMING_EMAIL_SIZE is rejected before delivery."""
        data = {
            "email": "sender@example.com",
            "textBody": "x" * 2048,  # exceeds the 1 KB limit
        }

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
        mock_deliver.assert_not_called()

    @patch(
        "core.api.viewsets.inbound.widget.deliver_inbound_message", return_value=True
    )
    @override_settings(MAX_INCOMING_EMAIL_SIZE=1024)
    def test_body_within_limit_accepted(self, mock_deliver, api_client, channel):
        """A body within the limit still goes through."""
        data = {"email": "sender@example.com", "textBody": "x" * 100}

        response = api_client.post(
            "/api/v1.0/inbound/widget/deliver/",
            data=data,
            HTTP_X_CHANNEL_ID=str(channel.id),
        )

        assert response.status_code == status.HTTP_200_OK
        mock_deliver.assert_called_once()
