# -*- coding: utf8 -*-
"""
.. module:: burpui.misc.parser.utils
    :platform: Unix
    :synopsis: Burp-UI configuration file parser utilities.

.. moduleauthor:: Ziirish <hi+burpui@ziirish.me>
"""
import os
import re
import codecs
import shutil

from copy import copy
from hashlib import md5
from collections import OrderedDict
from glob import glob

from ...utils import NOTIF_ERROR, NOTIF_OK, NOTIF_WARN
from ...security import sanitize_string
from ...datastructures import MultiDict


RESET_IDENTIFIER = "_reset_bui_CUSTOM"
BEGIN_TEMPLATES = "BURP-UI TEMPLATES"
END_TEMPLATES = "END TEMPLATES"


class Option(object):
    """Object representing an option

    :param name: Option name
    :type name: str

    :param value: Option value
    :type value: str
    """

    type = None
    delim = "="
    reset_delim = ":="
    _dirty = False
    idx = -1

    def __init__(self, name, value=None):
        self.name = name
        self.value = value
        self._dirty = True
        # is there a special := syntax for this option
        self._is_reset = []

    @property
    def dirty(self):
        return self._dirty

    def is_reset(self):
        """Check if a special ':=' syntax is required based on the value"""
        if self.idx < 0 or not self._is_reset:
            return False
        if self.idx >= len(self._is_reset):
            self.idx = len(self._is_reset) - 1
        return self._is_reset[self.idx]

    def set_reset(self, value=False):
        """Mark this value as requiring a special ':=' syntax"""
        self._is_reset.append("{}".format(value).lower() == "true")
        self.idx += 1

    def set_resets(self, resets):
        self._is_reset = resets

    def get_resets(self):
        return self._is_reset

    def get_reset(self):
        """Return the reset list/flag"""
        return self.is_reset()

    def update(self, value):
        """Change the option value"""
        self._dirty = True
        self.value = value
        self._is_reset = []

    def clean(self):
        """Mark the option as clean"""
        self._dirty = False

    def parse(self):
        """Parse the option value"""
        return self.value

    def dump(self):
        """Return the option representation to store in configuration file"""
        delim = self.delim
        if self.is_reset():
            delim = self.reset_delim
        return "{} {} {}".format(self.name, delim, self.value)

    def __repr__(self):
        """Option representation"""
        return "{} -> {}".format(self.name, self.parse())

    def __str__(self):
        """Option to string"""
        return self.dump()

    def __eq__(self, other):
        other_name = getattr(other, "name", None)
        other_value = getattr(other, "value", None)
        return self.name == other_name and self.value == other_value


class OptionStr(Option):
    """Option type String

    Example:
        server = toto
    """

    type = "string"


class OptionInt(Option):
    """Option type Integer

    Example:
        port = 1234
    """

    type = "integer"

    def parse(self):
        """Parse the option value"""
        try:
            return int(self.value)
        except (ValueError, TypeError):
            return 0


class OptionBool(Option):
    """Option type Boolean

    Example:
        hardlinked_archive = 1
    """

    type = "boolean"

    def __init__(self, name, value=None):
        self.name = name
        self.value = self._format_value(value)
        self._dirty = True
        self._is_reset = []

    def update(self, value):
        """Change the option value"""
        self._dirty = True
        self.value = self._format_value(value)
        self._is_reset = []

    def _format_value(self, value):
        if self._parse(value):
            return 1
        return 0

    @staticmethod
    def _parse(value):
        try:
            if not value:
                return False
            elif value is True:
                return True
            elif "{}".format(value).lower() == "true":
                return True
            # any string will raise the ValueError
            return int(value) == 1
        except ValueError:
            return False

    def parse(self):
        """Parse the option value"""
        return self._parse(self.value)


class OptionInc(Option):
    """Option type Include

    Example:
        . incexc/windows
    """

    type = "include"
    delim = ""

    def __init__(self, parser, name, value=None, root=None, mode="srv", template=False):
        """
        :param parser: Parser instance
        :type parser: :class:`burpui.misc.parser.burp1.Parser`
        """
        super(OptionInc, self).__init__(name, value)
        self.parser = parser
        self.mode = mode
        self.is_template = template
        self.extended = []
        self._is_reset = []
        self._dirty = True
        self._glob = False
        self._glob_dir = None
        self._glob_mtime = -1
        if root:
            self.root = os.path.dirname(root)
        else:
            self.root = None

    @property
    def dirty(self):
        return self._dirty or self._glob_changed

    def _path_absolute(self, path):
        absolute = path
        if not os.path.isabs(path):
            if self.root:
                absolute = os.path.join(self.root, path)
            elif self.mode == "srv":
                absolute = os.path.join(self.parser.root, path)
            else:
                absolute = os.path.join(self.parser.clientconfdir, path)
        return absolute

    @property
    def _glob_changed(self):
        if not self._glob or not self._glob_dir:
            return False
        mtime = os.path.getmtime(self._glob_dir)
        return mtime != self._glob_mtime

    def extend(self):
        """Helper function for the parsing"""
        if not self._dirty and self.extended and not self._glob_changed:
            return self.extended
        paths = []
        root = self._path_absolute(self.value)
        for path in glob(root):
            if (
                self.parser._is_secure_path(path)
                and os.path.isfile(path)
                and not path.endswith("~")
                and not path.endswith(".back")
            ):
                paths.append(path)
        # more than one match mean we have a glob, for sure
        if len(paths) > 1 or (len(paths) == 1 and paths[0] != root):
            self._glob = True
            self._glob_dir = os.path.dirname(root)
            self._glob_mtime = os.path.getmtime(self._glob_dir)
        else:
            self._glob = False
        self.clean()
        self.extended = paths
        return paths

    def parse(self):
        """Parse the option value"""
        return self.extend()

    def dump(self):
        """Return the option representation to store in configuration file"""
        if self.extend() and not self.parser.backend.enforce:
            name = self.name or ""
            if self.is_template and name.startswith("../"):
                name = str(name[3:])
            return ". {}".format(name)
        # if the include did not match anything, we can safely remove it
        return ""


