#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
#  king_phisher/ics.py
#
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions are
#  met:
#
#  * Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above
#    copyright notice, this list of conditions and the following disclaimer
#    in the documentation and/or other materials provided with the
#    distribution.
#  * Neither the name of the project nor the names of its
#    contributors may be used to endorse or promote products derived from
#    this software without specific prior written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
#  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
#  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
#  OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
#  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
#  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
#  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
#  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
#  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
#  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#

import collections
import datetime
import os
import re
import uuid

from king_phisher import its
from king_phisher import utilities

import dateutil.tz
import icalendar
import pytz.tzfile
import tzlocal
import smoke_zephyr.utilities

DAY_ABBREVIATIONS = ('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA')
"""The abbreviations of day names for use in :py:class:`icalendar.vRecur` instances."""
SECONDS_IN_ONE_DAY = (24 * 60 * 60)
SECONDS_IN_ONE_HOUR = (60 * 60)

POSIX_VAR_OFFSET = re.compile(
	r'<?(?:[A-Z]{3,5})?'
	r'(?P<offset>([\+\-])?[0-9:]{1,5})'
	r'(<|([A-Z]{3,5})(?P<offset_dst>([\+\-])?([0-9:]{1,5})?))?',
	flags=re.IGNORECASE
)
POSIX_VAR = re.compile(
	POSIX_VAR_OFFSET.pattern +
	r',(?P<start>M\d{1,2}\.[1-5]\.[0-6](/([\+\-])?[0-9:]{1,5})?),'
	r'(?P<end>M\d{1,2}\.[1-5]\.[0-6](/([\+\-])?[0-9:]{1,5})?)',
	flags=re.IGNORECASE
)
POSIX_VAR_DST_RRULE = re.compile(r'M(?P<month>\d{1,2}).(?P<week>[1-5]).(?P<day>[0-6])(/\d{1,2})?', flags=re.IGNORECASE)

zoneinfo_path = os.path.join(os.path.dirname(pytz.tzfile.__file__), 'zoneinfo')
"""The path to the directory which holds the IANA timezone data files."""

if not os.path.isdir(zoneinfo_path) and its.on_linux:
	path = None
	for path in ('/usr/share/zoneinfo', '/usr/local/share/zoneinfo'):
		if os.path.isdir(path):
			zoneinfo_path = path
			break
	del path

TimezoneOffsetDetails = collections.namedtuple(
	'TimezoneOffsetDetails',
	(
		'offset',
		'offset_dst',
		'dst_start',
		'dst_end'
	)
)
"""A named tuple describing the details of a timezone's UTC offset and DST occurrence."""

def get_timedelta_for_offset(offset):
	"""
	Take a POSIX environment variable style offset from UTC and convert it into
	a :py:class:`~datetime.timedelta` instance suitable for use with the
	:py:mod:`icalendar`.

	:param str offset: The offset from UTC such as "-5:00"
	:return: The parsed offset.
	:rtype: :py:class:`datetime.timedelta`
	"""
	sign = '+'
	if offset[0] in ('+', '-'):
		sign = offset[0]
		offset = offset[1:]
	if ':' in offset:
		hours, minutes = offset.split(':')
	else:
		hours, minutes = offset, 0
	hours = int(hours)
	minutes = int(minutes)
	seconds = ((hours * 60 * 60) + (minutes * 60))
	if sign == '-':
		delta = datetime.timedelta(0, seconds)
	else:
		delta = datetime.timedelta(-1, SECONDS_IN_ONE_DAY - seconds)
	return delta

@smoke_zephyr.utilities.Cache(float('inf'))
def get_tz_posix_env_var(tz_name):
	"""
	Get the timezone information in the POSIX TZ environment variable format
	from the IANA timezone data files included in the :py:mod:`pytz` package.

	:param str tz_name: The name of the timezone to get the environment variable
		for such as "America/New_York".
	:return: The TZ environment variable string, if it is specified in the
		timezone data file.
	:rtype: str
	"""
	buffer_size = 2048
	if not os.path.isdir(zoneinfo_path):
		raise ValueError('zoneinfo_path must be a valid directory')
	file_path = os.path.join(zoneinfo_path, *tz_name.split('/'))
	with open(file_path, 'rb') as file_h:
		magic = file_h.read(4)
		if magic != b'TZif':
			raise ValueError('the timezone file header is incorrect')
		version = file_h.read(1)
		if version != b'2':
			return ''
		file_h.seek(max(os.path.getsize(file_path) - buffer_size, 0), os.SEEK_SET)
		data = file_h.read(buffer_size)
	end_pos = -2
	if its.py_v3:
		newline = 0x0a
	else:
		newline = '\n'
	while data[end_pos] != newline:
		end_pos -= 1
	end_pos += 1
	env_var = data[end_pos:-1]
	if its.py_v3:
		env_var = env_var.decode('utf-8')
	return env_var

