# SMTP Plugins for cherrypy
# Copyright (C) 2022-2026 IKUS Software
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

from unittest import mock, skipUnless

import cherrypy
from cherrypy.test import helper
from parameterized import parameterized

from cherrypy_foundation.plugins import smtp


class SmtpPluginTest(helper.CPWebCase):
    @classmethod
    def setup_server(cls):
        cherrypy.config.update(
            {
                'smtp.server': '__default__',
                'smtp.username': 'username',
                'smtp.password': 'password',
                'smtp.email_from': 'Test <email_from@test.com>',
            }
        )

    def setUp(self):
        super().setUp()
        # Reset optional plugin-level settings between tests
        cherrypy.smtp.bcc = None
        cherrypy.smtp.sender = None
        cherrypy.smtp.encryption = None

    # -------------------------------------------------------------------------
    # send_mail
    # -------------------------------------------------------------------------

    def test_send_mail(self):
        # Given a valid smtp server
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # When publishing a send_mail
            cherrypy.engine.publish('send_mail', to='target@test.com', subject='subjet', message='body')
            # Then smtplib is called to send the mail.
            smtplib.SMTP.assert_called_once_with('__default__', 25)
            smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
            smtplib.SMTP.return_value.quit.assert_called_once_with()

    def test_send_mail_with_to_tuple(self):
        # Given a valid smtp server
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # When publishing a send_mail
            cherrypy.engine.publish(
                'send_mail',
                to=('A name', 'target@test.com'),
                subject='subjet',
                message='body',
                bcc=('A bcc name', 'bcc@test.com'),
                reply_to=('A Reply Name', 'replyto@test.com'),
            )
            # Then smtplib is called to send the mail.
            smtplib.SMTP.assert_called_once_with('__default__', 25)
            smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
            smtplib.SMTP.return_value.quit.assert_called_once_with()

    def test_send_mail_with_ssl(self):
        # Given encryption is set to ssl
        cherrypy.smtp.encryption = 'ssl'
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # When sending a mail
            cherrypy.engine.publish('send_mail', to='target@test.com', subject='subject', message='body')
            # Then SMTP_SSL is used instead of SMTP
            smtplib.SMTP_SSL.assert_called_once_with('__default__', 465)
            smtplib.SMTP.assert_not_called()
            smtplib.SMTP_SSL.return_value.send_message.assert_called_once_with(mock.ANY)
            smtplib.SMTP_SSL.return_value.quit.assert_called_once_with()

    def test_send_mail_with_starttls(self):
        # Given encryption is set to starttls
        cherrypy.smtp.encryption = 'starttls'
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # When sending a mail
            cherrypy.engine.publish('send_mail', to='target@test.com', subject='subject', message='body')
            # Then a plain SMTP connection is made and starttls() is called on it
            smtplib.SMTP.assert_called_once_with('__default__', 25)
            smtplib.SMTP.return_value.starttls.assert_called_once_with()
            smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
            smtplib.SMTP.return_value.quit.assert_called_once_with()

    def test_send_mail_with_port(self):
        # Given the server includes an explicit port
        cherrypy.smtp.server = '__default__:587'
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # When sending a mail
            cherrypy.engine.publish('send_mail', to='target@test.com', subject='subject', message='body')
            # Then host and port are split correctly
            smtplib.SMTP.assert_called_once_with('__default__', '587')
            smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
            smtplib.SMTP.return_value.quit.assert_called_once_with()
        # Restore
        cherrypy.smtp.server = '__default__'

    def test_send_mail_with_auth(self):
        # Given username and password are configured (set in setup_server)
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # When sending a mail
            cherrypy.engine.publish('send_mail', to='target@test.com', subject='subject', message='body')
            # Then login() is called with the configured credentials
            smtplib.SMTP.return_value.login.assert_called_once_with('username', 'password')

    def test_send_mail_not_configured_no_server(self):
        # Given no server is configured
        original = cherrypy.smtp.server
        cherrypy.smtp.server = None
        try:
            # When publishing a send_mail
            # Then SmtpNotConfiguredError is raised
            with self.assertRaises(smtp.SmtpNotConfiguredError):
                cherrypy.smtp.send_mail(to='target@test.com', subject='subject', message='body')
        finally:
            cherrypy.smtp.server = original

    def test_send_mail_not_configured_no_email_from(self):
        # Given no email_from is configured
        original = cherrypy.smtp.email_from
        cherrypy.smtp.email_from = None
        try:
            # When publishing a send_mail
            # Then SmtpNotConfiguredError is raised
            with self.assertRaises(smtp.SmtpNotConfiguredError):
                cherrypy.smtp.send_mail(to='target@test.com', subject='subject', message='body')
        finally:
            cherrypy.smtp.email_from = original

    def test_send_mail_subject_non_ascii(self):
        # Given a subject with non-ASCII characters
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # When sending the mail
            cherrypy.engine.publish('send_mail', to='target@test.com', subject='Héllo Wörld', message='body')
            # Then the message is sent without error
            smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
            msg = smtplib.SMTP.return_value.send_message.call_args.args[0]
            # And the subject is preserved
            self.assertEqual(msg['Subject'], 'Héllo Wörld')

    # -------------------------------------------------------------------------
    # queue_mail
    # -------------------------------------------------------------------------

    @skipUnless(hasattr(cherrypy, 'scheduler'), reason='Required scheduler')
    def test_queue_mail(self):
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # Given a mail being queued.
            cherrypy.engine.publish('queue_mail', to='target@test.com', subject='subjet', message='body')
            # When waiting for all task to be processed
            cherrypy.scheduler.wait_for_jobs()
            # Then smtplib is called to send the mail.
            smtplib.SMTP.assert_called_once_with('__default__', 25)
            smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
            smtplib.SMTP.return_value.quit.assert_called_once_with()

    def test_queue_mail_not_configured(self):
        # Given no server is configured
        original = cherrypy.smtp.server
        cherrypy.smtp.server = None
        try:
            with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
                # When publishing a queue_mail
                cherrypy.engine.publish('queue_mail', to='target@test.com', subject='subject', message='body')
                # Then no delivery attempt is made — error is logged silently
                smtplib.SMTP.assert_not_called()
        finally:
            cherrypy.smtp.server = original

    # -------------------------------------------------------------------------
    # _html2plaintext
    # -------------------------------------------------------------------------

    def test_html2plaintext(self):
        html = """<html>
  <head>
    <style type="text/css">
      body { font-family:Helvetica; }
    </style>
  </head>
  <body>
    <h1>Hi!</h1>
    <p id="test"
        class="mb-2 text-center">
      How are you?<br/>
      Here&#160;is the <a href="https://www.python.org">link</a> you wanted.
    </p>
  </body>
</html>
"""
        expected = """**Hi!**
How are you?
Here is the link [1] you wanted.

[1] https://www.python.org"""
        self.assertEqual(expected, smtp._html2plaintext(html))

    # -------------------------------------------------------------------------
    # _formataddr
    # -------------------------------------------------------------------------

    def test_formataddr_none(self):
        # Given None is passed to _formataddr
        # Then None is returned
        self.assertIsNone(smtp._formataddr(None))

    def test_formataddr_invalid_type(self):
        # Given an unsupported type
        # Then a TypeError is raised
        with self.assertRaises(TypeError):
            smtp._formataddr(42)

    @parameterized.expand(
        [
            ('test@test.com', 'test@test.com'),
            (('TEST', 'test@test.com'), 'TEST <test@test.com>'),
            (['test2@test.com', ('TEST3', 'test3@test.com')], 'test2@test.com, TEST3 <test3@test.com>'),
            (('Valérie', 'val@test.com'), '=?utf-8?q?Val=C3=A9rie?= <val@test.com>'),
            (('Say "Hello"', 'hello@test.com'), r'"Say \\\"Hello\\\"" <hello@test.com>'),
            ('Valérie <foo@bar.com>', '=?utf-8?q?Val=C3=A9rie?= <foo@bar.com>'),
        ]
    )
    def test_formataddr(self, addr, expected_value):
        self.assertEqual(smtp._formataddr(addr), expected_value)

    # -------------------------------------------------------------------------
    # _create_msg
    # -------------------------------------------------------------------------

    def test_create_msg(self):
        msg = cherrypy.smtp._create_msg(
            subject='Test Subject',
            message='<p>Hello World</p>',
            to='recipient@example.com',
            cc='cc@example.com',
            bcc='bcc@example.com',
            reply_to='replyto@example.com',
            headers={'X-Custom-Header': 'CustomValue'},
        )

        # Assert - Headers
        self.assertEqual(msg['Subject'], 'Test Subject')
        self.assertEqual(msg['From'], 'Test <email_from@test.com>')
        self.assertEqual(msg['To'], 'recipient@example.com')
        self.assertEqual(msg['Cc'], 'cc@example.com')
        self.assertEqual(msg['Reply-To'], 'replyto@example.com')
        self.assertEqual(msg['X-Custom-Header'], 'CustomValue')
        self.assertIn('bcc@example.com', msg['Bcc'])

        # Assert - Message parts (plain text and html)
        payloads = msg.get_payload()
        self.assertEqual(len(payloads), 2)
        self.assertEqual(payloads[0].get_content_type(), 'text/plain')
        self.assertEqual(payloads[1].get_content_type(), 'text/html')

    def test_create_msg_no_optional_headers(self):
        # Given only the required fields
        msg = cherrypy.smtp._create_msg(
            subject='Minimal',
            message='<p>Hi</p>',
            to='recipient@example.com',
        )
        # Then optional headers are absent
        self.assertIsNone(msg['Cc'])
        self.assertIsNone(msg['Bcc'])
        self.assertIsNone(msg['Reply-To'])
        self.assertIsNone(msg['Sender'])

    def test_create_msg_requires_to_or_bcc(self):
        # Given neither to nor bcc are provided
        # Then an AssertionError is raised
        with self.assertRaises(AssertionError):
            cherrypy.smtp._create_msg(subject='No recipient', message='<p>body</p>')

    def test_create_msg_message_id_present(self):
        # Given a valid message
        msg = cherrypy.smtp._create_msg(
            subject='Test',
            message='<p>body</p>',
            to='recipient@example.com',
        )
        # Then a Message-ID header is generated
        self.assertIsNotNone(msg['Message-ID'])

    # -------------------------------------------------------------------------
    # Bcc merging
    # -------------------------------------------------------------------------

    @parameterized.expand(
        [
            (None, None, None),
            (None, 'test2@test.com', 'test2@test.com'),
            ('test2@test.com', None, 'test2@test.com'),
            ('test1@test.com', 'test2@test.com', 'test1@test.com, test2@test.com'),
        ]
    )
    def test_with_bcc(self, smtp_bcc, bcc, expected_bcc):
        # Given a valid smtp server
        with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
            # Given bcc is defined at plugin level.
            cherrypy.smtp.bcc = smtp_bcc
            # When publishing a send_mail with Bcc
            cherrypy.engine.publish(
                'send_mail',
                to=('A name', 'target@test.com'),
                subject='subjet',
                message='body',
                bcc=bcc,
            )
            # Then message is sent
            smtplib.SMTP.assert_called_once_with('__default__', 25)
            smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
            msg = smtplib.SMTP.return_value.send_message.call_args.args[0]
            smtplib.SMTP.return_value.quit.assert_called_once_with()
            # Message include our expected Bcc
            self.assertEqual(expected_bcc, msg['Bcc'])
