import json
from base64 import b64decode
from datetime import datetime, timezone

from ..exceptions import AnymailConfigurationError
from ..inbound import AnymailInboundMessage
from ..signals import (
    AnymailInboundEvent,
    AnymailTrackingEvent,
    EventType,
    RejectReason,
    inbound,
    tracking,
)
from ..utils import get_anymail_setting
from .base import AnymailBaseWebhookView


class SparkPostBaseWebhookView(AnymailBaseWebhookView):
    """Base view class for SparkPost webhooks"""

    esp_name = "SparkPost"

    def parse_events(self, request):
        raw_events = json.loads(request.body.decode("utf-8"))
        unwrapped_events = [self.unwrap_event(raw_event) for raw_event in raw_events]
        return [
            self.esp_to_anymail_event(event_class, event, raw_event)
            for (event_class, event, raw_event) in unwrapped_events
            if event is not None  # filter out empty "ping" events
        ]

    def unwrap_event(self, raw_event):
        """Unwraps SparkPost event structure, and returns event_class, event, raw_event

        raw_event is of form {'msys': {event_class: {...event...}}}

        Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}}
        """
        event_classes = raw_event["msys"].keys()
        try:
            (event_class,) = event_classes
            event = raw_event["msys"][event_class]
        except ValueError:  # too many/not enough event_classes to unpack
            if len(event_classes) == 0:
                # Empty event (SparkPost sometimes sends as a "ping")
                event_class = event = None
            else:
                raise TypeError(
                    "Invalid SparkPost webhook event has multiple event classes: %r"
                    % raw_event
                ) from None
        return event_class, event, raw_event

    def esp_to_anymail_event(self, event_class, event, raw_event):
        raise NotImplementedError()