class OptionTpl(Option):
    """Option type Template

    Example:
        . .buitemplates/windows
    """

    type = "template"
    delim = ""

    def __init__(self, parser, name, value=None):
        """
        :param parser: Parser instance
        :type parser: :class:`burpui.misc.parser.burp1.Parser`
        """
        super(OptionTpl, self).__init__(name, value)
        self.parser = parser
        self.extended = False
        self._dirty = True
        if name:
            self._id = name.split(os.path.sep)[-1]
        else:
            self._id = ""

    @property
    def dirty(self):
        return self._dirty

    def _path_absolute(self, path):
        absolute = path
        if not os.path.isabs(path):
            absolute = os.path.join(self.parser.clientconfdir, path)
        return absolute

    def extend(self):
        """Helper function for the parsing"""
        if not self._dirty and self.extended:
            return self.extended
        path = self._path_absolute(self.value)
        self.clean()
        self.extended = path
        return path

    def parse(self):
        """Parse the option value"""
        return self.extend()

    def dump(self):
        """Return the option representation to store in configuration file"""
        if self.extend() and not self.parser.backend.enforce:
            return ". {}".format(self.name)
        # if the include did not match anything, we can safely remove it
        return ""


option_for_type = {
    "integer": OptionInt,
    "string": OptionStr,
    "boolean": OptionBool,
    "include": OptionInc,
}


class OptionMulti(Option):
    """Option type Multi

    Example:
        keep = 7
        keep = 4
    """

    type = "multi"

    def __init__(self, parser, name, value=None):
        self.parser = parser
        self.name = name
        self._dirty = True
        self._is_reset = []
        self.content_type = self.parser.advanced_type.get(name, "string")
        self.associate = getattr(self.parser, "pair_associations", {}).get(self.name)
        self._init_value(value)
        self.idx = len(self.value) - 1

    def _init_value(self, value):
        if value:
            container = self._obj_for_type()
            if not isinstance(value, list):
                value = container(self.name, value)
                self.value = [value]
            else:
                self.value = [container(self.name, x) for x in value]
        else:
            self.value = []

    def _obj_for_type(self):
        return option_for_type.get(self.content_type, OptionStr)

    def _wrap_object(self, value):
        container = self._obj_for_type()
        return container(self.name, value)

    def append(self, value, reset=None):
        self._dirty = True
        if isinstance(value, list):
            for v in value:
                self.value.append(self._wrap_object(v))
        else:
            self.value.append(self._wrap_object(value))
        if reset is not None:
            self.set_reset(reset)
        return self.value

    def update(self, value):
        """Change the option value"""
        self._dirty = True
        self._init_value(value)
        self._is_reset = []

    def remove(self, value):
        idx = self.value(value)
        del self.value[idx]
        self._dirty = True
        return self.value

    def index(self, value):
        for i, x in enumerate(self):
            if x == value:
                return i
        raise ValueError("{} is not in list".format(value))

    def len(self):
        return len(self.value)

    def dump(self, start=0, strict=True):
        """Return the option representation to store in configuration file"""
        ret = ""
        if start > len(self.value):
            return ret
        res = [self.dump_index(i, strict) for i in range(start, len(self.value))]
        ret = "\n".join(res)
        return ret.rstrip("\n")

    def dump_index(self, index, strict=True):
        if index >= len(self.value):
            return ""
        val = self.value[index]
        delim = self.delim
        self.idx = index
        if self.is_reset():
            delim = self.reset_delim
        return "{} {} {}".format(
            self.name, delim, sanitize_string("{}".format(val.parse()), strict)
        )

    def parse(self):
        return [x.parse() for x in self.value]

    def get_reset(self):
        return self._is_reset

    def __len__(self):
        return len(self.value)

    def __getitem__(self, ii):
        return self.value[ii].parse()

    def __delitem__(self, ii):
        del self.value[ii]
        self._dirty = True

    def __setitem__(self, ii, val):
        self.value[ii] = self._wrap_object(val)
        self._dirty = True

    def insert(self, ii, val):
        self.value.insert(ii, self._wrap_object(val))

    def __iter__(self):
        for x in self.value:
            yield x.parse()


