##
# Copyright (c) 2009-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.
##

import copy
import grp
import os
import pwd
import time

from twisted.internet.defer import inlineCallbacks, returnValue, succeed

from twext.python.log import Logger

from twistedcaldav.config import fullServerPath, config
from twistedcaldav.directory import xmlaugmentsparser
from twistedcaldav.directory.xmlaugmentsparser import XMLAugmentsParser
from twistedcaldav.xmlutil import newElementTreeWithRoot, addSubElement, \
    writeXML, readXML
from twistedcaldav.directory.util import normalizeUUID


log = Logger()

allowedAutoScheduleModes = frozenset((
    "default",
    "none",
    "accept-always",
    "decline-always",
    "accept-if-free",
    "decline-if-busy",
    "automatic",
))


class AugmentRecord(object):
    """
    Augmented directory record information
    """

    def __init__(
        self,
        uid,
        enabled=False,
        serverID="",
        enabledForCalendaring=False,
        autoSchedule=False,
        autoScheduleMode="default",
        autoAcceptGroup=None,
        enabledForAddressBooks=False,
        enabledForLogin=True,
    ):
        self.uid = uid
        self.enabled = enabled
        self.serverID = serverID
        self.enabledForCalendaring = enabledForCalendaring
        self.enabledForAddressBooks = enabledForAddressBooks
        self.enabledForLogin = enabledForLogin
        self.autoSchedule = autoSchedule
        self.autoScheduleMode = autoScheduleMode if autoScheduleMode in allowedAutoScheduleModes else "default"
        self.autoAcceptGroup = autoAcceptGroup
        self.clonedFromDefault = False

recordTypesMap = {
    "users": "User",
    "groups": "Group",
    "locations": "Location",
    "resources": "Resource",
    "addresses": "Address",
    "wikis": "Wiki",
}


class AugmentDB(object):
    """
    Abstract base class for an augment record database.
    """

    def __init__(self):

        self.cachedRecords = {}

    @inlineCallbacks
    def normalizeUUIDs(self):
        """
        Normalize (uppercase) all augment UIDs which are parseable as UUIDs.

        @return: a L{Deferred} that fires when all records have been
            normalized.
        """
        remove = []
        add = []
        for uid in (yield self.getAllUIDs()):
            nuid = normalizeUUID(uid)
            if uid != nuid:
                old = yield self._lookupAugmentRecord(uid)
                new = copy.deepcopy(old)
                new.uid = uid.upper()
                remove.append(uid)
                add.append(new)
        try:
            yield self.removeAugmentRecords(remove)
            yield self.addAugmentRecords(add)
        except IOError:
            # It's OK if we can't re-write the file.
            pass

    @inlineCallbacks
    def getAugmentRecord(self, uid, recordType):
        """
        Get an AugmentRecord for the specified UID or the default.

        @param uid: directory UID to lookup
        @type uid: C{str}

        @return: L{Deferred}
        """
        if recordType not in recordTypesMap:
            returnValue(None)

        recordType = recordTypesMap[recordType]

        result = (yield self._lookupAugmentRecord(uid))
        if result is not None:
            returnValue(result)

        # Try wildcard/default matches next
        for lookup in (
            "%s-%s*" % (recordType, uid[0:2]),
            "%s-%s*" % (recordType, uid[0]),
            "%s*" % (uid[0:2],),
            "%s*" % (uid[0],),
            "%s-Default" % (recordType,),
            "Default",
        ):
            result = (yield self._cachedAugmentRecord(lookup))
            if result is not None:
                result = copy.deepcopy(result)
                result.uid = uid
                result.clonedFromDefault = True
                returnValue(result)

        # No default was specified in the db, so generate one
        result = AugmentRecord(
            "Default",
            enabled=True,
            enabledForCalendaring=True,
            enabledForAddressBooks=True,
            enabledForLogin=True,
        )
        self.cachedRecords["Default"] = result
        result = copy.deepcopy(result)
        result.uid = uid
        result.clonedFromDefault = True
        returnValue(result)

    @inlineCallbacks
    def getAllUIDs(self):
        """
        Get all AugmentRecord UIDs.

        @return: L{Deferred}
        """

        raise NotImplementedError("Child class must define this.")

    def _lookupAugmentRecord(self, uid):
        """
        Get an AugmentRecord for the specified UID.

        @param uid: directory UID to lookup
        @type uid: C{str}

        @return: L{Deferred}
        """

        raise NotImplementedError("Child class must define this.")

    @inlineCallbacks
    def _cachedAugmentRecord(self, uid):
        """
        Get an AugmentRecord for the specified UID from the cache.

        @param uid: directory UID to lookup
        @type uid: C{str}

        @return: L{Deferred}
        """

        if uid not in self.cachedRecords:
            result = (yield self._lookupAugmentRecord(uid))
            self.cachedRecords[uid] = result
        returnValue(self.cachedRecords[uid])

    def addAugmentRecords(self, records):
        """
        Add an AugmentRecord to the DB.

        @param record: augment records to add
        @type record: C{list} of L{AugmentRecord}

        @return: L{Deferred}
        """

        raise NotImplementedError("Child class must define this.")

    def removeAugmentRecords(self, uids):
        """
        Remove AugmentRecords with the specified UIDs.

        @param uid: directory UIDs to remove
        @type uid: C{list} of C{str}

        @return: L{Deferred}
        """

        raise NotImplementedError("Child class must define this.")

    def refresh(self):
        """
        Refresh any cached data.

        @return: L{Deferred}
        """

        self.cachedRecords.clear()
        return None

    def clean(self):
        """
        Remove all records.

        @return: L{Deferred}
        """

        raise NotImplementedError("Child class must define this.")


