from AccessControl import Unauthorized
from plone import api
from plone.event.utils import pydt
from ploneintranet import api as pi_api
from ploneintranet.microblog.interfaces import IStatusUpdate
from ploneintranet.notifications.interfaces import ICategoryProvider
from ploneintranet.notifications.interfaces import ILoggedEvent
from ploneintranet.notifications.interfaces import IProvideContextUID
from ploneintranet.notifications.interfaces import IResolveContextUID
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import inspect
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.types import DateTime
from sqlalchemy.types import TypeDecorator
from sqlalchemy.types import Unicode
from sqlalchemy.types import UnicodeText
from uuid import uuid4
from z3c.saconfig import named_scoped_session
from zope.component import getUtility
from zope.component import queryAdapter
from zope.interface import implementer
from zope.interface.interfaces import ComponentLookupError
from zope.sqlalchemy.datamanager import NO_SAVEPOINT_SUPPORT

import datetime
import json
import logging

logger = logging.getLogger(__name__)


if "sqlite" in NO_SAVEPOINT_SUPPORT:
    # sqlite does actually support savepoints, see:
    # https://github.com/zopefoundation/zope.sqlalchemy/issues/44
    NO_SAVEPOINT_SUPPORT.remove("sqlite")

Base = declarative_base()
session = named_scoped_session("ploneintranet_notifications")


def default_actor_id(*args, **kwargs):
    try:
        actor = pi_api.userprofile.get_current()
    except api.exc.CannotGetPortalError:
        # Test setup
        return ""
    if not actor:
        return ""
    return actor.id


def uuid_generator():
    return uuid4().hex


def utc_now():
    """Current time localized to UTC, without microseconds.

    Can be conveniently frozen via @mock.patch for unittests.
    """
    return datetime.datetime.now(datetime.UTC).replace(microsecond=0)


def local_timezone():
    """Helper function extracted so it can be unittest mockable.

    Just calling now().astimezone() is insufficient to get the right timezone,
    if now() is in DST but the test is running in wintertime, and v.v.. See
    https://stackoverflow.com/questions/2659908/python-datetime-not-including-dst-when-using-pytz-timezone

    See the context managers in ploneintranet.notifications.testing that use this.
    """  # noqa
    return datetime.datetime.now().astimezone().tzinfo


def local_to_utc(dt):
    """Convert digest schedule in naive local time, into UTC suitable for storage.
    See issues #5028 and #5290.
    """
    # first ensure timezone-awareness in system timezone, then convert to UTC.
    return (
        dt.replace(tzinfo=(local_timezone()))
        .astimezone(datetime.UTC)
        .replace(microsecond=0)
    )


def utc_to_local(dt):
    """Convert stored time (either UTC or naive UTC-equivalent) to local timezone."""
    # first ensure timezone awareness in UTC, then convert to system timezone.
    return dt.astimezone(local_timezone())


class UTCDateTime(TypeDecorator):
    """Ensure datetimes are stored and retrieved as UTC-localized.

    https://docs.sqlalchemy.org/en/14/core/custom_types.html
    #store-timezone-aware-timestamps-as-timezone-naive-utc

    See testing.SQLLayer for background and testing instructions.
    """

    impl = DateTime
    cache_ok = True

    def process_bind_param(self, value, dialect):
        if value is not None:
            if not value.tzinfo:
                raise TypeError("tzinfo is required")
            value = value.astimezone(datetime.UTC).replace(tzinfo=None)
        return value

    def process_result_value(self, value, dialect):
        if value is not None:
            value = value.replace(tzinfo=datetime.UTC)
        return value


class BrokenNotificationError(ValueError):
    """Raised when a notification has become invalid."""


class MissingContextError(BrokenNotificationError):
    """Raised when a notification lost its context"""


class UnauthorizedContextError(Unauthorized, BrokenNotificationError):
    """Raised when a notifications context was made inaccessibe."""


class MissingCategoryProviderError(BrokenNotificationError):
    """Raised when a notification has no valid category provider"""


class MissingCategoryError(BrokenNotificationError):
    """Raised when a notification has no valid category"""


class StaleNotificationError(BrokenNotificationError):
    """Raised when a notification has become outdated.

    For example: when it's a review notification and the
    object was reviewed already.
    """