class OptionPair(Option, dict):
    """Option type Pair

    Example:
        port = 4971
        max_children = 5
    """

    type = "pair"

    def __init__(self, parser, name, value=None):
        self.parser = parser
        self.name = name
        self.association = getattr(self.parser, "pair_associations", {}).get(self.name)
        self._dirty = True
        self._init_value(name, value)
        # is there a special := syntax for this option
        self._is_reset = []

    def _init_value(self, name, value):
        self.value = {}
        if value:
            value = OptionMulti(self.parser, name, value)
            self.value[name] = value

    def append(self, name, value):
        self._dirty = True
        if name != self.name:
            self.association = name
        if name in self.value:
            self.value[name].append(value)
        else:
            self.value[name] = OptionMulti(self.parser, name, value)
        return self.value

    def update(self, name, value):
        """Change the option value"""
        self._dirty = True
        self._init_value(name, value)
        self._is_reset = []

    def remove(self, name, value):
        self._dirty = True
        try:
            self.value.get(name, []).remove(value)
        except ValueError:
            pass
        return self.value

    def index(self, name, value):
        try:
            return self.value.get(name, []).index(value)
        except ValueError:
            return -1

    def len(self, name):
        if name in self.value:
            return self.value[name].len()
        return -1

    def dump(self, start=0, strict=True):
        """Return the option representation to store in configuration file"""
        ret = ""
        try:
            self_length = len(self.value[self.name])
        except KeyError:
            self_length = -1
        try:
            associate_length = len(self.value[self.association])
        except KeyError:
            associate_length = -1
        if start >= self_length and start >= associate_length:
            return ret
        for idx in range(start, max(self_length, associate_length)):
            v1 = self.dump_index(self.name, idx, strict)
            v2 = self.dump_index(self.association, idx, strict)
            if v1:
                ret += "{}\n".format(v1)
            if v2:
                ret += "{}\n".format(v2)
        return ret.rstrip("\n")

    def dump_index(self, name, index, strict):
        length = self.len(name)
        try:
            length = len(self.value[name])
        except KeyError:
            length = -1
        if index >= length:
            return ""
        return self.value.get(name).dump_index(index, strict)

    def parse(self, key=None):
        if key:
            if key not in self.value:
                return []
            return self.value[key].parse()
        ret = {}
        for key, opts in self.value.items():
            ret[key] = opts.parse()
        return ret

    def __setitem__(self, key, item):
        self.append(key, item)

    def __getitem__(self, key):
        return self.value.get(key, OptionMulti(self.parser, key))

    def get(self, key, default=None):
        try:
            return self.value[key]
        except KeyError:
            if default:
                return default
            return OptionMulti(self.parser, key)

    def __len__(self):
        return len(self.value)

    def __delitem__(self, key):
        del self.value[key]

    def clear(self):
        self.value.clear()

    def copy(self):
        return self.value.copy()

    def has_key(self, key):
        return key in self.value

    def keys(self):
        return self.value.keys()

    def values(self):
        return self.value.values()

    def items(self):
        return self.value.items()

    def pop(self, *args):
        return self.value.pop(*args)

    def __contains__(self, item):
        return item in self.value

    def __iter__(self):
        return iter(self.value)


