"""Business logic related to downloading, copying, extracting files

The functions here use git clone, rsync, tar, etc. to make the necessary
file system changes for cfbs add / cfbs download / cfbs build to work.

The functions here are quite "contained", they don't rely on the
global config (read and writen to cfbs.json), just their parameters
and what is on the file system (in ~/.cache/cfengine and ./out).
"""

import os
import re
import shutil
from typing import Optional

from cfbs.git import ls_remote, treeish_exists
from cfbs.utils import (
    cfbs_dir,
    cp,
    fetch_url,
    CFBSNetworkError,
    is_a_commit_hash,
    mkdir,
    pad_right,
    rm,
    sh,
    strip_right,
    CFBSExitError,
)

from cfbs.git import git_clean_reset

_SUPPORTED_TAR_TYPES = (".tar.gz", ".tgz")
SUPPORTED_ARCHIVES = (".zip",) + _SUPPORTED_TAR_TYPES
SUPPORTED_URI_SCHEMES = ("https://", "ssh://", "git://")


def local_module_name(module_path: str):
    assert os.path.exists(module_path)
    module = module_path

    if module.endswith((".cf", ".json", "/")) and not module.startswith("./"):
        module = "./" + module
    if not module.startswith("./"):
        raise CFBSExitError(
            "Please prepend local files or folders with './' to avoid ambiguity"
        )

    for illegal in ["//", "..", " ", "\n", "\t", " "]:
        if illegal in module:
            raise CFBSExitError("Module path cannot contain %s" % repr(illegal))

    if os.path.isdir(module) and not module.endswith("/"):
        module = module + "/"
    while "/./" in module:
        module = module.replace("/./", "/")

    assert os.path.exists(module)
    if os.path.isfile(module):
        if not module.endswith((".cf", ".json")):
            raise CFBSExitError("Only .cf and .json files supported currently")
    else:
        if not os.path.isdir(module):
            raise CFBSExitError("'%s' must be either a directory or a file" % module)

    return module


def absolute_module_name(module_path: str):
    assert os.path.exists(module_path)
    module = module_path
    assert module.startswith("/")

    for illegal in ["//", "..", " ", "\n", "\t", " "]:
        if illegal in module:
            raise CFBSExitError("Module path cannot contain %s" % repr(illegal))

    if not module.endswith("/"):
        module = module + "/"
    while "/./" in module:
        module = module.replace("/./", "/")

    assert os.path.exists(module)
    if not os.path.isdir(module):
        raise CFBSExitError("'%s' must be a directory" % module)

    return module


def get_download_path(module) -> str:
    downloads = os.path.join(cfbs_dir(), "downloads")

    commit = module["commit"]
    if not is_a_commit_hash(commit):
        raise CFBSExitError("'%s' is not a commit reference" % commit)

    url = module.get("url") or module["repo"]
    if url.endswith(SUPPORTED_ARCHIVES):
        url = os.path.dirname(url)
    else:
        url = strip_right(url, ".git")
    repo = url[url.index("://") + 3 :]
    repo_dir = os.path.join(downloads, repo)
    mkdir(repo_dir)
    return os.path.join(repo_dir, commit)


def _prettify_name(name):
    if "/" not in name:
        return name
    while name.endswith("/"):
        name = name[:-1]
    if "/" in name:
        name = name.split("/")[-1]
    assert name
    return name


def local_module_copy(module, counter, max_length):
    name = module["name"]
    if not name.startswith("./"):
        raise CFBSExitError("module %s must start with ./" % name)
    if not os.path.isfile(name) and not os.path.isdir(name):
        raise CFBSExitError("module %s does not exist" % name)
    pretty_name = _prettify_name(name)
    target = "out/steps/%03d_%s_local/" % (counter, pretty_name)
    module["_directory"] = target
    module["_counter"] = counter
    if name.endswith(("/", "/.")):
        # If this is a local folder, the target should be a copy of the folder
        # (Don't create an extra unnecessary subfolder)
        cp(name, target)
    else:
        # If this is not a folder it is a file
        # create a copy of that file in the target folder
        cp(name, target + name)
    print(
        "%03d %s @ local                                    (Copied)"
        % (counter, pad_right(name, max_length))
    )


def absolute_module_copy(module, counter, max_length):
    assert "commit" in module
    name = module["name"]
    pretty_name = _prettify_name(name)
    target = "out/steps/%03d_%s_local/" % (counter, pretty_name)
    module["_directory"] = target
    module["_counter"] = counter

    cp(name, target)
    git_clean_reset(target, module["commit"])

    print(
        "%03d %s @ %s                                  (Copied)"
        % (counter, pad_right(name, max_length), module["commit"][:7])
    )


def _get_path_from_url(url):
    if not url.startswith(SUPPORTED_URI_SCHEMES):
        if "://" in url:
            raise CFBSExitError("Unsupported URL protocol in '%s'" % url)
        else:
            # It's a path already, just remove trailing slashes (if any).
            return url.rstrip("/")

    path = None
    if url.startswith("ssh://"):
        match = re.match(r"ssh://(\w+)@(.+)", url)
        if match is not None:
            path = match[2]
    path = path or url[url.index("://") + 3 :]
    path = strip_right(path, ".git")
    path = path.rstrip("/")

    return path


