#
# Copyright (c) 2006 Canonical
# Copyright (c) 2004 Conectiva, Inc.
#
# Written by Gustavo Niemeyer <gustavo@niemeyer.net>
#
# This Python module is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License as published
# by the Free Software Foundation; either version 2 of the License, or (at
# your option) any later version.
#
# This Python module is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this Python module; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
import copy
import os
import re
import shutil
import sys

__all__ = [
    "Persist",
    "PickleBackend",
    "BPickleBackend",
    "path_string_to_tuple",
    "path_tuple_to_string",
    "RootedPersist",
    "PersistError",
    "PersistReadOnlyError",
]


NOTHING = object()


class PersistError(Exception):
    pass


class PersistReadOnlyError(PersistError):
    pass


class Persist:
    """Persist a hierarchical database of key=>value pairs.

    There are three different kinds of option maps, regarding the
    persistence and priority that maps are queried.

      - hard - Options are persistent.
      - soft - Options are not persistent, and have a higher priority
           than persistent options.
      - weak - Options are not persistent, and have a lower priority
           than persistent options.

    @ivar filename: The name of the file where persist data is saved
        or None if no filename is available.

    """

    def __init__(self, backend=None, filename=None, user=None, group=None):
        """
        @param backend: The backend to use. If none is specified,
            L{BPickleBackend} will be used.
        @param filename: The default filename to save to and load from. If
            specified, and the file exists, it will be immediately
            loaded. Specifying this will also allow L{save} to be called
            without any arguments to save the persist.
        """
        if backend is None:
            backend = BPickleBackend()
        self._backend = backend
        self._hardmap = backend.new()
        self._softmap = {}
        self._weakmap = {}
        self._readonly = False
        self._modified = False
        self._config = self
        self._user = user
        self._group = group
        self.filename = filename
        if filename is not None and os.path.exists(filename):
            self.load(filename)

    def _get_readonly(self):
        return self._readonly

    def _set_readonly(self, flag):
        self._readonly = bool(flag)

    def _get_modified(self):
        return self._modified

    readonly = property(_get_readonly, _set_readonly)
    modified = property(_get_modified)

    def reset_modified(self):
        """Set the database status as non-modified."""
        self._modified = False

    def assert_writable(self):
        """Assert if the object is writable

        @raise: L{PersistReadOnlyError}
        """
        if self._readonly:
            raise PersistReadOnlyError("Configuration is in readonly mode.")

    def load(self, filepath):
        """Load a persisted database."""

        def load_old():
            filepathold = filepath + ".old"
            if os.path.isfile(filepathold) and os.path.getsize(filepathold) > 0:
                # warning(f"Broken configuration file at {filepath}")
                # warning(f"Trying backup at {filepathold}")
                try:
                    self._hardmap = self._backend.load(filepathold)
                except Exception:
                    raise PersistError(
                        f"Broken configuration file at {filepathold}",
                    )
                return True
            return False

        filepath = os.path.expanduser(filepath)
        if not os.path.isfile(filepath):
            if load_old():
                return
            raise PersistError(f"File not found: {filepath}")
        if os.path.getsize(filepath) == 0:
            load_old()
            return
        try:
            self._hardmap = self._backend.load(filepath)
        except Exception:
            if load_old():
                return
            raise PersistError(f"Broken configuration file at {filepath}")

    def save(self, filepath=None):
        """Save the persist to the given C{filepath}.

        If None is specified, then the filename passed during construction will
        be used.

        If the destination file already exists, it will be renamed
        to C{<filepath>.old}.
        """
        if filepath is None:
            if self.filename is None:
                raise PersistError("Need a filename!")
            filepath = self.filename
        filepath = os.path.expanduser(filepath)
        if os.path.isfile(filepath):
            os.rename(filepath, filepath + ".old")
        dirname = os.path.dirname(filepath)
        if dirname and not os.path.isdir(dirname):
            os.makedirs(dirname)
        self._backend.save(filepath, self._hardmap)

        if self._user is not None or self._group is not None:
            try:
                if dirname:
                    shutil.chown(dirname, user=self._user, group=self._group)
                shutil.chown(filepath, user=self._user, group=self._group)
            except PermissionError:
                # A perist directory has been selected that can't be owned by
                # landscape:landscape. This often happens in /tmp for tests,
                # but there could be other reasons. Just leave it be.
                pass

    def _traverse(self, obj, path, default=NOTHING, setvalue=NOTHING):
        if setvalue is not NOTHING:
            setvalue = self._backend.copy(setvalue)
        queue = list(path)
        marker = NOTHING
        newobj = obj
        while queue:
            obj = newobj
            elem = queue.pop(0)
            newobj = self._backend.get(obj, elem)
            if newobj is NotImplemented:
                if queue:
                    path = path[: -len(queue)]
                raise PersistError(
                    f"Can't traverse {type(obj)!r} "
                    f"({path_tuple_to_string(path)!r}): {str(obj)!r}",
                )
            if newobj is marker:
                break
        if newobj is not marker:
            if setvalue is not marker:
                newobj = self._backend.set(obj, elem, setvalue)
        else:
            if setvalue is marker:
                newobj = default
            else:
                while True:
                    if len(queue) > 0:
                        if type(queue[0]) is int:
                            newvalue = []
                        else:
                            newvalue = {}
                    else:
                        newvalue = setvalue
                    newobj = self._backend.set(obj, elem, newvalue)
                    if newobj is NotImplemented:
                        raise PersistError(
                            f"Can't traverse {type(obj)!r} with {type(elem)!r}",
                        )
                    if not queue:
                        break
                    obj = newobj
                    elem = queue.pop(0)
        return newobj

    def _getvalue(self, path, soft=False, hard=False, weak=False):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        marker = NOTHING
        if soft:
            value = self._traverse(self._softmap, path, marker)
        elif hard:
            value = self._traverse(self._hardmap, path, marker)
        elif weak:
            value = self._traverse(self._weakmap, path, marker)
        else:
            value = self._traverse(self._softmap, path, marker)
            if value is marker:
                value = self._traverse(self._hardmap, path, marker)
                if value is marker:
                    value = self._traverse(self._weakmap, path, marker)
        return value

    def has(self, path, value=NOTHING, soft=False, hard=False, weak=False):
        obj = self._getvalue(path, soft, hard, weak)
        marker = NOTHING
        if obj is marker:
            return False
        elif value is marker:
            return True
        result = self._backend.has(obj, value)
        if result is NotImplemented:
            raise PersistError(f"Can't check {type(obj)!r} for containment")
        return result

    def keys(self, path, soft=False, hard=False, weak=False):
        obj = self._getvalue(path, soft, hard, weak)
        if obj is NOTHING:
            return []
        result = self._backend.keys(obj)
        if result is NotImplemented:
            raise PersistError(f"Can't return keys for {type(obj)}")
        return result

    def get(self, path, default=None, soft=False, hard=False, weak=False):
        value = self._getvalue(path, soft, hard, weak)
        if value is NOTHING:
            return default
        return self._backend.copy(value)

    def set(self, path, value, soft=False, weak=False):
        assert path
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        if soft:
            map = self._softmap
        elif weak:
            map = self._weakmap
        else:
            self.assert_writable()
            self._modified = True
            map = self._hardmap
        self._traverse(map, path, setvalue=value)

    def add(self, path, value, unique=False, soft=False, weak=False):
        assert path
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        if soft:
            map = self._softmap
        elif weak:
            map = self._weakmap
        else:
            self.assert_writable()
            self._modified = True
            map = self._hardmap
        if unique:
            current = self._traverse(map, path)
            if type(current) is list and value in current:
                return
        path = path + (sys.maxsize,)
        self._traverse(map, path, setvalue=value)

    def remove(self, path, value=NOTHING, soft=False, weak=False):
        assert path
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        if soft:
            map = self._softmap
        elif weak:
            map = self._weakmap
        else:
            self.assert_writable()
            self._modified = True
            map = self._hardmap
        marker = NOTHING
        while path:
            if value is marker:
                obj = self._traverse(map, path[:-1])
                elem = path[-1]
                isvalue = False
            else:
                obj = self._traverse(map, path)
                elem = value
                isvalue = True
            result = False
            if obj is not marker:
                result = self._backend.remove(obj, elem, isvalue)
                if result is NotImplemented:
                    raise PersistError(
                        f"Can't remove {elem!r} from {type(obj)!r}",
                    )
            if self._backend.empty(obj):
                if value is not marker:
                    value = marker
                else:
                    path = path[:-1]
            else:
                break
        return result

    def move(self, oldpath, newpath, soft=False, weak=False):
        if not (soft or weak):
            self.assert_writable()
        if isinstance(oldpath, str):
            oldpath = path_string_to_tuple(oldpath)
        if isinstance(newpath, str):
            newpath = path_string_to_tuple(newpath)
        result = False
        marker = NOTHING
        value = self._getvalue(oldpath, soft, not (soft or weak), weak)
        if value is not marker:
            self.remove(oldpath, soft=soft, weak=weak)
            self.set(newpath, value, weak, soft)
            result = True
        return result

    def root_at(self, path):
        """
        Rebase the database hierarchy.

        @return: A L{RootedPersist} using this L{Persist} as parent.
        """
        return RootedPersist(self, path)


