#!/usr/bin/env python
# -*- test-case-name: calendarserver.tools.test.test_importer -*-
##
# Copyright (c) 2014-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.
##

"""
This tool imports calendar data

This tool requires access to the calendar server's configuration and data
storage; it does not operate by talking to the server via the network.  It
therefore does not apply any of the access restrictions that the server would.
"""

from __future__ import print_function

import os
import sys
import uuid

from calendarserver.tools.cmdline import utilityMain, WorkerService
from twext.python.log import Logger
from twisted.internet.defer import inlineCallbacks
from twisted.python.text import wordWrap
from twisted.python.usage import Options, UsageError
from twistedcaldav import customxml
from twistedcaldav.ical import Component
from twistedcaldav.stdconfig import DEFAULT_CONFIG_FILE
from twistedcaldav.timezones import TimezoneCache
from txdav.base.propertystore.base import PropertyName
from txdav.caldav.datastore.scheduling.cuaddress import LocalCalendarUser
from txdav.caldav.datastore.scheduling.itip import iTipGenerator
from txdav.caldav.datastore.scheduling.processing import ImplicitProcessor
from txdav.caldav.icalendarstore import ComponentUpdateState
from txdav.common.icommondatastore import UIDExistsError
from txdav.xml import element as davxml

log = Logger()


def usage(e=None):
    if e:
        print(e)
        print("")
    try:
        ImportOptions().opt_help()
    except SystemExit:
        pass
    if e:
        sys.exit(64)
    else:
        sys.exit(0)


description = '\n'.join(
    wordWrap(
        """
        Usage: calendarserver_import [options] [input specifiers]\n
        """ + __doc__,
        int(os.environ.get('COLUMNS', '80'))
    )
)


class ImportException(Exception):
    """
    An error occurred during import
    """


class ImportOptions(Options):
    """
    Command-line options for 'calendarserver_import'
    """

    synopsis = description

    optFlags = [
        ['debug', 'D', "Debug logging."],
    ]

    optParameters = [
        ['config', 'f', DEFAULT_CONFIG_FILE, "Specify caldavd.plist configuration path."],
    ]

    def __init__(self):
        super(ImportOptions, self).__init__()
        self.inputName = '-'
        self.inputDirectoryName = None

    def opt_directory(self, dirname):
        """
        Specify input directory path.
        """
        self.inputDirectoryName = dirname

    opt_d = opt_directory

    def opt_input(self, filename):
        """
        Specify input file path (default: '-', meaning stdin).
        """
        self.inputName = filename

    opt_i = opt_input

    def openInput(self):
        """
        Open the appropriate input file based on the '--input' option.
        """
        if self.inputName == '-':
            return sys.stdin
        else:
            return open(self.inputName, 'r')


# These could probably live on the collection class:

def setCollectionPropertyValue(collection, element, value):
    collectionProperties = collection.properties()
    collectionProperties[PropertyName.fromElement(element)] = (
        element.fromString(value)
    )


def getCollectionPropertyValue(collection, element):
    collectionProperties = collection.properties()
    name = PropertyName.fromElement(element)
    if name in collectionProperties:
        return str(collectionProperties[name])
    else:
        return None


@inlineCallbacks
def importCollectionComponent(store, component):
    """
    Import a component representing a collection (e.g. VCALENDAR) into the
    store.

    The homeUID and collection resource name the component will be imported
    into is derived from the SOURCE property on the VCALENDAR (which must
    be present).  The code assumes it will be a URI with slash-separated parts
    with the penultimate part specifying the homeUID and the last part
    specifying the calendar resource name.  The NAME property will be used
    to set the DAV:display-name, while the COLOR property will be used to set
    calendar-color.

    Subcomponents (e.g. VEVENTs) are grouped into resources by UID.  Objects
    which have a UID already in use within the home will be skipped.

    @param store: The db store to add the component to
    @type store: L{IDataStore}
    @param component: The component to store
    @type component: L{twistedcaldav.ical.Component}
    """

    sourceURI = component.propertyValue("SOURCE")
    if not sourceURI:
        raise ImportException("Calendar is missing SOURCE property")

    ownerUID, collectionResourceName = sourceURI.strip("/").split("/")[-2:]

    dir = store.directoryService()
    ownerRecord = yield dir.recordWithUID(ownerUID)
    if not ownerRecord:
        raise ImportException("{} is not in the directory".format(ownerUID))

    # Set properties on the collection
    txn = store.newTransaction()
    home = yield txn.calendarHomeWithUID(ownerUID, create=True)
    collection = yield home.childWithName(collectionResourceName)
    if not collection:
        print("Creating calendar: {}".format(collectionResourceName))
        collection = yield home.createChildWithName(collectionResourceName)
    for propertyName, element in (
        ("NAME", davxml.DisplayName),
        ("COLOR", customxml.CalendarColor),
    ):
        value = component.propertyValue(propertyName)
        if value is not None:
            setCollectionPropertyValue(collection, element, value)
            print(
                "Setting {name} to {value}".format(name=propertyName, value=value)
            )
    yield txn.commit()

    # Populate the collection; NB we use a txn for each object, and we might
    # want to batch them?
    groupedComponents = Component.componentsFromComponent(component)
    for groupedComponent in groupedComponents:

        try:
            uid = list(groupedComponent.subcomponents())[0].propertyValue("UID")
        except:
            continue

        # If event is unscheduled or the organizer matches homeUID, store the
        # component

        print("Event UID: {}".format(uid))
        storeDirectly = True
        organizer = groupedComponent.getOrganizer()
        if organizer is not None:
            organizerRecord = yield dir.recordWithCalendarUserAddress(organizer)
            if organizerRecord is None:
                # Organizer does not exist, so skip this event
                continue
            else:
                if ownerRecord.uid != organizerRecord.uid:
                    # Owner is not the organizer
                    storeDirectly = False

        if storeDirectly:
            resourceName = "{}.ics".format(str(uuid.uuid4()))
            try:
                yield storeComponentInHomeAndCalendar(
                    store, groupedComponent, ownerUID, collectionResourceName,
                    resourceName
                )
                print("Imported: {}".format(uid))
            except UIDExistsError:
                # That event is already in the home
                print("Skipping since UID already exists: {}".format(uid))

            except Exception, e:
                print(
                    "Failed to import due to: {error}\n{comp}".format(
                        error=e,
                        comp=groupedComponent
                    )
                )

        else:
            # Owner is an attendee, not the organizer
            # Apply the PARTSTATs from the import and from the possibly
            # existing event (existing event takes precedence) to the
            # organizer's copy.

            # Put the attendee copy into the right calendar now otherwise it
            # could end up on the default calendar when the change to the
            # organizer's copy causes an attendee update
            resourceName = "{}.ics".format(str(uuid.uuid4()))
            try:
                yield storeComponentInHomeAndCalendar(
                    store, groupedComponent, ownerUID, collectionResourceName,
                    resourceName, asAttendee=True
                )
                print("Imported: {}".format(uid))
            except UIDExistsError:
                # No need since the event is already in the home
                pass

            # Now use the iTip reply processing to update the organizer's copy
            # with the PARTSTATs from the component we're restoring.
            attendeeCUA = ownerRecord.canonicalCalendarUserAddress()
            organizerCUA = organizerRecord.canonicalCalendarUserAddress()
            processor = ImplicitProcessor()
            newComponent = iTipGenerator.generateAttendeeReply(groupedComponent, attendeeCUA, method="X-RESTORE")
            if newComponent is not None:
                txn = store.newTransaction()
                yield processor.doImplicitProcessing(
                    txn,
                    newComponent,
                    LocalCalendarUser(attendeeCUA, ownerRecord),
                    LocalCalendarUser(organizerCUA, organizerRecord)
                )
                yield txn.commit()