@implementer(ILoggedEvent)
class LoggedEvent(Base):
    __tablename__ = "logged_events"

    uid = Column(Unicode(32), primary_key=True, unique=True, default=uuid_generator)
    _context = Column(Unicode(255), nullable=False)
    _zope_event = Column(Unicode(255), nullable=False)
    # It's crucial to use utc_now here and not sqlalchemy.func.now(),
    # because the latter will happily provide a naive CET timestamp
    # if you're using a postgres configured in timezone CET.
    time = Column(UTCDateTime, nullable=False, default=utc_now)
    actor_id = Column(Unicode(255), nullable=False, default=default_actor_id)
    processed = Column(UTCDateTime, nullable=True)
    _data = Column(UnicodeText, nullable=True)

    def __init__(self, *args, **kwargs):
        if not kwargs.get("actor_id"):
            # Force the actor id to be the current user
            # The user might change between the time the event is logged
            # and the time the defaults are calculated.
            # This happens, e.g., when api.env.adopt_user is used
            kwargs["actor_id"] = default_actor_id()
        super().__init__(*args, **kwargs)

    @property
    def context(self):
        if not self._context:
            return None
        name, uid = self._context.partition(":")[::2]
        return getUtility(IResolveContextUID, name=name)(uid)

    @context.setter
    def context(self, value):
        self._context = IProvideContextUID(value)

    @property
    def zope_event(self):
        return self._zope_event

    @zope_event.setter
    def zope_event(self, value):
        self._zope_event = str(value)

    @property
    def data(self):
        return json.loads(self._data or "{}")

    @data.setter
    def data(self, value):
        self._data = json.dumps(value)

    @property
    def category_provider(self):
        try:
            return queryAdapter(self, ICategoryProvider, name=self.zope_event)
        except ComponentLookupError:
            return None

    @property
    def categories(self):
        adapter = self.category_provider
        if not adapter:
            return []
        return adapter.categories

    @property
    def recipients_channels(self):
        """This data structure is a mapping of recipients to channels.

        Each channel is bound to a category.
        E.g.:
        {
            "allan": {
                "email": <Category1>,
                "push": <Category2>,
                ...
            },
            ...
        }
        """
        adapter = self.category_provider
        if not adapter:
            return {}

        recipients_channels = adapter.recipients_channels
        recipients_channels.pop(self.actor_id, None)
        return recipients_channels

    def get_scheduled_time(self, channel):
        """Calculate time this notification should be sent.

        Schedules digests at 7:30 local time, converts that to UTC.
        """
        # Past effective dates are scheduled as of <now>
        # but may show up in the UI displaying the past effective date.
        if self.zope_event in {"newsitem_published"} and self.context is not None:
            effective_utc = pydt(self.context.effective())
            # If publication is scheduled in the future, do not create notifications yet
            if effective_utc is not None and effective_utc > datetime.datetime.now(
                datetime.UTC
            ):
                return None

        if channel in ("record", "banners", "desktop"):
            return self.time + datetime.timedelta(seconds=5)
        if channel == "email" and self.zope_event in {"workspace_content"}:
            # mini-digest every 10 minutes for some categories
            # plan on the 10-minute mark, at least 5 minutes in the future
            # so the batch handling window effectively varies from 5-15 minutes
            current_time = self.time.time()
            offset = 10 - current_time.minute % 10
            if offset <= 5:
                offset += 10
            scheduled_minute = current_time.minute + offset
            scheduled_hour = current_time.hour
            # handle minute >= 60 and hour >= 24 overflow
            # by using timedelta after most recent midnight.
            return datetime.datetime.combine(
                self.time.date(), datetime.time(0, 0, 0)
            ).replace(tzinfo=self.time.tzinfo) + datetime.timedelta(
                hours=scheduled_hour, minutes=scheduled_minute
            )
        elif channel == "email":
            # default to no batching (i.e. every minute)
            return self.time + datetime.timedelta(seconds=10)
        if channel == "digest_daily":
            local_time = utc_to_local(self.time)
            # the next 7:30 occurrence, either today or tomorrow
            if local_time.time() > datetime.time(7, 30):
                scheduled_date = local_time.date() + datetime.timedelta(days=1)
            else:
                scheduled_date = local_time.date()
            # Schedule in local time, store as UTC equivalent naive
            return local_to_utc(
                datetime.datetime.combine(scheduled_date, datetime.time(7, 30))
            )
        if channel == "digest_weekly":
            local_time = utc_to_local(self.time)
            if local_time.weekday() != 0:
                # if not on a Monday: next Monday
                days = 7 - local_time.weekday()
            elif local_time.time() < datetime.time(7, 30):
                # Monday early morning: send today
                days = 0
            else:
                # on a Monday after 7:30: send next Monday
                days = 7
            scheduled_date = local_time.date() + datetime.timedelta(days=days)
            # Schedule in local time, store as UTC equivalent naive
            return local_to_utc(
                datetime.datetime.combine(scheduled_date, datetime.time(7, 30))
            )
        return self.time

    def notify(self, recipient_id=None, category=None, channel=None):
        if not all((recipient_id, category, channel)):
            # Do not create with incomplete information
            return

        scheduled_time = self.get_scheduled_time(channel)
        if scheduled_time is None:
            # skip future notifications
            return
        notification = Notification(
            recipient_id=recipient_id,
            logged_event_uid=self.uid,
            channel=channel,
            category=category,
            scheduled_time=scheduled_time,
        )
        session().add(notification)
        return notification

    def is_recipient_enabled(self, recipient_id):
        """Filter out recipient ids that are not enabled.

        Consider a user enable if the portal cannot be found.
        That happens in unit tests.
        """
        try:
            user = pi_api.userprofile.get(recipient_id)
        except api.exc.CannotGetPortalError:
            # Test setup
            return True

        if not user:
            return False

        review_state = api.content.get_state(user)
        return review_state == "enabled"

    def notify_all(self):
        if self.processed:
            return

        try:
            context = self.context
        except Exception:
            # The context may have been deleted
            context = None
        if not context:
            self.processed = utc_now()
            return

        # skip future news items
        if self.get_scheduled_time(channel=None) is None:
            return

        recipients_channels = self.recipients_channels
        for recipient_id in filter(self.is_recipient_enabled, recipients_channels):
            channel_category_mapping = recipients_channels[recipient_id]
            for channel, category in channel_category_mapping.items():
                self.notify(
                    recipient_id=recipient_id,
                    channel=channel,
                    category=category.name,
                )
        self.processed = utc_now()

    @property
    def actor_fullname(self):
        helper = api.content.get_view("view", api.portal.get().profiles)
        return helper.get_fullname_for(self.actor_id)

    @property
    def formatted_date(self):
        proto = api.content.get_view("proto", api.portal.get())
        return proto.format_date(self.time)