@smoke_zephyr.utilities.Cache(float('inf'))
def parse_tz_posix_env_var(posix_env_var):
	"""
	Get the details regarding a timezone by parsing the POSIX style TZ
	environment variable.

	:param str posix_env_var: The POSIX style TZ environment variable.
	:return: The parsed TZ environment variable.
	:rtype: :py:class:`.TimezoneOffsetDetails`
	"""
	match = POSIX_VAR_OFFSET.match(posix_env_var)
	if match is None:
		return
	match = match.groupdict()
	offset = get_timedelta_for_offset(match['offset'])
	if match['offset_dst'] is None:
		offset_dst = None
	elif len(match['offset_dst']):
		offset_dst = get_timedelta_for_offset(match['offset_dst'])
	else:
		# default to an hour difference if it's not specified
		offset_dst = offset - datetime.timedelta(0, SECONDS_IN_ONE_HOUR)
	dst_start = None
	dst_end = None
	match = POSIX_VAR.match(posix_env_var)
	if match:
		match = match.groupdict()
		dst_start = match['start']
		dst_end = match['end']

		match = POSIX_VAR_DST_RRULE.match(dst_start)
		details = match.groupdict()
		byday = details['week'] + DAY_ABBREVIATIONS[int(details['day'])]
		dst_start = icalendar.vRecur({'BYMONTH': details['month'], 'FREQ': 'YEARLY', 'INTERVAL': 1, 'BYDAY': byday})

		match = POSIX_VAR_DST_RRULE.match(dst_end)
		details = match.groupdict()
		byday = details['week'] + DAY_ABBREVIATIONS[int(details['day'])]
		dst_end = icalendar.vRecur({'BYMONTH': details['month'], 'FREQ': 'YEARLY', 'INTERVAL': 1, 'BYDAY': byday})
	else:
		# remove the dst offset if not rrule is present on when it's active
		offset_dst = None
	details = TimezoneOffsetDetails(offset, offset_dst, dst_start, dst_end)
	return details

class DurationAllDay(object):
	"""
	A representation of a duration that can be used for an event to indicate
	that it takes place all day.
	"""
	__slots__ = ('days')
	def __init__(self, days=1):
		self.days = max(days, 0)

class Timezone(icalendar.Timezone):
	"""
	An icalendar formatted timezone with all properties populated for the
	specified zone.
	"""
	def __init__(self, tz_name=None):
		"""
		:param str tz_name: The timezone to represent, if not specified it
			defaults to the local timezone.
		"""
		super(Timezone, self).__init__()
		if tz_name is None:
			tz_name = tzlocal.get_localzone().zone
		self.add('tzid', tz_name)

		tz_details = parse_tz_posix_env_var(get_tz_posix_env_var(tz_name))
		timezone_standard = icalendar.TimezoneStandard()
		timezone_standard.add('dtstart', datetime.datetime(1601, 1, 1, 2, 0, tzinfo=dateutil.tz.tzutc()))
		timezone_standard.add('tzoffsetfrom', tz_details.offset + datetime.timedelta(0, SECONDS_IN_ONE_HOUR))
		timezone_standard.add('tzoffsetto', tz_details.offset)

		if tz_details.offset_dst:
			timezone_standard.add('rrule', tz_details.dst_end)
			timezone_daylight = icalendar.TimezoneDaylight()
			timezone_daylight.add('dtstart', datetime.datetime(1601, 1, 1, 2, 0, tzinfo=dateutil.tz.tzutc()))
			timezone_daylight.add('tzoffsetfrom', tz_details.offset)
			timezone_daylight.add('tzoffsetto', tz_details.offset + datetime.timedelta(0, SECONDS_IN_ONE_HOUR))
			timezone_daylight.add('rrule', tz_details.dst_start)
			self.add_component(timezone_daylight)
		self.add_component(timezone_standard)

