#!/usr/bin/env python3
# queue2ics - Convert and wrangle queue and .ics documents
# Author: Perette Barella
# Copyright 2018 Devious Fish.  All rights reserved.
# vim: ts=4:sw=4:expandtab
version = "$Id: queue2ics.py 57 2020-09-27 15:55:25Z perette $"

import calendar, datetime, pytz, time
import argparse, re, io, os, sys
import html, urllib
import tzlocal
from arrow import arrow
from dateutil import tz
from collections import namedtuple
from urllib.request import urlopen

import ics

ONE_DAY = datetime.timedelta (days=1)

global debug



# Given dictionaries of user options and default options, flesh
# out the user's options with any missing values.
# Also sanity check user provided types by validating against defaults.
# option_defaults may be:
#   A value -- the default; user-supplied values must match type.
#   NoneType -- the default; user-supplied values may be any type.
#   An empty list -- the default; user-supplied values must be a list.
#   A non-empty list -- the first value is the default.  Additional items
#       indicate alternate item values or types.  User-supplied value must
#       match type of first item in list, exact value of subsequent items
#       (if values) or type of subsequent items (if types).
# @return The fleshed out user defaults.
def complete_options (user_options, option_defaults):
    assert (user_options is None or isinstance (user_options, dict))
    assert (isinstance (option_defaults, dict))

    # If user didn't provide options, use the defaults wholesale
    if user_options is None:
        user_options = option_defaults

    # Merge missing values and move into an object for easier access
    defaults = namedtuple('options', option_defaults.keys())
    for value in option_defaults:
        assert value.isidentifier ()
        default = option_defaults [value]
        if value not in user_options or user_options [value] is None:
            # Value not in hash, use default provided
            if isinstance (default, list) and len (default) != 0:
                setattr (defaults, value, default [0])
            else:
                setattr (defaults, value, default)
        elif isinstance (default, list) and len (default) != 0:
            if type (default [0]) != type (option_defaults [value]):
                allowed = False
                for alternate in default:
                    if isinstance (alternate, type):
                        if isinstance (user_options [value], alternate):
                            allowed = True
                    elif alternate == user_options [value]:
                        allowed = True
                if not allowed:
                    print ("Invalid value '{value}' for {name}".format (
                        value=user_options [value], name=value), file=sys.stderr)
                    assert (allowed)
            setattr (defaults, value, user_options [value])
        elif default is not None:
            assert (type (default) == type (option_defaults [value]))
            setattr (defaults, value, user_options [value])
    return defaults




# Read a details mappings file
# @param source_file The filename of the source file
# @return A tuple containing:
# - A list of strings to match, in order they should be tested.
# - A dictionary of those same strings to their details.
# Details are themselves a dictionary containing:
# - url - a default URL for matching events
# - location - a default location for matching events
def read_details_mappings (source_file):
    pattern_to_url = re.compile ("(.*)=>\\s*(.*)\\s*$")
    pattern_to_location = re.compile ("(.*)=@\\s*(.*)\\s*$")
    is_blank = re.compile ("^\\s*$")
    new_pattern = True

    global debug

    search_order = [ ]
    detail_map = { }
    with open (source_file, encoding='utf-8') as source:
        line_count=0
        pattern = None
        patterns = None
        for line in source:
            line_count += 1
            line = line.strip()
            if line == "":
                continue

            if debug:
                print ("{line_num}: {text}".format (line_num=line_count, text=line),
                       file=sys.stderr)

            line_pattern = None
            target = None
            if "=>" in line:
                line_pattern = pattern_to_url
                target = "url"
            elif "=@" in line:
                line_pattern = pattern_to_location
                target = "location"

            info = None
            if line_pattern != None:
                match = line_pattern.match (line)
                assert (match)
                pattern = match.group (1).strip()
                info = match.group (2)
            else:
                pattern = line
            if debug:
                print ("Pattern: '{pattern}', info: '{info}'".format (
                        pattern=pattern, info=info if info else "<None>"), file=sys.stderr)

            if pattern != "":
                if new_pattern:
                    patterns = []
                    details = { 'url': None, 'location': None }
                    new_pattern = False
                patterns.append (pattern)

            if info != None:
                details [target] = info
                if patterns == None:
                    print ("{filename}:{linenum}: {target} target has no patterns.".format (
                           filename = source_file,
                           linenum = line_count, target = target),
                           file=sys.stderr)
                    patterns = []

                if not new_pattern:
                    for pattern in patterns:
                        if pattern in detail_map:
                            print ("{filename}:{linenum}: Pattern {pattern} redefined.".format (
                                   filename = source_file,
                                   linenum = line_count, pattern=pattern),
                                   file=sys.stderr)
                        else:
                            search_order.append (pattern)
                            detail_map [pattern] = details
                        new_pattern = True
    return (search_order, detail_map)