class File(dict):
    """Object representing a configuration file

    :param parser: Parser object
    :type parser: :class:`burpui.misc.parser.doc.Doc`
    """

    md5 = None
    mtime = 0

    def __init__(self, parser, name=None, mode="srv", parent=None, is_template=False):
        """
        :param parser: Parser object
        :type parser: :class:`burpui.misc.parser.doc.Doc`

        :param name: File name
        :type name: str

        :param mode: Configuration type
        :type mode: str
        """
        # _dirty is used to know if the object changed
        self._dirty = False
        # _changed is used to know if the file changed since last read
        self._changed = True
        # _parsing_templates is used to know if we are currently parsing templates
        self._parsing_templates = False
        # cache the content of the file
        self._raw = []
        self._raw_data = MultiDict()
        self._data = MultiDict()
        self._is_template = is_template
        self.parser = parser
        self.mode = mode
        self.name = name
        self.parent = parent
        self.updated = []
        self.associations = set()
        self.reset = {}
        self.options = OrderedDict()
        self.types = {
            "boolean": OrderedDict(),
            "integer": OrderedDict(),
            "include": OrderedDict(),
            "multi": OrderedDict(),
            "pair": OrderedDict(),
            "string": OrderedDict(),
            "template": OrderedDict(),
        }
        if self.name:
            self.parse()

    @property
    def changed(self):
        for key, val in self.types["include"].items():
            if val.dirty:
                self._changed = True
                return self._changed
        for key, val in self.types["template"].items():
            if val.dirty:
                self._changed = True
                return self._changed
        try:
            if self.name:
                mtime = os.path.getmtime(self.name)
            else:
                self._changed = True
                return True
        except os.error:
            self._changed = True
            return True
        if mtime != self.mtime:
            self._changed = self._md5 != self.md5
            return self._changed
        self._changed = mtime != self.mtime
        return self._changed

    @property
    def version(self):
        return (
            getattr(self.parser.backend, "server_version", None)
            or getattr(self.parser.backend, "client_version", None)
            or ""
            if self.parser.backend
            else ""
        )

    def _ret_data(self, raw=True):
        if raw:
            ret = self._raw_data
        else:
            ret = self._data
        if ret and not self.dirty:
            ret.setlist("includes", [x for x in self.flatten("include", False).keys()])
            return ret
        ret.clear()
        for key, val in self.options.items():
            if isinstance(val, OptionMulti) and not raw:
                ret.setlist(key, val.parse())
            elif isinstance(val, OptionPair) and not raw:
                ret.setlist(key, val.parse(key))
            else:
                ret[key] = val if raw else val.parse()
        ret.setlist("includes", [x for x in self.flatten("include", False).keys()])
        ret.setlist("includes_ori", [x for x in self.flatten("include", False).keys()])
        return ret

    @property
    def raw_data(self):
        return self._ret_data()

    @property
    def data(self):
        return self._ret_data(False)

    def clone(self):
        cpy = File(self.parser, name=self.name, mode=self.mode)
        cpy.options = copy(self.options)
        cpy.types = copy(self.types)
        return cpy

    def clean(self):
        self._dirty = False
        for opt in self.options.values():
            opt.clean()

    def get_name(self):
        return self.name

    def set_name(self, name):
        self.name = name
        self.parse()

    def flatten(self, typ, listed=True, parse=True):
        self._refresh_types()
        if listed:
            return [
                {
                    "name": key,
                    "value": opt.parse() if parse else opt,
                    "reset": opt.get_reset(),
                }
                for key, opt in self.types[typ].items()
            ]
        ret = OrderedDict()
        if typ in self.types:
            for key, opt in self.types[typ].items():
                ret[key] = opt.parse() if parse else opt
        return ret

    def flatten_obj(self, name, obj, parse=True):
        return {
            "name": name,
            "value": obj.parse() if parse else obj,
            "reset": obj.get_reset(),
        }

    @property
    def dirty(self):
        if not self._dirty:
            self._dirty = any(x.dirty for x in self.options.values())
        return self._dirty

    @property
    def boolean(self):
        return self.flatten("boolean")

    @property
    def integer(self):
        return self.flatten("integer")

    @property
    def pair(self):
        return self.flatten("pair")

    @property
    def include(self):
        return self.flatten("include")

    @property
    def template(self):
        ret = []
        for tpl in self.flatten("template", parse=False):
            ret.append(
                {
                    "value": tpl["name"],
                    "name": tpl["value"]._id,
                }
            )
        return ret

    @property
    def multi(self):
        return self.flatten("multi")

    @property
    def string(self):
        return self.flatten("string")

    def _refresh_types(self):
        if self._dirty:
            for key in self.types.keys():
                self.types[key] = OrderedDict()

            for key, opt in self.options.items():
                if key not in self.associations:
                    self.types[opt.type][key] = opt

        self._dirty = False

    def _options_for_type(self, typ):
        return getattr(self.parser, "{}_{}".format(typ, self.mode), [])

    def _type_for_option(self, opt):
        if opt == ".":
            return "include"

        for typ in ["boolean", "integer", "multi", "string", "pair"]:
            if opt in self._options_for_type(typ):
                return typ
        return None

    def _new_opt(self, key, value=None, typ=None):
        typ = typ or self._type_for_option(key)

        if typ == "boolean":
            return OptionBool(key, value)
        if typ == "integer":
            return OptionInt(key, value)
        if typ == "multi":
            opt = self.options.get(key, OptionMulti(self.parser, key))
            opt.append(value)
            return opt
        if typ == "pair":
            return OptionPair(self.parser, key, value)
        if typ == "include":
            key = value
            return OptionInc(
                self.parser,
                key,
                value,
                root=self.name,
                mode=self.mode,
                template=self._is_template,
            )

        return OptionStr(key, value)

    @property
    def _md5(self):
        """Compute the md5sum of the file"""
        hash_md5 = md5()
        try:
            with open(self.name, "rb") as bfile:
                for chunk in iter(lambda: bfile.read(4096), b""):
                    hash_md5.update(chunk)
            return hash_md5.hexdigest()
        except IOError:
            return None

    def get(self, key, default=None):
        try:
            if key in self._options_for_type("pair"):
                return self.options[key].get(key)
            return self.options[key]
        except KeyError:
            return default

    def __getitem__(self, key):
        if key in self._options_for_type("pair"):
            return self.options[key].get(key)
        return self.options[key]

    def __setitem__(self, key, value):
        self._dirty = True
        if key in self._options_for_type("boolean"):
            opt = OptionBool(key, value)
        elif key in self._options_for_type("integer"):
            opt = OptionInt(key, value)
        elif key in self._options_for_type("multi"):
            opt = self.options.get(key, OptionMulti(self.parser, key))
            if isinstance(value, list):
                opt.update(value)
            else:
                opt.append(value)
        elif key in self._options_for_type("pair"):
            association = self.parser.pair_associations.get(key)
            if key not in self.options and association not in self.options:
                self.associations.add(association)
                opt = OptionPair(self.parser, key)
            elif association in self.options:
                opt = self.options.get(association)
            else:
                opt = self.options.get(key)
            if isinstance(value, list):
                opt.update(key, value)
            else:
                opt.append(key, value)
        elif key == ".":
            key = value
            if self._parsing_templates:
                opt = OptionTpl(self.parser, key, value)
            else:
                opt = OptionInc(
                    self.parser,
                    key,
                    value,
                    root=self.name,
                    mode=self.mode,
                    template=self._is_template,
                )
                if self._is_template and key.startswith("../"):
                    key = str(key[3:])
        else:
            opt = OptionStr(key, value)
        self.options[key] = opt
        if key not in self.associations:
            self.types[opt.type][key] = opt

    def __repr__(self):
        self._refresh_types()
        ret = ""
        for key, opts in self.types.items():
            ret += "{} =>\n".format(key)
            for key2, opt in opts.items():
                if key2 in self.associations:
                    continue
                ret += "\t" + repr(opt) + "\n"
        return ret.rstrip("\n")

    def __str__(self):
        ret = ""
        for key, val in self.options.items():
            if key in self.associations:
                continue
            tmp = str(val)
            if tmp:
                ret += tmp + "\n"
        return ret.rstrip("\n")

    def __len__(self):
        return len(self.options)

    def __delitem__(self, key):
        self._dirty = True
        del self.options[key]

    def clear(self):
        self._dirty = True
        for key in self.types.keys():
            self.types[key].clear()
        return self.options.clear()

    def copy(self):
        return self.options.copy()

    def has_key(self, k):
        return k in self.options

    def update(self, *args, **kwargs):
        self._dirty = True
        return self.options.update(*args, **kwargs)

    def keys(self):
        return self.options.keys()

    def values(self):
        return self.options.values()

    def items(self):
        return self.options.items()

    def pop(self, *args):
        self._dirty = True
        return self.options.pop(*args)

    def __contains__(self, item):
        return item in self.options

    def __iter__(self):
        return iter(self.options)

    @property
    def raw(self):
        if self._raw and not self._changed and not self.changed:
            return self._raw
        if not self.name:
            return self._raw
        try:
            with codecs.open(self.name, "r", "utf-8", errors="ignore") as fil:
                self._raw = [x.rstrip("\n") for x in fil.readlines()]
        except IOError:
            return self._raw

        self._changed = False
        self.mtime = os.path.getmtime(self.name)
        self.md5 = self._md5
        return self._raw

    def _dump_resets(self):
        ret = {}
        for key, val in self.options.items():
            resets = val.get_resets()
            if resets:
                ret[key] = resets
        return ret

    def _format_key(self, key, data, index=None, dry=False):
        strict = "regex" not in key
        if not dry:
            self._changed = True

        # special case
        if key == ".":
            val = sanitize_string(data)
            if self._is_template and not val.startswith("../"):
                val = "../{}".format(val)
            if dry:
                return val
            else:
                return f". {val}"

        if index is not None and index >= 0:
            if key not in self.updated:
                val = data.getlist(key)
                if key in self:
                    self[key].update(val)
                else:
                    self[key] = val
                self.updated.append(key)
                if key in self.reset:
                    self[key].set_resets(self.reset[key])
            return str(self[key].dump_index(index, strict))

        if key in getattr(self.parser, "multi_{}".format(self.mode)) or key in getattr(
            self.parser, "pair_{}".format(self.mode), []
        ):
            if key not in self.updated:
                val = data.getlist(key)
                if key in self:
                    self[key].update(val)
                else:
                    self[key] = val
                self.updated.append(key)
                if key in self.reset:
                    self[key].set_resets(self.reset[key])
            val = self[key]
            return str(self[key].dump(strict=strict))

        if key not in self.updated:
            if key in self.parser.boolean_srv or key in self.parser.boolean_cli:
                val = data.get(key)
            else:
                val = sanitize_string(str(data.get(key)), strict)
            if key in self:
                self[key].update(val)
            else:
                self[key] = val
            self.updated.append(key)
            if key in self.reset:
                self[key].set_resets(self.reset[key])

        if dry:
            ret = self[key].parse()
            if index is not None:
                return ret[index]
            return ret
        return str(self[key])

    def parse(self, force=False):
        """Parse the current config"""
        if not self._changed and not self.changed and not force:
            return

        self.clear()
        for line in self.raw:
            if re.match(r"^\s*#", line):
                if BEGIN_TEMPLATES in line:
                    self._parsing_templates = True
                if END_TEMPLATES in line:
                    self._parsing_templates = False
                continue
            res = re.search(r"\s*([^=\s]+)\s*(:)?=?\s*(.*)$", line)
            if res:
                key = res.group(1)
                reset = res.group(2)
                val = res.group(3)
                if key == "compression":
                    val = val.replace("zlib", "gzip")
                elif key == "ssl_compression":
                    val = val.replace("gzip", "zlib")
                self[key] = val
                if key in self:
                    try:
                        self[key].set_reset(reset is not None)
                    except AttributeError:
                        pass

        self._dirty = False

    def _store(self, data, dest=None, insecure=False):
        """Store the config"""
        dest = dest or self.name
        if not dest:
            return [[NOTIF_ERROR, "No file defined!"]]

        dirname = os.path.dirname(dest)
        filename = os.path.basename(dest)
        if dirname and not os.path.exists(dirname):
            try:
                os.makedirs(dirname, 0o755)
            except OSError as exp:
                return [[NOTIF_WARN, str(exp)]]

        if not insecure:
            self.reset = {}
            ref = os.path.join(dirname, ".{}.bui.init.back~".format(filename))
            bak = os.path.join(dirname, ".{}.back~".format(filename))

            if not os.path.isfile(ref) and os.path.isfile(dest):
                try:
                    shutil.copy(dest, ref)
                except IOError as exp:
                    return [[NOTIF_ERROR, str(exp)]]
            elif os.path.isfile(dest):
                try:
                    shutil.copy(dest, bak)
                except IOError as exp:
                    return [[NOTIF_ERROR, str(exp)]]
        else:
            self.reset = self._dump_resets()

        def _make_it_bool(array):
            return ["{}".format(x).lower() == "true" for x in array]

        errs = []

        for key in data.keys():
            if key in self.parser.files:
                dat = data.get(key)
                if not os.path.isfile(dat):
                    typ = "strings"
                    if key in getattr(self.parser, "multi_{}".format(self.mode)):
                        typ = "multis"
                    elif key in getattr(self.parser, "boolean_{}".format(self.mode)):
                        typ = "bools"
                    elif key in getattr(self.parser, "integer_{}".format(self.mode)):
                        typ = "integers"
                    # highlight the wrong parameters
                    errs.append(
                        [
                            NOTIF_ERROR,
                            "Sorry, the file '{}' does not exist".format(dat),
                            key,
                            typ,
                        ]
                    )
            elif key.endswith(RESET_IDENTIFIER):
                target = key.replace(RESET_IDENTIFIER, "")
                if target in getattr(self.parser, "multi_{}".format(self.mode)):
                    self.reset[target] = _make_it_bool(data.getlist(key))
                else:
                    self.reset[target] = data.get(key)

        if errs and not insecure:
            return errs

        orig = self.raw
        oldkeys = [self._get_line_key(x) for x in orig]
        newkeys = list(set(data.keys()) - set(oldkeys))

        multi_index_map = {}
        pair_index_map = {}
        already_multi = set()
        already_pair = set()
        already_file = []
        written = []
        result = []
        self.updated = []

        def _lookup_option(key, val, start=0, strict=True, comment=True):
            """returns a list of tuples (idx, line)"""
            # re.match implies /^.../
            reg = r"\s*{}\s*:?=?".format(key)
            if comment:
                reg = r"\s*#*{}".format(reg)
            if strict:
                reg = r"{}\s*{}$".format(reg, val)
            start = min(start, len(orig))
            for idx, line in enumerate(orig[start:], start):
                if re.match(reg, line.rstrip("\n")):
                    return idx, line
            return -1, None

        def _is_key_after(key, start=0):
            """checks if a key is present in the following lines"""
            idx, _ = _lookup_option(key, None, start, False)
            return idx != -1

        def _dump(line, comment=None, raw=False):
            if raw:
                return line
            lead = ""
            if comment:
                lead = "#"
            return "{}{}".format(lead, line)

        try:
            with codecs.open(dest, "w", "utf-8", errors="ignore") as fil:
                # f.write('# Auto-generated configuration using Burp-UI\n')
                data_keys = list(data.keys())
                if "templates" in data:
                    result.append(_dump(" {}".format(BEGIN_TEMPLATES), True))
                    tpls = data.getlist("templates")
                    for tpl in tpls:
                        result.append(self._format_key(".", tpl))
                    result.append(_dump(" {}".format(END_TEMPLATES), True))
                skip_line = False
                for idx, line in enumerate(orig):
                    if self._line_is_comment(line) and BEGIN_TEMPLATES in line:
                        skip_line = True
                    if self._line_is_comment(line) and END_TEMPLATES in line:
                        skip_line = False
                        continue
                    if skip_line:
                        continue
                    key = self._get_line_key(line, False)
                    if (
                        self._line_removed(line, data_keys)
                        and not self._line_is_comment(line)
                        and not self._line_is_file_include(line)
                    ):
                        # The line was removed, we comment it
                        result.append(_dump(line, comment=True))
                    elif self._line_is_file_include(line):
                        # The line is a file inclusion, we check if the line
                        # was already present
                        ori = self._include_get_file(line)
                        if self._is_template and ori.startswith("../"):
                            ori = str(ori[3:])
                        if (
                            ori in data.getlist("includes_ori")
                            and ori not in already_file
                        ):
                            idx = data.getlist("includes_ori").index(ori)
                            inc = data.getlist("includes")[idx]
                            result.append(self._format_key(".", inc))
                            already_file.append(inc)
                        else:
                            if not insecure:
                                comment = not self._line_is_comment(line)
                                result.append(_dump(line, comment=comment))
                            else:
                                result.append(_dump(line))

                    elif key in data_keys:
                        # The line is still present or has been un-commented,
                        # rewrite it with eventual changes
                        multi = key in getattr(
                            self.parser, "multi_{}".format(self.mode)
                        )
                        pair = key in getattr(
                            self.parser, "pair_{}".format(self.mode), []
                        )
                        if pair and key not in pair_index_map:
                            pair_index_map[key] = 0
                        if multi and key not in multi_index_map:
                            multi_index_map[key] = 0
                        if key in written:
                            result.append(
                                _dump(line, comment=(not self._line_is_comment(line)))
                            )
                        else:
                            if multi:
                                length = len(self[key]) if key in self else -1
                                if key not in already_multi and (
                                    key not in self
                                    or (key in self and length > multi_index_map[key])
                                ):
                                    result.append(
                                        self._format_key(
                                            key, data, multi_index_map[key]
                                        )
                                    )
                                    multi_index_map[key] += 1
                                else:
                                    result.append(
                                        _dump(
                                            line,
                                            comment=(not self._line_is_comment(line)),
                                        )
                                    )
                                    continue
                                if len(self[key]) == multi_index_map[key]:
                                    already_multi.add(key)
                                    continue
                                # dump the rest of the multi if there are no
                                # more keys in the conf
                                if not _is_key_after(key, idx + 1):
                                    rest = self[key].dump(multi_index_map[key])
                                    if rest:
                                        result.append(_dump(rest))
                                    multi_index_map[key] = length
                                    already_multi.add(key)
                            elif pair:
                                length = len(self[key]) if key in self else -1
                                if key not in already_pair and (
                                    key not in self
                                    or (key in self and length > pair_index_map[key])
                                ):
                                    result.append(
                                        self._format_key(key, data, pair_index_map[key])
                                    )
                                    pair_index_map[key] += 1
                                else:
                                    result.append(
                                        _dump(
                                            line,
                                            comment=(not self._line_is_comment(line)),
                                        )
                                    )
                                    continue
                                if len(self[key]) == pair_index_map[key]:
                                    already_pair.add(key)
                                    continue
                                # dump the rest of the pair if there are no more
                                # keys in the conf
                                if not _is_key_after(key, idx + 1):
                                    rest = self[key].dump(pair_index_map[key])
                                    if rest:
                                        result.append(_dump(rest))
                                    pair_index_map[key] = length
                                    already_pair.add(key)
                            else:
                                # if key is not a multi and is not a password, we
                                # recycle the line
                                if key not in getattr(
                                    self.parser, "multi_{}".format(self.mode)
                                ) and (
                                    key == "password_check" or "password" not in key
                                ):
                                    if self._line_is_comment(line) and key in data:
                                        result.append(self._format_key(key, data))
                                        if key in newkeys:
                                            ridx = newkeys.index(key)
                                            del newkeys[ridx]
                                        continue
                                # The line was a comment and there was a further
                                # matching setting, so we just jump to the
                                # following
                                else:
                                    if self._line_is_comment(line) and _lookup_option(
                                        key, None, idx + 1, False
                                    ):
                                        result.append(_dump(line, raw=True))
                                        continue

                                val = self._format_key(key, data, dry=True)
                                lookup, _ = _lookup_option(key, val, idx + 1)
                                # The same option is here later, skip the
                                # current one
                                if lookup != -1:
                                    result.append(_dump(line, raw=True))
                                    continue

                                written.append(key)
                                result.append(self._format_key(key, data))
                    else:
                        result.append(
                            _dump(
                                line,
                                comment=(
                                    key in written and not self._line_is_comment(line)
                                ),
                            )
                        )

                # write the rest of the multi settings
                for key, idx in multi_index_map.items():
                    if key not in already_multi and idx < self[key].len():
                        result.append(_dump(self[key].dump(idx)))
                # write the rest of the pair settings
                for key, idx in pair_index_map.items():
                    if key not in already_pair and idx < self[key].len():
                        result.append(_dump(self[key].dump(idx)))
                # Write the rest of file inclusions
                if "includes" in data:
                    for inc in data.getlist("includes"):
                        if inc not in already_file:
                            result.append(self._format_key(".", inc))
                # Write the new keys
                for key in newkeys:
                    if key.endswith(RESET_IDENTIFIER):
                        continue
                    if (
                        key not in written
                        and key not in already_multi
                        and key not in already_pair
                        and key not in ["includes", "includes_ori", "templates"]
                    ):
                        result.append(self._format_key(key, data))

                if len(result) > 0 and result[-1] != "":
                    result.append("")
                fil.write("\n".join(result))

        except Exception as exp:
            # something went wrong, we revert to the last file
            with codecs.open(dest, "w", "utf-8", errors="ignore") as fil:
                fil.writelines(orig)
            return [[NOTIF_ERROR, str(exp)]]

        self.parse(True)

        return [[NOTIF_OK, "Configuration successfully saved."]]

    def store(self, dest=None, insecure=False):
        """Store the current conf object"""
        data = self.data
        return self._store(data, dest, insecure)

    def store_data(self, data, insecure=False):
        """Store the conf given a object data"""
        return self._store(data, insecure=insecure)

    @staticmethod
    def _line_is_comment(line):
        """Check whether a given line is a comment or not"""
        if not line:
            return False
        return line.startswith("#")

    @staticmethod
    def _line_is_file_include(line):
        """Check whether a given line is a file inclusion or not"""
        if not line:
            return False
        return line.startswith(".") or re.match(r"^#+\s*\.", line) is not None

    @staticmethod
    def _include_get_file(line):
        """Return the path of the included file(s)"""
        if not line:
            return None
        _, fil = re.split(r"\s+", line, 1)
        return fil

    @staticmethod
    def _get_line_key(line, ignore_comments=True):
        """Return the key of a given line"""
        if not line:
            return ""
        if "=" not in line:
            return line
        (key, _) = re.split(r"\s*:?=\s*", line, 1)
        if not ignore_comments:
            key = key.strip("# ")
        return key.strip()

    @staticmethod
    def _line_removed(line, keys):
        """Check whether a given line has been removed in the updated version"""
        if not line:
            return False
        (key, _) = re.split(r"\s+|:?=", line, 1)
        key = key.strip()
        return key not in keys


