import json
from datetime import date, timedelta
from types import SimpleNamespace
from urllib.parse import parse_qs
from unittest.mock import Mock, patch

from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone

from .contact_submission_processor import (
    SpamDetectionClient,
    SpamDetectionError,
    SpamDetectionResult,
    process_contact_submissions,
)
from .newsletter import (
    APIResult,
    MemberCenterClient,
    SendEngineClient,
    extract_token,
    generate_one_click_token,
    build_weekly_news_body_html,
    render_placeholders,
    render_newsletter_html,
    verify_one_click_token,
)
from .models import (
    ContactFormSettings,
    ContactFormSubmission,
    ContactSpamDetectionState,
    SystemNotificationMailSettings,
)
from .security import decrypt_text, encrypt_text


class NewsletterTemplateTests(TestCase):
    def _member_center_config(self, **overrides):
        defaults = {
            "member_center_base_url": "https://member.example.com",
            "member_center_subscribe_path": "/newsletter/subscribe",
            "member_center_confirm_path": "/newsletter/confirm",
            "member_center_unsubscribe_token_path": "/newsletter/unsubscribe-token",
            "member_center_unsubscribe_path": "/newsletter/unsubscribe",
            "member_center_one_click_unsubscribe_path": "/api/subscriptions/unsubscribe",
            "member_center_oauth_client_id": "",
            "member_center_oauth_client_secret": "",
            "member_center_oauth_token_path": "/oauth/token",
            "member_center_oauth_scope": "newsletter:list.read",
            "member_center_oauth_audience": "",
            "send_engine_oauth_client_id": "",
            "send_engine_oauth_client_secret": "",
            "send_engine_timeout_seconds": 10,
            "member_center_timeout_seconds": 10,
        }
        defaults.update(overrides)
        return SimpleNamespace(**defaults)

    def test_member_center_write_calls_include_bearer_when_oauth_is_configured(self):
        client = MemberCenterClient(
            self._member_center_config(
                member_center_oauth_client_id="cms-client",
                member_center_oauth_client_secret="cms-secret",
            )
        )

        with (
            patch.object(client, "_get_oauth_access_token", return_value=("access-token", "")) as token_call,
            patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
        ):
            result = client.subscribe({"email": "demo@example.com"})

        self.assertTrue(result.ok)
        token_call.assert_called_once_with()
        request = send.call_args.args[0]
        self.assertEqual(request.get_header("Authorization"), "Bearer access-token")

    def test_member_center_token_request_uses_client_credentials_without_scope_or_audience(self):
        client = MemberCenterClient(
            self._member_center_config(
                member_center_oauth_client_id="tenant-client",
                member_center_oauth_client_secret="tenant-secret",
                member_center_oauth_scope="newsletter:subscriptions.write",
                member_center_oauth_audience="member_center_api",
            )
        )

        with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
            token, error = client._get_oauth_access_token()

        self.assertEqual(token, "token")
        self.assertEqual(error, "")
        request = send.call_args.args[0]
        body = parse_qs(request.data.decode("utf-8"))
        self.assertEqual(body["grant_type"], ["client_credentials"])
        self.assertEqual(body["client_id"], ["tenant-client"])
        self.assertEqual(body["client_secret"], ["tenant-secret"])
        self.assertNotIn("scope", body)
        self.assertNotIn("audience", body)

    def test_send_engine_token_request_uses_send_engine_oauth_client_without_scope_or_audience(self):
        client = SendEngineClient(
            self._member_center_config(
                member_center_oauth_client_id="tenant-client",
                member_center_oauth_client_secret="tenant-secret",
                send_engine_oauth_client_id="send-client",
                send_engine_oauth_client_secret="send-secret",
                member_center_oauth_audience="send_engine_api",
            )
        )

        with patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={"access_token": "token"})) as send:
            token, error = client._get_member_center_access_token(required_scope="newsletter:send.write")

        self.assertEqual(token, "token")
        self.assertEqual(error, "")
        request = send.call_args.args[0]
        body = parse_qs(request.data.decode("utf-8"))
        self.assertEqual(body["grant_type"], ["client_credentials"])
        self.assertEqual(body["client_id"], ["send-client"])
        self.assertEqual(body["client_secret"], ["send-secret"])
        self.assertNotIn("scope", body)
        self.assertNotIn("audience", body)

    def test_member_center_write_calls_remain_unauthenticated_without_oauth_config(self):
        client = MemberCenterClient(self._member_center_config())

        with (
            patch.object(client, "_get_oauth_access_token") as token_call,
            patch.object(client, "_send", return_value=APIResult(ok=True, status=200, data={})) as send,
        ):
            result = client.unsubscribe({"token": "unsubscribe-token"})

        self.assertTrue(result.ok)
        token_call.assert_not_called()
        request = send.call_args.args[0]
        self.assertIsNone(request.get_header("Authorization"))

    def test_render_placeholders_replaces_known_keys(self):
        template = "confirm={{confirm_url}} email={{email}} token={{token}}"
        rendered = render_placeholders(
            template,
            {
                "confirm_url": "https://example.com/confirm?token=abc",
                "email": "demo@example.com",
                "token": "abc",
            },
        )

        self.assertEqual(
            rendered,
            "confirm=https://example.com/confirm?token=abc email=demo@example.com token=abc",
        )

    def test_extract_token_supports_top_level_and_nested_data(self):
        self.assertEqual(extract_token({"token": "t1"}), "t1")
        self.assertEqual(extract_token({"data": {"unsubscribe_token": "t2"}}), "t2")
        self.assertEqual(extract_token({}), "")

    def test_encrypt_decrypt_roundtrip(self):
        secret = "mailrelay-secret"
        encrypted = encrypt_text(secret)
        self.assertTrue(encrypted.startswith("enc1:"))
        self.assertNotEqual(encrypted, secret)
        self.assertEqual(decrypt_text(encrypted), secret)

    def test_one_click_token_roundtrip(self):
        token = generate_one_click_token(
            subscriber_id="sub-1",
            list_id="list-1",
            site_id="site-1",
            campaign_id="cmp-1",
            secret="test-secret",
            ttl_seconds=60,
        )
        payload, error = verify_one_click_token(token, "test-secret")
        self.assertIsNone(error)
        self.assertEqual(payload["subscriber_id"], "sub-1")
        self.assertEqual(payload["list_id"], "list-1")

    def test_one_click_token_invalid_signature(self):
        token = generate_one_click_token(
            subscriber_id="sub-1",
            list_id="list-1",
            site_id="site-1",
            campaign_id="cmp-1",
            secret="test-secret",
            ttl_seconds=60,
        )
        payload, error = verify_one_click_token(token, "other-secret")
        self.assertIsNone(payload)
        self.assertEqual(error, "invalid")

    def test_render_newsletter_html_absolutizes_relative_links(self):
        rendered = render_newsletter_html(
            '<p><a href="/a">A</a><img src="/media/x.jpg"></p>',
            values={},
            site_base_url="https://news.example.com",
        )
        self.assertIn('href="https://news.example.com/a"', rendered)
        self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)

    def test_newsletter_subscribe_uses_system_notification_template(self):
        notification_settings = SystemNotificationMailSettings.load()
        notification_settings.subscribe_subject_template = "Confirm {{email}}"
        notification_settings.subscribe_html_template = "<table><tr><td>New {{confirm_url}}</td></tr></table>"
        notification_settings.subscribe_text_template = "Text {{confirm_url}}"
        notification_settings.save()

        with (
            patch("base.views.MemberCenterClient") as client_cls,
            patch("base.views.send_subscribe_email") as send_email,
        ):
            client_cls.return_value.subscribe.return_value = SimpleNamespace(
                ok=True,
                data={"token": "abc123"},
            )

            response = self.client.post(
                reverse("newsletter_subscribe"),
                data={"email": "demo@example.com"},
            )

        self.assertEqual(response.status_code, 200)
        send_email.assert_called_once()
        kwargs = send_email.call_args.kwargs
        self.assertEqual(kwargs["subject"], "Confirm demo@example.com")
        self.assertIn("<table><tr><td>New http://testserver/newsletter/confirm/", kwargs["html_body"])

    def test_weekly_news_body_uses_fixed_size_cover_rendition(self):
        image = Mock()
        image.get_rendition.return_value = SimpleNamespace(url="/media/images/article.fill-596x333.jpg")
        image.file.url = "/media/images/article-original.jpg"
        article = SimpleNamespace(
            id=12,
            title="Weekly title",
            intro="Weekly intro",
            date=date(2026, 6, 30),
            url="/news/weekly-title/",
            cover_image_id=34,
            cover_image=image,
        )
        item_template_html = (
            '<a href="{{article_url}}">{{article_title}}</a>'
            '<img src="{{article_cover_url}}" alt="{{article_title}}">'
            "<p>{{article_intro}}</p>"
        )

        with patch("home.models.ArticlePage") as article_page:
            article_page.objects.live.return_value.filter.return_value = [article]
            rendered = build_weekly_news_body_html(
                article_page_ids=[article.id],
                item_template_html=item_template_html,
                site_base_url="https://debutai.net",
            )

        image.get_rendition.assert_called_once_with("fill-596x333")
        self.assertIn('href="https://debutai.net/news/weekly-title/"', rendered)
        self.assertIn('src="https://debutai.net/media/images/article.fill-596x333.jpg"', rendered)
        self.assertIn("Weekly intro", rendered)

    def test_contact_form_submit_saves_submission(self):
        response = self.client.post(
            reverse("contact_form_submit"),
            data={
                "name": "Tester",
                "contact": "tester@example.com",
                "email": "tester@example.com",
                "category": "other",
                "message": "hello",
                "source_page": "/",
            },
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )
        self.assertEqual(response.status_code, 200)
        self.assertEqual(ContactFormSubmission.objects.count(), 1)
        submission = ContactFormSubmission.objects.first()
        self.assertEqual(submission.name, "Tester")
        self.assertEqual(submission.email, "tester@example.com")
        self.assertEqual(submission.category, "other")
        self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_QUEUED)

    def test_contact_form_submit_rejects_invalid_email(self):
        response = self.client.post(
            reverse("contact_form_submit"),
            data={
                "name": "Tester",
                "contact": "123",
                "email": "not-an-email",
                "category": "other",
                "message": "hello",
                "source_page": "/",
            },
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )
        self.assertEqual(response.status_code, 400)
        self.assertEqual(ContactFormSubmission.objects.count(), 0)


