#!/usr/bin/python
# -*- coding: utf-8 -*-

# Hive Pushi System
# Copyright (c) 2008-2024 Hive Solutions Lda.
#
# This file is part of Hive Pushi System.
#
# Hive Pushi System is free software: you can redistribute it and/or modify
# it under the terms of the Apache License as published by the Apache
# Foundation, either version 2.0 of the License, or (at your option) any
# later version.
#
# Hive Pushi System 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
# Apache License for more details.
#
# You should have received a copy of the Apache License along with
# Hive Pushi System. If not, see <http://www.apache.org/licenses/>.

__author__ = "João Magalhães <joamag@hive.pt>"
""" The author(s) of the module """

__copyright__ = "Copyright (c) 2008-2024 Hive Solutions Lda."
""" The copyright for the module """

__license__ = "Apache License, Version 2.0"
""" The license for the module """

import os
import shutil
import tempfile

import netius.clients

import pushi

from . import handler


class APNHandler(handler.Handler):
    """
    Pushi handler (adapter) for the Apple Push Notifications
    (APN) infra-structure, so that it's possible to send
    push notification to iOS/OSX devices.
    """

    def __init__(self, owner):
        handler.Handler.__init__(self, owner, name="apn")
        self.subs = {}

    def send(self, app_id, event, json_d, invalid={}):
        # tries to retrieve the appropriate message starting from
        # the most general values to the most specific values,
        # be aware that the value may be a bit fuzzy
        message = json_d.get("data", None)
        message = json_d.get("push", message)
        message = json_d.get("apn", message)
        if not message:
            raise RuntimeError("No message defined")

        # retrieves the reference to the app with the defined app id
        # and extracts the APN specific values for it to be used in
        # the process of authentication
        app = self.owner.get_app(app_id=app_id)
        key_data = app.apn_key
        cer_data = app.apn_cer
        sandbox = app.apn_sandbox

        # in case no key data or certificate data is present a runtime
        # error is raised to indicate the problem
        if not key_data:
            raise RuntimeError("No APN key defined")
        if not cer_data:
            raise RuntimeError("No APN certificate defined")

        # ensures that the complete set of data is encoded as bytes, as
        # this is required for the proper writing of the file, otherwise
        # and invalid encoding would be raised for some platforms
        key_data = netius.legacy.bytes(key_data)
        cer_data = netius.legacy.bytes(cer_data)

        # creates a new temporary directory that will be used to store
        # the temporary key and certificate files for SSL
        path = tempfile.mkdtemp()

        # creates the full paths to both the key and certificate
        # files using the temporary path as base
        key_path = os.path.join(path, "apn.key")
        cer_path = os.path.join(path, "apn.cer")

        # opens the SSL key file for writing (in binary mode) and
        # then writes the current data into it so that it may be
        # used by the encryption infra-structure
        key_file = open(key_path, "wb")
        try:
            key_file.write(key_data)
        finally:
            key_file.close()

        # opens the temporary certificate file and writes
        # the retrieved certificate data into it, to be used
        # temporarily by the SSL infra-structure
        cer_file = open(cer_path, "wb")
        try:
            cer_file.write(cer_data)
        finally:
            cer_file.close()

        # retrieves the app key for the retrieved app by unpacking the current
        # app structure into the appropriate values
        app_key = app.key

        # saves the original event name for the received event, so that it may
        # be used latter for debugging/log purposes
        root_event = event

        # resolves the complete set of (extra) channels for the provided
        # event assuming that it may be associated with alias, then creates
        # the complete list of event containing also the "extra" events
        extra = self.owner.get_channels(app_key, event)
        events = [event] + extra

        # retrieves the complete set of subscriptions for the current APN
        # infra-structure to be able to resolve the appropriate tokens
        subs = self.subs.get(app_id, {})

        # creates the initial list of tokens to be notified and then populates
        # the list with the various token associated with the complete set of
        # resolved events, note that a set is created at the end so that one
        # token gets notified only once (no double notifications)
        tokens = []
        for event in events:
            _tokens = subs.get(event, [])
            tokens.extend(_tokens)
        tokens = set(tokens)
        count = len(tokens)

        # prints a logging message about the various (web) subscriptions
        # that were found for the event that was triggered
        self.logger.debug("Found %d APN subscription(s) for '%s'" % (count, root_event))

        # creates the counter that will be used by the cleanup function
        # to know exactly when to remove the SSL associated files
        pending = len(tokens)
        clojure = dict(pending=pending)

        # creates the cleanup function that will be called for
        # the close operation of the APN protocol, this function
        # will remove the temporary path when called as a response
        # to the "stop" of the last protocol
        def cleanup(protocol):
            # schedules the stop of the current event loop as the
            # APN operation has been completed with success
            netius.compat_loop(loop).stop()

            # retrieve the number of pending APN messages from the
            # clojure and verifies if the end has been reached
            pending = clojure["pending"]
            pending -= 1
            clojure["pending"] = pending
            if not pending == 0:
                return

            # removes the temporary keys and certificated files as
            # they are no longer required for APN usage
            self.logger.debug("Removing temporary APN keys")
            shutil.rmtree(path, ignore_errors=True)

        # iterates over the complete set of tokens to be notified and notifies
        # them using the current APN client infra-structure
        for token in tokens:
            # in case the current token is present in the current
            # map of invalid items must skip iteration as the message
            # has probably already been sent "to the token"
            if token in invalid:
                continue

            # prints a debug message about the APN message that
            # is going to be sent (includes token)
            self.logger.debug("Sending APN message to '%s'" % token)

            # creates the new APN protocol to be used and uses it to
            # send the new message (should be correctly serialized)
            loop, protocol = netius.clients.APNClient.notify_s(
                token,
                message=message,
                sandbox=sandbox,
                key_file=key_path,
                cer_file=cer_path,
            )
            protocol.bind("finish", cleanup)
            loop.run_forever()

            # adds the current token to the list of invalid item for
            # the current message sending stream
            invalid[token] = True

    def load(self):
        subs = pushi.APN.find()
        for sub in subs:
            app_id = sub.app_id
            token = sub.token
            event = sub.event
            self.add(app_id, token, event)

    def add(self, app_id, token, event):
        events = self.subs.get(app_id, {})
        tokens = events.get(event, [])
        tokens.append(token)
        events[event] = tokens
        self.subs[app_id] = events

    def remove(self, app_id, token, event):
        events = self.subs.get(app_id, {})
        tokens = events.get(event, [])
        if token in tokens:
            tokens.remove(token)

    def subscriptions(self, token=None, event=None):
        filter = dict()
        if token:
            filter["token"] = token
        if event:
            filter["event"] = event
        subscriptions = pushi.APN.find(map=True, **filter)
        return dict(subscriptions=subscriptions)

    def subscribe(self, apn, auth=None, unsubscribe=True):
        self.logger.debug("Subscribing '%s' for '%s'" % (apn.token, apn.event))

        is_private = (
            apn.event.startswith("private-")
            or apn.event.startswith("presence-")
            or apn.event.startswith("peer-")
            or apn.event.startswith("personal-")
        )

        is_private and self.owner.verify(apn.app_key, apn.token, apn.event, auth)
        unsubscribe and self.unsubscribe(apn.token, force=False)

        exists = pushi.APN.exists(token=apn.token, event=apn.event)
        if exists:
            apn = exists
        else:
            apn.save()

        self.logger.debug("Subscribed '%s' for '%s'" % (apn.token, apn.event))

        return apn

    def unsubscribe(self, token, event=None, force=True):
        self.logger.debug("Unsubscribing '%s' from '%s'" % (token, event or "*"))

        kwargs = dict(token=token, raise_e=force)
        if event:
            kwargs["event"] = event

        apn = pushi.APN.get(**kwargs)
        if not apn:
            return None

        apn.delete()

        self.logger.debug("Unsubscribed '%s' for '%s'" % (token, event or "*"))

        return apn

    def unsubscribes(self, token, event=None):
        kwargs = dict(token=token)
        if event:
            kwargs["event"] = event

        apns = pushi.APN.find(**kwargs)
        for apn in apns:
            apn.delete()

        return apns
