##
# Copyright (c) 2011-2017 Apple Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
##

import json
import struct
import time
from calendarserver.push.applepush import (
    ApplePushNotifierService, APNProviderProtocol, ApplePushPriority
)
from calendarserver.push.ipush import PushPriority
from calendarserver.push.util import validToken, TokenHistory
from twistedcaldav.test.util import StoreTestCase
from twisted.internet.defer import inlineCallbacks, succeed
from twisted.internet.task import Clock
from txdav.common.icommondatastore import InvalidSubscriptionValues
from twistedcaldav.config import ConfigDict


class ApplePushNotifierServiceTests(StoreTestCase):

    @inlineCallbacks
    def test_ApplePushNotifierService(self):

        settings = ConfigDict({
            "Enabled": True,
            "SubscriptionURL": "apn",
            "SubscriptionPurgeSeconds": 24 * 60 * 60,
            "SubscriptionPurgeIntervalSeconds": 24 * 60 * 60,
            "ProviderHost": "gateway.push.apple.com",
            "ProviderPort": 2195,
            "FeedbackHost": "feedback.push.apple.com",
            "FeedbackPort": 2196,
            "FeedbackUpdateSeconds": 300,
            "EnableStaggering": True,
            "StaggerSeconds": 3,
            "CalDAV": {
                "Enabled": True,
                "CertificatePath": "caldav.cer",
                "PrivateKeyPath": "caldav.pem",
                "AuthorityChainPath": "chain.pem",
                "Passphrase": "",
                "KeychainIdentity": "org.calendarserver.test",
                "Topic": "caldav_topic",
            },
            "CardDAV": {
                "Enabled": True,
                "CertificatePath": "carddav.cer",
                "PrivateKeyPath": "carddav.pem",
                "AuthorityChainPath": "chain.pem",
                "Passphrase": "",
                "KeychainIdentity": "org.calendarserver.test",
                "Topic": "carddav_topic",
            },
        })

        # Add subscriptions
        txn = self._sqlCalendarStore.newTransaction()

        # Ensure empty values don't get through
        try:
            yield txn.addAPNSubscription("", "", "", "", "", "")
        except InvalidSubscriptionValues:
            pass
        try:
            yield txn.addAPNSubscription("", "1", "2", "3", "", "")
        except InvalidSubscriptionValues:
            pass

        token = "2d0d55cd7f98bcb81c6e24abcdc35168254c7846a43e2828b1ba5a8f82e219df"
        token2 = "3d0d55cd7f98bcb81c6e24abcdc35168254c7846a43e2828b1ba5a8f82e219df"
        key1 = "/CalDAV/calendars.example.com/user01/calendar/"
        timestamp1 = 1000
        uid = "D2256BCC-48E2-42D1-BD89-CBA1E4CCDFFB"
        userAgent = "test agent"
        ipAddr = "127.0.0.1"
        yield txn.addAPNSubscription(token, key1, timestamp1, uid, userAgent, ipAddr)
        yield txn.addAPNSubscription(token2, key1, timestamp1, uid, userAgent, ipAddr)

        key2 = "/CalDAV/calendars.example.com/user02/calendar/"
        timestamp2 = 3000
        yield txn.addAPNSubscription(token, key2, timestamp2, uid, userAgent, ipAddr)

        subscriptions = (yield txn.apnSubscriptionsBySubscriber(uid))
        subscriptions = [[record.token, record.resourceKey, record.modified, record.userAgent, record.ipAddr] for record in subscriptions]
        self.assertTrue([token, key1, timestamp1, userAgent, ipAddr] in subscriptions)
        self.assertTrue([token, key2, timestamp2, userAgent, ipAddr] in subscriptions)
        self.assertTrue([token2, key1, timestamp1, userAgent, ipAddr] in subscriptions)

        # Verify an update to a subscription with a different uid takes on
        # the new uid
        timestamp3 = 5000
        uid2 = "D8FFB335-9D36-4CE8-A3B9-D1859E38C0DA"
        yield txn.addAPNSubscription(token, key2, timestamp3, uid2, userAgent, ipAddr)
        subscriptions = (yield txn.apnSubscriptionsBySubscriber(uid))
        subscriptions = [[record.token, record.resourceKey, record.modified, record.userAgent, record.ipAddr] for record in subscriptions]
        self.assertTrue([token, key1, timestamp1, userAgent, ipAddr] in subscriptions)
        self.assertFalse([token, key2, timestamp3, userAgent, ipAddr] in subscriptions)
        subscriptions = (yield txn.apnSubscriptionsBySubscriber(uid2))
        subscriptions = [[record.token, record.resourceKey, record.modified, record.userAgent, record.ipAddr] for record in subscriptions]
        self.assertTrue([token, key2, timestamp3, userAgent, ipAddr] in subscriptions)
        # Change it back
        yield txn.addAPNSubscription(token, key2, timestamp2, uid, userAgent, ipAddr)

        yield txn.commit()

        # Set up the service (note since Clock has no 'callWhenRunning' we add our own)
        def callWhenRunning(callable, *args):
            callable(*args)
        clock = Clock()
        clock.callWhenRunning = callWhenRunning

        service = (yield ApplePushNotifierService.makeService(
            settings,
            self._sqlCalendarStore, testConnectorClass=TestConnector, reactor=clock))
        self.assertEquals(set(service.providers.keys()), set(["CalDAV", "CardDAV"]))
        self.assertEquals(set(service.feedbacks.keys()), set(["CalDAV", "CardDAV"]))

        # First, enqueue a notification while we have no connection, in this
        # case by doing it prior to startService()

        # Notification arrives from calendar server
        dataChangedTimestamp = 1354815999
        txn = self._sqlCalendarStore.newTransaction()
        yield service.enqueue(
            txn, "/CalDAV/calendars.example.com/user01/calendar/",
            dataChangedTimestamp=dataChangedTimestamp, priority=PushPriority.high)
        yield txn.commit()

        # The notifications should be in the queue
        self.assertTrue(
            ((token, key1), dataChangedTimestamp, PushPriority.high)
            in service.providers["CalDAV"].queue)
        self.assertTrue(
            ((token2, key1), dataChangedTimestamp, PushPriority.high)
            in service.providers["CalDAV"].queue)

        # Start the service, making the connection which should service the
        # queue
        service.startService()

        # The queue should be empty
        self.assertEquals(service.providers["CalDAV"].queue, [])

        # Verify data sent to APN
        providerConnector = service.providers["CalDAV"].testConnector
        rawData = providerConnector.transport.data
        self.assertEquals(len(rawData), 199)
        data = struct.unpack("!BI", rawData[:5])
        self.assertEquals(data[0], 2)  # command
        self.assertEquals(data[1], 194)  # frame length
        # Item 1 (device token)
        data = struct.unpack("!BH32s", rawData[5:40])
        self.assertEquals(data[0], 1)
        self.assertEquals(data[1], 32)
        self.assertEquals(data[2].encode("hex"), token.replace(" ", ""))  # token
        # Item 2 (payload)
        data = struct.unpack("!BH", rawData[40:43])
        self.assertEquals(data[0], 2)
        payloadLength = data[1]
        self.assertEquals(payloadLength, 138)
        payload = struct.unpack("!%ds" % (payloadLength,), rawData[43:181])
        payload = json.loads(payload[0])
        self.assertEquals(payload["key"], u"/CalDAV/calendars.example.com/user01/calendar/")
        self.assertEquals(payload["dataChangedTimestamp"], dataChangedTimestamp)
        self.assertTrue("pushRequestSubmittedTimestamp" in payload)
        # Item 3 (notification id)
        data = struct.unpack("!BHI", rawData[181:188])
        self.assertEquals(data[0], 3)
        self.assertEquals(data[1], 4)
        self.assertEquals(data[2], 2)
        # Item 4 (expiration)
        data = struct.unpack("!BHI", rawData[188:195])
        self.assertEquals(data[0], 4)
        self.assertEquals(data[1], 4)
        # Item 5 (priority)
        data = struct.unpack("!BHB", rawData[195:199])
        self.assertEquals(data[0], 5)
        self.assertEquals(data[1], 1)
        self.assertEquals(data[2], ApplePushPriority.high.value)

        # Verify token history is updated
        self.assertTrue(token in [t for (_ignore_i, t) in providerConnector.service.protocol.history.history])
        self.assertTrue(token2 in [t for (_ignore_i, t) in providerConnector.service.protocol.history.history])

        #
        # Verify staggering behavior
        #

        # Reset sent data
        providerConnector.transport.data = None
        # Send notification while service is connected
        txn = self._sqlCalendarStore.newTransaction()
        yield service.enqueue(
            txn, "/CalDAV/calendars.example.com/user01/calendar/",
            priority=PushPriority.low)
        yield txn.commit()
        clock.advance(1)  # so that first push is sent
        self.assertEquals(len(providerConnector.transport.data), 199)
        # Ensure that the priority is "low"
        data = struct.unpack("!BHB", providerConnector.transport.data[195:199])
        self.assertEquals(data[0], 5)
        self.assertEquals(data[1], 1)
        self.assertEquals(data[2], ApplePushPriority.low.value)

        # Reset sent data
        providerConnector.transport.data = None
        clock.advance(3)  # so that second push is sent
        self.assertEquals(len(providerConnector.transport.data), 199)

        history = []

        def errorTestFunction(status, identifier):
            history.append((status, identifier))
            return succeed(None)

        # Simulate an error
        errorData = struct.pack("!BBI", APNProviderProtocol.COMMAND_ERROR, 1, 2)
        yield providerConnector.receiveData(errorData, fn=errorTestFunction)
        clock.advance(301)

        # Simulate multiple errors and dataReceived called
        # with amounts of data not fitting message boundaries
        # Send 1st 4 bytes
        history = []
        errorData = struct.pack(
            "!BBIBBI",
            APNProviderProtocol.COMMAND_ERROR, 3, 4,
            APNProviderProtocol.COMMAND_ERROR, 5, 6,
        )
        yield providerConnector.receiveData(errorData[:4], fn=errorTestFunction)
        # Send remaining bytes
        yield providerConnector.receiveData(errorData[4:], fn=errorTestFunction)
        self.assertEquals(history, [(3, 4), (5, 6)])
        # Buffer is empty
        self.assertEquals(len(providerConnector.service.protocol.buffer), 0)

        # Sending 7 bytes
        yield providerConnector.receiveData("!" * 7, fn=errorTestFunction)
        # Buffer has 1 byte remaining
        self.assertEquals(len(providerConnector.service.protocol.buffer), 1)

        # Prior to feedback, there are 2 subscriptions
        txn = self._sqlCalendarStore.newTransaction()
        subscriptions = (yield txn.apnSubscriptionsByToken(token))
        yield txn.commit()
        self.assertEquals(len(subscriptions), 2)

        # Simulate feedback with a single token
        feedbackConnector = service.feedbacks["CalDAV"].testConnector
        timestamp = 2000
        binaryToken = token.decode("hex")
        feedbackData = struct.pack(
            "!IH32s", timestamp, len(binaryToken),
            binaryToken)
        yield feedbackConnector.receiveData(feedbackData)

        # Simulate feedback with multiple tokens, and dataReceived called
        # with amounts of data not fitting message boundaries
        history = []

        def feedbackTestFunction(timestamp, token):
            history.append((timestamp, token))
            return succeed(None)
        timestamp = 2000
        binaryToken = token.decode("hex")
        feedbackData = struct.pack(
            "!IH32sIH32s",
            timestamp, len(binaryToken), binaryToken,
            timestamp, len(binaryToken), binaryToken,
        )
        # Send 1st 10 bytes
        yield feedbackConnector.receiveData(feedbackData[:10], fn=feedbackTestFunction)
        # Send remaining bytes
        yield feedbackConnector.receiveData(feedbackData[10:], fn=feedbackTestFunction)
        self.assertEquals(history, [(timestamp, token), (timestamp, token)])
        # Buffer is empty
        self.assertEquals(len(feedbackConnector.service.protocol.buffer), 0)

        # Sending 39 bytes
        yield feedbackConnector.receiveData("!" * 39, fn=feedbackTestFunction)
        # Buffer has 1 byte remaining
        self.assertEquals(len(feedbackConnector.service.protocol.buffer), 1)

        # The second subscription should now be gone
        txn = self._sqlCalendarStore.newTransaction()
        subscriptions = (yield txn.apnSubscriptionsByToken(token))
        yield txn.commit()
        self.assertEquals(len(subscriptions), 1)
        self.assertEqual(subscriptions[0].resourceKey, "/CalDAV/calendars.example.com/user02/calendar/")
        self.assertEqual(subscriptions[0].modified, 3000)
        self.assertEqual(subscriptions[0].subscriberGUID, "D2256BCC-48E2-42D1-BD89-CBA1E4CCDFFB")

        # Verify processError removes associated subscriptions and history
        # First find the id corresponding to token2
        for (id, t) in providerConnector.service.protocol.history.history:
            if t == token2:
                break

        yield providerConnector.service.protocol.processError(8, id)
        # The token for this identifier is gone
        self.assertTrue((id, token2) not in providerConnector.service.protocol.history.history)

        # All subscriptions for this token should now be gone
        txn = self._sqlCalendarStore.newTransaction()
        subscriptions = (yield txn.apnSubscriptionsByToken(token2))
        yield txn.commit()
        self.assertEquals(subscriptions, [])

        #
        # Verify purgeOldAPNSubscriptions
        #

        # Create two subscriptions, one old and one new
        txn = self._sqlCalendarStore.newTransaction()
        now = int(time.time())
        yield txn.addAPNSubscription(token2, key1, now - 2 * 24 * 60 * 60, uid, userAgent, ipAddr)  # old
        yield txn.addAPNSubscription(token2, key2, now, uid, userAgent, ipAddr)  # recent
        yield txn.commit()

        # Purge old subscriptions
        txn = self._sqlCalendarStore.newTransaction()
        yield txn.purgeOldAPNSubscriptions(now - 60 * 60)
        yield txn.commit()

        # Check that only the recent subscription remains
        txn = self._sqlCalendarStore.newTransaction()
        subscriptions = (yield txn.apnSubscriptionsByToken(token2))
        yield txn.commit()
        self.assertEquals(len(subscriptions), 1)
        self.assertEquals(subscriptions[0].resourceKey, key2)

        service.stopService()

    def test_validToken(self):
        self.assertTrue(validToken("2d0d55cd7f98bcb81c6e24abcdc35168254c7846a43e2828b1ba5a8f82e219df"))
        self.assertFalse(validToken("d0d55cd7f98bcb81c6e24abcdc35168254c7846a43e2828b1ba5a8f82e219df"))
        self.assertFalse(validToken("foo"))
        self.assertFalse(validToken(""))

    def test_TokenHistory(self):
        history = TokenHistory(maxSize=5)

        # Ensure returned identifiers increment
        for id, token in enumerate(
            ("one", "two", "three", "four", "five"),
            start=1
        ):
            self.assertEquals(id, history.add(token))
        self.assertEquals(len(history.history), 5)

        # History size never exceeds maxSize
        id = history.add("six")
        self.assertEquals(id, 6)
        self.assertEquals(len(history.history), 5)
        self.assertEquals(
            history.history,
            [(2, "two"), (3, "three"), (4, "four"), (5, "five"), (6, "six")]
        )
        id = history.add("seven")
        self.assertEquals(id, 7)
        self.assertEquals(len(history.history), 5)
        self.assertEquals(
            history.history,
            [(3, "three"), (4, "four"), (5, "five"), (6, "six"), (7, "seven")]
        )

        # Look up non-existent identifier
        token = history.extractIdentifier(9999)
        self.assertEquals(token, None)
        self.assertEquals(
            history.history,
            [(3, "three"), (4, "four"), (5, "five"), (6, "six"), (7, "seven")]
        )

        # Look up oldest identifier in history
        token = history.extractIdentifier(3)
        self.assertEquals(token, "three")
        self.assertEquals(
            history.history,
            [(4, "four"), (5, "five"), (6, "six"), (7, "seven")]
        )

        # Look up latest identifier in history
        token = history.extractIdentifier(7)
        self.assertEquals(token, "seven")
        self.assertEquals(
            history.history,
            [(4, "four"), (5, "five"), (6, "six")]
        )

        # Look up an identifier in the middle
        token = history.extractIdentifier(5)
        self.assertEquals(token, "five")
        self.assertEquals(
            history.history,
            [(4, "four"), (6, "six")]
        )


class TestConnector(object):

    def connect(self, service, factory):
        self.service = service
        service.protocol = factory.buildProtocol(None)
        service.connected = 1
        self.transport = StubTransport()
        service.protocol.makeConnection(self.transport)

    def receiveData(self, data, fn=None):
        return self.service.protocol.dataReceived(data, fn=fn)


class StubTransport(object):

    def __init__(self):
        self.data = None

    def write(self, data):
        self.data = data