class Notification(Base):
    __tablename__ = "notifications"

    uid = Column(Unicode(32), primary_key=True, unique=True, default=uuid_generator)
    recipient_id = Column(Unicode(255), nullable=False, index=True)
    logged_event_uid = Column(
        Unicode(255), ForeignKey("logged_events.uid"), nullable=False
    )
    channel = Column(Unicode(255), nullable=False)
    category = Column(Unicode(255), nullable=False)
    scheduled_time = Column(UTCDateTime, nullable=False)
    logged_event = relationship("LoggedEvent", backref="notifications")
    # The notified column is meant to store the time when the notification was
    # dispatched to the user. For push notifications (record, desktop) this
    # indicates the time when the push notification was sent.
    notified = Column(UTCDateTime, nullable=True)
    # The read column stores the time when the notification was first accessed.
    # - For record notifications, this is set when opening the notifications
    # listing. The number of unread messages is displayed in the unread counter.
    # - For desktop notifications, this is set when fetching a desktop message
    # to display. Unread messages are shown as a popup on the desktop, then
    # marked read.
    # - For all other channels, this is only set when the user confirmed having
    # seen the message by clicking it (i.e. we cannot reliably track 'read' for
    # email messages.)
    read = Column(UTCDateTime, nullable=True)
    # The clicked column is meant to store the time the delivered notification link
    # was clicked by the user meaning that he got the the logged_event context.
    # This can be used to determine through which channel we triggered a conversion.
    clicked = Column(UTCDateTime, nullable=True)

    def mark_notified(self):
        """Convenience API to simplify browser views and tests."""
        self.notified = utc_now()

    def mark_read(self):
        """Convenience API to simplify browser views and tests."""
        self.read = utc_now()

    def mark_clicked(self):
        """Convenience API to simplify browser views and tests."""
        self.clicked = utc_now()
        self.read = utc_now()

    def absolute_url(self):
        """Return the absolute url that we will use to see this notification

        We can have different urls for different channels about the same event, e.g.:

        - /++quaive_notification++<uid-email>
        - /++quaive_notification++<uid-record>

        We want to make sure that the user will be redirected to object view
        after marking the notification for that channel **and** the record channel
        as clicked.
        """
        return f"{api.portal.get().absolute_url()}/++quaive_notification++{self.uid}"

    def icon_url(self):
        """Return the publicly accessible url for the icon we show on this notification.
        This is:
        - For status updates:
            - A user profile image, if that is available
            - A anonymous user avatar, if no profile image
        - For all other updates:
            - The bell icon conform design
        """
        portal = api.portal.get()
        portal_url = portal.absolute_url()
        if self.logged_event.zope_event not in {"statusupdate_added"}:
            return f"{portal_url}/++theme++ploneintranet.theme/generated/assets/quaive/style/notification.png"  # noqa
        profile = portal.profiles.get(self.logged_event.actor_id)
        enabled = api.content.get_state(obj=profile) == "enabled" if profile else False
        if enabled and profile.portrait:
            timestamp = profile.modified().ISO()
            uid = profile.UID()
            return f"{portal_url}/++quaive_profile_avatar++{uid}.jpg?ts={timestamp}"
        return f"{portal_url}/++theme++ploneintranet.theme/generated/assets/quaive/style/default-user-168-round.png"  # noqa

    def get_app(self):
        category_provider = self.logged_event.category_provider
        if not category_provider:
            return None
        for category in category_provider.categories:
            if category.name == self.category:
                return category.get_app()
        return None

    def context_absolute_url(self):
        """Return the absolute url of the context

        By default it is self.logged_event.context.absolute_url(),
        but it can be overridden by the category
        """
        category_provider = self.logged_event.category_provider
        for category in category_provider.categories:
            if category.name == self.category:
                return category.get_absolute_url_for(self)
        try:
            return self.logged_event.context.absolute_url()
        except AttributeError:
            portal_url = api.portal.get().absolute_url()
            return f"{portal_url}/apps/notifications"

    def assert_context_is_accessible_for_current_user(self):
        """
        This performs various content checks, since a security context may have been
        removed or restricted between the logged event and the dispatching of
        this message.

        If the context is not accessible, this will raise BrokenNotificationError.
        """
        try:
            context = self.logged_event.context
        except Unauthorized:
            raise UnauthorizedContextError(
                f"Cannot access context for {self.logged_event.uid}"
            )
        if context is None:
            raise MissingContextError(f"Missing context for {self.logged_event.uid}")
        elif IStatusUpdate.providedBy(context):
            try:
                # we have indirect security constraints. check them.
                context.microblog_context
                context.content_context
            except Unauthorized:
                raise UnauthorizedContextError(
                    f"Cannot access context for {self.logged_event.uid}"
                )
        return True

    def assert_context_is_accessible_for_recipient(self):
        """Ensure we perform security checks for the recipient.
        In the aggregator dispatch, we will be logged in as a different user
        """
        try:
            # adopt_user cannot handle userid
            with api.env.adopt_user(user=api.user.get(self.recipient_id)):
                return self.assert_context_is_accessible_for_current_user()
        except api.exc.CannotGetPortalError:
            # Happens in unittest. Fine, avoid having to set up complex fixtures.
            return True
        return False

    def get_message(self, with_text=True, highlight=False):
        """Get the right message for a specific user.

        If something is amiss, this will raise.
        """
        self.assert_context_is_accessible_for_recipient()
        category_provider = self.logged_event.category_provider
        if not category_provider:
            raise MissingCategoryProviderError(
                f"Missing category provider for {self.logged_event.context}"
            )
        for category in category_provider.categories:
            if category.name == self.category:
                return category.get_message_for(self, with_text, highlight)
        raise MissingCategoryError(
            f"No category provides {self.category} for {self.logged_event.context}"
        )

    def get_safe_message(self, with_text=True, highlight=False):
        try:
            return self.get_message(with_text, highlight)
        except BrokenNotificationError as exc:
            return str(exc)

    @property
    def message(self):
        return self.get_safe_message()

    @property
    def short_message(self):
        return self.get_safe_message(with_text=False)

    @property
    def highlighted_message(self):
        return self.get_safe_message(with_text=True, highlight=True)


