from ploneintranet.api.env import system_timezone_CEST
from ploneintranet.api.env import system_timezone_CET
from ploneintranet.notifications.adapters.category_provider import BaseCategory
from ploneintranet.notifications.adapters.category_provider import BaseCategoryProvider
from ploneintranet.notifications.interfaces import IProvideContextUID
from ploneintranet.notifications.interfaces import IResolveContextUID
from ploneintranet.notifications.sql.saconfig import get_notifications_for
from ploneintranet.notifications.sql.saconfig import get_notifications_query
from ploneintranet.notifications.sql.saconfig import local_to_utc
from ploneintranet.notifications.sql.saconfig import LoggedEvent
from ploneintranet.notifications.sql.saconfig import Notification
from ploneintranet.notifications.sql.saconfig import session
from ploneintranet.notifications.sql.saconfig import utc_now
from ploneintranet.notifications.sql.saconfig import utc_to_local
from ploneintranet.notifications.testing import postgres_active
from ploneintranet.notifications.testing import SQL_FIXTURE
from unittest import mock
from unittest import TestCase
from unittest.mock import Mock
from zope.component import adapter
from zope.component import getGlobalSiteManager
from zope.interface import implementer
from zope.interface import provider

import datetime
import os


class MockContext(Mock):
    def absolute_url(self):
        return "http://example.com"

    def effective(self):
        return datetime.datetime(2011, 1, 1, 1, tzinfo=datetime.UTC)


@adapter(Mock)
@implementer(IProvideContextUID)
def mock_event_context_uid(value):
    return "Mock:mock"


@provider(IResolveContextUID)
def MockNotificationResolveContextUID(uid):
    return MockContext()


class MockCategory(BaseCategory):
    def __init__(self, name, recipient_ids=None, matrix=None):
        self.name = name
        self._recipient_ids = recipient_ids or set()
        self._matrix = matrix or {}

    @property
    def expanded_recipient_ids(self):
        return self._recipient_ids

    def get_recipient_channels(self, recipient_id):
        return self._matrix.get(recipient_id, set())


class MockCategoryProvider(BaseCategoryProvider):
    categories = [
        MockCategory(
            "foo",
            recipient_ids={"allan", "bernard"},
            matrix={
                "allan": {"record", "email"},
                "bernard": {"email", "digest_daily"},
            },
        ),
        MockCategory(
            "bar",
            recipient_ids={"bernard", "claire"},
            matrix={
                "bernard": {"record"},
                "claire": {"record", "digest_daily"},
            },
        ),
    ]