class RootedPersist:
    """Root a L{Persist}'s tree at a particular branch.

    This class shares the same interface of L{Persist} and provides a shortcut
    to access the nodes of a particular branch in a L{Persist}'s tree.

    The chosen branch will be viewed as the root of the tree of the
    L{RootedPersist} and all operations will be forwarded to the parent
    L{Persist} as appropriate.
    """

    def __init__(self, parent, root):
        """
        @param parent: the parent L{Persist}.
        @param root: a branch of the parent L{Persist}'s tree, that
            will be used as root of this L{RootedPersist}.
        """
        self.parent = parent
        if isinstance(root, str):
            self.root = path_string_to_tuple(root)
        else:
            self.root = root

    readonly = property(lambda self: self.parent.readonly)
    modified = property(lambda self: self.parent.modified)

    def assert_writable(self):
        self.parent.assert_writable()

    def has(self, path, value=NOTHING, soft=False, hard=False, weak=False):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        return self.parent.has(self.root + path, value, soft, hard, weak)

    def keys(self, path, soft=False, hard=False, weak=False):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        return self.parent.keys(self.root + path, soft, hard, weak)

    def get(self, path, default=None, soft=False, hard=False, weak=False):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        return self.parent.get(self.root + path, default, soft, hard, weak)

    def set(self, path, value, soft=False, weak=False):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        return self.parent.set(self.root + path, value, soft, weak)

    def add(self, path, value, unique=False, soft=False, weak=False):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        return self.parent.add(self.root + path, value, unique, soft, weak)

    def remove(self, path, value=NOTHING, soft=False, weak=False):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        return self.parent.remove(self.root + path, value, soft, weak)

    def move(self, oldpath, newpath, soft=False, weak=False):
        if isinstance(oldpath, str):
            oldpath = path_string_to_tuple(oldpath)
        if isinstance(newpath, str):
            newpath = path_string_to_tuple(newpath)
        return self.parent.move(
            self.root + oldpath,
            self.root + newpath,
            soft,
            weak,
        )

    def root_at(self, path):
        if isinstance(path, str):
            path = path_string_to_tuple(path)
        return self.parent.root_at(self.root + path)