def can_log_events():
    try:
        session = named_scoped_session("ploneintranet_notifications")()
    except ComponentLookupError:
        # XXX This happens when no connection is setup yet
        return False
    return inspect(session.bind).has_table(LoggedEvent.__tablename__)


def log_event(context, event, data=None):
    """Event should be something that can be converted to a string or a string

    Think about a label that easily describes what is happening.
    """
    if not can_log_events():
        return

    if data is None:
        data = {}

    logged_event = LoggedEvent(context=context, zope_event=event, data=data)
    session.add(logged_event)
    return logged_event


def get_notifications_query(omit_record_channel=True):
    """Query all the notifications that should be notified.

    If the omit_record_channel flag is truish (the default), exclude the
    notifications for the record channel.
    """
    query = (
        session()
        .query(Notification)
        .filter(
            Notification.notified == None,  # noqa: E711
            Notification.scheduled_time < utc_now(),
        )
    )
    if omit_record_channel:
        query = query.filter(Notification.channel != "record")
    return query


def get_notifications_for(context, zope_event=None, only_unsent=False):
    """Query the notifications.

    If context is provided, filter by that context.
    If zope_event is provided, filter by that event.
    If only_unsent is True, match only unsent notifications.
    """
    logged_event_uids = (
        session()
        .query(LoggedEvent.uid)
        .filter(
            LoggedEvent._context == IProvideContextUID(context),
        )
    )
    if zope_event:
        logged_event_uids = logged_event_uids.filter(
            LoggedEvent._zope_event == zope_event
        )

    query = (
        session()
        .query(Notification)
        .filter(
            Notification.logged_event_uid.in_(logged_event_uids.subquery().select()),
        )
    )
    if only_unsent:
        query = query.filter(Notification.notified == None)  # noqa: E711
    return query


