# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import re
from compare_locales import mozpath
from .matcher import Matcher


class ExcludeError(ValueError):
    pass


class ProjectConfig:
    """Abstraction of l10n project configuration data."""

    def __init__(self, path):
        self.filter_py = None  # legacy filter code
        # {
        #  'l10n': pattern,
        #  'reference': pattern,  # optional
        #  'locales': [],  # optional
        #  'test': [],  # optional
        # }
        self.path = path
        self.root = None
        self.paths = []
        self.rules = []
        self.locales = None
        # cache for all_locales, as that's not in `filter`
        self._all_locales = None
        self.environ = {}
        self.children = []
        self.excludes = []
        self._cache = None

    def same(self, other):
        """Equality test, ignoring locales."""
        if other.__class__ is not self.__class__:
            return False
        if len(self.children) != len(other.children):
            return False
        for prop in ("path", "root", "paths", "rules", "environ"):
            if getattr(self, prop) != getattr(other, prop):
                return False
        for this_child, other_child in zip(self.children, other.children):
            if not this_child.same(other_child):
                return False
        return True

    def set_root(self, basepath):
        if self.path is None:
            self.root = None
            return
        self.root = mozpath.abspath(mozpath.join(mozpath.dirname(self.path), basepath))

    def add_environment(self, **kwargs):
        self.environ.update(kwargs)

    def add_paths(self, *paths):
        """Add path dictionaries to this config.
        The dictionaries must have a `l10n` key. For monolingual files,
        `reference` is also required.
        An optional key `test` is allowed to enable additional tests for this
        path pattern.
        """
        self._all_locales = None  # clear cache
        for d in paths:
            rv = {
                "l10n": Matcher(d["l10n"], env=self.environ, root=self.root),
                "module": d.get("module"),
            }
            if "reference" in d:
                rv["reference"] = Matcher(
                    d["reference"], env=self.environ, root=self.root
                )
            if "test" in d:
                rv["test"] = d["test"]
            if "locales" in d:
                rv["locales"] = d["locales"][:]
            self.paths.append(rv)

    def set_filter_py(self, filter_function):
        """Set legacy filter.py code.
        Assert that no rules are set.
        Also, normalize output already here.
        """
        assert not self.rules

        def filter_(module, path, entity=None):
            try:
                rv = filter_function(module, path, entity=entity)
            except BaseException:  # we really want to handle EVERYTHING here
                return "error"
            rv = {True: "error", False: "ignore", "report": "warning"}.get(rv, rv)
            assert rv in ("error", "ignore", "warning", None)
            return rv

        self.filter_py = filter_

    def add_rules(self, *rules):
        """Add rules to filter on.
        Assert that there's no legacy filter.py code hooked up.
        """
        assert self.filter_py is None
        for rule in rules:
            self.rules.extend(self._compile_rule(rule))

    def add_child(self, child):
        self._all_locales = None  # clear cache
        if child.excludes:
            raise ExcludeError("Included configs cannot declare their own excludes.")
        self.children.append(child)

    def exclude(self, child):
        for config in child.configs:
            if config.excludes:
                raise ExcludeError(
                    "Excluded configs cannot declare their own excludes."
                )
        self.excludes.append(child)

    def set_locales(self, locales, deep=False):
        self._all_locales = None  # clear cache
        self.locales = locales
        if not deep:
            return
        for child in self.children:
            child.set_locales(locales, deep=deep)

    @property
    def configs(self):
        "Recursively get all configs in this project and its children"
        yield self
        for child in self.children:
            yield from child.configs

    @property
    def all_locales(self):
        "Recursively get all locales in this project and its paths"
        if self._all_locales is None:
            all_locales = set()
            for config in self.configs:
                if config.locales is not None:
                    all_locales.update(config.locales)
                for paths in config.paths:
                    if "locales" in paths:
                        all_locales.update(paths["locales"])
            self._all_locales = sorted(all_locales)
        return self._all_locales

    def filter(self, l10n_file, entity=None):
        """Filter a localization file or entities within, according to
        this configuration file."""
        if l10n_file.locale not in self.all_locales:
            return "ignore"
        if self.filter_py is not None:
            return self.filter_py(l10n_file.module, l10n_file.file, entity=entity)
        rv = self._filter(l10n_file, entity=entity)
        if rv is None:
            return "ignore"
        return rv

    class FilterCache:
        def __init__(self, locale):
            self.locale = locale
            self.rules = []
            self.l10n_paths = []

    def cache(self, locale):
        if self._cache and self._cache.locale == locale:
            return self._cache
        self._cache = self.FilterCache(locale)
        for paths in self.paths:
            if "locales" in paths and locale not in paths["locales"]:
                continue
            self._cache.l10n_paths.append(paths["l10n"].with_env({"locale": locale}))
        for rule in self.rules:
            cached_rule = rule.copy()
            cached_rule["path"] = rule["path"].with_env({"locale": locale})
            self._cache.rules.append(cached_rule)
        return self._cache

    def _filter(self, l10n_file, entity=None):
        if any(exclude.filter(l10n_file) == "error" for exclude in self.excludes):
            return
        actions = {child._filter(l10n_file, entity=entity) for child in self.children}
        if "error" in actions:
            # return early if we know we'll error
            return "error"

        cached = self.cache(l10n_file.locale)
        if any(p.match(l10n_file.fullpath) for p in cached.l10n_paths):
            action = "error"
            for rule in reversed(cached.rules):
                if not rule["path"].match(l10n_file.fullpath):
                    continue
                if ("key" in rule) ^ (entity is not None):
                    # key/file mismatch, not a matching rule
                    continue
                if "key" in rule and not rule["key"].match(entity):
                    continue
                action = rule["action"]
                break
            actions.add(action)
        if "error" in actions:
            return "error"
        if "warning" in actions:
            return "warning"
        if "ignore" in actions:
            return "ignore"

    def _compile_rule(self, rule):
        assert "path" in rule
        if isinstance(rule["path"], list):
            for path in rule["path"]:
                _rule = rule.copy()
                _rule["path"] = Matcher(path, env=self.environ, root=self.root)
                yield from self._compile_rule(_rule)
            return
        if isinstance(rule["path"], str):
            rule["path"] = Matcher(rule["path"], env=self.environ, root=self.root)
        if "key" not in rule:
            yield rule
            return
        if not isinstance(rule["key"], str):
            for key in rule["key"]:
                _rule = rule.copy()
                _rule["key"] = key
                yield from self._compile_rule(_rule)
            return
        rule = rule.copy()
        key = rule["key"]
        if key.startswith("re:"):
            key = key[3:]
        else:
            key = re.escape(key) + "$"
        rule["key"] = re.compile(key)
        yield rule
