import json
import os
import re
import shutil
import subprocess
from collections import OrderedDict
from pathlib import Path

import taf.log
import taf.settings as settings
from taf.exceptions import InvalidRepositoryError
from taf.utils import run

logger = taf.log.get_logger(__name__)

EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"


class GitRepository(object):
    def __init__(
        self, repo_path, repo_urls=None, additional_info=None, default_branch="master"
    ):
        """
    Args:
      repo_path: repository's path
      repo_urls: repository's urls (optional)
      additional_info: a dictionary containing other data (optional)
      default_branch: repository's default branch
    """
        self.repo_path = str(repo_path)
        self.default_branch = default_branch
        if repo_urls is not None:
            if settings.update_from_filesystem is False:
                for url in repo_urls:
                    _validate_url(url)
            else:
                repo_urls = [
                    os.path.normpath(os.path.join(self.repo_path, url))
                    if not os.path.isabs(url)
                    else url
                    for url in repo_urls
                ]
        self.repo_urls = repo_urls
        self.additional_info = additional_info
        self.repo_name = os.path.basename(self.repo_path)

    _remotes = None

    @property
    def remotes(self):
        if self._remotes is None:
            self._remotes = self._git("remote").split("\n")
        return self._remotes

    @property
    def is_git_repository_root(self):
        """Check if path is git repository."""
        git_path = Path(self.repo_path) / ".git"
        return self.is_git_repository and (git_path.is_dir() or git_path.is_file())

    @property
    def is_git_repository(self):
        try:
            self._git("rev-parse --git-dir")
            return True
        except subprocess.CalledProcessError:
            return False

    @property
    def initial_commit(self):
        return (
            self._git("rev-list --max-parents=0 HEAD").strip()
            if self.is_git_repository
            else None
        )

    def is_remote_branch(self, branch_name):
        for remote in self.remotes:
            if branch_name.startswith(remote + "/"):
                return True
        return False

    def _git(self, cmd, *args, **kwargs):
        """Call git commands in subprocess
    e.g.:
      self._git('checkout {}', branch_name)
    """
        log_error = kwargs.pop("log_error", False)
        log_error_msg = kwargs.pop("log_error_msg", "")
        reraise_error = kwargs.pop("reraise_error", False)
        log_success_msg = kwargs.pop("log_success_msg", "")

        if len(args):
            cmd = cmd.format(*args)
        command = f"git -C {self.repo_path} {cmd}"
        if log_error or log_error_msg:
            try:
                result = run(command)
                if log_success_msg:
                    logger.debug("Repo %s:" + log_success_msg, self.repo_name)
            except subprocess.CalledProcessError as e:
                if log_error_msg:
                    logger.error(log_error_msg)
                else:
                    logger.error(
                        "Repo %s: error occurred while executing %s:\n%s",
                        self.repo_name,
                        command,
                        e.output,
                    )
                if reraise_error:
                    raise
        else:
            result = run(command)
            if log_success_msg:
                logger.debug("Repo %s: " + log_success_msg, self.repo_name)
        return result

    def all_commits_on_branch(self, branch=None, reverse=True):
        """Returns a list of all commits on the specified branch. If branch is None,
        all commits on the currently checked out branch will be returned
        """
        if branch is None:
            branch = ""
        commits = self._git("log {} --format=format:%H", branch).strip()
        if not commits:
            commits = []
        else:
            commits = commits.split("\n")
            if reverse:
                commits.reverse()

        logger.debug(
            "Repo %s: found the following commits: %s",
            self.repo_name,
            ", ".join(commits),
        )
        return commits

    def all_commits_since_commit(self, since_commit, reverse=True):
        """Returns a list of all commits since the specified commit on the
        currently checked out branch
        """
        if since_commit is None:
            return self.all_commits_on_branch(reverse=reverse)
        commits = self._git("rev-list {}..HEAD", since_commit).strip()
        if not commits:
            commits = []
        else:
            commits = commits.split("\n")
            if reverse:
                commits.reverse()

        logger.debug(
            "Repo %s: found the following commits after commit %s: %s",
            self.repo_name,
            since_commit,
            ", ".join(commits),
        )
        return commits

    def all_fetched_commits(self, branch="master"):
        commits = self._git("rev-list ..origin/{}", branch).strip()
        if not commits:
            commits = []
        else:
            commits = commits.split("\n")
            commits.reverse()
        logger.debug(
            "Repo %s: fetched the following commits %s",
            self.repo_name,
            ", ".join(commits),
        )
        return commits

    def branches(self):
        """Returns all branches."""
        return [
            branch.strip('"').strip("'").strip()
            for branch in self._git("branch --format='%(refname:short)'").split("\n")
        ]

    def branches_containing_commit(self, commit, strip_remote=False):
        """Finds all branches that contain the given commit"""
        local_branches = self._git(f"branch --contains {commit}").split("\n")
        if local_branches:
            local_branches = [
                branch.replace("*", "").strip() for branch in local_branches
            ]
        remote_branches = self._git(f"branch -r --contains {commit}").split("\n")
        filtered_remote_branches = []
        if remote_branches:
            for branch in remote_branches:
                branch = branch.strip()
                local_name = self.branch_local_name(branch)
                if (
                    local_name
                    and "HEAD" not in local_name
                    and local_name not in local_branches
                ):
                    if strip_remote:
                        branch = self.branch_local_name(branch)
                    filtered_remote_branches.append(branch)
        branches = {branch: False for branch in local_branches}
        branches.update({branch: True for branch in filtered_remote_branches})
        return OrderedDict(sorted(branches.items(), reverse=True))

    def branch_exists(self, branch_name, include_remotes=True):
        """
        Checks if a branch with the given name exists.
        If include_remotes is set to True, this checks if
        a remote branch exists.
        """
        branch = self._git(f"branch --list {branch_name}")
        if branch:
            return True
        if include_remotes:
            for remote in self.remotes:
                branch = self._git(f"branch -r --list origin/{branch_name}")
                if branch:
                    return True
        return False

    def branch_off_commit(self, branch_name, commit):
        """Create a new branch by branching off of the specified commit"""
        self._git(f"checkout -b {branch_name} {commit}")

    def branch_local_name(self, remote_branch_name):
        """Strip remote from the given remote branch"""
        for remote in self.remotes:
            if remote_branch_name.startswith(remote + "/"):
                return remote_branch_name.split("/", 1)[1]

    def checkout_branch(self, branch_name, create=False):
        """Check out the specified branch. If it does not exists and
    the create parameter is set to True, create a new branch.
    If the branch does not exist and create is set to False,
    raise an exception."""
        try:
            self._git(
                "checkout {}",
                branch_name,
                log_error=True,
                reraise_error=True,
                log_success_msg=f"checked out branch {branch_name}",
            )
        except subprocess.CalledProcessError as e:
            if create:
                self.create_and_checkout_branch(branch_name)
            else:
                raise (e)

    def checkout_paths(self, commit, *args):
        for file_path in args:
            self._git(f"checkout {commit} {file_path}")

    def clean(self):
        self._git("clean -fd")

    def clone(self, no_checkout=False, bare=False):

        logger.info("Repo %s: cloning repository", self.repo_name)
        shutil.rmtree(self.repo_path, True)
        os.makedirs(self.repo_path, exist_ok=True)
        if self.repo_urls is None:
            raise Exception("Cannot clone repository. No urls were specified")
        params = ""
        if bare:
            params = "--bare"
        elif no_checkout:
            params = "--no-checkout"
        for url in self.repo_urls:
            try:
                self._git(
                    "clone {} . {}", url, params, log_success_msg="successfully cloned"
                )
            except subprocess.CalledProcessError:
                logger.error("Repo %s: cannot clone from url %s", self.repo_name, url)
            else:
                break

    def create_and_checkout_branch(self, branch_name, raise_error_if_exists=True):
        flag = "-b" if raise_error_if_exists else "-B"
        self._git(
            "checkout {} {}",
            flag,
            branch_name,
            log_success_msg=f"created and checked out branch {branch_name}",
            log_error=True,
            reraise_error=True,
        )

    def create_branch(self, branch_name, commit=None):
        if commit is None:
            commit = ""
        self._git(
            "branch {} {}",
            branch_name,
            commit,
            log_success_msg=f"created branch {branch_name}",
            log_error=True,
            reraise_error=True,
        )

    def create_local_branch(self, branch_name):
        """Create local branch by checking it if it does not exist out and making sure
        to check out previously checked out branch
        """
        if not self.branch_exists(branch_name, include_remotes=False):
            current_branch = self.get_current_branch()
            self.checkout_branch(branch_name)
            self.checkout_branch(current_branch)

    def checkout_commit(self, commit):
        self._git("checkout {}", commit, log_success_msg=f"checked out commit {commit}")

    def commit(self, message):
        """Create a commit with the provided message on the currently checked out branch"""
        self._git("add -A")
        try:
            self._git("diff --cached --exit-code --shortstat")
        except subprocess.CalledProcessError:
            run("git", "-C", self.repo_path, "commit", "--quiet", "-m", message)
        return self._git("rev-parse HEAD")

    def commit_empty(self, message):
        run(
            "git",
            "-C",
            self.repo_path,
            "commit",
            "--quiet",
            "--allow-empty",
            "-m",
            message,
        )
        return self._git("rev-parse HEAD")

    def commits_on_branch_and_not_other(
        self, branch1, branch2, include_branching_commit=False
    ):
        """
        Meant to find commits belonging to a branch which branches off of
        a commit from another branch. For example, to find only commits
        on a speculative branch and not on the master branch.
        """

        logger.debug(
            "Repo %s: finding commits which are on branch %s, but not on branch %s",
            self.repo_name,
            branch1,
            branch2,
        )
        commits = self._git(
            "log {} --not {} --no-merges --format=format:%H", branch1, branch2
        )
        commits = commits.split("\n") if commits else []
        if include_branching_commit:
            branching_commit = self._git("rev-list -n 1 {}~1", commits[-1])
            commits.append(branching_commit)
        logger.debug(
            "Repo %s: found the following commits: %s", self.repo_name, commits
        )
        return commits

    def delete_local_branch(self, branch_name, force=False):
        """Deletes local branch."""
        flag = "-D" if force else "-d"
        self._git(f"branch {flag} {branch_name}")

    def delete_remote_branch(self, branch_name, remote=None):
        if remote is None:
            remote = self.remotes[0]
        self._git(f"push {remote} --delete {branch_name}")

    def get_commits_date(self, commit):
        date = self._git("show -s --format=%at {}", commit)
        return date.split(" ", 1)[0]

    def get_commit_message(self, commit):
        """Returns commit message of the given commit"""
        return self._git("log --format=%B -n 1 {}", commit).strip()

    def get_commit_sha(self, behind_head):
        """Get commit sha of HEAD~{behind_head}"""
        return self._git("rev-parse HEAD~{}", behind_head)

    def get_json(self, commit, path):
        s = self.get_file(commit, path)
        return json.loads(s)

    def get_file(self, commit, path):
        return self._git("show {}:{}", commit, path)

    def get_last_branch_by_committer_date(self):
        """Find the latest branch based on committer date. Should only be used for
        testing purposes"""
        branches = self._git("branch --sort=committerdate").strip().split("\n")
        if not len(branches):
            return None
        return branches[-1]

    def get_remote_url(self):
        try:
            return self._git("config --get remote.origin.url").strip()
        except subprocess.CalledProcessError:
            return None

    def delete_branch(self, branch_name):
        self._git("branch -D {}", branch_name)

    def diff_between_revisions(self, revision1=EMPTY_TREE, revision2="HEAD"):
        return self._git("diff {} {}", revision1, revision2)

    def has_remote(self):
        return bool(self._git("remote"))

    def head_commit_sha(self):
        """Finds sha of the commit to which the current HEAD points"""
        try:
            return self._git("rev-parse HEAD")
        except subprocess.CalledProcessError:
            return None

    def fetch(self, fetch_all=False):
        if fetch_all:
            self._git("fetch --all")
        else:
            self._git("fetch")

    def get_current_branch(self):
        """Return current branch."""
        return self._git("rev-parse --abbrev-ref HEAD").strip()

    def get_merge_base(self, branch1, branch2):
        """Finds the best common ancestor between two branches"""
        return self._git(f"merge-base {branch1} {branch2}")

    def get_tracking_branch(self, branch=""):
        """Returns tracking branch name in format origin/branch-name or None if branch does not
        track remote branch.
        """
        try:
            return self._git(f"rev-parse --abbrev-ref {branch}@{{u}}")
        except subprocess.CalledProcessError:
            return None

    def init_repo(self, bare=False):
        if not os.path.isdir(self.repo_path):
            os.makedirs(self.repo_path, exist_ok=True)
        flag = "--bare" if bare else ""
        self._git(f"init {flag}")
        if self.repo_urls is not None and len(self.repo_urls):
            self._git("remote add origin {}", self.repo_urls[0])

    def list_files_at_revision(self, commit, path=""):
        if path is None:
            path = ""
        file_names = self._git("ls-tree -r --name-only {}", commit)
        list_of_files = []
        if not file_names:
            return list_of_files
        for file_in_repo in file_names.split("\n"):
            if not file_in_repo.startswith(path):
                continue
            file_in_repo = os.path.relpath(file_in_repo, path)
            list_of_files.append(file_in_repo)
        return list_of_files

    def list_commits(self, **kwargs):
        params = []
        for name, value in kwargs.items():
            params.append(f"--{name}={value}")

        return self._git("log {}", " ".join(params)).split("\n")

    def list_n_commits(self, number=10, skip=None, branch=None):
        """List the specified number of top commits of the current branch
        Optionally skip a number of commits from the top"""
        if branch is None:
            branch = ""
        if skip:
            commits = self._git(
                f"log {branch} --format=format:%H --skip={skip} -n {number}"
            )
        else:
            commits = self._git(f"log {branch} --format=format:%H -n {number}")
        return commits.split("\n") if commits else []

    def merge_commit(self, commit):
        self._git("merge {}", commit)

    def pull(self):
        """Pull current branch"""
        self._git("pull")

    def push(self, branch=None, set_upstream=False, force=False):
        """Push all changes"""
        if branch is None:
            branch = self.get_current_branch()
        upstream_flag = "-u" if set_upstream else ""
        force_flag = "-f" if force else ""
        self._git("push {} {} origin {}", upstream_flag, force_flag, branch)

    def rename_branch(self, old_name, new_name):
        self._git("branch -m {} {}", old_name, new_name)

    def reset_num_of_commits(self, num_of_commits, hard=False):
        flag = "--hard" if hard else "--soft"
        self._git(f"reset {flag} HEAD~{num_of_commits}")

    def reset_to_commit(self, commit, hard=False):
        flag = "--hard" if hard else "--soft"
        self._git(f"reset {flag} {commit}")

    def reset_to_head(self):
        self._git("reset --hard HEAD")

    def set_upstream(self, branch_name):
        self._git("branch -u origin/{}", branch_name)

    def something_to_commit(self):
        """Checks if there are any uncommitted changes"""
        uncommitted_changes = self._git("status --porcelain")
        return bool(uncommitted_changes)

    def synced_with_remote(self, branch="master"):
        """Checks if local branch is synced with its remote branch"""
        # check if the latest local commit matches
        # the latest remote commit on the specified branch
        tracking_branch = self.get_tracking_branch(branch)
        if not tracking_branch:
            return False

        try:
            local_commit = self._git(f"rev-parse {branch}")
        except subprocess.CalledProcessError as e:
            if "unknown revision or path not in the working tree" not in e.output:
                raise e
            local_commit = None

        try:
            remote_commit = self._git(f"rev-parse {tracking_branch}")
        except subprocess.CalledProcessError as e:
            if "unknown revision or path not in the working tree" not in e.output:
                raise e
            remote_commit = None

        return local_commit == remote_commit

    def top_commit_of_branch(self, branch):
        return self._git(f"rev-parse {branch}")