_splitpath = re.compile(r"(\[-?\d+\])|(?<!\\)\.").split


def path_string_to_tuple(path):
    """Convert a L{Persist} path string to a path tuple.

    Examples:

        >>> path_string_to_tuple("ab")
        ("ab",)
        >>> path_string_to_tuple("ab.cd")
        ("ab", "cd"))
        >>> path_string_to_tuple("ab[0][1]")
        ("ab", 0, 1)
        >>> path_string_to_tuple("ab[0].cd[1]")
        ("ab", 0, "cd", 1)

    Raises L{PersistError} if the given path string is invalid.
    """
    if "." not in path and "[" not in path:
        return (path,)
    result = []
    tokens = _splitpath(path)
    for token in tokens:
        if token:
            if token[0] == "[" and token[-1] == "]":
                try:
                    result.append(int(token[1:-1]))
                except ValueError:
                    raise PersistError(f"Invalid path index: {token!r}")
            else:
                result.append(token.replace(r"\.", "."))
    return tuple(result)


def path_tuple_to_string(path):
    result = []
    for elem in path:
        if type(elem) is int:
            result[-1] += f"[{elem:d}]"
        else:
            result.append(str(elem).replace(".", r"\."))
    return ".".join(result)


class Backend:
    """
    Base class for L{Persist} backends implementing hierarchical storage
    functionality.

    Each node of the hierarchy is an object of type C{dict}, C{list}
    or C{tuple}. A node can have zero or more children, each child can be
    another node or a leaf value compatible with the backend's serialization
    mechanism.

    Each child element is associated with a unique key, that can be used to
    get, set or remove the child itself from its containing node. If the node
    object is of type C{dict}, then the child keys will be the keys of the
    dictionary, otherwise if the node object is of type C{list} or C{tuple}
    the child element keys are the indexes of the available items, or the value
    of items theselves.

    The root node object is always a C{dict}.

    For example:

        >>> backend = Backend()
        >>> root = backend.new()
        >>> backend.set(root, "foo", "bar")
        'bar'
        >>> egg = backend.set(root, "egg", [1, 2, 3])
        >>> backend.set(egg, 0, 10)
        10
        >>> root
        {'foo': 'bar', 'egg': [10, 2, 3]}
    """

    def new(self):
        raise NotImplementedError

    def load(self, filepath):
        raise NotImplementedError

    def save(self, filepath, map):
        raise NotImplementedError

    def get(self, obj, elem, _marker=NOTHING):
        """Lookup a child in the given node object."""
        if type(obj) is dict:
            newobj = obj.get(elem, _marker)
        elif type(obj) in (tuple, list):
            if type(elem) is int:
                try:
                    newobj = obj[elem]
                except IndexError:
                    newobj = _marker
            elif elem in obj:
                newobj = elem
            else:
                newobj = _marker
        else:
            newobj = NotImplemented
        return newobj

    def set(self, obj, elem, value):
        """Set the value of the given child in the given node object."""
        if type(obj) is dict:
            newobj = obj[elem] = value
        elif type(obj) is list and type(elem) is int:
            lenobj = len(obj)
            if lenobj <= elem:
                obj.append(None)
                elem = lenobj
            elif elem < 0 and abs(elem) > lenobj:
                obj.insert(0, None)
                elem = 0
            newobj = obj[elem] = value
        else:
            newobj = NotImplemented
        return newobj

    def remove(self, obj, elem, isvalue):
        """Remove a the given child in the given node object.

        @param isvalue: In case the node object is a C{list}, a boolean
            indicating if C{elem} is the index of the child or the value
            of the child itself.
        """
        result = False
        if type(obj) is dict:
            if elem in obj:
                del obj[elem]
                result = True
        elif type(obj) is list:
            if not isvalue and type(elem) is int:
                try:
                    del obj[elem]
                    result = True
                except IndexError:
                    pass
            elif elem in obj:
                obj[:] = [x for x in obj if x != elem]
                result = True
        else:
            result = NotImplemented
        return result

    def copy(self, value):
        """Copy a node or a value."""
        if type(value) in (dict, list):
            return copy.deepcopy(value)
        return value

    def empty(self, obj):
        """Whether the given node object has no children."""
        return not obj

    def has(self, obj, elem):
        """Whether the given node object contains the given child element."""
        contains = getattr(obj, "__contains__", None)
        if contains:
            return contains(elem)
        return NotImplemented

    def keys(self, obj):
        """Return the keys of the child elements of the given node object."""
        keys = getattr(obj, "keys", None)
        if keys:
            return keys()
        elif type(obj) is list:
            return range(len(obj))
        return NotImplemented


class PickleBackend(Backend):
    def __init__(self):
        import pickle

        self._pickle = pickle

    def new(self):
        return {}

    def load(self, filepath):
        with open(filepath, "rb") as fd:
            return self._pickle.load(fd)

    def save(self, filepath, map):
        with open(filepath, "wb") as fd:
            self._pickle.dump(map, fd, 2)


class BPickleBackend(Backend):
    def __init__(self):
        from landscape.lib import bpickle

        self._bpickle = bpickle

    def new(self):
        return {}

    def load(self, filepath):
        with open(filepath, "rb") as fd:
            return self._bpickle.loads(fd.read())

    def save(self, filepath, map):
        with open(filepath, "wb") as fd:
            fd.write(self._bpickle.dumps(map))