def _clone_and_checkout(url, path, treeish):
    # NOTE: If any of these shell (git) commands fail, we will exit
    if not os.path.exists(os.path.join(path, ".git")):
        sh("git clone --no-checkout %s %s" % (url, path))
    if not treeish_exists(treeish, path):
        raise CFBSExitError("%s not found in %s" % (treeish, url))

    sh("git checkout " + treeish, directory=path)


def clone_url_repo(repo_url: str, reference: Optional[str] = None):
    """Clones a Git repository at `repo_url` URL, optionally checking out the `reference` commit or branch.
    If `reference` is `None`, the repository's default branch will be used for the checkout.

    Returns path to the `cfbs.json` located in the cloned Git repository, and the Git commit hash.
    """
    assert repo_url.startswith(SUPPORTED_URI_SCHEMES)
    assert "@" not in repo_url or (repo_url.rindex("@") < repo_url.rindex("."))

    downloads = os.path.join(cfbs_dir(), "downloads")

    repo_path = _get_path_from_url(repo_url)
    repo_dir = os.path.join(downloads, repo_path)
    os.makedirs(repo_dir, exist_ok=True)

    if reference is None:
        reference = "HEAD"

    # always store versions of the repository in cfbs/downloads by commit hash
    # therefore for branches, first find the commit it points to
    if is_a_commit_hash(reference):
        commit = reference
    else:
        # `reference` is a branch
        commit = ls_remote(repo_url, reference)
        if commit is None:
            raise CFBSExitError(
                "Failed to find branch %s at %s" % (reference, repo_url)
            )

    commit_path = os.path.join(repo_dir, commit)
    _clone_and_checkout(repo_url, commit_path, commit)

    json_path = os.path.join(commit_path, "cfbs.json")
    if os.path.exists(json_path):
        return (json_path, commit)
    else:
        raise CFBSExitError(
            "Repository '%s' doesn't contain a valid cfbs.json index file" % repo_url
        )


def fetch_archive(
    url: str, checksum=None, directory=None, with_index=True, extract_to_directory=False
):
    assert url.endswith(SUPPORTED_ARCHIVES)

    url_path = url[url.index("://") + 3 :]
    archive_dirname = os.path.dirname(url_path)
    archive_filename = os.path.basename(url_path)

    for ext in SUPPORTED_ARCHIVES:
        if archive_filename.endswith(ext):
            archive_type = ext
            break
    else:
        raise CFBSExitError("Unsupported archive type: '%s'" % url)

    downloads = os.path.join(cfbs_dir(), "downloads")

    archive_dir = os.path.join(downloads, archive_dirname)
    if not extract_to_directory or not os.path.exists(archive_dir):
        mkdir(archive_dir)

    archive_path = os.path.join(downloads, archive_dir, archive_filename)
    try:
        archive_checksum = fetch_url(url, archive_path, checksum)
    except CFBSNetworkError as e:
        raise CFBSExitError(str(e))

    content_dir = os.path.join(downloads, archive_dir, archive_checksum)
    if extract_to_directory:
        assert directory is not None
        content_dir = directory
    index_path = os.path.join(content_dir, "cfbs.json")
    if with_index and os.path.exists(index_path):
        # available already
        return (index_path, archive_checksum)
    else:
        if not extract_to_directory or not os.path.exists(content_dir):
            mkdir(content_dir)

    # TODO: use Python modules instead of CLI tools?
    if archive_type.startswith(_SUPPORTED_TAR_TYPES):
        if shutil.which("tar"):
            sh("cd %s; tar -xzf %s" % (content_dir, archive_path))
        else:
            raise CFBSExitError("Working with .tar archives requires the 'tar' utility")
    elif archive_type == (".zip"):
        if shutil.which("unzip"):
            sh("cd %s; unzip %s" % (content_dir, archive_path))
        else:
            raise CFBSExitError(
                "Working with .zip archives requires the 'unzip' utility"
            )
    else:
        raise RuntimeError(
            "Unhandled archive type: '%s'. Please report this at %s."
            % (url, "https://github.com/cfengine/cfbs/issues")
        )

    os.unlink(archive_path)

    content_root_items = [
        os.path.join(content_dir, item) for item in os.listdir(content_dir)
    ]
    if (
        with_index
        and len(content_root_items) == 1
        and os.path.isdir(content_root_items[0])
        and os.path.exists(os.path.join(content_root_items[0], "cfbs.json"))
    ):
        # the archive contains a top-level folder, let's just move things one
        # level up from inside it
        sh("mv %s %s" % (os.path.join(content_root_items[0], "*"), content_dir))
        shutil.rmtree(content_root_items[0])

    if with_index:
        if os.path.exists(index_path):
            return (index_path, archive_checksum)
        else:
            raise CFBSExitError(
                "Archive '%s' doesn't contain a valid cfbs.json index file" % url
            )
    else:
        if not extract_to_directory and directory is not None:
            directory = directory.rstrip("/")
            mkdir(os.path.dirname(directory))
            sh("rsync -a %s/ %s/" % (content_dir, directory))
            rm(content_dir)
            return (directory, archive_checksum)
        return (content_dir, archive_checksum)