# Read all events from a queue-format file.
# @param document An iterable of strings containing queue entries.
# @param options A dictionary controlling read behaviors.
# @param title A title (usually filename) to be used when outputting error messages.
def load_queue_file (document, options = None, title = "Untitled document"):
    split_date = re.compile ("(\\d\\d\\d\\d-[01]\\d-[0-3]\\d) (.*)$")
    split_time = re.compile ("([0-2]\\d):([0-5]\\d) (.*)$")
    end_datetime = re.compile ("- (\\d*)[-:]([0-5]\\d)")
    extract_url = re.compile ("\\b(https?:\\/\\/\\S*)\\b")
    extract_completion = re.compile ("\\s(\\[[Xx _]\\])( .*)?$")
    conjunction = re.compile ("(.*?\\S)\\s*([-@.])\\s+(\\S.*)$")

    # Extract time from a line
    def extract_datetime (line, date_hint = None):
        assert (isinstance (line, str))

        # Extract the date
        splits = split_date.match (line)
        if splits == None and date_hint == None:
            return None
        elif splits:
            line = splits.group (2)
            (year, month, day) = tuple (splits.group (1).split("-"))
            specified_date = True
        else:
            year = date_hint.year;
            month = date_hint.month;
            day = date_hint.day;
            specified_date = False

        # Extract the time
        pieces = split_time.match (line)
        if pieces:
            allday = False
            (hour, minute, line) = pieces.group (1, 2, 3)
            when = options.timezone.localize (datetime.datetime (int (year),
                                              int (month), int (day),
                                              int (hour), int (minute)))
            if not specified_date and when < date_hint:
                when += ONE_DAY
        elif not specified_date:
            return None
        else:
            allday = True
            when = options.timezone.localize (datetime.datetime (int (year),
                                              int (month), int (day)))
        return [line, when, allday]
            
    assert (iter (document))

    zone = tzlocal.get_localzone()
    options = complete_options (options, {
        'categories': [None, list, set, True],
                                           # List of categories, True, or None
        'default_category': [None, str],   # Default category if above don't match.
        'completed_category': [None, str], # Category for completed tasks
        'remove_category': True,           # Remove category from title
        'duration': 120,                   # Event duration, in minutes.
        'alarm': [None, int],              # How long to set alarm before events.
        'detail_search_order': [ ],        # Order in which to match for URL guessing
        'detail_map': { },                 # Match -> URL for URL guessing
        'verbose': False,                  # If true, prints event details as processed.
        'timezone': zone                   # Import timezone
    })
    assert ((options.detail_search_order == None) == (options.detail_map == None))
    # Default is meaningless if we're always taking category from queue entry.
    assert (categories != True or default_category == None)

    if options.categories != None:
        category_set = set() if options.categories == True else set (options.categories)
    else:
        category_set = set()

    # Convert search patterns to lowercase, validate mapping exists
    search_list = {}
    for item in options.detail_search_order:
        assert item in options.detail_map
        search_list [item] = re.compile ("\\b" + item + "\\b", re.IGNORECASE)

    # Process document contents
    cal = [ ]
    line_count = 0
    for line in document:
        line_count += 1
        line = line.strip()
        if line == "":
            continue

        # Remove completion checkbox if present
        completed = False
        splits = extract_completion.search (line)
        if splits:
            completed = splits.group (1)[1:2] in ['x', 'X']
            line = line [:splits.start (1)] + line [splits.end(1):];
            
        # Extract the start time
        start_details = extract_datetime (line)
        if start_details == None:
            print ("{title}:{line}: Invalid start date or time".format (
                title = title, line = line_count), file=sys.stderr)
            continue
        [rest, start_time, allday] = start_details
            
        # Extract or determine the end time
        if end_datetime.match (rest):
            end_details = extract_datetime (rest [2:], start_time)
            if end_details == None:
                print ("{title}:{line}: Invalid end date or time".format (
                    title = title, line = line_count), file=sys.stderr)
                continue
            [rest, end_time, end_allday] = end_details
            if allday != end_allday:
                print ("{title}:{line}: Mixed all-day and start/end times".format (
                    title = title, line = line_count), file=sys.stderr)
                continue
            if end_time < start_time:
                print ("{title}:{line}: End precedes start".format (
                    title = title, line = line_count), file=sys.stderr)
                continue
        elif allday:
            end_time = start_time
        else:
            end_time = start_time + datetime.timedelta (minutes = options.duration)
                
        # Determine a default URL and location
        url=None
        default_location = None
        for item in options.detail_search_order:
            if search_list [item].search (rest):
                url = options.detail_map [item]['url']
                default_location = options.detail_map [item]['location']
                break

        # Extract and remove a URL, if present.  If not, make one up.
        splits = extract_url.search (rest)
        if splits:
            url = splits.group (1)
            rest = rest [:splits.start (1)] + rest [splits.end(1):];
                    
        # Extract the category name.  If categorizing, remove it from text.
        splits = rest.split ()
        category = splits [0]
        
        # If all categories are labels, or it matches a category label, strip it.
        strip_category = options.remove_category
        if category in category_set:
            # It's a recognized category, use it.
            if debug:
                print ("Using known category", category, file=sys.stderr)
            pass
        elif options.categories == True:
            # It's not recognized and there's no default, so use whatever was found.
            if debug:
                print ("Using found category", category, file=sys.stderr)
            pass
        elif options.default_category:
            # It's not a recognized category, so use the provided default.
            if debug:
                print ("Using default category", options.default_category, file=sys.stderr)
            strip_category = False
            category = options.default_category
        else:
            # Categories are turned off.
            if debug:
                print ("Categories not in use", file=sys.stderr)
            category = None
            strip_category = False

        if strip_category:
            rest = " ".join (splits [1:])

        conj = ""
        location = ""
        description = ""
        while rest != '':
            splits = conjunction.match (rest)
            if (splits):
                (piece, next_conj, rest) = splits.group (1, 2, 3)
            else:
                piece = rest
                next_conj = ""
                rest = ""
            piece = piece.strip()
            while piece != "" and piece [-1] in " .-,":
                piece = piece [:-1]
            if (conj == ""):
                summary = piece
            elif (conj == "@"):
                location += ("" if location == "" else ". ") + piece
            else:
                description += ("" if description == "" else ". ") + piece
            conj = next_conj

        if location == "" and default_location != None:
            location = default_location

        if (options.verbose):
            print ("Summary: {summary}\nDescription: {description}\nLocation: {location}\nURL: {url}\nCategory: {cat}\nCompleted: {done}\n"
                   .format (summary=summary, description=description,
                            location=location, cat=category,
                            url=url, done=completed))

        alarm = None
        if options.alarm:
            alarm = ics.alarm.DisplayAlarm ("Event alarm", trigger=datetime.timedelta (minutes = options.alarm))

        event_categories = set()
        if category != None:
            event_categories.add (category)
        if completed and options.completed_category != None:
            event_categories.add (options.completed_category)

        event = ics.Event (name = summary,
                           begin = start_time,
                           end = end_time,
                           description = description,
                           location = location,
                           url = url,
                           alarms = None if alarm is None else {alarm},
                           categories = event_categories)
        if allday:
            event.make_all_day()
            event.end = end_time + ONE_DAY

        cal.append (event)

    return ics.Calendar (events = cal)


