# Description: gozerbot iCal interface with exports
# Author: Wijnand 'tehmaze' Modderman
# Homepage: http://tehmaze.com
# License: BSD

from gozerbot.addon import addon
from gozerbot.commands import cmnds
from gozerbot.datadir import datadir
from gozerbot.generic import geturl2, rlog
from gozerbot.pdod import Pdod
from gozerbot.persistconfig import PersistConfig
from gozerbot.plughelp import plughelp
from gozerbot.users import users
import datetime, os, time, types, re, urllib2, random
from zlib import crc32

__version__ = '20070718'

icalendar = addon.load('icalendar', 'http://codespeak.net/icalendar/iCalendar-1.2.tgz', md5sig='810cb3707605b75e51163e6eb1c1410a')
cfg = PersistConfig()
cfg.define('export', '')
cfg.define('baseurl', '')
cfg.define('seed', str(random.randint(1000,9999)))

plughelp.add('calendar', 'Various (i)calendar functions')

class CalendarException(Exception):
    pass

class Calendar(icalendar.Calendar):

    ICS = None
    # valid time
    RE_TIME = re.compile('(?P<hour>[01]\d|2[0-3]|\d):(?P<min>[0-5]\d)')
    # valid date
    RE_DATE = re.compile('(?P<day>[1-9]|0[1-9]|[12][0-9]|3[01])[-/](?P<month>[1-9]|0[1-9]|1[012])(?:[-/](?P<year>(?:19|20)\d\d|\d\d))?')

    def __init__(self, ICS=None, suffix=''):
        '''
        Create or load a calendar from ``ICS``. Optionally a ``suffix`` may be specified as a
        suffix for the entry uids.
        '''
        icalendar.Calendar.__init__(self)
        self.suffix = suffix
        if ICS:
            self.ICS = ICS
        elif not self.ICS:
            self.ICS = cfg.get('export') or os.path.join(datadir, 'gozerbot.ics')
        if os.path.isfile(self.ICS):
            self.load(self.ICS)
        else:
            rlog(10, 'calendar', 'initialized new calendar')
        try:
            self['prodid']
        except KeyError:
            self.add('prodid', '-//GOZERBOT//gozerbot.org//')
            self.add('version', '2.0')

    def stop(self):
        self.save()

    #
    # Load / Save
    #

    def load(self, filename=None):
        '''
        Load a calendar from disk using ``filename``.
        '''
        if not filename:
            filename = self.ICS
        filedata = open(filename).read()
        component = self.from_string(filedata)
        self.subcomponents = component.subcomponents
        for key in component.keys():
            self.set(key, component[key])
        rlog(10, 'calendar', 'loaded %d events from %s' % (len(component.subcomponents), filename))

    def save(self, filename=None):
        '''
        Save a calendar to disk to ``filename``.
        '''
        if not filename:
            filename = self.ICS
        open(filename, 'wb').write(self.as_string())

    def import_url(self, url, overwrite=False):
        '''
        Import a calendar from ``url``. This will only import non-existing uids, unless
        ``overwrite`` is true.
        '''
        calendar = icalendar.Calendar.from_string(geturl2(url))
        imported = 0
        for component in calendar.subcomponents:
            component.add('readonly', 'yes')
            if not overwrite:
                if not self._event_by_uid(component['UID']):
                    self.add_component(component)
                    imported += 1
            else:
                oldcomponent = self._event_by_uid(component['UID'])
                if oldcomponent:
                    self.subcomponents.remove(oldcomponent)
                self.add_component(component)
                imported += 1
        if imported:
            self.save()
        return imported

    #
    # Date / Time
    #

    def datetime_dict(self, d):
        '''
        Convert a ``datetime`` ``dict`` to a ``datetime.datetime`` object.
        '''
        if not d['year']:
            d['year'] = time.localtime()[0]
        elif d['year'] < 100:
            d['year'] += 2000
        if not d['sec']:
            d['sec'] = 0
        rlog(10, 'cal', 'd %s' % str(d))
        return datetime.datetime(*[int(d[k]) for k in ['year', 'month', 'day', 'hour', 'min', 'sec']] + [0, icalendar.UTC])

    @staticmethod
    def datetime_str(d):
        '''
        Convert a ``datetime.datetime`` object to human-readable form.  
        '''
        return '%02d:%02d %02d-%02d-%04d' % (d.hour, d.minute, d.day, d.month, d.year)

    def parse_to_datetime_dict(self, text, initial=None):
        '''
        Parse a human-readable string to a ``datetime`` dict, an optional ``initial`` datetime ``dict`` may
        be specified.
        '''
        if initial:
            dt = initial.copy()
        else:
            dt = {'hour': 0, 'min': 0, 'sec': 0, 'day': 0, 'month': 0, 'year': 1970}
        test_date = self.RE_DATE.search(text)
        test_time = self.RE_TIME.search(text)
        if test_date:
            dt.update(dict([(k, v and int(v) or 0) for k, v in test_date.groupdict().items()]))
            text = text[:test_date.span()[0]] + text[test_date.span()[1]:] # remove from text
            test_time = self.RE_TIME.search(text) # reparse, we altered the string
        if test_time:
            dt.update(test_time.groupdict())
            text = text[:test_time.span()[0]] + text[test_time.span()[1]:] # remove from text
            if not test_date and dt['year'] == 1970:
                dt['year'], dt['month'], dt['day'] = time.localtime()[:3]
        return dt, text

    #
    # Events
    #

    def _event_uid(self):
        '''
        Allocate a 'free' UID.
        '''
        uids = []
        for component in self.subcomponents:
            try:
                cuid = str(component['UID'])
                if self.suffix and cuid.endswith('@%s' % self.suffix):
                    cuid = cuid.split('@')[0]
                uids.append(cuid)
            except ValueError:
                pass
            except KeyError:
                pass
        uid = 1
        while str(uid) in uids:
            uid += 1
        if self.suffix:
            return '%s@%s' % (uid, self.suffix)
        return uid
    
    def _event_by_uid(self, uid, strict=True):
        '''
        Get an event by ``uid``, if ``strict`` is False, it will only check the left-hand part, splitted
        on '@'.
        '''
        uid = str(uid)
        res = []
        for component in self.subcomponents:
            cuid = ''
            try:
                cuid = str(component['UID'])
            except ValueError:
                pass
            except KeyError:
                pass
            if cuid == uid:
                res.append(component)
            if not strict and cuid.startswith('%s@' % uid):
                res.append(component)
        if not res:
            return None
        if len(res) > 1:
            raise CalendarException('Multiple events found, please refine your uid (include suffix)')
        else:
            return res[0]

    @staticmethod
    def _event_humanize(component):
        '''
        Return a ``tuple`` with more human-friendly values.
        '''
        try:
            attendees = component['attendee']
        except KeyError:
            attendees = []
        if type(attendees) in [types.StringType, types.UnicodeType] or isinstance(attendees, icalendar.vText) or \
            isinstance(attendees, icalendar.vCalAddress):
            attendees = [attendees]
        return (
            component['UID'],
            icalendar.vDatetime.from_ical(component['dtstart'].ical()),
            icalendar.vDatetime.from_ical(component['dtend'].ical()),
            component['summary'].ical().replace('\\,', ','),
            attendees
            )

    @staticmethod
    def _event_start(component):
        try:
            start = icalendar.vDatetime.from_ical(component['dtstart'].ical())
            if not start.tzinfo:
                start = start.replace(tzinfo=icalendar.UTC)
            return start
        except:
            return datetime.datetime(1970, 1, 1)

    @staticmethod
    def _event_sort(a, b):
        dta = icalendar.vDatetime.from_ical(a['dtstart'].ical())
        dtb = icalendar.vDatetime.from_ical(b['dtstart'].ical())
        if not dta.tzinfo:
            dta = dta.replace(tzinfo=icalendar.UTC)
        if not dtb.tzinfo:
            dtb = dtb.replace(tzinfo=icalendar.UTC)
        return cmp(dta, dtb)

    def event_add(self, start, end, summary, prio=5):
        event = icalendar.Event()
        event.add('summary', summary)
        if type(start) in [types.IntType, types.FloatType]:
            start = datetime.datetime(*time.localtime(float(start))[:7])
        if type(end) in [types.IntType, types.FloatType]:
            end = datetime.datetime(*time.localtime(float(end))[:7])
        event.add('dtstart', start)
        event.add('dtend', end)
        event.add('priority', prio)
        event.add('uid', str(self._event_uid()))
        self.add_component(event)
        self.save()
        return event

    def event_add_str(self, text):
        test_date = self.RE_DATE.search(text)
        test_time = self.RE_TIME.search(text)
        if not test_date and not test_time:
            raise CalendarException('No valid date or timestamp found')
        start, text = self.parse_to_datetime_dict(text)
        end, text = self.parse_to_datetime_dict(text, initial=start)
        start = self.datetime_dict(start)
        end = self.datetime_dict(end)
        if end < start:
            raise CalendarException('End date %s is before start date %s' % (self.datetime_str(start), self.datetime_str(end)))
        text = text.strip()
        if not text:
            raise CalendarException('No summary found')
        component = self.event_add(start, end, text)
        return start, end, text, component

    def event_del(self, uid):
        component = self._event_by_uid(uid, False)
        if component:
            try:
                self.subcomponents.remove(component)
                self.save()
            except ValueError:
                return False
            return component
        return False

    def event_attend(self, uid, who):
        component = self._event_by_uid(uid, False)
        if component:
            if component.has_key('READONLY'):
                raise CalendarException('Can not modify readonly event')
            try:
                attendees = component['attendee']
            except KeyError:
                attendees = []
            if type(attendees) in [types.StringType, types.UnicodeType] or isinstance(attendees, icalendar.vText):
                attendees = [attendees]
            if not who in attendees:
                attendee = icalendar.vText(who)
                attendee.params['ROLE'] = icalendar.vText('REQ-PARTICIPANT')
                component.add('attendee', attendee, encode=0)
                return True
        return False

    def event_unattend(self, uid, who):
        component = self._event_by_uid(uid, False)
        if component:
            if component.has_key('READONLY'):
                raise CalendarException('Can not modify readonly event')
            try:
                attendees = component['attendee']
            except KeyError:
                attendees = []
            if type(attendees) in [types.StringType, types.UnicodeType] or isinstance(attendees, icalendar.vText):
                attendees = [attendees]
            try:
                attendees.remove(who)
                component.set('attendee', attendees)
            except ValueError:
                return False
            return True
        return False
        
    def event_list(self):
        now = datetime.datetime.now().replace(tzinfo=icalendar.UTC)
        return [x for x in self.subcomponents if self._event_start(x) >= now]

    def event_show(self, uid):
        component = self._event_by_uid(uid, False)
        return component