class Calendar(icalendar.Calendar):
	"""
	An icalendar formatted event for converting to an ICS file and then sending
	in an email.
	"""
	def __init__(self, organizer_email, start, summary, organizer_cn=None, description=None, duration='1h', location=None):
		"""
		:param str organizer_email: The email of the event organizer.
		:param start: The start time for the event.
		:type start: :py:class:`datetime.datetime`
		:param str summary: A short summary of the event.
		:param str organizer_cn: The name of the event organizer.
		:param str description: A more complete description of the event than
			what is provided by the *summary* parameter.
		:param duration: The events scheduled duration.
		:type duration: int, str, :py:class:`~datetime.timedelta`, :py:class:`.DurationAllDay`
		:param str location: The location for the event.
		"""
		utilities.assert_arg_type(start, datetime.datetime, 2)
		super(Calendar, self).__init__()
		if start.tzinfo is None:
			start = start.replace(tzinfo=dateutil.tz.tzlocal())
		start = start.astimezone(dateutil.tz.tzutc())

		for case in utilities.switch(duration, comp=isinstance):
			if case(str):
				duration = smoke_zephyr.utilities.parse_timespan(duration)
				duration = datetime.timedelta(seconds=duration)
				break
			if case(int):
				duration = datetime.timedelta(seconds=duration)
				break
			if case(datetime.timedelta):
				break
			if case(DurationAllDay):
				break
		else:
			raise TypeError('unknown duration type')

		self.add('method', 'REQUEST')
		self.add('prodid', 'Microsoft Exchange Server 2010')
		self.add('version', '2.0')

		self._event = icalendar.Event()
		event = self._event
		self.add_component(event)
		self.add_component(Timezone())

		organizer = icalendar.vCalAddress('MAILTO:' + organizer_email)
		organizer.params['cn'] = icalendar.vText(organizer_cn or organizer_email)
		event['organizer'] = organizer

		event.add('description', description or summary)
		event.add('uid', str(uuid.uuid4()))
		event.add('summary', summary)
		if isinstance(duration, DurationAllDay):
			event.add('dtstart', start.date())
			event.add('dtend', (start + datetime.timedelta(days=duration.days)).date())
		else:
			event.add('dtstart', start)
			event.add('dtend', start + duration)
		event.add('class', 'PUBLIC')
		event.add('priority', 5)
		event.add('dtstamp', datetime.datetime.now(dateutil.tz.tzutc()))
		event.add('transp', 'OPAQUE')
		event.add('status', 'CONFIRMED')
		event.add('sequence', 0)
		if location:
			event.add('location', icalendar.vText(location))

		alarm = icalendar.Alarm()
		alarm.add('description', 'REMINDER')
		alarm.add('trigger;related=start', '-PT1H')
		alarm.add('action', 'DISPLAY')
		event.add_component(alarm)

	def __str__(self):
		return self.to_ical()

	def add_attendee(self, email, cn=None, rsvp=True):
		"""
		Add an attendee to the event. If the event is being sent via an email,
		the recipient should be added as an attendee.

		:param str email: The attendee's email address.
		:param str cn: The attendee's common name.
		:param bool rsvp: Whether or not to request an RSVP response from the
			attendee.
		"""
		attendee = icalendar.vCalAddress('MAILTO:' + email)
		attendee.params['ROLE'] = icalendar.vText('REQ-PARTICIPANT')
		attendee.params['PARTSTAT'] = icalendar.vText('NEEDS-ACTION')
		attendee.params['RSVP'] = icalendar.vText(str(bool(rsvp)).upper())
		attendee.params['CN'] = icalendar.vText(cn or email)
		self._event.add('attendee', attendee)

	def to_ical(self, encoding='utf-8', **kwargs):
		"""
		Convert the calendar object to a string in the iCalendar format.

		:return: The string representation of the data.
		:rtype: str
		"""
		return super(Calendar, self).to_ical(**kwargs).decode('utf-8')