class NamedGitRepository(GitRepository):
    def __init__(
        self,
        root_dir,
        repo_name,
        repo_urls=None,
        additional_info=None,
        default_branch="master",
    ):
        """
    Args:
      root_dir: the root directory
      repo_name: repository's path relative to the root directory root_dir
      repo_urls: repository's urls (optional)
      additional_info: a dictionary containing other data (optional)
      default_branch: repository's default branch
    repo_path is the absolute path to this repository. It is set by joining
    root_dir and repo_name.
    """
        repo_path = _get_repo_path(root_dir, repo_name)
        super().__init__(repo_path, repo_urls, additional_info, default_branch)
        self.repo_name = repo_name


def _get_repo_path(root_dir, repo_name):
    """
  get the path to a repo and ensure it is valid.
  (since this is coming from potentially untrusted data)
  """
    _validate_repo_name(repo_name)
    repo_dir = str((Path(root_dir) / (repo_name or "")))
    if not repo_dir.startswith(repo_dir):
        logger.error("Repo %s: repository name is not valid", repo_name)
        raise InvalidRepositoryError(f"Invalid repository name: {repo_name}")
    return repo_dir


_repo_name_re = re.compile(r"^\w[\w_-]*/\w[\w_-]*$")


def _validate_repo_name(repo_name):
    """ Ensure the repo name is not malicious """
    match = _repo_name_re.match(repo_name)
    if not match:
        logger.error("Repo %s: repository name is not valid", repo_name)
        raise InvalidRepositoryError(
            "Repository name must be in format namespace/repository "
            "and can only contain letters, numbers, underscores and "
            f'dashes, but got "{repo_name}"'
        )


_http_fttp_url = re.compile(
    r"^(?:http|ftp)s?://"  # http:// or https://
    # domain...
    r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"
    r"localhost|"  # localhost...
    r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"  # ...or ip
    r"(?::\d+)?"  # optional port
    r"(?:/?|[/?]\S+)$",
    re.IGNORECASE,
)

_ssh_url = re.compile(
    r"((git|ssh|http(s)?)|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)?(/)?"
)


def _validate_url(url):
    """ ensure valid URL """
    for _url_re in [_http_fttp_url, _ssh_url]:
        match = _url_re.match(url)
        if match:
            return
    logger.error("Repository URL (%s) is not valid", url)
    raise InvalidRepositoryError(
        f'Repository URL must be a valid URL, but got "{url}".'
    )