class Calendars(Pdod):
    def __init__(self):
        Pdod.__init__(self, os.path.join(datadir, 'calendar'))
        self.cals = {}
        for bot in self.data.keys():
            self.cals[bot] = {}
            for channel in self.data[bot].keys():
                self.cals[bot][channel] = Calendar(self.get(bot, channel), '%s@%s' % (bot, channel))

    def filename(self, bot, target):
        name = '%08x.ics' % abs(crc32(bot+target+cfg.get('seed')))
        if cfg.get('export'):
            return os.path.join(cfg.get('export'), name)
        else:
            return os.path.join(datadir, name)

    def save(self, bot, target):
        Pdod.save(self)
        if not self.data.has_key(bot) or not self.data[bot].has_key(target):
            return None
        return self.cals[bot][target].save()

    def check(self, bot, target):
        if not self.data.has_key(bot):
            self.data[bot] = {}
            self.cals[bot] = {}
        if not self.data[bot].has_key(target):
            self.data[bot][target] = self.filename(bot, target)
            self.cals[bot][target] = Calendar(self.get(bot, target), '%s@%s' % (bot, target))
            self.save(bot, target)

    def import_url(self, bot, target, url):
        self.check(bot, target)
        return self.cals[bot][target].import_url(url)

    def event_add_str(self, bot, target, text):
        self.check(bot, target)
        start, end, text, comp = self.cals[bot][target].event_add_str(text)
        return self.cals[bot][target].datetime_str(start), self.cals[bot][target].datetime_str(end), text, comp

    def event_del(self, bot, target, uid):
        if not self.data.has_key(bot) or not self.data[bot].has_key(target):
            return None
        return self.cals[bot][target].event_del(uid)

    def event_attend(self, bot, target, uid, who):
        if not self.data.has_key(bot) or not self.data[bot].has_key(target):
            return None
        return self.cals[bot][target].event_attend(uid, who)

    def event_unattend(self, bot, target, uid, who):
        if not self.data.has_key(bot) or not self.data[bot].has_key(target):
            return None
        return self.cals[bot][target].event_unattend(uid, who)

    def event_list(self, bot, target):
        if not self.data.has_key(bot) or not self.data[bot].has_key(target):
            return []
        return self.cals[bot][target].event_list()

    def event_show(self, bot, target, uid):
        if not self.data.has_key(bot) or not self.data[bot].has_key(target):
            return None
        return self.cals[bot][target].event_show(uid)