# Validate a time zone's name.  Return zone if valid, otherwise generate error.
def get_timezone (name):
    if name == None:
        return None

    assert isinstance (name, str)
    try:
        return pytz.timezone (name)
    except KeyError:
        sys.exit (name + ": time zone unknown")


def write_queue_file (target, calendar, options):
    zone = tzlocal.get_localzone()
    options = complete_options (options, {
        'timezone': zone                   # Output timezone
    })
    convert_calendar_to_time_zone (calendar, options.timezone)
    assert (isinstance (target, io.IOBase))
    for event in sorted (calendar.events, key=lambda ev: (ev.begin, ev.name.lower())):
        line = event.begin.strftime ('%Y-%m-%d ')
        if not event.all_day:
            line += event.begin.strftime ('%H:%M ')
        if event.has_end():
            if event.all_day and event.duration > ONE_DAY:
                line += (event.end - ONE_DAY).strftime ('- %Y-%m-%d ')
            elif not event.all_day:
                line += event.end.strftime ('- %H:%M ' if event.duration < ONE_DAY else '- %Y-%m-%d %H:%M ')
        line += event.name.strip() + ' ';
        if event.description != None and event.description.strip() != '':
            line += '- ' + event.description.strip() + ' ';
        if event.location != None and event.location.strip() != '':
            line += '@ ' + event.location.strip() + ' ';
        if event.url != None and event.url.strip() != '':
            line += event.url.strip()
        else:
            line = line [:-1]
        print (line, file=target)



def friendly_date (date, weekday = False):
    return date.strftime ("%A, %d %B, %Y" if weekday else "%d %B, %Y")