@inlineCallbacks
def storeComponentInHomeAndCalendar(
    store, component, homeUID, collectionResourceName, objectResourceName,
    asAttendee=False
):
    """
    Add a component to the store as an objectResource

    If the calendar home does not yet exist for homeUID it will be created.
    If the collection by the name collectionResourceName does not yet exist
    it will be created.

    @param store: The db store to add the component to
    @type store: L{IDataStore}
    @param component: The component to store
    @type component: L{twistedcaldav.ical.Component}
    @param homeUID: uid of the home collection
    @type collectionResourceName: C{str}
    @param collectionResourceName: name of the collection resource
    @type collectionResourceName: C{str}
    @param objectResourceName: name of the objectresource
    @type objectResourceName: C{str}
    """
    txn = store.newTransaction()
    home = yield txn.calendarHomeWithUID(homeUID, create=True)
    collection = yield home.childWithName(collectionResourceName)
    if not collection:
        collection = yield home.createChildWithName(collectionResourceName)

    yield collection._createCalendarObjectWithNameInternal(
        objectResourceName, component,
        (
            ComponentUpdateState.ATTENDEE_ITIP_UPDATE
            if asAttendee else
            ComponentUpdateState.NORMAL
        )
    )
    yield txn.commit()


class ImporterService(WorkerService, object):
    """
    Service which runs, imports the data, then stops the reactor.
    """

    def __init__(self, store, options, reactor, config):
        super(ImporterService, self).__init__(store)
        self.options = options
        self.reactor = reactor
        self.config = config
        self._directory = self.store.directoryService()

        TimezoneCache.create()

    @inlineCallbacks
    def doWork(self):
        """
        Do the export, stopping the reactor when done.
        """
        try:
            if self.options.inputDirectoryName:
                dirname = self.options.inputDirectoryName
                if not os.path.exists(dirname):
                    sys.stderr.write(
                        "Directory does not exist: {}\n".format(dirname)
                    )
                    sys.exit(1)
                for filename in os.listdir(dirname):
                    fullpath = os.path.join(dirname, filename)
                    print("Importing {}".format(fullpath))
                    with open(fullpath, 'r') as fileobj:
                        component = Component.allFromStream(fileobj)
                    yield importCollectionComponent(self.store, component)

            else:
                try:
                    input = self.options.openInput()
                except IOError, e:
                    sys.stderr.write(
                        "Unable to open input file for reading: %s\n" % (e)
                    )
                    sys.exit(1)

                component = Component.allFromStream(input)
                input.close()
                yield importCollectionComponent(self.store, component)
        except:
            log.failure("doWork()")

    def directoryService(self):
        """
        Get an appropriate directory service.
        """
        return self._directory

    def stopService(self):
        """
        Stop the service.  Nothing to do; everything should be finished by this
        time.
        """
        # TODO: stopping this service mid-import should really stop the import
        # loop, but this is not implemented because nothing will actually do it
        # except hitting ^C (which also calls reactor.stop(), so that will exit
        # anyway).


def main(argv=sys.argv, reactor=None):
    """
    Do the import.
    """
    if reactor is None:
        from twisted.internet import reactor

    options = ImportOptions()
    try:
        options.parseOptions(argv[1:])
    except UsageError, e:
        usage(e)

    def makeService(store):
        from twistedcaldav.config import config
        return ImporterService(store, options, reactor, config)

    utilityMain(options["config"], makeService, reactor, verbose=options["debug"])
