import ast
import hashlib
import logging
import os
import re
from collections import defaultdict, namedtuple
from difflib import SequenceMatcher
from functools import lru_cache
from glob import glob
from pathlib import Path
from shutil import copyfile
from typing import List, Optional, Union

from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
from souschef.recipe import Recipe
from souschef.section import Section

log = logging.getLogger(__name__)

PyVer = namedtuple("PyVer", ["major", "minor"])
yaml = YAML(typ="jinja2")
yaml.indent(mapping=2, sequence=4, offset=2)
yaml.width = 600


#  PURL fields               scheme      type           name
RE_PEP725_PURL = re.compile(r"[a-z]+\:[\.a-z0-9_-]+\/[\.a-z0-9_-]+", re.IGNORECASE)


@lru_cache(maxsize=10)
def get_std_modules() -> List:
    from stdlib_list import stdlib_list

    all_libs = set()
    for py_ver in (
        "2.7",
        "3.6",
        "3.7",
        "3.8",
    ):
        all_libs.update(stdlib_list(py_ver))
    return list(all_libs)


def get_all_modules_imported_script(script_file: str) -> set:
    modules = set()

    def visit_Import(node):
        for name in node.names:
            if name.name:
                modules.add(name.name.split(".")[0])

    def visit_ImportFrom(node):
        # if node.module is missing it's a "from . import ..." statement
        # if level > 0 it's a "from .submodule import ..." statement
        if node.module is not None and node.level == 0 and node.module:
            modules.add(node.module.split(".")[0])

    node_iter = ast.NodeVisitor()
    node_iter.visit_Import = visit_Import
    node_iter.visit_ImportFrom = visit_ImportFrom
    with open(script_file, "r") as f:
        node_iter.visit(ast.parse(f.read()))
    return modules


def get_vendored_dependencies(script_file: str) -> List:
    """Get all third part dependencies which are being in use in the setup.py

    :param script_file: Path to the setup.py
    :return: List with all vendored dependencies
    """
    all_std_modules = get_std_modules()
    all_modules_used = get_all_modules_imported_script(script_file)
    local_modules = get_local_modules(os.path.dirname(script_file))
    return [
        dep.lower()
        for dep in all_modules_used
        if dep not in local_modules and dep not in all_std_modules
    ]


@lru_cache(maxsize=20)
def get_local_modules(sdist_folder: str) -> List:
    result = []
    for py_file in glob(f"{sdist_folder}/*.py"):
        py_file = os.path.basename(py_file)
        if py_file == "setup.py":
            continue
        result.append(os.path.splitext(py_file)[0])
    return result


def origin_is_github(name_or_url: str) -> bool:
    return (
        name_or_url.startswith(("http://", "https://")) and "github.com" in name_or_url
    )


def origin_is_local_sdist(name: str) -> bool:
    """Return True if the given local file can be a sdist"""
    # Available formats according to
    # https://docs.python.org/3/distutils/sourcedist.html#creating-a-source-distribution
    return (
        name.endswith((".tar.gz", ".tar.bz2", ".tar.xz", ".tar.Z", ".tar", ".zip"))
        and Path(name).is_file()
    )


def sha256_checksum(filename, block_size=65536):
    sha256 = hashlib.sha256()
    with open(filename, "rb") as f:
        for block in iter(lambda: f.read(block_size), b""):
            sha256.update(block)
    return sha256.hexdigest()


def string_similarity(a, b):
    return SequenceMatcher(None, a, b).ratio()


def rm_duplicated_deps(all_requirements: Union[list, set, None]) -> Optional[list]:
    if not all_requirements:
        return None
    # Keep track of requirements which have already been added to the list.
    # The key is a canonicalized version of the requirement: lowercase,
    # and underscores converted to dashes. The value is the requirement itself,
    # as it should be added.
    # (This is order-preserving since dicts are ordered by first insertion.)
    new_reqs: dict[str, str] = {}
    re_split = re.compile(r"\s+(|>|=|<|~|!|#)+")
    for dep in all_requirements:
        if dep.strip().startswith(("{{", "<{")):
            new_reqs[dep] = dep
            continue
        dep_name, *constrains = re_split.split(dep.strip())
        dep_name = dep_name.strip()
        constrains = [
            c.strip()
            for c in constrains
            if c.strip() not in {"=*", "==*", "*", "*.*", "*.*.*", ""}
        ]
        canonicalized = dep_name.replace("_", "-").lower()
        constrains.insert(0, dep_name)
        if canonicalized in new_reqs:
            # In order to break ties deterministically, we prioritize the requirement
            # which is alphanumerically lowest. This happens to prioritize the "-"
            # character over "_".
            # Example: given "importlib_metadata" and "importlib-metadata", we will
            # keep "importlib-metadata" because it is alphabetically lower.
            previous_req = new_reqs[canonicalized]
            if len(dep) > len(previous_req) or "-" in dep_name:
                new_reqs[canonicalized] = " ".join(constrains)
        else:
            new_reqs[canonicalized] = " ".join(constrains)
    return [re.sub(r"\s+(#)", "  \\1", v.strip()) for v in new_reqs.values()]


