import codecs
import json
import os
import pkgutil
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union

import yaml

from prospector.profiles.exceptions import CannotParseProfile, ProfileNotFound
from prospector.tools import DEFAULT_TOOLS, TOOLS

BUILTIN_PROFILE_PATH = (Path(__file__).parent / "profiles").absolute()


class ProspectorProfile:
    def __init__(self, name: str, profile_dict: Dict[str, Any], inherit_order: List[str]):
        self.name = name
        self.inherit_order = inherit_order

        self.ignore_paths = _ensure_list(profile_dict.get("ignore-paths", []))
        # The 'ignore' directive is an old one which should be deprecated at some point
        self.ignore_patterns = _ensure_list(profile_dict.get("ignore-patterns", []) + profile_dict.get("ignore", []))

        self.output_format = profile_dict.get("output-format")
        self.output_target = profile_dict.get("output-target")
        self.autodetect = profile_dict.get("autodetect", True)
        self.uses = [
            uses for uses in _ensure_list(profile_dict.get("uses", [])) if uses in ("django", "celery", "flask")
        ]
        self.max_line_length = profile_dict.get("max-line-length")

        # informational shorthands
        self.strictness = profile_dict.get("strictness")
        self.test_warnings = profile_dict.get("test-warnings")
        self.doc_warnings = profile_dict.get("doc-warnings")
        self.member_warnings = profile_dict.get("member-warnings")

        # TODO: this is needed by Landscape but not by prospector; there is probably a better place for it
        self.requirements = _ensure_list(profile_dict.get("requirements", []))

        for tool in TOOLS:
            tool_conf = profile_dict.get(tool, {})

            # set the defaults for everything
            conf: Dict[str, Any] = {"disable": [], "enable": [], "run": None, "options": {}}
            # use the "old" tool name
            conf.update(tool_conf)

            if self.max_line_length is not None and tool in ("pylint", "pycodestyle"):
                conf["options"]["max-line-length"] = self.max_line_length

            setattr(self, tool, conf)

    def get_disabled_messages(self, tool_name):
        disable = getattr(self, tool_name)["disable"]
        enable = getattr(self, tool_name)["enable"]
        return list(set(disable) - set(enable))

    def is_tool_enabled(self, name):
        enabled = getattr(self, name).get("run")
        if enabled is not None:
            return enabled
        # this is not explicitly enabled or disabled, so use the default
        return name in DEFAULT_TOOLS

    def list_profiles(self):
        # this profile is itself included
        return [str(profile) for profile in self.inherit_order]

    def as_dict(self):
        out = {
            "ignore-paths": self.ignore_paths,
            "ignore-patterns": self.ignore_patterns,
            "output-format": self.output_format,
            "output-target": self.output_target,
            "autodetect": self.autodetect,
            "uses": self.uses,
            "max-line-length": self.max_line_length,
            "member-warnings": self.member_warnings,
            "doc-warnings": self.doc_warnings,
            "test-warnings": self.test_warnings,
            "strictness": self.strictness,
            "requirements": self.requirements,
        }
        for tool in TOOLS:
            out[tool] = getattr(self, tool)
        return out

    def as_json(self):
        return json.dumps(self.as_dict())

    def as_yaml(self):
        return yaml.safe_dump(self.as_dict())

    @staticmethod
    def load(
        name_or_path: Union[str, Path],
        profile_path: List[Path],
        allow_shorthand: bool = True,
        forced_inherits: Optional[List[str]] = None,
    ):
        # First simply load all of the profiles and those that it explicitly inherits from
        data, inherits = _load_and_merge(
            name_or_path,
            profile_path,
            allow_shorthand,
            forced_inherits=forced_inherits or [],
        )
        return ProspectorProfile(str(name_or_path), data, inherits)


def _is_valid_extension(filename):
    ext = os.path.splitext(filename)[1]
    return ext in (".yml", ".yaml")


def _load_content_package(name):
    name_split = name.split(":", 1)
    module_name = f"prospector_profile_{name_split[0]}"
    file_names = (
        ["prospector.yaml", "prospector.yml"]
        if len(name_split) == 1
        else [f"{name_split[1]}.yaml", f"{name_split[1]}.yml"]
    )

    data = None
    used_name = None
    for file_name in file_names:
        used_name = f"{module_name}:{file_name}"
        data = pkgutil.get_data(module_name, file_name)
        if data is not None:
            break

    if data is None:
        return None

    try:
        return yaml.safe_load(data) or {}
    except yaml.parser.ParserError as parse_error:
        raise CannotParseProfile(used_name, parse_error) from parse_error