class ContactFormSubmissionFlowTests(TestCase):
    def _post(self, *, remote_addr="10.0.1.10", forwarded_for="198.51.100.20"):
        return self.client.post(
            reverse("contact_form_submit"),
            data={
                "name": "Tester",
                "contact": "tester@example.com",
                "email": "tester@example.com",
                "category": "other",
                "message": "hello",
                "source_page": "/contact/",
            },
            REMOTE_ADDR=remote_addr,
            HTTP_X_FORWARDED_FOR=forwarded_for,
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
        )

    def test_records_rightmost_ip_appended_by_alb(self):
        response = self._post(forwarded_for="203.0.113.99, 198.51.100.20")

        self.assertEqual(response.status_code, 200)
        self.assertEqual(ContactFormSubmission.objects.get().ip_address, "198.51.100.20")

    def test_falls_back_to_remote_address_without_forwarded_header(self):
        response = self._post(remote_addr="10.0.1.10", forwarded_for="")

        self.assertEqual(response.status_code, 200)
        self.assertEqual(ContactFormSubmission.objects.get().ip_address, "10.0.1.10")

    def test_fourth_submission_in_default_window_is_rate_limited(self):
        for _ in range(4):
            response = self._post()
            self.assertEqual(response.status_code, 200)

        statuses = list(ContactFormSubmission.objects.order_by("created_at", "id").values_list("processing_status", flat=True))
        self.assertEqual(
            statuses,
            [
                ContactFormSubmission.STATUS_QUEUED,
                ContactFormSubmission.STATUS_QUEUED,
                ContactFormSubmission.STATUS_QUEUED,
                ContactFormSubmission.STATUS_RATE_LIMITED,
            ],
        )


