"""
Apple Push Notification Service
Documentation is available on the iOS Developer Library:
https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html
"""

import codecs
import json
import ssl
import struct
import socket
import time
from contextlib import closing
from binascii import unhexlify
from django.core.exceptions import ImproperlyConfigured
from . import NotificationError
from .settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS


class APNSError(NotificationError):
	pass


class APNSServerError(APNSError):
	def __init__(self, status, identifier):
		super(APNSServerError, self).__init__(status, identifier)
		self.status = status
		self.identifier = identifier


class APNSDataOverflow(APNSError):
	pass


def _apns_create_socket(address_tuple):
	certfile = SETTINGS.get("APNS_CERTIFICATE")
	if not certfile:
		raise ImproperlyConfigured(
			'You need to set PUSH_NOTIFICATIONS_SETTINGS["APNS_CERTIFICATE"] to send messages through APNS.'
		)

	try:
		with open(certfile, "r") as f:
			f.read()
	except Exception as e:
		raise ImproperlyConfigured("The APNS certificate file at %r is not readable: %s" % (certfile, e))

	ca_certs = SETTINGS.get("APNS_CA_CERTIFICATES")

	sock = socket.socket()
	sock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLSv1, certfile=certfile, ca_certs=ca_certs)
	sock.connect(address_tuple)

	return sock


def _apns_create_socket_to_push():
	return _apns_create_socket((SETTINGS["APNS_HOST"], SETTINGS["APNS_PORT"]))


def _apns_create_socket_to_feedback():
	return _apns_create_socket((SETTINGS["APNS_FEEDBACK_HOST"], SETTINGS["APNS_FEEDBACK_PORT"]))


def _apns_pack_frame(token_hex, payload, identifier, expiration, priority):
	token = unhexlify(token_hex)
	# |COMMAND|FRAME-LEN|{token}|{payload}|{id:4}|{expiration:4}|{priority:1}
	frame_len = 3 * 5 + len(token) + len(payload) + 4 + 4 + 1  # 5 items, each 3 bytes prefix, then each item length
	frame_fmt = "!BIBH%ssBH%ssBHIBHIBHB" % (len(token), len(payload))
	frame = struct.pack(
		frame_fmt,
		2, frame_len,
		1, len(token), token,
		2, len(payload), payload,
		3, 4, identifier,
		4, 4, expiration,
		5, 1, priority)

	return frame


def _apns_check_errors(sock):
	timeout = SETTINGS["APNS_ERROR_TIMEOUT"]
	if timeout is None:
		return  # assume everything went fine!
	saved_timeout = sock.gettimeout()
	try:
		sock.settimeout(timeout)
		data = sock.recv(6)
		if data:
			command, status, identifier = struct.unpack("!BBI", data)
			# apple protocol says command is always 8. See http://goo.gl/ENUjXg
			assert command == 8, "Command must be 8!"
			if status != 0:
				raise APNSServerError(status, identifier)
	except socket.timeout:  # py3, see http://bugs.python.org/issue10272
		pass
	except ssl.SSLError as e:  # py2
		if "timed out" not in e.message:
			raise
	finally:
		sock.settimeout(saved_timeout)


def _apns_send(token, alert, badge=None, sound=None, category=None, content_available=False,
	action_loc_key=None, loc_key=None, loc_args=[], extra={}, identifier=0,
	expiration=None, priority=10, socket=None):
	data = {}
	aps_data = {}

	if action_loc_key or loc_key or loc_args:
		alert = {"body": alert} if alert else {}
		if action_loc_key:
			alert["action-loc-key"] = action_loc_key
		if loc_key:
			alert["loc-key"] = loc_key
		if loc_args:
			alert["loc-args"] = loc_args

	if alert is not None:
		aps_data["alert"] = alert

	if badge is not None:
		aps_data["badge"] = badge

	if sound is not None:
		aps_data["sound"] = sound

	if category is not None:
		aps_data["category"] = category

	if content_available:
		aps_data["content-available"] = 1

	data["aps"] = aps_data
	data.update(extra)

	# convert to json, avoiding unnecessary whitespace with separators (keys sorted for tests)
	json_data = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")

	max_size = SETTINGS["APNS_MAX_NOTIFICATION_SIZE"]
	if len(json_data) > max_size:
		raise APNSDataOverflow("Notification body cannot exceed %i bytes" % (max_size))

	# if expiration isn't specified use 1 month from now
	expiration_time = expiration if expiration is not None else int(time.time()) + 2592000

	frame = _apns_pack_frame(token, json_data, identifier, expiration_time, priority)

	if socket:
		socket.write(frame)
	else:
		with closing(_apns_create_socket_to_push()) as socket:
			socket.write(frame)
			_apns_check_errors(socket)


def _apns_read_and_unpack(socket, data_format):
	length = struct.calcsize(data_format)
	data = socket.recv(length)
	if data:
		return struct.unpack_from(data_format, data, 0)
	else:
		return None


def _apns_receive_feedback(socket):
	expired_token_list = []

	# read a timestamp (4 bytes) and device token length (2 bytes)
	header_format = '!LH'
	has_data = True
	while has_data:
		try:
			# read the header tuple
			header_data = _apns_read_and_unpack(socket, header_format)
			if header_data is not None:
				timestamp, token_length = header_data
				# Unpack format for a single value of length bytes
				token_format = '%ss' % token_length
				device_token = _apns_read_and_unpack(socket, token_format)
				if device_token is not None:
					# _apns_read_and_unpack returns a tuple, but
					# it's just one item, so get the first.
					expired_token_list.append((timestamp, device_token[0]))
			else:
				has_data = False
		except socket.timeout:  # py3, see http://bugs.python.org/issue10272
			pass
		except ssl.SSLError as e:  # py2
			if "timed out" not in e.message:
				raise

	return expired_token_list


def apns_send_message(registration_id, alert, **kwargs):
	"""
	Sends an APNS notification to a single registration_id.
	This will send the notification as form data.
	If sending multiple notifications, it is more efficient to use
	apns_send_bulk_message()

	Note that if set alert should always be a string. If it is not set,
	it won't be included in the notification. You will need to pass None
	to this for silent notifications.
	"""

	_apns_send(registration_id, alert, **kwargs)


def apns_send_bulk_message(registration_ids, alert, **kwargs):
	"""
	Sends an APNS notification to one or more registration_ids.
	The registration_ids argument needs to be a list.

	Note that if set alert should always be a string. If it is not set,
	it won't be included in the notification. You will need to pass None
	to this for silent notifications.
	"""
	with closing(_apns_create_socket_to_push()) as socket:
		for identifier, registration_id in enumerate(registration_ids):
			_apns_send(registration_id, alert, identifier=identifier, socket=socket, **kwargs)
		_apns_check_errors(socket)


def apns_fetch_inactive_ids():
	"""
	Queries the APNS server for id's that are no longer active since
	the last fetch
	"""
	with closing(_apns_create_socket_to_feedback()) as socket:
		inactive_ids = []
		# Maybe we should have a flag to return the timestamp?
		# It doesn't seem that useful right now, though.
		for tStamp, registration_id in _apns_receive_feedback(socket):
			inactive_ids.append(codecs.encode(registration_id, 'hex_codec'))
		return inactive_ids