class Config(File):
    """Object representing a configuration

    A config is like a virtual file so we can reuse some methods

    :param parser: Parser object
    :type parser: :class:`burpui.misc.parser.doc.Doc`
    """

    def __init__(self, path=None, parser=None, mode="srv"):
        """
        :param parser: Parser object
        :type parser: :class:`burpui.misc.parser.doc.Doc`

        :param mode: Configuration type
        :type mode: str
        """
        # we need an OrderedDict since the order of the configuration matters
        self.files = OrderedDict()
        self._tree = []
        self.default = path
        self.name = path
        self._includes = []
        self._templates = []
        self._is_template = False
        self._dirty = True
        if path:
            self.files[path] = File(parser, path, mode=mode)
        super(Config, self).__init__(parser, path, mode)

    @property
    def changed(self):
        for path, conf in self.files.items():
            if conf.changed:
                return True
        return False

    def _parse(self):

        orig = self.files.copy()
        for root, conf in orig.items():
            conf.parse()
            for key, val in conf.flatten("include", False).items():
                for path in val:
                    if not os.path.isabs(path):
                        path = os.path.join(os.path.dirname(root), path)
                    self.add_file(path, root)
                    self._includes.append(path)
            for key, path in conf.flatten("template", False).items():
                if not os.path.isabs(path):
                    path = os.path.join(os.path.dirname(root), path)
                self.add_file(path, root)
                self._templates.append(path)

        # recursively parse the conf
        if orig != self.files:
            self._parse()

    def parse(self, force=False):
        if not self.changed and not force:
            return

        del self._includes[:]
        del self._templates[:]
        self._parse()

        removed = []
        orig = self.files.copy()
        for path, conf in orig.items():
            if conf.parent and (
                (conf.name not in self._includes and conf.name not in self._templates)
                or conf.name in removed
            ):
                removed.append(path)
                self.del_file(path)

    @property
    def tree(self):
        if not self.changed and not self.dirty and self._tree:
            return self._tree

        # make sure to refresh files list
        self.parse(True)

        def __new_node(name, parent=None):
            dirname = os.path.dirname(name)
            basename = os.path.basename(name)
            return {
                "name": basename,
                "title": basename,
                "full": name,
                "dir": dirname,
                "parent": parent,
                "children": [],
            }

        self._tree[:]
        dflt = self.get_default(True)
        temp = {}

        # retrieve the offset of the default conf
        offset = 0
        for idx, path in enumerate(self.files.keys()):
            if path == dflt.name:
                offset = idx
                break

        for idx, (top, conf) in enumerate(self.files.items()):
            if idx < offset:
                continue
            if idx > offset:
                break
            node = __new_node(conf.name)
            for key, val in conf.flatten("include", False).items():
                for path in val:
                    if not os.path.isabs(path):
                        path = os.path.join(os.path.dirname(top), path)
                    node["children"].append(__new_node(path, node["full"]))
            temp[conf.name] = node
        self._tree = [x for x in temp.values()]
        return self._tree

    def store(self, conf=None, dest=None, insecure=False):
        ret = []
        if conf and conf in self.files:
            return self.files[conf].store(dest, insecure)
        for name, conf in self.files.items():
            ret += conf.store(insecure=insecure)
        return ret

    def store_data(self, conf, data, insecure=False):
        return self.get_file(conf).store_data(data, insecure)

    def clone(self):
        cpy = Config(self.name, self.parser, self.mode)
        for path, parsed in self.files.items():
            if path == self.name:
                continue
            cpy.add_file(path)
        return cpy

    def set_default(self, path):
        self.default = path
        self.name = path
        try:
            default = self.get_default(True)
            if not self.parser and default:
                self.parser = getattr(self.get_default(), "parser")
            if not self.mode and default:
                self.mode = getattr(self.get_default(), "mode")
        except ValueError:
            pass

    def get_default(self, exc=False):
        if self.default:
            return self.get_file(self.default)
        if exc:
            raise ValueError("No default configuration found")
        return File(self.parser, mode=self.mode)

    def add_file(self, path=None, parent=None):
        idx = path or self.default
        if idx not in self.files:
            self.files[idx] = File(
                self.parser, idx, self.mode, parent, self._is_template
            )
            self._dirty = True
        return self.files[idx]

    def get_file(self, path):
        ret = self.files.get(path, File(self.parser, path, mode=self.mode))
        ret.parse()
        return ret

    def del_file(self, path):
        self._dirty = True
        del self.files[path]

    def list_files(self):
        return self.files.keys()

    def set_template(self, val):
        self._is_template = val

    def _refresh(self):
        if self._dirty or any(x.dirty for x in self.files.values()):
            # cleanup "caches"
            self.options.clear()
            self.options = OrderedDict()
            for key in list(self.types.keys()):
                del self.types[key]
                self.types[key] = OrderedDict()

            # now update caches with new values
            for fil in self.files.values():
                self.options.update(fil.options)
                self.associations = self.associations.union(fil.associations)
                # FIXME: find a way to cache efficiently
                # fil.clean()

            for key, val in self.options.items():
                if key not in self.associations:
                    self.types[val.type][key] = val

        self._dirty = False

    def _get(self, key, default=None, raw=False):
        self._refresh()
        try:
            obj = self.options[key]
            if obj.type in ["pair"] and not raw:
                obj = obj.get(key)
        except KeyError:
            if default:
                return default
            if self.parser and key in self.parser.defaults:
                obj = self._new_opt(key, self.parser.defaults[key])
            else:
                return None
        return obj if raw else obj.parse()

    def get_raw(self, key, default=None):
        return self._get(key, default, True)

    def get(self, key, default=None):
        return self._get(key, default)

    def getlist(self, key):
        obj = self._get(key, raw=True)
        if not obj:
            return []
        if isinstance(obj, OptionMulti):
            return obj.parse()
        else:
            return [obj.parse()]

    def __getitem__(self, key):
        self._refresh()
        if key in self._options_for_type("pair"):
            return self.options[key].get(key)
        return self.options[key]

    def __setitem__(self, key, value):
        self.get_default(True)[key] = value
        self._dirty = True

    def __repr__(self):
        self._refresh()
        ret = ""
        for key, fil in self.files.items():
            ret += ">" * 5 + key + "<" * 5 + "\n"
            ret += repr(fil) + "\n"
        return ret.rstrip("\n")

    def __str__(self):
        self._refresh()
        return super(Config, self).__str__()

    def __len__(self):
        self._refresh()
        return len(self.options)

    def __delitem__(self, key):
        del self.get_default(True)[key]
        self._dirty = True

    def clear(self):
        self._dirty = True
        return self.files.clear()

    def copy(self):
        return self.files.copy()

    def has_key(self, k):
        self._refresh()
        return k in self.options

    def update(self, *args, **kwargs):
        self._dirty = True
        return self.get_default(True).update(*args, **kwargs)

    def keys(self):
        self._refresh()
        return self.options.keys()

    def values(self):
        self._refresh()
        return self.options.values()

    def items(self):
        self._refresh()
        return self.options.items()

    def pop(self, *args):
        self._dirty = True
        return self.get_default(True).pop(*args)

    def __contains__(self, item):
        self._refresh()
        return item in self.options

    def __iter__(self):
        self._refresh()
        return iter(self.options)