class SparkPostTrackingWebhookView(SparkPostBaseWebhookView):
    """Handler for SparkPost message, engagement, and generation event webhooks"""

    signal = tracking

    event_types = {
        # Map SparkPost event.type: Anymail normalized type
        "bounce": EventType.BOUNCED,
        "delivery": EventType.DELIVERED,
        "injection": EventType.QUEUED,
        "spam_complaint": EventType.COMPLAINED,
        "out_of_band": EventType.BOUNCED,
        "policy_rejection": EventType.REJECTED,
        "delay": EventType.DEFERRED,
        "click": EventType.CLICKED,
        "open": EventType.OPENED,
        "amp_click": EventType.CLICKED,
        "amp_open": EventType.OPENED,
        "generation_failure": EventType.FAILED,
        "generation_rejection": EventType.REJECTED,
        "list_unsubscribe": EventType.UNSUBSCRIBED,
        "link_unsubscribe": EventType.UNSUBSCRIBED,
    }

    # Additional event_types mapping when Anymail setting
    # SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED is enabled.
    initial_open_event_types = {
        "initial_open": EventType.OPENED,
        "amp_initial_open": EventType.OPENED,
    }

    reject_reasons = {
        # Map SparkPost event.bounce_class: Anymail normalized reject reason.
        # Can also supply (RejectReason, EventType) for bounce_class that affects our
        # event_type. https://support.sparkpost.com/customer/portal/articles/1929896
        "1": RejectReason.OTHER,  # Undetermined (response text could not be identified)
        "10": RejectReason.INVALID,  # Invalid Recipient
        "20": RejectReason.BOUNCED,  # Soft Bounce
        "21": RejectReason.BOUNCED,  # DNS Failure
        "22": RejectReason.BOUNCED,  # Mailbox Full
        "23": RejectReason.BOUNCED,  # Too Large
        "24": RejectReason.TIMED_OUT,  # Timeout
        "25": RejectReason.BLOCKED,  # Admin Failure (configured policies)
        "30": RejectReason.BOUNCED,  # Generic Bounce: No RCPT
        "40": RejectReason.BOUNCED,  # Generic Bounce: unspecified reasons
        "50": RejectReason.BLOCKED,  # Mail Block (by the receiver)
        "51": RejectReason.SPAM,  # Spam Block (by the receiver)
        "52": RejectReason.SPAM,  # Spam Content (by the receiver)
        "53": RejectReason.OTHER,  # Prohibited Attachment (by the receiver)
        "54": RejectReason.BLOCKED,  # Relaying Denied (by the receiver)
        "60": (RejectReason.OTHER, EventType.AUTORESPONDED),  # Auto-Reply/vacation
        "70": RejectReason.BOUNCED,  # Transient Failure
        "80": (RejectReason.OTHER, EventType.SUBSCRIBED),  # Subscribe
        "90": (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED),  # Unsubscribe
        "100": (RejectReason.OTHER, EventType.AUTORESPONDED),  # Challenge-Response
    }

    def __init__(self, **kwargs):
        # Set Anymail setting SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED True
        # to report *both* "open" and "initial_open" as Anymail "opened" events.
        # (Otherwise only "open" maps to "opened", matching the behavior of most
        # other ESPs.) Handling "initial_open" is opt-in, to help avoid duplicate
        # "opened" events on the same first open.
        track_initial_open_as_opened = get_anymail_setting(
            "track_initial_open_as_opened",
            default=False,
            esp_name=self.esp_name,
            kwargs=kwargs,
        )
        if track_initial_open_as_opened:
            self.event_types = {**self.event_types, **self.initial_open_event_types}
        super().__init__(**kwargs)

    def esp_to_anymail_event(self, event_class, event, raw_event):
        if event_class == "relay_message":
            # This is an inbound event
            raise AnymailConfigurationError(
                "You seem to have set SparkPost's *inbound* relay webhook URL "
                "to Anymail's SparkPost *tracking* webhook URL."
            )

        event_type = self.event_types.get(event["type"], EventType.UNKNOWN)
        try:
            timestamp = datetime.fromtimestamp(int(event["timestamp"]), tz=timezone.utc)
        except (KeyError, TypeError, ValueError):
            timestamp = None

        try:
            tag = event["campaign_id"]
            # not "rcpt_tags" -- those don't come from sending a message
            tags = [tag] if tag else None
        except KeyError:
            tags = []

        try:
            reject_reason = self.reject_reasons.get(
                event["bounce_class"], RejectReason.OTHER
            )
            try:
                # unpack (RejectReason, EventType)
                # for reasons that change our event type
                reject_reason, event_type = reject_reason
            except ValueError:
                pass
        except KeyError:
            reject_reason = None  # no bounce_class

        return AnymailTrackingEvent(
            event_type=event_type,
            timestamp=timestamp,
            # use transmission_id, not message_id -- see SparkPost backend
            message_id=event.get("transmission_id", None),
            event_id=event.get("event_id", None),
            # raw_rcpt_to preserves email case (vs. rcpt_to)
            recipient=event.get("raw_rcpt_to", None),
            reject_reason=reject_reason,
            mta_response=event.get("raw_reason", None),
            # description=???,
            tags=tags,
            # metadata includes message + recipient metadata
            metadata=event.get("rcpt_meta", None) or {},
            click_url=event.get("target_link_url", None),
            user_agent=event.get("user_agent", None),
            esp_event=raw_event,
        )


class SparkPostInboundWebhookView(SparkPostBaseWebhookView):
    """Handler for SparkPost inbound relay webhook"""

    signal = inbound

    def esp_to_anymail_event(self, event_class, event, raw_event):
        if event_class != "relay_message":
            # This is not an inbound event
            raise AnymailConfigurationError(
                "You seem to have set SparkPost's *tracking* webhook URL "
                "to Anymail's SparkPost *inbound* relay webhook URL."
            )

        if event["protocol"] != "smtp":
            raise AnymailConfigurationError(
                "You cannot use Anymail's webhooks for SparkPost '{protocol}' relay"
                " events. Anymail only handles the 'smtp' protocol".format(
                    protocol=event["protocol"]
                )
            )

        raw_mime = event["content"]["email_rfc822"]
        if event["content"]["email_rfc822_is_base64"]:
            raw_mime = b64decode(raw_mime).decode("utf-8")
        message = AnymailInboundMessage.parse_raw_mime(raw_mime)

        message.envelope_sender = event.get("msg_from", None)
        message.envelope_recipient = event.get("rcpt_to", None)

        return AnymailInboundEvent(
            event_type=EventType.INBOUND,
            # SparkPost does not provide a relay event timestamp
            timestamp=None,
            # SparkPost does not provide an idempotent id for relay events
            event_id=None,
            esp_event=raw_event,
            message=message,
        )
