import logging
import re
import zipfile
from pathlib import Path
from typing import Callable

from .library import get_natives_platform_info, is_library_allowed, generate_formatted_artifact_with_name, \
    is_native_allowed, is_legacy_native_allowed, remove_duplicate_artifacts
from .libraryx import normalize_library_to_common_struct
from ..common.common import get_platform_info, FileObject, download_multiple_files, unzip, is_valid_zipfile
from .version_info import find_useful_part_in_specific_version_manifest
from ..common.exception import UnsupportedPlatformException, NativeResolveException, DependencyException, \
    VersionDataKeyNotFoundException, DownloadException

logger = logging.getLogger("Launcher.CoreLib")

def collect_library_artifacts(specific_version_manifest):
    _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest)

    items = []

    # Same as natives
    _, _, os_version = get_platform_info()
    platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info()

    for library_raw in libraries:
        library = normalize_library_to_common_struct(
            library_raw
        )

        name = library.get("name", "")
        artifact_raw = library.get("downloads", {}).get("artifact", {})
        rules = library.get("rules", [])
        classifiers = library.get("downloads", {}).get("classifiers", {})
        natives = library.get("natives", {})

        # Ignore natives
        if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or
                any(native.startswith("natives-") for native in classifiers)):
            logger.info(f"Skipping {name} because its a native library.")
            continue

        if not is_library_allowed(
                rules,
                platform_rule_name,
                platform_arch_type,
                os_version,
        ):
            logger.debug(f"Library {name} can't pass library rules.")
            continue

        artifact = generate_formatted_artifact_with_name(
            name,
            artifact_raw
        )

        if artifact:
            items.append(artifact)

    if not items:
        raise DependencyException(
            "Could not find any artifact in any of the libraries."
        )

    return items


def collect_native_artifacts(specific_version_manifest):
    """
    Collects native artifacts that support current platform
    :param specific_version_manifest:
    :return:
        filtered_natives_artifacts: list[dict]
    """
    filtered = []

    # Convert current platform and architecture type to same formula
    _, _, os_version = get_platform_info()
    platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info()

    if platform_key_name is None or platform_arch_type is None or platform_rule_name is None:
        raise UnsupportedPlatformException(
            f"Unsupported platform: key={platform_key_name}, "
            f"rule={platform_rule_name}, arch={platform_arch_type}"
        )

    _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest)

    for library_raw in libraries:
        library = normalize_library_to_common_struct(
            library_raw
        )

        name = library.get("name", "")
        artifact = library.get("downloads", {}).get("artifact", {})
        # For newer Minecraft (VER>1.7.10)
        rules = library.get("rules", [])
        # For legacy version (VER<1.8)
        classifiers = library.get("downloads", {}).get("classifiers", {})
        natives = library.get("natives", {})
        exclude = library.get("extract", {}).get("exclude", ["META-INF/"])  # For extract use

        is_natives = False

        # Check if library is a native
        if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or
                any(native.startswith("natives-") for native in classifiers)):
            is_natives = True

        if not is_natives:
            continue

        if rules and classifiers:
            logger.debug(f"Found newer and legacy (classifier) native rules for {name}")
        elif rules:
            logger.debug(f"Found newer native rules for {name}")
        elif classifiers:
            logger.debug(f"Found legacy native rules (classifier) for {name}")

        # =============== New rules ===============

        # Parse action section
        if not is_library_allowed(
                rules,
                platform_rule_name,
                platform_arch_type,
                os_version,
        ):
            logger.debug(f"Library {name} can't pass library rules.")
            continue

        if is_native_allowed(
                name,
                platform_key_name,
                platform_arch_type,
        ):
            result_artifact = generate_formatted_artifact_with_name(name, artifact, extra={"exclude": exclude,
                                                                                           "flatten": True})
            if result_artifact:
                filtered.append(result_artifact)
            # For some reason, we still need to continue process because some version use multiple type of
            # classifier. (Such as 1.14.2)
        else:
            logger.debug(f"Library {name} can't pass native rules.")

        # =============== Old rules ===============
        logger.debug(
            "Library {} doesn't have native key. This library may use old rule.".format(name)
        )

        result, result_artifact, classifier = is_legacy_native_allowed(
            natives,
            classifiers,
            platform_rule_name,
            platform_arch_type,
        )

        if not result:
            logger.debug(f"Library {name} can't pass legacy native rules.")
            continue
        else:
            result_artifact = generate_formatted_artifact_with_name(name,
                                                                    result_artifact,
                                                                    extra={"exclude": exclude, "flatten": True},
                                                                    relative_args={
                                                                        "extra": classifier
                                                                    })
        if result_artifact:
            filtered.append(result_artifact)

    # Remove duplicate items
    items = remove_duplicate_artifacts(filtered)

    if not filtered:
        raise NativeResolveException(
            "No compatible native artifact found.",
            user_message="No compatible native library was found for this platform.",
        )
    elif not items:
        raise NativeResolveException(
            "No compatible native artifact found. Report developer because this may be a issue"
            " at function \"remove_duplicate_artifacts\"",
        )

    return items