class AugmentXMLDB(AugmentDB):
    """
    XMLFile based augment database implementation.
    """

    def __init__(self, xmlFiles, statSeconds=15):

        super(AugmentXMLDB, self).__init__()
        self.xmlFiles = [fullServerPath(config.DataRoot, path) for path in xmlFiles]
        self.xmlFileStats = {}
        for path in self.xmlFiles:
            self.xmlFileStats[path] = (0, 0)  # mtime, size

        self.statSeconds = statSeconds  # Don't stat more often than this value
        self.lastCached = 0
        self.db = {}

        try:
            self.db = self._parseXML()
        except RuntimeError:
            log.error("Failed to parse XML augments file - fatal error on startup")
            raise

        self.lastCached = time.time()
        self.normalizeUUIDs()

    def getAllUIDs(self):
        """
        Get all AugmentRecord UIDs.

        @return: L{Deferred}
        """
        return succeed(self.db.keys())

    def _lookupAugmentRecord(self, uid):
        """
        Get an AugmentRecord for the specified UID.

        @param uid: directory UID to lookup
        @type uid: C{str}

        @return: L{Deferred}
        """

        # May need to re-cache
        if time.time() - self.lastCached > self.statSeconds:
            self.refresh()

        return succeed(self.db.get(uid))

    def addAugmentRecords(self, records):
        """
        Add an AugmentRecord to the DB.

        @param records: augment records to add
        @type records: C{list} of L{AugmentRecord}
        @param update: C{True} if changing an existing record
        @type update: C{bool}

        @return: L{Deferred}
        """

        # Look at each record and determine whether it is new or a modify
        new_records = list()
        existing_records = list()
        for record in records:
            (existing_records if record.uid in self.db else new_records).append(record)

        if existing_records:
            # Now look at each file and modify the UIDs
            for xmlFile in self.xmlFiles:
                self._doModifyInFile(xmlFile, existing_records)

        if new_records:
            # Add to first file in list
            self._doAddToFile(self.xmlFiles[0], new_records)

        # This is required to invalidate self.db
        self.lastCached = 0

        return succeed(None)

    def _doAddToFile(self, xmlfile, records):

        if not os.path.exists(xmlfile):

            # File doesn't yet exist.  Create it with items in self.db, and
            # set file permissions.

            _ignore_etree, augments_node = newElementTreeWithRoot(xmlaugmentsparser.ELEMENT_AUGMENTS)
            for record in self.db.itervalues():
                self._addRecordToXMLDB(record, augments_node)

            writeXML(xmlfile, augments_node)

            # Set permissions
            uid = -1
            if config.UserName:
                try:
                    uid = pwd.getpwnam(config.UserName).pw_uid
                except KeyError:
                    log.error("User not found: {user}", user=config.UserName)
            gid = -1
            if config.GroupName:
                try:
                    gid = grp.getgrnam(config.GroupName).gr_gid
                except KeyError:
                    log.error("Group not found: {grp}", grp=config.GroupName)
            if uid != -1 and gid != -1:
                os.chown(xmlfile, uid, gid)

        _ignore_etree, augments_node = readXML(xmlfile)

        # Create new record
        for record in records:
            self._addRecordToXMLDB(record, augments_node)

        # Modify xmlfile
        writeXML(xmlfile, augments_node)

    def _doModifyInFile(self, xmlfile, records):

        if not os.path.exists(xmlfile):
            return

        _ignore_etree, augments_node = readXML(xmlfile)

        # Map uid->record for fast lookup
        recordMap = dict([(record.uid, record) for record in records])

        # Make sure UID is present
        changed = False
        for record_node in augments_node:

            if record_node.tag != xmlaugmentsparser.ELEMENT_RECORD:
                continue

            uid = record_node.find(xmlaugmentsparser.ELEMENT_UID).text
            if uid in recordMap:
                # Modify record
                record = recordMap[uid]
                self._updateRecordInXMLDB(record, record_node)
                changed = True

        # Modify xmlfile
        if changed:
            writeXML(xmlfile, augments_node)

    def removeAugmentRecords(self, uids):
        """
        Remove AugmentRecords with the specified UIDs.

        @param uids: list of uids to remove
        @type uids: C{list} of C{str}

        @return: L{Deferred}
        """

        # Remove from cache first
        removed = set()
        for uid in uids:
            if uid in self.db:
                del self.db[uid]
                removed.add(uid)

        # Now look at each file and remove the UIDs
        for xmlFile in self.xmlFiles:
            self._doRemoveFromFile(xmlFile, removed)

        return succeed(None)

    def _doRemoveFromFile(self, xmlfile, uids):

        try:
            _ignore_etree, augments_node = readXML(xmlfile)
        except IOError:
            # If the file is missing, then removal is a no-op
            return

        # Remove all UIDs present
        changed = False
        for child in augments_node:
            if child.tag != xmlaugmentsparser.ELEMENT_RECORD:
                continue

            if child.find(xmlaugmentsparser.ELEMENT_UID).text in uids:
                augments_node.remove(child)
                changed = True

        # Modify xmlfile
        if changed:
            writeXML(xmlfile, augments_node)

    def _addRecordToXMLDB(self, record, parentNode):
        record_node = addSubElement(parentNode, xmlaugmentsparser.ELEMENT_RECORD)
        self._updateRecordInXMLDB(record, record_node)

    def _updateRecordInXMLDB(self, record, recordNode):
        del recordNode[:]
        addSubElement(recordNode, xmlaugmentsparser.ELEMENT_UID, record.uid)
        if record.serverID:
            addSubElement(recordNode, xmlaugmentsparser.ELEMENT_SERVERID, record.serverID)
        addSubElement(recordNode, xmlaugmentsparser.ELEMENT_ENABLECALENDAR, "true" if record.enabledForCalendaring else "false")
        addSubElement(recordNode, xmlaugmentsparser.ELEMENT_ENABLEADDRESSBOOK, "true" if record.enabledForAddressBooks else "false")
        addSubElement(recordNode, xmlaugmentsparser.ELEMENT_ENABLELOGIN, "true" if record.enabledForLogin else "false")
        if record.autoScheduleMode:
            addSubElement(recordNode, xmlaugmentsparser.ELEMENT_AUTOSCHEDULE_MODE, record.autoScheduleMode)
        if record.autoAcceptGroup:
            addSubElement(recordNode, xmlaugmentsparser.ELEMENT_AUTOACCEPTGROUP, record.autoAcceptGroup)

    def refresh(self):
        """
        Refresh any cached data.
        """
        super(AugmentXMLDB, self).refresh()
        try:
            results = self._parseXML()
            # Only update the cache if _parseXML( ) returns anything
            if results:
                self.db = results
        except RuntimeError:
            log.error("Failed to parse XML augments file during cache refresh - ignoring")
        self.lastCached = time.time()

        return None

    def clean(self):
        """
        Remove all records.
        """

        return self.removeAugmentRecords(self.db.keys())

    def _shouldReparse(self, xmlFiles):
        """
        Check to see whether any of the given files have been modified since
        we last parsed them.
        """
        for xmlFile in xmlFiles:
            if os.path.exists(xmlFile):
                oldModTime, oldSize = self.xmlFileStats.get(xmlFile, (0, 0))
                newModTime = os.path.getmtime(xmlFile)
                newSize = os.path.getsize(xmlFile)
                if (oldModTime != newModTime) or (oldSize != newSize):
                    return True
        return False

    def _parseXML(self):
        """
        Parse self.xmlFiles into AugmentRecords.

        If none of the xmlFiles exist, create a default record.
        """

        results = {}

        # If all augments files are missing, return a default record
        for xmlFile in self.xmlFiles:
            if os.path.exists(xmlFile):
                break
        else:
            results["Default"] = AugmentRecord(
                "Default",
                enabled=True,
                enabledForCalendaring=True,
                enabledForAddressBooks=True,
                enabledForLogin=True,
            )

        # Compare previously seen modification time and size of each
        # xml file.  If all are unchanged, skip.
        if self._shouldReparse(self.xmlFiles):
            for xmlFile in self.xmlFiles:
                if os.path.exists(xmlFile):
                    # Creating a parser does the parse
                    XMLAugmentsParser(xmlFile, results)
                    newModTime = os.path.getmtime(xmlFile)
                    newSize = os.path.getsize(xmlFile)
                    self.xmlFileStats[xmlFile] = (newModTime, newSize)

        return results