def first_of_this_month (date):
    assert (type (date) in [datetime.date, datetime.datetime, arrow.Arrow])
    return datetime.date (date.year, date.month, 1)

def first_of_nth_month (date, months):
    assert (type (date) in [datetime.date, datetime.datetime, arrow.Arrow])
    nmonth = date.month + months - 1
    return datetime.date (date.year + (nmonth // 12), nmonth % 12 + 1, 1)

def first_of_next_month (date):
    assert (type (date) in [datetime.date, datetime.datetime, arrow.Arrow])
    return first_of_nth_month (date, 1)

def compare_dates_ignoring_times (a, b):
    if a.year == b.year:
        if a.month == b.month:
            if a.day == b.day:
                return 0
            return -1 if a.day < b.day else 1
        return -2 if a.month < b.month else 2
    return -3 if a.year < b.year else 3

# Check if the event falls within the start and end dates.
def event_falls_within (event, start, end):
    global debug
    return (compare_dates_ignoring_times (start, event.begin) <= 0 and
            compare_dates_ignoring_times (event.begin, end) < 0)

# Adjust events en masse to target time zone.
def convert_calendar_to_time_zone (calendar, timezone):
    for ev in calendar.events:
        if ev.all_day:
            end_time = timezone.localize (datetime.datetime (ev.end.year,
                                          ev.end.month, ev.end.day))
            ev.end = None
            ev.begin = timezone.localize (datetime.datetime (ev.begin.year,
                                          ev.begin.month, ev.begin.day))
            ev.make_all_day()
            ev.end = end_time
        else:
            ev.begin = ev.begin.astimezone (timezone)
            ev.end = ev.end.astimezone (timezone)
    return calendar

# Determine the start and end dates to process.
# @param events An in-order list of events.
# @param start_date User-provided start date.
# @param end_date User-provided end date.
# @param repetitions User-provided number of {time units} to process.
# @param units Time units for repetitions, and used for rounding.
# @param trim If true and start_date is "none", skips empty periods of
#  time at start of calendar when:
# - time has already passed
# - time has no events listed in duration
def determine_range (events, start_date = None, end_date = None, repetitions = None, *,
                     units = None, trim = False):
    # Determine the start time if not given
    if start_date is None:
        if len (events) == 0:
            start_date = datetime.date.today();
        else:
            start_date = events [0].begin

    # Determine the end time if not given
    if end_date is None:
        if repetitions:
            if units == 'month':
                end_date = first_of_nth_month (start_date, repetitions)
            else:
                end_date = start_date + datetime.timedelta (days = repetitions)
        elif len (events) == 0:
            end_date = start_date
        else:
            end_date = events [-1].begin + ONE_DAY

    # Adjust to start of month/week, end of month (=start of next month):
    if units == 'month':
        if trim and start_date.day > start_date.isoweekday() :
            start_date = datetime.date (start_date.year, start_date.month, 
                                         start_date.day - (start_date.isoweekday() % 7))
        else:
            start_date = first_of_this_month (start_date) 

        if end_date.day != 1:
            end_date = first_of_next_month (end_date)
    return (start_date, end_date)

    

# Return a date formatted for hCalendar
def hformat_date (when, duration, field):
    return "<abbr class='{field}' title='{year:04d}{month:02d}{day:02d}'>({number}</abbr> <abbr class='duration' title='{quantity}D'>{units}</abbr>)".format (
           field=field,
           number = "whole" if duration == ONE_DAY else duration.days,
           quantity = duration.days,
           units = "day" if duration.days == 1 else "days",
           year=when.year, month=when.month, day=when.day)

# Return a date formatted for hCalendar
def hformat_time (when, field, timezone):
    utc = when.astimezone (pytz.utc)
    return "<abbr class='{field}' title='{year:04d}{month:02d}{day:02d}T{hour:02d}{minute:02d}{second:02d}Z'>{localhour:02d}:{localminute:02d}</abbr>".format (
           field=field,
           year=utc.year, month=utc.month, day=utc.day,
           hour=utc.hour, minute=utc.minute, second=utc.second,
           localhour=when.hour, localminute=when.minute)


# Render the event location.  If given a location link format,
# and the location appears to have adequate detail, construct
# construct result hyperlinked to the given site.
# @param location The location of the event
# @param link_format The URL template, with '{link}' where
# the URL-encoded location is to be inserted.  May be None
# to not perform location linking.
# @return A completed <address> element, escaped and encoded.
def render_location (location, link_format):
    assert (location)
    escaped = html.escape (location)
    if link_format != None:
        if len (render_location.patterns) == 0:
            # Look for a venue name with street address:
            # 3 comma-separated pieces of information, middle one with leading number
            render_location.patterns.append (re.compile ("[A-Za-z]{3}.*,\\s*\\d+\\s+[A-Za-z]{3}.*,\\s*[A-Za-z]{3}"))
            # Look for complete street address:
            # street with number, city, 2-letter state
            render_location.patterns.append (re.compile ("\\d+\\s+[A-Za-z]{3}.*,\\s*[A-Za-z]{3}.*\\s*\\b[A-Za-z]{2}\\b"))
            # City, 2-letter state, postal code
            render_location.patterns.append (re.compile ("[A-Za-z]{3}.*\\s*\\b[A-Za-z]{2}\\b,?\\s*\\b[A-Za-z]?\\d[A-Za-z ]{0,2}\\d[A-Za-z ]{0,2}\\d"))
        for pattern in render_location.patterns:
            if pattern.search (location):
                link = link_format.format (link = urllib.parse.quote_plus (location))
                return '<a href="{link}">{text}</a>'.format (link=link, text=escaped)
    return escaped
render_location.patterns = []
    

# Render event details in hcalendar format.
# @param target The file to write to.
# @param event The event whose details to write.
# @param options HTML formatting options.
def render_html_event (target, event, options):
    nonword = re.compile ("[^a-zA-Z0-9]")
    target.write ("      <div class='vevent")
    # space-separated categories as HTML class names
    for cat in event.categories:
        target.write (html.escape (" {category}".format (
                      category=nonword.sub ("-", cat))))
    target.write ("'>\n        <p>\n")
    if options.categories and len (event.categories) != 0:
        target.write ("        <span class='categories'>{categories}</span>".format (
                      categories=html.escape (",".join (list (event.categories)))))
    if event.all_day:
        target.write ("        <span class='notime'>\n          {data}".format (
                      data=hformat_date (event.begin, event.duration, "dtstart")))
        if options.until_text and event.has_end() and event.duration > ONE_DAY:
            target.write ("{until}{data}".format (until=options.until_text,
                          data=(event.end - ONE_DAY).strftime ("%B %d")))
    else:
        target.write ("        <span class='time'>\n          {data}".format (
                      data=hformat_time (event.begin, "dtstart", options.timezone)))
        if options.until_text and event.has_end():
            target.write ("{until}{date}{data}".format (until=options.until_text,
                          date="" if event.duration.days == 0 else event.end.strftime ("%B %d, "),
                          data=hformat_time (event.end, "dtend", options.timezone)))
    target.write ("\n        </span>\n        ")
    if options.link and event.url:
        target.write ("<a href='{url}' class='url'>".format (
                      url=html.escape (event.url, quote=True)))
    target.write ("<span class='summary'>{summary}</span>".format (
                  summary=html.escape (event.name)))
    if options.link and event.url:
        target.write ("</a>")
    if options.description and event.description:
        target.write ("\n        <pre class='description'>{description}</pre>".format (
                      description=html.escape (event.description)))
    if options.location and event.location:
        target.write ("\n        <address class='location'>{location}</address>".format (
                      location=render_location (event.location, options.location_link)))
    target.write ("\n      </div>\n")
    


# Write one month of an HTML calendar
# @param target The file to write to.
# @param events A list of in-order events.
# Only events belonging to the specified month are written.
# @param start_date The starting day of the month.
# @param options HTML formatting options.
def write_html_month (target, events, start_date, options):
    month = start_date.month
    next_month = first_of_next_month (start_date)
    last_day = (next_month - ONE_DAY).day

    target.write ("<table><caption>{heading}</caption>\n<thead>\n  <tr>\n".format (
                  heading=start_date.strftime ("%B %Y")))
    for day in range (1, 8):
        target.write ("    <th>{day}</th>\n".format (day=datetime.date (2000, 10, day).strftime (
                      "%A")))
    target.write ("</tr>\n</thead>\n<tbody>\n")
    events = [ev for ev in events if event_falls_within (ev, start_date, next_month)]

    # Iterate over the month, always starting on a Sunday, continuing onto the next Sunday
    # so we pad out the empty cells in the HTML table
    day = start_date.day - (start_date.isoweekday() % 7)
    index = 0
    weekday = 0
    while day <= last_day or weekday != 0:
        if (weekday == 0):
            target.write ("  <tr>\n")

        if day >= 1 and day <= last_day:
            target.write ("    <td><p class='date'>{day}\n".format (day=day))

            while index < len (events) and events [index].begin.day == day:
                render_html_event (target, events [index], options)
                index += 1
            target.write ("    </td>\n")
        else:
            target.write ("    <td class='skip'>&nbsp;</td>\n")
    
        if (weekday == 6):
            target.write ("  </tr>\n")
        weekday = (weekday + 1) % 7
        day += 1

    target.write ("</tbody>\n</table>\n")
    

# Write a list of events to a file formatted with HTML.
# @param target The file to write to.
# @param events A list of in-order events.  All provided events are written.
# @param options HTML formatting options.
def write_html_event_list (target, events, options):
    target.write ("  <ul class='eventlist'>")
    last_date = None
    for event in events:
        if event.begin.day != last_date:
            target.write ("    <li><p class='eventdate'>{date}</p>\n".format (
                          date=friendly_date (event.begin, True)))
            last_date = event.begin.day
        render_html_event (target, event, options)
    target.write ("  </ul>\n")


# Write calendar data as an HTML file.
# @param target The file to write the data to.
# @param calendar The calendar to write.
# @param options Encoding options dictionary.
# @param complete Whether or not to include HTML header/footer.
def write_html_file (target, calendar, options, *, complete = True):
    assert (isinstance (target, io.IOBase))
    global debug

    zone = tzlocal.get_localzone()
    options = complete_options (options, {
        'location': True,               # Include location in output
        'description': True,            # Include description in output
        'categories': True,             # Include categories in output
        'link': True,                   # Include links in output,
        'until_text': [None, str],      # Raw HTML separating start/end of event.
                                        # if omitted, ending time is omitted
        'trim_empty': False,            # Omit past months or weeks if empty.
        'layout': 'list',               # Month or list layout
        'location_link': [None, str],   # Location link format
        'start_date': [None, datetime.datetime, datetime.date], # Start date
        'end_date': [None, datetime.datetime, datetime.date], # End date
        'repeat': [None, int],           # Times to repeat layout
        'timezone': zone                 # Output timezone
    })

    assert (options.layout == 'month' or options.layout == 'list')
    if options.location_link != None and '{link}' not in str (options.location_link):
        options.location_link = str (options.location_link) + '{link}'

    convert_calendar_to_time_zone (calendar, options.timezone)

    # Get the events, and determine start/end time if necessary.
    events = sorted (calendar.events, key=lambda ev: (ev.begin, ev.name.lower()))
    (start_date, end_date) = determine_range (events, options.start_date, options.end_date,
                                              options.repeat,
                                              trim = options.trim_empty,
                                              units = options.layout)

    if debug:
        print ("start={start}, end={end}".format (start=str(start_date), end=str(end_date)))

    # Write header if required.
    if complete:
        if 'LANG' in os.environ:
            lang = (os.environ ['LANG'].split('.'))[0].replace ('_', '-')
        else:
            lang='en-US'

        target.write ("<!doctype html>\n"
            "<html lang='{language}'>\n"
            "<head>\n"
            "  <meta charset='UTF-8'>\n"
            "  <meta name='generator' content='queue2ics.py'>\n"
            "  <title>Calendar {start} - {end}</title>\n"
            "  <link rel='stylesheet' href='calendar.css' />\n"
            "  <link rel='profile' href='http://microformats.org/profile/hcalendar' />\n"
            "</head><body>\n".format (language=lang,
                              start=friendly_date (start_date),
                              end=friendly_date(end_date - ONE_DAY)))

    # Write content
    if options.layout == 'month':
        while start_date.year < end_date.year or start_date.month < end_date.month:
            write_html_month (target, events, start_date, options)
            start_date = first_of_next_month (start_date)
    else:
        subset = [ev for ev in events if event_falls_within (ev, start_date, end_date)]
        write_html_event_list (target, subset, options)

    # Write footer if required.
    if complete:
        target.write ("</body></html>\n")



def isFileOfType (filename, extension):
    (name, ext) = os.path.splitext (filename)
    return '.' + extension.lower() == ext.lower()



# Transform files between queue, iCalendar, and HTML formats.
# @param source_files The source documents.  If None, or a filename is "-", uses stdin.
# Source documents are read in queue format unless they have a .ics extension.
# @param target_files The target documents.  If None, or a filename is "-", uses stdout.
# Source documents are written in .ics format unless they have a .que or .html extension.
# @param [optional] options Options to pass to queue read.
# @param [optional] include Set of categories to include
# @param [optional] exclude Set of categories to exclude
# @param [optional] detail_map_file File from which to read URL mappings.
def transform_files (source_files, target_files, *,
                     include_categories = set(), exclude_categories = set(),
                     queue_options = {}, html_options = {},
                     detail_map_file = None):
    assert (source_files == None or isinstance (source_files, list))
    assert (target_files == None or isinstance (target_files, list))

    assert (isinstance (queue_options, dict))
    queue_options = queue_options.copy()
    queue_options ['detail_search_order'] = []
    queue_options ['detail_map'] = {}

    assert (len (include_categories) == 0 or len (exclude_categories) == 0)

    if detail_map_file != None:
        (queue_options ['detail_search_order'], queue_options ['detail_map']) = read_details_mappings (detail_map_file)

    # Load source documents
    schedules = []
    for source_file in source_files if source_files else [ "-" ]:
        try:
            if source_file == "-":
                schedule = load_queue_file (sys.stdin, queue_options, "stdin")
            elif (source_file [0:4] == "http"):
                schedule = ics.Calendar (urlopen (source_file).read().decode ('utf-8'))
            else:
                with open (source_file, encoding='utf-8') as source:
                    if isFileOfType (source_file, 'ics'):
                        schedule = ics.Calendar (source.read())
                    else:
                        schedule = load_queue_file (source, queue_options, source_file)
            schedules.append (schedule)
        except FileNotFoundError:
            print ("{file}: File not found".format (file=source_file), file=sys.stderr)
            return 1
        except IOError as e:
            (errno, strerror) = e
            print ("{file}: Error loading data: {why}".format (file=source_file, why=strerror),
                   file=sys.stderr)
            return 1
        #except:
            print ("{file}: Error extracting calendar: {why}".format (file=source_file,
                   why=sys.exc_info()[0]), file=sys.stderr)
            return 1

    # Amalgamate all data into one calendar while applying category inclusions/exclusions
    if len (include_categories) != 0:
        meets_criteria = lambda ev: not ev.categories.isdisjoint (include_categories)
    elif len (exclude_categories) != 0:
        meets_criteria = lambda ev: ev.categories.isdisjoint (exclude_categories)
    else:
        meets_criteria = lambda ev: True
    trimmed = []
    for sched in schedules:
        for event in sched.events:
            if meets_criteria (event):
                trimmed.append (event)
    calendar = ics.Calendar (events = trimmed)

    # Write destination documents
    for target_file in target_files if target_files else [ "-" ]:
        if target_file == "-":
            print (calendar)
        else:
            with open (target_file, "w", encoding='utf-8') as target:
                if isFileOfType (target_file, 'que') or isFileOfType (target_file, 'txt'):
                    write_queue_file (target, calendar, html_options)
                elif isFileOfType (target_file, 'm4'):
                    write_html_file (target, calendar, html_options, complete = False)
                elif isFileOfType (target_file, 'htm') or isFileOfType (target_file, 'html'):
                    write_html_file (target, calendar, html_options)
                else:
                    print (calendar, file=target)
    return 0




# Fix the goddamn argument parser to respect newlines in strings.  Dumbass thing.
class FixedArgumentFormatter(argparse.HelpFormatter):
    def _split_lines(self, text, width):
        results = []
        for paragraph in text.splitlines ():
            results += argparse.HelpFormatter._split_lines(self, paragraph, width)
        return results

    def _format_text (self, text):
        if text == None:
            return argparse.HelpFormatter.add_text (self, text)
        results = ''
        for paragraph in text.splitlines ():
            results += argparse.HelpFormatter._format_text (self, paragraph)
            results = results [:-1]
        return results + '\n'

def version_check():
    ver = ics.__version__.split ('.')
    major = int (ver[0])
    minor = int (ver[1])
    if major < 0 or (major == 0 and minor < 5):
        sys.exit ("Python ics library must be version 0.5 or later.")


if __name__ == "__main__":
    global debug

    time.tzset()
    version_check()
    parser = argparse.ArgumentParser (formatter_class = FixedArgumentFormatter,
        description="%(prog)s - Convert queue to ics format.",
        epilog="Copyright 2018 Devious Fish.\nAuthor: Perette Barella")
    
    # General options
    parser.add_argument ("-s", "--source", action="append", dest="source_files",
                         help="Source queue file")
    parser.add_argument ("-o", "--output", action="append", dest="output_files",
                         help="Output file")
    parser.add_argument ("-I", "--include", action="store", dest="include",
                         help="Include items in listed categories")
    parser.add_argument ("-X", "--exclude", action="store", dest="exclude",
                         help="Omit items in listed categories")

    # Status/debug options
    parser.add_argument ("-v", "--verbose", action="store_true", dest="verbose",
                         help="Display a list of processed events")
    parser.add_argument ("-Z", "--debug", action="store_true", dest="debug",
                        help=argparse.SUPPRESS)

    # Queue format read options
    parser.add_argument ("-a", "--alarm", action="store", dest="alarm",
                         type=int, default=0,
                         help="Leading alarm time in hours")
    parser.add_argument ("-m", "--http-mappings", action="store", dest="detail_map",
                         help="URL mapping file")
    parser.add_argument ("-C", "--categories", action="store", dest="categories",
                         help="List of category labels")
    parser.add_argument ("-c", "--category", action="store", dest="category",
                         help="Default category to assign to items.  '*' takes any value from file.")
    parser.add_argument ("-f", "--completed-category", action="store", dest="completed_category",
                         help="Category to add to completed items.")
    parser.add_argument ("-d", "--event-duration", action="store", dest="duration",
                         type=int, default=2,
                         help="Event duration in hours")
    parser.add_argument ("-k", "--keep-category", action="store_true", dest="keep_category",
                         help="Retain category in event titles.")
    parser.add_argument ("-i", "--input-timezone", action="store", dest="input_timezone",
                         help="Time zone for queue files")

    # HTML format write options
    parser.add_argument ("-b", "--begin-date", action="store", dest="start_date",
                         help="Start date for HTML calendars")
    parser.add_argument ("-e", "--end-date", action="store", dest="end_date",
                         help="End date for HTML calendars")
    parser.add_argument ("-l", "--layout", action="store", dest="layout", default='month',
                         choices = [ "month", "list" ],
                         help="HTML calendar layout")
    parser.add_argument ("-L", "--link-location", action="store", dest="location_link",
                         default=None,
                         help="URL pattern for location links")
    parser.add_argument ("-r", "--repetitions", action="store", dest="repetitions",
                         type=int, default=None,
                         help="Number of repetitions")
    parser.add_argument ("-t", "--trim-empty", action="store_true", dest="trim",
                         help="Trim empty past portions of HTML calendar.")
                         # Goal is this will trim prior months or weeks if empty.
    parser.add_argument ("-u", "--until-text", action="store", dest="until_text",
                         help="HTML that separates start and end times")
    parser.add_argument ("-z", "--output-timezone", action="store", dest="output_timezone",
                        help="Time zone for HTML files")

    parser.add_argument ("--version", action="version", version=version)
    parser.add_argument ("--about", action="version", version="version: " + version + "\n  author: Perette Barella <perette@deviousfish.com>")
    
    args = parser.parse_args ()
    debug = args.debug
    if debug:
        print ("Parsed arguments: ", args)

    # Work out the categories options
    splitter = re.compile ("[,| \t]")
    default_category = None
    inc_categories = set()
    exc_categories = set()
    if args.include:
        if args.exclude:
            sys.exit ("Cannot use both include and exclude category options.")
        inc_categories = set (splitter.split (args.include))
    if args.exclude:
        exc_categories = set (splitter.split (args.exclude))

    if args.categories:
        if args.category == "*":
            sys.exit ("Cannot use -C with -c *: all categories are implied.")
        categories = set (splitter.split (args.categories)).union (inc_categories, exc_categories)
    else:
        categories = inc_categories.union (exc_categories)
    if args.category == "*":
        categories = True
    elif args.category != None:
        default_category = args.category

    if args.end_date is not None and args.repetitions is not None:
        sys.exit ("Cannot specify both repetitions and end date.")

    # If URL map wasn't specified, and default file exists, use default file.
    default_map_file = "~/.queue2ics"
    map_file = args.detail_map
    if (map_file == None and os.path.isfile (default_map_file)):
        map_file = default_map_file

    # Parse start/end dates
    start_date = None
    try:
        if args.start_date is not None:
            start_date = datetime.datetime.strptime (args.start_date, "%Y-%m-%d")
    except ValueError:
        sys.exit ("Invalid date: {date}".format (date=args.start_date))

    end_date = None
    try:
        if args.end_date is not None:
            end_date = datetime.datetime.strptime (args.end_date, "%Y-%m-%d")
    except ValueError:
        sys.exit ("Invalid date: {date}".format (date=args.end_date))


    status = transform_files (args.source_files, args.output_files,
        queue_options = {
            'alarm': args.alarm * 60,
            'categories': categories,
            'default_category': default_category,
            'completed_category': args.completed_category,
            'duration': args.duration * 60,
            'remove_category': not args.keep_category,
            'verbose': args.verbose,
            'timezone': get_timezone (args.input_timezone)
        },
        html_options = {
            'until_text': args.until_text if args.until_text else None,
            'trim_empty': args.trim,
            'layout': args.layout,
            'start_date': start_date,
            'end_date': end_date,
            'repeat': args.repetitions,
            'timezone': get_timezone (args.output_timezone),
            'categories': False,
            'location_link': args.location_link
        },
        include_categories = inc_categories,
        exclude_categories = exc_categories,
        detail_map_file = map_file
    )
    exit (status)