def download_libraries(artifacts, output_dir, progress_callback: Callable[[str, float], None] = None) \
        -> tuple[list[FileObject], list[FileObject]]:
    """
    Download libraries from a list that contains artifacts
    :param artifacts:
    :param output_dir:
    :param progress_callback:
    :return:
        fails: list (List of artifacts that download failed)
        successes: list (artifacts that were downloaded)
    """
    files = []
    successes = []

    for artifact in artifacts:
        sha1 = artifact.get("sha1")
        sha256 = artifact.get("sha256")
        sha512 = artifact.get("sha512")
        relative_path = artifact.get("path")
        url = artifact.get("url")

        if not relative_path or not url:
            logger.warning(
                "Artifact {} has no path or url. Skipping.".format(artifact["name"])
            )
            continue
        else:
            full_path = output_dir / Path(relative_path)

        file = FileObject(
            full_path,
            url=url,
            sha1=sha1,
        )

        if sha1 is None:
            logger.warning(f"File {full_path} has no SHA1 hash.")

        if file.exists:
            if sha1 is not None:
                valid = file.verify("sha1", sha1)
            elif sha256 is not None:
                valid = file.verify("sha256", sha256)
            elif sha512 is not None:
                valid = file.verify("sha512", sha512)
            elif full_path.suffix.lower() == ".jar":
                valid = is_valid_zipfile(file.path)
            else:
                valid = full_path.stat().st_size > 0

            if valid:
                successes.append(file)
                continue

        files.append(file)

    fails, finished = download_multiple_files(files, progress_callback=progress_callback)
    successes.extend(finished)

    return fails, successes


def unzip_natives(filtered, libraries_dir, destination: Path):
    """
    Unzip native libraries from a list that contains artifacts
    :param filtered:
    :param libraries_dir:
    :param destination:
    :return:

    IMPORTANT:
    Please put "exclude" key inside the artifact (from the version data). If not, some unnecessary file will be also
    included when unzipping the native library.
    """
    destination.mkdir(parents=True, exist_ok=True)

    for result_artifact in filtered:
        exclude = result_artifact.get("exclude", [])
        flatten = result_artifact.get("flatten", False)
        relative_path = result_artifact.get("path")

        if not relative_path:
            logger.warning(
                "Artifact {} has no relative path. Skipping.".format(result_artifact["name"])
            )
            continue
        else:
            full_path = libraries_dir / Path(relative_path)

        unzip(full_path, destination, exclude=exclude, flatten=flatten)

        logger.debug(f"Unzipped {full_path}.")