def _load_content(name_or_path, profile_path):
    filename = None
    optional = False

    if isinstance(name_or_path, str) and name_or_path.endswith("?"):
        optional = True
        name_or_path = name_or_path[:-1]

    if _is_valid_extension(name_or_path):
        for path in profile_path:
            filepath = os.path.join(path, name_or_path)
            if os.path.exists(filepath):
                # this is a full path that we can load
                filename = filepath
                break
    else:
        for path in profile_path:
            for ext in ("yml", "yaml"):
                filepath = os.path.join(path, f"{name_or_path}.{ext}")
                if os.path.exists(filepath):
                    filename = filepath
                    break

    if filename is None:
        result = _load_content_package(name_or_path)
        if result is not None:
            return result

        if optional:
            return {}

        raise ProfileNotFound(name_or_path, profile_path)

    with codecs.open(filename) as fct:
        try:
            return yaml.safe_load(fct) or {}
        except yaml.parser.ParserError as parse_error:
            raise CannotParseProfile(filename, parse_error) from parse_error


def _ensure_list(value):
    if isinstance(value, list):
        return value
    return [value]


def _simple_merge_dict(priority, base):
    out = dict(base.items())
    out.update(dict(priority.items()))
    return out


def _merge_tool_config(priority, base):
    out = dict(base.items())

    # add options that are missing, but keep existing options from the priority dictionary
    # TODO: write a unit test for this :-|
    out["options"] = _simple_merge_dict(priority.get("options", {}), base.get("options", {}))

    # copy in some basic pieces
    for key in ("run", "load-plugins"):
        value = priority.get(key, base.get(key))
        if value is not None:
            out[key] = value

    # anything enabled in the 'priority' dict is removed
    # from 'disabled' in the base dict and vice versa
    base_disabled = base.get("disable") or []
    base_enabled = base.get("enable") or []
    pri_disabled = priority.get("disable") or []
    pri_enabled = priority.get("enable") or []

    out["disable"] = list(set(pri_disabled) | (set(base_disabled) - set(pri_enabled)))
    out["enable"] = list(set(pri_enabled) | (set(base_enabled) - set(pri_disabled)))

    return out


def _merge_profile_dict(priority: dict, base: dict) -> dict:
    # copy the base dict into our output
    out = dict(base.items())

    for key, value in priority.items():
        if key in (
            "strictness",
            "doc-warnings",
            "test-warnings",
            "member-warnings",
            "output-format",
            "autodetect",
            "max-line-length",
            "pep8",
        ):
            # some keys are simple values which are overwritten
            out[key] = value
        elif key in (
            "ignore",
            "ignore-patterns",
            "ignore-paths",
            "uses",
            "requirements",
            "python-targets",
            "output-target",
        ):
            # some keys should be appended
            out[key] = _ensure_list(value) + _ensure_list(base.get(key, []))
        elif key in TOOLS:
            # this is tool config!
            out[key] = _merge_tool_config(value, base.get(key, {}))

    return out


def _determine_strictness(profile_dict, inherits):
    for profile in inherits:
        if profile.startswith("strictness_"):
            return None, False

    strictness = profile_dict.get("strictness")
    if strictness is None:
        return None, False
    return ("strictness_%s" % strictness), True


def _determine_pep8(profile_dict):
    pep8 = profile_dict.get("pep8")
    if pep8 == "full":
        return "full_pep8", True
    if pep8 == "none":
        return "no_pep8", True
    if isinstance(pep8, dict) and pep8.get("full", False):
        return "full_pep8", False
    return None, False


def _determine_doc_warnings(profile_dict):
    doc_warnings = profile_dict.get("doc-warnings")
    if doc_warnings is None:
        return None, False
    return ("doc_warnings" if doc_warnings else "no_doc_warnings"), True


def _determine_test_warnings(profile_dict):
    test_warnings = profile_dict.get("test-warnings")
    if test_warnings is None:
        return None, False
    return (None if test_warnings else "no_test_warnings"), True


def _determine_member_warnings(profile_dict):
    member_warnings = profile_dict.get("member-warnings")
    if member_warnings is None:
        return None, False
    return ("member_warnings" if member_warnings else "no_member_warnings"), True


def _determine_implicit_inherits(profile_dict, already_inherits, shorthands_found):
    # Note: the ordering is very important here - the earlier items
    # in the list have precedence over the later items. The point of
    # the doc/test/pep8 profiles is usually to restore items which were
    # turned off in the strictness profile, so they must appear first.
    implicit = [
        ("pep8", _determine_pep8(profile_dict)),
        ("docs", _determine_doc_warnings(profile_dict)),
        ("tests", _determine_test_warnings(profile_dict)),
        ("strictness", _determine_strictness(profile_dict, already_inherits)),
        ("members", _determine_member_warnings(profile_dict)),
    ]
    inherits = []

    for shorthand_name, determined in implicit:
        if shorthand_name in shorthands_found:
            continue
        extra_inherits, shorthand_found = determined
        if not shorthand_found:
            continue
        shorthands_found.add(shorthand_name)
        if extra_inherits is not None:
            inherits.append(extra_inherits)

    return inherits, shorthands_found


def _append_profiles(name, profile_path, data, inherit_list, allow_shorthand=False):
    new_data, new_il, _ = _load_profile(name, profile_path, allow_shorthand=allow_shorthand)
    data.update(new_data)
    inherit_list += new_il
    return data, inherit_list