cals = Calendars()

def handle_caladd(bot, ievent):
    try:
        (start, end, text, component) = cals.event_add_str(bot.name, ievent.channel, ievent.rest)
        ievent.reply('added event %s from %s to %s: %s' % (component['UID'], start, end, text))
    except CalendarException, e:
        ievent.reply('could not add event: %s' % str(e))

cmnds.add('cal-add', handle_caladd, 'USER')

def handle_caldel(bot, ievent):
    if not ievent.rest:
        ievent.missing('<event id>')
        return
    uid = ievent.rest
    try:
        component = cals.event_del(bot.name, ievent.channel, uid)
        if component:
            ievent.reply('deleted event %s' % component['uid'])
            del component
        else:
            ievent.reply('deletion failed')
    except CalendarException, e:
        ievent.reply('could not del event: %s' % str(e))

cmnds.add('cal-del', handle_caldel, 'USER')

def handle_calimport(bot, ievent):
    try:
        url = ievent.args[0]
    except IndexError:
        ievent.missing('<url>')
        return
    except urllib2.HTTPError, e:
        ievent.reply('error: %s' % str(e))
        return
    imported = cals.import_url(bot.name, ievent.channel, url)
    if imported:
        ievent.reply('imported %d events from %s' % (imported, url))
    else:
        ievent.reply('nothing imported')