def check_libraries_exists(manifest: dict, libraries_dir: Path):
    """
    Check if libraries are present in the libraries directory
    :param manifest:
    :param libraries_dir:
    :return:
        not_found: list
    """
    libraries = manifest.get("libraries", [])

    if not libraries:
        raise VersionDataKeyNotFoundException(
            "Libraries not found in manifest.",
        )

    not_found = []

    # Same as natives
    _, _, os_version = get_platform_info()
    platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info()

    for library_raw in libraries:
        library = normalize_library_to_common_struct(
            library_raw
        )

        name = library.get("name", "")
        artifact_raw = library.get("downloads", {}).get("artifact", {})
        rules = library.get("rules", [])
        classifiers = library.get("downloads", {}).get("classifiers", {})
        natives = library.get("natives", {})

        # Ignore natives
        if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or
                any(native.startswith("natives-") for native in classifiers)):
            logger.info(f"Skipping {name} because its a native library.")
            continue

        if not is_library_allowed(
                rules,
                platform_rule_name,
                platform_arch_type,
                os_version,
        ):
            continue

        artifact = generate_formatted_artifact_with_name(
            name,
            artifact_raw
        )

        if not artifact:
            logger.warning(f"Artifact {name} are not valid. Skipping...")
            continue

        relative_path = artifact.get("path")
        full_path = libraries_dir / Path(relative_path)

        if not full_path.is_file() or not full_path.exists():
            logger.debug(f"Artifact {name} missing.")
            not_found.append(full_path)

    return not_found


def check_natives_exists(manifest: dict, libraries_dir: Path, natives_dir: Path):
    filtered = []

    # Convert current platform and architecture type to same formula
    _, _, os_version = get_platform_info()
    platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info()

    if platform_key_name is None or platform_arch_type is None or platform_rule_name is None:
        raise UnsupportedPlatformException(
            f"Unsupported platform: key={platform_key_name}, "
            f"rule={platform_rule_name}, arch={platform_arch_type}"
        )

    _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(manifest)

    for library_raw in libraries:
        library = normalize_library_to_common_struct(
            library_raw
        )

        name = library.get("name", "")
        artifact = library.get("downloads", {}).get("artifact", {})
        # For newer Minecraft (VER>1.7.10)
        rules = library.get("rules", [])
        # For legacy version (VER<1.8)
        classifiers = library.get("downloads", {}).get("classifiers", {})
        natives = library.get("natives", {})
        exclude = library.get("extract", {}).get("exclude", ["META-INF/"])  # For extract use

        is_natives = False

        # Check if library is a native
        if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or
                any(native.startswith("natives-") for native in classifiers)):
            is_natives = True

        if not is_natives:
            continue

        # =============== New rules ===============

        # Parse action section
        if not is_library_allowed(
                rules,
                platform_rule_name,
                platform_arch_type,
                os_version,
        ):
            continue

        if is_native_allowed(
                name,
                platform_key_name,
                platform_arch_type,
        ):
            result_artifact = generate_formatted_artifact_with_name(name, artifact, extra={"exclude": exclude,
                                                                                           "flatten": True})
            if result_artifact:
                filtered.append(result_artifact)

        # =============== Old rules ===============
        result, result_artifact, classifier = is_legacy_native_allowed(
            natives,
            classifiers,
            platform_rule_name,
            platform_arch_type,
        )

        if not result:
            continue

        result_artifact = generate_formatted_artifact_with_name(name,
                                                                result_artifact,
                                                                extra={"exclude": exclude, "flatten": True},
                                                                relative_args={
                                                                    "extra": classifier
                                                                })

        if result_artifact:
            filtered.append(result_artifact)

    # Remove duplicate items
    items = remove_duplicate_artifacts(filtered)

    if not filtered:
        raise NativeResolveException(
            "No compatible native artifact found.",
            user_message="No compatible native library was found for this platform.",
        )
    elif not items:
        raise NativeResolveException(
            "No compatible native artifact found. Report developer because this may be a issue"
            " at function \"remove_duplicate_artifacts\"",
        )

    expected = set()

    for result_artifact in items:
        full_path = libraries_dir / Path(result_artifact["path"])
        exclude = result_artifact.get("exclude", []) or []
        flatten = result_artifact.get("flatten", False)

        if not full_path.is_file() or not full_path.exists():
            return False

        with zipfile.ZipFile(full_path) as zf:
            for member in zf.namelist():
                if member.endswith("/"):
                    continue
                if any(member.startswith(item) for item in exclude):
                    continue

                target = Path(member).name if flatten else member
                if target:
                    expected.add(target)

    if not expected:
        return False

    for target in expected:
        if not (natives_dir / target).exists():
            return False

    return True