class TestSQLUnit(TestCase):
    """The tests here are deliberately run with insertion of naive datetimes,
    then check that the outputs are UTC localized.

    See testing.SQLLayer for documentation on how to test this against postgres.
    """

    layer = SQL_FIXTURE

    def setUp(self):
        """Custom shared utility setup for tests."""
        self.session = session()
        gsm = getGlobalSiteManager()
        gsm.registerAdapter(mock_event_context_uid)
        gsm.registerUtility(MockNotificationResolveContextUID, name="Mock")
        gsm.registerAdapter(
            factory=MockCategoryProvider,
            name="foo",
        )

    def tearDown(self):
        gsm = getGlobalSiteManager()
        gsm.unregisterAdapter(mock_event_context_uid)
        gsm.unregisterUtility(MockNotificationResolveContextUID, name="Mock")
        gsm.unregisterAdapter(MockCategoryProvider, name="foo")

    def test_sqlalchemy_session_correct(self):
        """Verify that the correct connection is loaded."""

        # If you ask to test postgres, ensure you don't accidentally run sqlite
        # tests because some of the environment vars are missing
        self.assertEqual(postgres_active, bool("PGTEST" in os.environ))

        engine_name = postgres_active and "postgresql" or "sqlite"
        self.assertEqual(
            self.session.connection().engine.name, engine_name, postgres_active
        )

    def test_sqlalchemy_tables_empty(self):
        self.assertEqual(0, self.session.query(LoggedEvent).count())
        self.assertEqual(0, self.session.query(Notification).count())

    def test_model_relations_work(self):
        """Test SQL roundtrip"""
        logged_events_uids = []
        for user_id in {"allan", "bernard"}:
            mockobj = Mock(user_id=user_id)
            mockevent = Mock()
            logged_event = LoggedEvent(context=mockobj, zope_event=mockevent)
            self.session.add(logged_event)
            # Flush the session to init the defaults
            self.session.flush()
            logged_events_uids.append(logged_event.uid)
            for channel in {"foo", "bar"}:
                logged_event.notify("charlie", channel=channel, category="baz")

        self.session.flush()
        notifications_query = self.session.query(Notification)

        # We have 4 notifications
        self.assertEqual(4, notifications_query.count())
        # 2 for each logged event
        self.assertEqual(
            2,
            notifications_query.filter(
                Notification.logged_event_uid == logged_events_uids[0]
            ).count(),
        )
        self.assertEqual(
            2,
            notifications_query.filter(
                Notification.logged_event_uid == logged_events_uids[1]
            ).count(),
        )

        # Check that each notification and the logged event references
        # work forth and back
        notification = notifications_query.first()
        logged_event = notification.logged_event

        self.assertEqual(logged_event.uid, logged_events_uids[0])
        self.assertSetEqual(
            {notification.uid for notification in logged_event.notifications},
            {
                notification.uid
                for notification in notifications_query.filter(
                    Notification.logged_event_uid == logged_events_uids[0]
                )
            },
        )

    def test_categories(self):
        """Test that the categories are provided based on the context
        by the ICategoryProvider adapter
        """
        fake_obj = Mock()
        # patch plone.api.content.get to return the fake object
        with mock.patch("plone.api.content.get", return_value=fake_obj):
            logged_event = LoggedEvent(context=fake_obj, zope_event="foo")
            self.assertListEqual(
                ["foo", "bar"], [category.name for category in logged_event.categories]
            )

    def test_recipients_channels_unit(self):
        """Test that the categories are provided based on the context
        by the ICategoryProvider adapter
        """
        fake_obj = Mock()

        # patch plone.api.content.get to return the fake object
        with mock.patch("plone.api.content.get", return_value=fake_obj):
            logged_event = LoggedEvent(context=fake_obj, zope_event="foo")

            foo, bar = logged_event.categories
            recipients_channels = logged_event.recipients_channels

        self.assertDictEqual(
            {
                "allan": {"record": foo, "email": foo},
                "bernard": {"record": bar, "digest_daily": foo, "email": foo},
                "claire": {"record": bar, "digest_daily": bar},
            },
            recipients_channels,
        )

    def test_scheduled_time_email_statusupdate(self):
        mockobj = Mock(
            type="statusupdate",
            mentions=["allan"],
            uid="1",
        )
        mockevent = "statusupdate_added"
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 1, 1, 1, 2, 3, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("email")
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 1, 1, 2, 13, tzinfo=datetime.UTC),
        )

    def test_scheduled_time_email_workspace_content(self):
        mockobj = Mock(
            type="File",
            title="Presentation",
            uid="1",
        )
        mockevent = "workspace_content"

        # more than 5 minutes to next 10 minute mark
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 1, 1, 1, 2, 3, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("email")
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 1, 1, 10, 0, tzinfo=datetime.UTC),
        )

        # less than 5 minutes before next 10 minute mark
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 1, 1, 1, 5, 3, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("email")
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 1, 1, 20, 0, tzinfo=datetime.UTC),
        )

        # less than 5 minutes before midnight
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 1, 1, 23, 56, 3, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("email")
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 2, 0, 10, 0, tzinfo=datetime.UTC),
        )

    @system_timezone_CET()
    def test_scheduled_time_digest_daily(self):
        mockobj = Mock(
            type="statusupdate",
            mentions=["allan"],
            uid="1",
        )
        mockevent = Mock()
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 1, 1, 1, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("digest_daily")
        # 7:30 CET is 6:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 1, 6, 30, tzinfo=datetime.UTC),
        )

        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 1, 1, 8, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("digest_daily")
        # 7:30 CET is 6:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 2, 6, 30, tzinfo=datetime.UTC),
        )

    @system_timezone_CET()
    def test_scheduled_time_digest_weekly_wintertime(self):
        mockobj = Mock(
            type="statusupdate",
            mentions=["allan"],
            uid="1",
        )
        # Monday just before 7:30, send today
        mockevent = Mock()
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(
                2023, 1, 2, 6, 10, tzinfo=datetime.UTC
            ),  # 6:10 UTC = 7:10 CET
        )
        scheduled_time = logged_event.get_scheduled_time("digest_weekly")
        # 7:30 CET is 6:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 2, 6, 30, tzinfo=datetime.UTC),
        )

        # Monday just after 7:30, send next Monday
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(
                2023, 1, 2, 6, 40, tzinfo=datetime.UTC
            ),  # 6:40 UTC = 7:40 CET
        )
        scheduled_time = logged_event.get_scheduled_time("digest_weekly")
        # 7:30 CET is 6:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 9, 6, 30, tzinfo=datetime.UTC),
        )

        # Tuesday, send next Monday
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 1, 3, 5, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("digest_weekly")
        # 7:30 CET is 6:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 1, 9, 6, 30, tzinfo=datetime.UTC),
        )

    @system_timezone_CEST()
    def test_scheduled_time_digest_weekly_summertime(self):
        mockobj = Mock(
            type="statusupdate",
            mentions=["allan"],
            uid="1",
        )
        # Monday just before 7:30, send today
        mockevent = Mock()
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(
                2023, 7, 3, 5, 10, tzinfo=datetime.UTC
            ),  # 5:10 UTC = 7:10 CEST
        )
        scheduled_time = logged_event.get_scheduled_time("digest_weekly")
        # 7:30 CEST is 5:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 7, 3, 5, 30, tzinfo=datetime.UTC),
        )

        # Monday just after 7:30, send next Monday
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(
                2023, 7, 3, 5, 40, tzinfo=datetime.UTC
            ),  # 5:40 UTC = 7:40 CEST
        )
        scheduled_time = logged_event.get_scheduled_time("digest_weekly")
        # 7:30 CEST is 5:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 7, 10, 5, 30, tzinfo=datetime.UTC),
        )

        # Tuesday, send next Monday
        logged_event = LoggedEvent(
            context=mockobj,
            zope_event=mockevent,
            time=datetime.datetime(2023, 7, 4, 5, tzinfo=datetime.UTC),
        )
        scheduled_time = logged_event.get_scheduled_time("digest_weekly")
        # 7:30 CEST is 5:30 UTC
        self.assertEqual(
            scheduled_time,
            datetime.datetime(2023, 7, 10, 5, 30, tzinfo=datetime.UTC),
        )

    @system_timezone_CET()
    def test_notify_all(self):
        fake_obj = Mock()
        with mock.patch(
            "ploneintranet.notifications.sql.saconfig.utc_now",
            return_value=datetime.datetime(2023, 1, 1, 2, tzinfo=datetime.UTC),
        ):
            logged_event = LoggedEvent(
                context=fake_obj,
                zope_event="foo",
                time=datetime.datetime(2023, 1, 1, 1, tzinfo=datetime.UTC),
            )
            self.session.add(logged_event)
            self.session.flush()
            logged_event.notify_all()
            self.session.flush()

        notifications = self.session.query(Notification)

        # Given our notifications matrix we will have 7 notifications
        self.assertEqual(7, notifications.count())
        expected = {
            (
                "allan",
                "foo",
                "record",
                datetime.datetime(2023, 1, 1, 1, 0, 5, tzinfo=datetime.UTC),
            ),
            (
                "allan",
                "foo",
                "email",
                datetime.datetime(2023, 1, 1, 1, 0, 10, tzinfo=datetime.UTC),
            ),
            (
                "bernard",
                "bar",
                "record",
                datetime.datetime(2023, 1, 1, 1, 0, 5, tzinfo=datetime.UTC),
            ),
            # 7:30 CET is 6:30 UTC
            (
                "bernard",
                "foo",
                "digest_daily",
                datetime.datetime(2023, 1, 1, 6, 30, tzinfo=datetime.UTC),
            ),
            (
                "bernard",
                "foo",
                "email",
                datetime.datetime(2023, 1, 1, 1, 0, 10, tzinfo=datetime.UTC),
            ),
            (
                "claire",
                "bar",
                "record",
                datetime.datetime(2023, 1, 1, 1, 0, 5, tzinfo=datetime.UTC),
            ),
            # 7:30 CET is 6:30 UTC
            (
                "claire",
                "bar",
                "digest_daily",
                datetime.datetime(2023, 1, 1, 6, 30, tzinfo=datetime.UTC),
            ),
        }
        observed = {
            (
                notification.recipient_id,
                notification.category,
                notification.channel,
                notification.scheduled_time,
            )
            for notification in notifications
        }
        self.assertSetEqual(expected, observed)

        # Be sure that rerunning notify_all does not create duplicates
        with mock.patch("plone.api.content.get", return_value=fake_obj):
            logged_event.notify_all()
            self.session.flush()
        self.assertEqual(7, notifications.count())

    def test_get_absolute_url_for(self):
        fake_obj = Mock()
        with mock.patch(
            "ploneintranet.notifications.sql.saconfig.utc_now",
            return_value=datetime.datetime(2023, 1, 1, 2, tzinfo=datetime.UTC),
        ):
            logged_event = LoggedEvent(
                context=fake_obj,
                zope_event="foo",
            )
            self.session.add(logged_event)
            self.session.flush()
            logged_event.notify_all()
            self.session.flush()

        notification = self.session.query(Notification).first()
        self.assertEqual(notification.context_absolute_url(), "http://example.com")

    @system_timezone_CET()
    def test_sqlalchemy_default_time(self):
        """Test timezone handling when inserting a logged event."""
        mockobj = Mock(user_id="allan")
        mockevent = Mock()
        logged_event = LoggedEvent(context=mockobj, zope_event=mockevent)
        self.session.add(logged_event)
        self.assertIsNone(logged_event.time)
        # Flush the session to init the time with func.now
        self.session.flush()
        current_utc_time = utc_now()
        # The stored time is naive, but converted into UTC localized on-read
        self.assertEqual(logged_event.time.tzinfo, datetime.UTC)
        difference = current_utc_time - logged_event.time
        # The times are equal if they're less than two seconds apart, not an hour.
        # This proves sqlalchemy stored a UTC equivalent, desplite system timezone CET
        # Note that we're tolerating a slight difference, since we're cutting off
        # microseconds in addition to any runtime delay we may induce in the code.
        self.assertTrue(
            datetime.timedelta(seconds=0) <= difference <= datetime.timedelta(seconds=2)
        )

    @system_timezone_CET()
    def test_email_digest_scheduled_time(self):
        """Test timezone handling in scheduling email/digest notifications.

        This checks that notifications are scheduled in the future.
        """

        # capture the time before running the scheduler
        # testing on sqlite using naive datetimes
        frozen_utc_now = utc_now()

        logged_events_uids = []
        for user_id in {"allan", "bernard"}:
            mockobj = Mock(user_id=user_id)
            mockevent = Mock()
            logged_event = LoggedEvent(context=mockobj, zope_event=mockevent)
            self.session.add(logged_event)
            # Flush the session to init the defaults
            self.session.flush()
            logged_events_uids.append(logged_event.uid)
            for channel in {"email", "digest_daily"}:
                logged_event.notify("charlie", channel=channel, category="baz")

        self.session.flush()

        for notification in self.session.query(Notification).all():
            self.assertTrue(notification.scheduled_time > frozen_utc_now)

        # All notifications are scheduled for the future, none are due
        notifications_query = get_notifications_query()
        self.assertEqual(0, notifications_query.count())

    @system_timezone_CET()
    def test_record_scheduled_time(self):
        """Test timezone handling in scheduling record notifications.

        This checks that notifications are not scheduled TOO FAR into the future,
        which may happen though a timezone mismatch.

        Note that this test doesn't cover the scenario, where a postgres server
        is configured to CET while the application server is running UTC. Testing
        that scenario has to be done manually.
        """

        # capture the time before running the scheduler
        # testing on sqlite using naive datetimes
        frozen_utc_now = utc_now()

        mockobj = Mock(user_id="allan")
        mockevent = Mock()
        logged_event = LoggedEvent(context=mockobj, zope_event=mockevent)
        self.session.add(logged_event)

        # Flush the session to init the defaults
        self.session.flush()

        logged_event.notify("charlie", channel="record", category="baz")

        self.session.flush()

        notification = (
            self.session.query(Notification)
            .filter(Notification.channel == "record")
            .one()
        )

        difference = notification.scheduled_time - frozen_utc_now
        self.assertTrue(
            datetime.timedelta(seconds=4) <= difference < datetime.timedelta(seconds=6)
        )

        # We don't perform the get_notifications_query() test
        # because the scheduled record time is too close to now
        # - we don't want to trigger a Heisenbug in the tests.

    @system_timezone_CET()
    def test_utc_to_local_wintertime(self):
        CET = datetime.timezone(datetime.timedelta(seconds=3600), "CET")
        local_time = datetime.datetime(2023, 1, 1, 2, tzinfo=CET)
        utc_time = datetime.datetime(2023, 1, 1, 1, tzinfo=datetime.UTC)
        self.assertEqual(utc_to_local(utc_time), local_time)

    @system_timezone_CET()
    def test_local_to_utc_wintertime(self):
        CET = datetime.timezone(datetime.timedelta(seconds=3600), "CET")
        local_time = datetime.datetime(2023, 1, 1, 2, tzinfo=CET)
        utc_time = datetime.datetime(2023, 1, 1, 1, tzinfo=datetime.UTC)
        self.assertEqual(local_to_utc(local_time), utc_time)

    @system_timezone_CEST()
    def test_utc_to_local_summertime(self):
        CEST = datetime.timezone(datetime.timedelta(seconds=7200), "CEST")
        local_time = datetime.datetime(2023, 7, 3, 12, tzinfo=CEST)
        utc_time = datetime.datetime(2023, 7, 3, 10, tzinfo=datetime.UTC)
        self.assertEqual(utc_to_local(utc_time), local_time)

    @system_timezone_CEST()
    def test_local_to_utc_summertime(self):
        CEST = datetime.timezone(datetime.timedelta(seconds=7200), "CEST")
        local_time = datetime.datetime(2023, 7, 3, 12, tzinfo=CEST)
        utc_time = datetime.datetime(2023, 7, 3, 10, tzinfo=datetime.UTC)
        self.assertEqual(local_to_utc(local_time), utc_time)

    @system_timezone_CEST()
    def test_get_notifications_for(self):
        mockobj = Mock(user_id="allan")
        mockevent = "newsitem_published"
        logged_event = LoggedEvent(context=mockobj, zope_event=mockevent)
        self.session.add(logged_event)
        self.session.flush()
        for channel in {"email", "digest_daily"}:
            logged_event.notify("charlie", channel=channel, category="baz")
        self.session.flush()

        notifications = self.session.query(Notification)
        self.assertEqual(2, notifications.count())
        notifications = get_notifications_for(mockobj, mockevent, only_unsent=False)
        self.assertEqual(2, notifications.count())
        notifications = get_notifications_for(mockobj, mockevent, only_unsent=True)
        self.assertEqual(2, notifications.count())