def get_events_for(context, zope_event=None, only_unprocessed=False):
    """Query the events.

    If context is provided, filter by that context.
    If zope_event is provided, filter by that event.
    If only_unprocessed is True, match only unprocessed events.
    """
    query = (
        session()
        .query(LoggedEvent)
        .filter(
            LoggedEvent._context == IProvideContextUID(context),
        )
    )
    if zope_event:
        query = query.filter(LoggedEvent._zope_event == zope_event)
    if only_unprocessed:
        query = query.filter(LoggedEvent.processed == None)  # noqa: E711
    return query


def delete_unprocessed_events_for(context, zope_event=None):
    """When a future newsitem is retracted or deleted, cancel its pending
    logged_event.
    """
    # There's a potential edge case here but only if you retract a news item
    # exactly on its effective date
    query = get_events_for(context, zope_event, only_unprocessed=True)
    query.delete(synchronize_session=False)


def delete_unsent_notifications_for(context, zope_event=None):
    """When a future newsitem is retracted, cancel its pending notifications."""

    # Delete any matching pending logged_events. On processing those,
    # they would emit exactly the notifications we want to prevent from being
    # sent here.
    events_query = (
        session()
        .query(LoggedEvent)
        .filter(
            LoggedEvent.processed == None,  # noqa: E711
            LoggedEvent._context == IProvideContextUID(context),
        )
    )
    if zope_event:
        events_query = events_query.filter(LoggedEvent._zope_event == zope_event)
    count = events_query.delete(synchronize_session=False)

    # Work around the fact that we cannot use a JOIN in a DELETE
    logged_event_uids = (
        session()
        .query(LoggedEvent.uid)
        .filter(
            LoggedEvent._context == IProvideContextUID(context),
        )
    )
    if zope_event:
        logged_event_uids = logged_event_uids.filter(
            LoggedEvent._zope_event == zope_event
        )
    # Remove all unsent notifications for this context and zope_event
    notifications_query = (
        session()
        .query(Notification)
        .filter(
            Notification.notified == None,  # noqa: E711
            Notification.logged_event_uid.in_(logged_event_uids.subquery().select()),
        )
    )
    count += notifications_query.delete(synchronize_session=False)
    if count == 0:
        return

    if zope_event:
        logger.info("Deleted unsent %r notifications for %r", zope_event, context)
    else:
        logger.info("Deleted all unsent notifications for %r", context)