def format_dependencies(all_dependencies: List, name: str) -> List:
    """Just format the given dependency to a string which is valid for the
    recipe

    :param all_dependencies: list of dependencies
    :param name: package name
    :return: list of dependencies formatted
    """
    formatted_dependencies = []
    re_deps = re.compile(r"^\s*([\.a-zA-Z0-9_-]+)\s*(.*)\s*$", re.MULTILINE | re.DOTALL)
    re_remove_space = re.compile(r"([<>!=]+)\s+")
    re_remove_tags = re.compile(r"\s*(\[.*\])", re.DOTALL)
    re_remove_comments = re.compile(r"\s+#.*", re.DOTALL)
    for req in all_dependencies:
        if RE_PEP725_PURL.match(req):
            formatted_dependencies.append(req)
            continue
        match_req = re_deps.match(req)
        deps_name = req
        if name is not None and deps_name.replace("-", "_") == name.replace("-", "_"):
            continue
        if match_req:
            match_req = match_req.groups()
            deps_name = match_req[0]
            if len(match_req) > 1:
                deps_name = " ".join(match_req)
        deps_name = re_remove_space.sub(r"\1", deps_name.strip())
        deps_name = re_remove_tags.sub(r" ", deps_name.strip())
        deps_name = re_remove_comments.sub("", deps_name)
        formatted_dependencies.append(deps_name.strip())
    return formatted_dependencies


def generate_recipe(
    recipe: Recipe,
    config,
    folder_path: Union[str, Path] = ".",
):
    """Write the recipe in a location. It will create a folder with the
    package name and the recipe will be there.

    :param folder_path: Path to the folder
    """
    if recipe["package"]["name"].value.startswith("r-{{"):
        pkg_name = f"r-{config.name}"
    else:
        pkg_name = config.name
    if origin_is_github(pkg_name):
        pkg_name = pkg_name.split("/")[-1]
    if Path(folder_path).is_file():
        folder_path = Path(folder_path)
        recipe_path = folder_path
        recipe_folder = folder_path.parent
    else:
        recipe_dir = Path(folder_path) / pkg_name
        logging.debug(f"Generating recipe on: {recipe_dir}")
        if not recipe_dir.is_dir():
            recipe_dir.mkdir()
        recipe_path = recipe_dir / "meta.yaml"
        recipe_folder = recipe_dir
        add_new_lines_after_section(recipe.yaml)

    clean_yaml(recipe)
    recipe.save(recipe_path)
    for file_to_recipe in config.files_to_copy:
        name = file_to_recipe.split(os.path.sep)[-1]
        if os.path.isfile(file_to_recipe):
            copyfile(file_to_recipe, os.path.join(recipe_folder, name))


def add_new_lines_after_section(recipe_yaml: CommentedMap) -> CommentedMap:
    for section in recipe_yaml.keys():
        if section == "package":
            recipe_yaml.yaml_set_comment_before_after_key(section, "\n\n")
        else:
            recipe_yaml.yaml_set_comment_before_after_key(section, "\n")
    return recipe_yaml


def clean_yaml(recipe):
    for key, yaml_obj in _clean_yaml(recipe):
        del yaml_obj[key]


def _clean_yaml(recipe, all_obj_to_delete=None):
    all_obj_to_delete = all_obj_to_delete or []
    for key, value in recipe.items():
        value_to_delete = []
        if not isinstance(value, bool) and not value:
            value_to_delete = [(key, recipe)]
        elif isinstance(value, Section):
            value_to_delete = _clean_yaml(value)
        all_obj_to_delete.extend(value_to_delete)
    return all_obj_to_delete


def merge_list_item(destination: dict, add: dict, key: str) -> None:
    """Modify the list 'destination[key]' to include missing elements from 'add[key]'.

    Duplicated elements are removed. The order of the elements is preserved.
    In case 'key' is undefined or empty in both lists, 'destination' is unmodified.
    """
    result = []
    destination_list = destination.get(key, [])
    add_list = add.get(key, [])
    for item in destination_list + add_list:
        if item not in result:
            result.append(item)
    if len(result) > 0:
        destination[key] = result


def merge_dict_of_lists_item(destination: dict, add: dict, key: str) -> None:
    sub_destination = destination.get(key, {})
    sub_add = add.get(key, {})
    for sub_key in set(sub_destination) | set(sub_add):
        merge_list_item(sub_destination, sub_add, sub_key)
    if sub_destination:
        destination[key] = sub_destination


def nested_dict():
    return defaultdict(nested_dict)