def _load_and_merge(
    name_or_path: Union[str, Path],
    profile_path: List[Path],
    allow_shorthand: bool = True,
    forced_inherits: List[str] = None,
) -> Tuple[Dict[str, Any], List[str]]:
    # First simply load all of the profiles and those that it explicitly inherits from
    data, inherit_list, shorthands_found = _load_profile(
        str(name_or_path),
        profile_path,
        allow_shorthand=allow_shorthand,
        forced_inherits=forced_inherits or [],
    )

    if allow_shorthand:
        if "docs" not in shorthands_found:
            data, inherit_list = _append_profiles("no_doc_warnings", profile_path, data, inherit_list)

        if "members" not in shorthands_found:
            data, inherit_list = _append_profiles("no_member_warnings", profile_path, data, inherit_list)

        if "tests" not in shorthands_found:
            data, inherit_list = _append_profiles("no_test_warnings", profile_path, data, inherit_list)

        if "strictness" not in shorthands_found:
            # if no strictness was specified, then we should manually insert the medium strictness
            for inherit in inherit_list:
                if inherit.startswith("strictness_"):
                    break
            else:
                data, inherit_list = _append_profiles("strictness_medium", profile_path, data, inherit_list)

    # Now we merge all of the values together, from 'right to left' (ie, from the
    # top of the inheritance tree to the bottom). This means that the lower down
    # values overwrite those from above, meaning that the initially provided profile
    # has precedence.
    merged: dict = {}
    for name in inherit_list[::-1]:
        priority = data[name]
        merged = _merge_profile_dict(priority, merged)

    return merged, inherit_list


def _transform_legacy(profile_dict):
    """
    After pep8 was renamed to pycodestyle, this pre-filter just moves profile
    config blocks using the old name to use the new name, merging if both are
    specified.

    Same for pep257->pydocstyle
    """
    out = {}

    # copy in existing pep8/pep257 using new names to start
    if "pycodestyle" in profile_dict:
        out["pycodestyle"] = profile_dict["pycodestyle"]
    if "pydocstyle" in profile_dict:
        out["pydocstyle"] = profile_dict["pydocstyle"]

    # pep8 is tricky as it's overloaded as a tool configuration and a shorthand
    # first, is this the short "pep8: full" version or a configuration of the
    # pycodestyle tool using the old name?
    if "pep8" in profile_dict:
        pep8conf = profile_dict["pep8"]
        if isinstance(pep8conf, dict):
            # merge in with existing config if there is any
            out["pycodestyle"] = _simple_merge_dict(out.get("pycodestyle", {}), pep8conf)
        else:
            # otherwise it's shortform, just copy it in directly
            out["pep8"] = pep8conf
        del profile_dict["pep8"]

    if "pep257" in profile_dict:
        out["pydocstyle"] = _simple_merge_dict(out.get("pydocstyle", {}), profile_dict["pep257"])
        del profile_dict["pep257"]

    # now just copy the rest in
    for key, value in profile_dict.items():
        if key in ("pycodestyle", "pydocstyle"):
            # already handled these
            continue
        out[key] = value

    return out


def _load_profile(
    name_or_path,
    profile_path,
    shorthands_found=None,
    already_loaded=None,
    allow_shorthand=True,
    forced_inherits=None,
):
    # recursively get the contents of the basic profile and those it inherits from
    base_contents = _load_content(name_or_path, profile_path)

    base_contents = _transform_legacy(base_contents)

    inherit_order = [name_or_path]
    shorthands_found = shorthands_found or set()

    already_loaded = already_loaded or []
    already_loaded.append(name_or_path)

    inherits = _ensure_list(base_contents.get("inherits", []))
    if forced_inherits is not None:
        inherits += forced_inherits

    # There are some 'shorthand' options in profiles which implicitly mean that we
    # should inherit from some of prospector's built-in profiles
    if base_contents.get("allow-shorthand", True) and allow_shorthand:
        extra_inherits, extra_shorthands = _determine_implicit_inherits(base_contents, inherits, shorthands_found)
        inherits += extra_inherits
        shorthands_found |= extra_shorthands

    contents_dict = {name_or_path: base_contents}

    for inherit_profile in inherits:
        if inherit_profile in already_loaded:
            # we already have this loaded and in the list
            continue

        already_loaded.append(inherit_profile)
        new_cd, new_il, new_sh = _load_profile(
            inherit_profile,
            profile_path,
            shorthands_found,
            already_loaded,
            allow_shorthand,
        )
        contents_dict.update(new_cd)
        inherit_order += new_il
        shorthands_found |= new_sh

    # note: a new list is returned here rather than simply using inherit_order to give astroid a
    # clue about the type of the returned object, as otherwise it can recurse infinitely and crash,
    # this meaning that prospector does not run on prospector cleanly!
    return contents_dict, list(inherit_order), shorthands_found