def check_full_libraries_exists(manifest: dict, libraries_dir: Path, natives_dir: Path) -> bool:
    natives_exist = check_natives_exists(
        manifest,
        libraries_dir,
        natives_dir,
    )
    missing_libraries = check_libraries_exists(
        manifest,
        libraries_dir,
    )

    return natives_exist and not missing_libraries


def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path):
    """
    Generate classpath by using specific version's data
    :param manifest:
    :param libraries_dir:
    :param core_file_path:
    :return:
        not_found: list
    """
    libraries = manifest.get("libraries", [])

    if not libraries:
        raise VersionDataKeyNotFoundException(
            "Libraries not found in manifest.",
        )

    # Same as natives
    _, _, os_version = get_platform_info()
    platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info()

    separator = ";" if platform_key_name == "windows" else ":"
    classes = []

    for library_raw in libraries:
        library = normalize_library_to_common_struct(
            library_raw
        )

        name = library.get("name", "")
        artifact_raw = library.get("downloads", {}).get("artifact", {})
        rules = library.get("rules", [])
        classifiers = library.get("downloads", {}).get("classifiers", {})
        natives = library.get("natives", {})

        # Ignore natives
        if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or
                any(native.startswith("natives-") for native in classifiers)):
            logger.info(f"Skipping {name} because its a native library.")
            continue

        if not is_library_allowed(
                rules,
                platform_rule_name,
                platform_arch_type,
                os_version,
        ):
            continue

        artifact = generate_formatted_artifact_with_name(
            name,
            artifact_raw
        )

        if not artifact:
            logger.warning(f"Artifact {name} are not valid. Skipping...")
            continue

        relative_path = artifact.get("path")
        full_path = libraries_dir / Path(relative_path)

        if not full_path.is_file() or not full_path.exists():
            raise FileNotFoundError(f"Artifact {name} missing.")

        classes.append(full_path.as_posix())

    # Append core file
    classes.append(core_file_path.as_posix())

    return f"{separator}".join(classes)


def download_full_libraries(manifest: dict, libraries_dir: Path, natives_dir: Path,
                            progress_callback: Callable[[str, float], None] = None,
                            redownload_callback: Callable[
                                [list[FileObject]], tuple[bool, list[FileObject]]] = None) -> None:
    # libraries
    libraries = collect_library_artifacts(manifest)

    # natives
    natives = collect_native_artifacts(manifest)

    # all
    full = list(libraries)
    full.extend(natives)

    logger.info(
        "Supported native count: {}".format(len(natives))
    )

    # Start download
    fails, successes = download_libraries(full, libraries_dir, progress_callback=progress_callback)

    download_ok = len(fails) == 0

    if redownload_callback and not download_ok:
        download_ok, fails = redownload_callback(fails)

        if not download_ok:
            raise DownloadException(
                "Unable to continue process due to certain libraries download failed:\n"
                + "\n".join(file.posix_path for file in fails)
            )

    if download_ok:
        # Start unzip natives
        unzip_natives(natives, libraries_dir, natives_dir)
    else:
        raise DependencyException(
            "Certain libraries download failed. Unable to continue unzip process.\n"
        )