@override_settings(
    CONTACT_SPAM_DETECTION_ENABLED=True,
    CONTACT_SPAM_DETECTION_URL="https://spam.example.com/classify",
    CONTACT_SPAM_DETECTION_API_KEY="test-key",
    CONTACT_SPAM_DETECTION_RETRY_DELAY_SECONDS=300,
    CONTACT_SPAM_DETECTION_MAX_ATTEMPTS=3,
    CONTACT_FORM_MAIL_RETRY_DELAY_SECONDS=300,
    CONTACT_FORM_MAIL_MAX_ATTEMPTS=3,
)
class ContactSubmissionProcessorTests(TestCase):
    def _submission(self, **overrides):
        values = {
            "name": "Tester",
            "contact": "tester@example.com",
            "email": "tester@example.com",
            "category": ContactFormSubmission.CATEGORY_OTHER,
            "message": "hello",
            "source_page": "/contact/",
            "ip_address": "198.51.100.20",
        }
        values.update(overrides)
        return ContactFormSubmission.objects.create(**values)

    @patch("base.contact_submission_processor.send_contact_user_email")
    @patch("base.contact_submission_processor.send_contact_notification_email")
    @patch("base.contact_submission_processor.SpamDetectionClient.classify")
    def test_allowed_submission_is_sent_after_scan(self, classify, send_notification, send_user):
        submission = self._submission()
        classify.return_value = SpamDetectionResult(category=0, source="kneo300")

        result = process_contact_submissions(limit=1)

        submission.refresh_from_db()
        self.assertEqual(result["sent"], 1)
        self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SENT)
        self.assertEqual(submission.spam_category, 0)
        self.assertIsNotNone(submission.notification_sent_at)
        send_notification.assert_called_once()
        send_user.assert_not_called()

    @patch("base.contact_submission_processor.send_contact_notification_email")
    @patch("base.contact_submission_processor.SpamDetectionClient.classify")
    def test_spam_submission_is_recorded_and_not_sent(self, classify, send_notification):
        submission = self._submission()
        classify.return_value = SpamDetectionResult(category=2, source="kneo300")

        result = process_contact_submissions(limit=1)

        submission.refresh_from_db()
        self.assertEqual(result["blocked"], 1)
        self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_BLOCKED)
        self.assertEqual(submission.spam_category, 2)
        send_notification.assert_not_called()

    @patch("base.contact_submission_processor.send_contact_notification_email")
    @patch("base.contact_submission_processor.SpamDetectionClient.classify")
    def test_detection_error_schedules_cooldown_and_does_not_send(self, classify, send_notification):
        submission = self._submission()
        classify.side_effect = SpamDetectionError("AI box unavailable")

        result = process_contact_submissions(limit=1)

        submission.refresh_from_db()
        self.assertEqual(result["failed"], 1)
        self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SCAN_RETRY)
        self.assertGreater(submission.next_attempt_at, timezone.now())
        send_notification.assert_not_called()

    @patch("base.contact_submission_processor.SpamDetectionClient.classify")
    def test_active_detection_cooldown_prevents_calls_for_other_rows(self, classify):
        ContactSpamDetectionState.objects.create(
            pk=1,
            cooldown_until=timezone.now() + timedelta(minutes=5),
            last_error="AI box unavailable",
        )
        self._submission(email="second@example.com")

        result = process_contact_submissions(limit=2)

        self.assertEqual(result["processed"], 0)
        self.assertIsNotNone(result["cooldown_until"])
        classify.assert_not_called()

    @patch("base.contact_submission_processor.SpamDetectionClient.classify")
    def test_active_detection_lease_prevents_parallel_ai_calls(self, classify):
        ContactSpamDetectionState.objects.create(
            pk=1,
            lock_token="another-worker",
            locked_until=timezone.now() + timedelta(minutes=5),
        )
        self._submission()

        result = process_contact_submissions(limit=1)

        self.assertEqual(result["processed"], 0)
        self.assertTrue(result["detector_busy"])
        classify.assert_not_called()

    @patch("base.contact_submission_processor.send_contact_user_email")
    @patch("base.contact_submission_processor.send_contact_notification_email")
    @patch("base.contact_submission_processor.SpamDetectionClient.classify")
    def test_mail_retry_does_not_repeat_successful_notification(self, classify, send_notification, send_user):
        ContactFormSettings.objects.create(send_user_copy=True)
        submission = self._submission()
        classify.return_value = SpamDetectionResult(category=0, source="kneo300")
        send_user.side_effect = [OSError("SMTP unavailable"), 1]

        process_contact_submissions(limit=1)
        submission.refresh_from_db()
        self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SEND_RETRY)
        self.assertIsNotNone(submission.notification_sent_at)

        submission.next_attempt_at = timezone.now() - timedelta(seconds=1)
        submission.save(update_fields=["next_attempt_at", "updated_at"])
        process_contact_submissions(limit=1)

        submission.refresh_from_db()
        self.assertEqual(submission.processing_status, ContactFormSubmission.STATUS_SENT)
        send_notification.assert_called_once()
        self.assertEqual(send_user.call_count, 2)

    @patch("base.contact_submission_processor.urlopen")
    def test_category_zero_with_service_error_is_not_accepted(self, urlopen_mock):
        response = Mock()
        response.read.return_value = json.dumps(
            {"category": 0, "source": "kneo300", "error": "timeout"}
        ).encode("utf-8")
        urlopen_mock.return_value.__enter__.return_value = response

        with self.assertRaises(SpamDetectionError):
            SpamDetectionClient().classify("hello")