cmnds.add('cal-import', handle_calimport, 'OPER')

def handle_calattend(bot, ievent):
    if not ievent.args:
        ievent.missing('<event id>')
        return
    if cals.event_attend(bot.name, ievent.channel, ievent.args[0], users.getname(ievent.userhost)):
        ievent.reply('ok')
        cals.save(bot.name, ievent.channel)
    else:
        ievent.reply('adding failed') 

cmnds.add('cal-attend', handle_calattend, 'USER')

def handle_calunattend(bot, ievent):
    if not ievent.args:
        ievent.missing('<event id>')
        return
    if cals.event_unattend(bot.name, ievent.channel, ievent.args[0], users.getname(ievent.userhost)):
        ievent.reply('ok')
        cals.save(bot.name, ievent.channel)
    else:
        ievent.reply('removing failed') 

cmnds.add('cal-unattend', handle_calunattend, 'USER')

def handle_callist(bot, ievent):
    events = list(cals.event_list(bot.name, ievent.channel))
    if events:
        events.sort(Calendar._event_sort)
        events = [Calendar._event_humanize(e) for e in events]
        reply = []
        for uid, start, end, text, attendee in events:
            start = Calendar.datetime_str(start).replace('00:00 ', '')
            end = Calendar.datetime_str(end).replace('00:00 ', '')
            if start != end:
                reply.append('(%s) %s - %s: %s' % (str(uid), start, end, text))
            else:
                reply.append('(%s) %s: %s' % (str(uid), start, text)) 
        ievent.reply(reply, dot=True)
    else:
        ievent.reply('no events')

cmnds.add('cal-list', handle_callist, 'USER')

def handle_calshow(bot, ievent):
    if not ievent.rest:
        ievent.missing('<event id>')
        return
    uid = ievent.rest
    event = cals.event_show(bot.name, ievent.channel, uid)
    if event:
        uid, start, end, text, attendees = Calendar._event_humanize(event)
        start = Calendar.datetime_str(start).replace('00:00 ', '')
        end = Calendar.datetime_str(end).replace('00:00 ', '')
        span = ''
        if start != end:
            span = '%s - %s' % (start, end)
        else:
            span = start
        if attendees:
            attendees = ', '.join(attendees)
        else:
            attendees = 'nobody'
        ievent.reply('(%s) %s: %s, attendees: %s' % (str(uid), span, text, attendees))
    else:
        ievent.reply('calendar event not found')

cmnds.add('cal-show', handle_calshow, 'USER')

def handle_calurl(bot, ievent):
    if not cfg.get('baseurl'):
        ievent.reply('no baseurl is configured, use !calendar-cfg baseurl <base>')
    elif not cals.cals.has_key(bot.name) or not cals.cals[bot.name].has_key(ievent.channel):
        ievent.reply('no calendar available for this channel')
    else:
        filename = cals.filename(bot.name, ievent.channel)
        ievent.reply('%s/%s' % (cfg.get('baseurl'), os.path.basename(filename)))

cmnds.add('cal-url', handle_calurl, 'USER')

#def handle_caldebug(bot, ievent):
#    ievent.reply('DATA = %s' % str(cals.data))
#    ievent.reply('BOTS = %s' % str(cals.cals))
#
#cmnds.add('cal-debug', handle_caldebug, 'OPER')
