import os
import re
import subprocess
import sys
import urllib
from typing import Dict
from typing import Iterator
from typing import List
from typing import Optional
from typing import Tuple

from . import exceptions


class SshParseResult(urllib.parse.ParseResult):
    """
    Class to distinguish parsed SSH URLs
    """


class Git:
    @staticmethod
    def urlparse(url: str) -> urllib.parse.ParseResult:
        """
        Parse git url.

        Supported formats:
            - https://example.com/owner/repo.git
            - https://example.com:1234/owner/repo.git
            - example.com/owner/repo.git
            - user@example.com:owner/repo.git
            - user@example.com:1234:owner/repo.git"
        """
        # try ssh clone url first
        pattern = r"(?P<netloc>[^@:]+@[^@:]+(:[0-9]+)?):(?P<path>.+)"
        match = re.match(pattern, url)
        if match:
            scheme = ""
            netloc = match.groupdict()["netloc"]
            path = match.groupdict()["path"]
            params = ''
            query = ''
            fragment = ''
            result = SshParseResult(scheme, netloc, path, params, query, fragment)
            return result

        result = urllib.parse.urlparse(url)
        if not result.netloc:
            # empty netloc is most likely an error, prepend and then discard scheme to trick urlparse()
            result = urllib.parse.urlparse("https://" + url)
            result = urllib.parse.ParseResult("", *list(result)[1:])
        return result

    @staticmethod
    def urljoin(url: str, path: str) -> str:
        """
        Append ``path`` to ``url``.
        """
        parts = Git.urlparse(url)
        # we're using os.path.normpath() and os.path.join() for working with URL paths, which may not be ideal, but seems to be working fine (on Linux)
        # we need to remove leading forward slash from ``parts.path`` because ``os.path.normpath("/../")`` resolves to "/" and we don't want that
        new_path = os.path.normpath(os.path.join(parts.path.lstrip("/"), path.lstrip("/")))

        parts = parts._replace(path=new_path)

        if isinstance(parts, SshParseResult):
            new_url = f"{parts.netloc}:{parts.path}"
        else:
            new_url = urllib.parse.urlunparse(parts)

        if new_path.startswith("../") or "/../" in new_path:
            raise ValueError(f"URL must not contain relative path: {new_url}")

        return new_url

    @staticmethod
    def parse_githook_stdin() -> Iterator[Tuple[str, str, str]]:
        """
        Returns [(old_rev, new_rev, ref_name)] from stdin.
        Standard for pre-receive and post-receive hooks.
        """
        result = []
        for line in sys.stdin:
            parts = line.split()
            if len(parts) != 3:
                raise RuntimeError(f"Unexpected line in githook stdin: {line}")
            result.append(parts)
        return result

    @staticmethod
    def is_null_rev(rev: str) -> bool:
        """Check if a revision is the null revision (all zeros)."""
        return not rev or rev.strip("0") == ""

    @classmethod
    def detect_git(cls, path: str) -> Optional[Tuple[bool, str, str]]:
        """
        Detect git repo based on given ``path``.
        Return (is_bare, git_dir, top_dir).
        """

        if not os.path.isdir(path):
            # subprocess.run() would fail with FileNotFoundError or NotADirectoryError
            return None

        cmd = ["git", "rev-parse", "--is-bare-repository", "--is-inside-git-dir", "--absolute-git-dir", "--show-cdup"]
        proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", check=False, cwd=path)

        if proc.returncode != 0:
            return None

        lines = proc.stdout.splitlines()
        if len(lines) < 3:
            return None

        is_bare = lines[0] == "true"
        is_inside_git_dir = lines[1] == "true"
        git_dir = lines[2]

        if is_bare:
            # in bare repo, top dir and git dir are the same
            top_dir = git_dir
        else:
            if is_inside_git_dir:
                top_dir = os.path.dirname(git_dir)
            else:
                # in non-bare repo, the 4th line is the relative path to root
                cdup = lines[3] if len(lines) > 3 else ""
                top_dir = os.path.abspath(os.path.join(path, cdup))

        return is_bare, git_dir, top_dir

    def __init__(self, workdir):
        self.abspath = os.path.abspath(workdir)
        self.is_bare = None
        self.git_dir = None
        self.topdir = None

        values = self.detect_git(self.abspath)
        if values:
            self.is_bare, self.git_dir, self.topdir = values

    def _run_git(self, args: List[str], use_topdir: bool = False, mute_stderr: bool = False) -> str:
        cwd = self.topdir if use_topdir else self.abspath
        # HACK: having 2 nearly identical commands is stupid, but it muted a mypy error
        if mute_stderr:
            return subprocess.check_output(["git"] + args, encoding="utf-8", cwd=cwd, stderr=subprocess.DEVNULL).strip()
        return subprocess.check_output(["git"] + args, encoding="utf-8", cwd=cwd).strip()

    def init(self, *, initial_branch: Optional[str] = None, quiet: bool = True, mute_stderr: bool = False):
        cmd = ["init", "--object-format=sha256"]
        if initial_branch:
            cmd += ["-b", initial_branch]
        if quiet:
            cmd += ["-q"]
        self._run_git(cmd, mute_stderr=mute_stderr)

    def clone(self,
        url: str,
        *,
        directory: Optional[str] = None,
        reference: Optional[str] = None,
        reference_if_able: Optional[str] = None,
        quiet: bool = True
    ):
        cmd = ["clone", url]
        if directory:
            cmd += [directory]
        if reference:
            cmd += ["--reference", reference]
        if reference_if_able:
            cmd += ["--reference-if-able", reference_if_able]
        if quiet:
            cmd += ["-q"]
        self._run_git(cmd)

    # BRANCHES

    @property
    def current_branch(self) -> Optional[str]:
        try:
            result = self._run_git(["branch", "--show-current"], mute_stderr=True)
        except subprocess.CalledProcessError:
            result = None

        if not result:
            # try to determine the branch during rebase
            git_dir = self.git_dir
            if git_dir:
                paths = [
                    os.path.join(git_dir, "rebase-apply", "head-name"),
                    os.path.join(git_dir, "rebase-merge", "head-name"),
                ]
                for path in paths:
                    try:
                        with open(path, "r", encoding="utf-8") as f:
                            line = f.readline()
                            # parse "refs/heads/<branch>"
                            result = line.strip().split("/", 2)[-1]
                            break
                    except FileNotFoundError:
                        pass

        return result or None

    def branch(self, branch: str, set_upstream_to: Optional[str] = None):
        cmd = ["branch"]
        if set_upstream_to:
            cmd += ["--set-upstream-to", set_upstream_to]
        cmd += [branch]
        return self._run_git(cmd)

    def branch_contains_commit(self, commit: str, branch: Optional[str] = None, remote: Optional[str] = None) -> bool:
        if not branch:
            branch = self.current_branch

        if remote:
            try:
                self._run_git(["merge-base", "--is-ancestor", commit, f"{remote}/{branch}"], mute_stderr=True)
                return True
            except subprocess.CalledProcessError:
                return False

        try:
            stdout = self._run_git(["branch", branch, "--contains", commit, "--format", "%(objectname) %(objecttype) %(refname)"])
            return stdout.strip() == f"{commit} commit refs/heads/{branch}"
        except subprocess.CalledProcessError:
            return False

    def branch_is_fast_forwardable(self, other_branch: str) -> bool:
        cmd = ["merge-base", "--is-ancestor", "HEAD", other_branch]
        try:
            self._run_git(cmd)
            return True
        except subprocess.CalledProcessError:
            return False

    def get_branch_head(self, branch: Optional[str] = None, *, remote: Optional[str] = None) -> str:
        if not branch:
            branch = self.current_branch

        if remote:
            try:
                return self._run_git(["rev-parse", f"refs/remotes/{remote}/{branch}"], mute_stderr=True)
            except subprocess.CalledProcessError:
                raise exceptions.GitObsRuntimeError(f"Unable to retrieve HEAD from remote '{remote}', branch '{branch}'. Does the branch exist?")

        try:
            return self._run_git(["rev-parse", f"refs/heads/{branch}"], mute_stderr=True)
        except subprocess.CalledProcessError:
            raise exceptions.GitObsRuntimeError(f"Unable to retrieve HEAD from branch '{branch}'. Does the branch exist?")

    def branch_exists(self, branch: str) -> bool:
        try:
            self._run_git(["rev-parse", f"refs/heads/{branch}", "--"], mute_stderr=True)
        except subprocess.CalledProcessError:
            return False
        return True

    def commit_count(self, branch: str) -> int:
        try:
            commits = self._run_git(["rev-list", "--count", f"refs/heads/{branch}", "--"], mute_stderr=True)
            return int(commits)
        except subprocess.CalledProcessError:
            return -1

    def reset(self, commit: Optional[str] = None, *, hard: bool = False):
        cmd = ["reset"]
        if commit:
            cmd += [commit]
        if hard:
            cmd += ["--hard"]
        self._run_git(cmd)

    def switch(self, branch: str, *, orphan: bool = False, quiet: bool = False):
        cmd = ["switch"]
        if quiet:
            cmd += ["--quiet"]
        if orphan:
            cmd += ["--orphan"]
        cmd += [branch]
        self._run_git(cmd)

    def fetch_pull_request(
        self,
        pull_number: int,
        *,
        remote: Optional[str] = None,
        commit: Optional[str] = None,
        depth: Optional[int] = None,
        force: bool = False,
    ):
        """
        Fetch pull/$pull_number/head to pull/$pull_number branch
        """
        target_branch = f"pull/{pull_number}"

        # if the branch exists and the head matches the expected commit, skip running 'git fetch'
        if commit and self.branch_exists(target_branch) and self.get_branch_head(target_branch) == commit:
            return target_branch

        if not remote:
            remote = self.get_current_remote()

        cmd = ["fetch", remote, f"pull/{pull_number}/head:{target_branch}"]
        if depth:
            cmd += ["--depth", str(depth)]
        if force:
            cmd += [
                "--force",
                "--update-head-ok",
            ]
        self._run_git(cmd)
        return target_branch

    @property
    def has_changes(self) -> bool:
        """
        Determine if the current branch has any changes (staged or unstaged).
        """
        cmd = ["diff-index", "--quiet", "HEAD", "--"]
        try:
            self._run_git(cmd)
            return False
        except subprocess.CalledProcessError:
            return True

    # CONFIG

    def set_config(self, key: str, value: str):
        self._run_git(["config", "--local", key, value])

    # REMOTES

    def get_remote_url(self, name: Optional[str] = None) -> Optional[str]:
        if not name:
            name = self.get_current_remote()
        if not name:
            return None
        return self._run_git(["remote", "get-url", name])

    def add_remote(self, name: str, url: str):
        self._run_git(["remote", "add", name, url])

    def get_current_remote(self, fallback_to_origin: bool = True) -> Optional[str]:
        result = None
        try:
            # get the upstream branch that the current branch is tracking:
            #   case 1: upstream is a remote-tracking branch    origin/main
            #   case 2: upstream is a local branch              slfo-main
            upstream = self._run_git(
                ["rev-parse", "--abbrev-ref", "@{u}"], mute_stderr=True
            )
            if "/" in upstream:
                result = upstream.split("/")[0]
                try:
                    self._run_git(["remote", "get-url", result], mute_stderr=True)
                except subprocess.CalledProcessError:
                    result = None
            else:
                # case 2: upstream is a local branch
                # look up the remote that the local branch tracks
                try:
                    remote_ref = self._run_git(
                        ["config", f"branch.{upstream}.remote"], mute_stderr=True
                    )
                    if remote_ref:
                        result = remote_ref
                except subprocess.CalledProcessError:
                    pass
        except subprocess.CalledProcessError:
            pass

        # the tracking information isn't sometimes set
        # let's fall back to 'origin' if available
        if not result and fallback_to_origin:
            try:
                self._run_git(["remote", "get-url", "origin"], mute_stderr=True)
                result = "origin"
            except subprocess.CalledProcessError:
                pass

        return result

    def fetch(self, name: Optional[str] = None):
        if name:
            cmd = ["fetch", name]
        else:
            cmd = ["fetch", "--all"]
        self._run_git(cmd)

    @staticmethod
    def split_owner_repo(path: str) -> Tuple[str, str]:
        if path.endswith(".git"):
            path = path[:-4]
        return path.strip("/").split("/")[-2:]

    def get_owner_repo(self, remote: Optional[str] = None) -> Tuple[str, str]:
        remote_url = self.get_remote_url(name=remote)
        if not remote_url:
            raise exceptions.GitObsRuntimeError("Couldn't determine owner and repo due to a missing remote")
        return self.get_owner_repo_from_url(remote_url)

    @staticmethod
    def get_owner_repo_from_url(url: str) -> Tuple[str, str]:
        if "@" in url:
            # ssh://gitea@example.com:owner/repo.git
            # ssh://gitea@example.com:22/owner/repo.git
            url = url.rsplit("@", 1)[-1]
        parsed_url = urllib.parse.urlparse(url)
        return Git.split_owner_repo(parsed_url.path)

    # LFS

    def lfs_ls_files(self, ref: str = "HEAD", suffixes: Optional[List[str]] = None) -> Dict[str, str]:
        # TODO: --size; returns human readable string; can we somehow get the exact value in bytes instead?
        out = self._run_git(["lfs", "ls-files", "--long", ref])
        regex = re.compile(r"^(?P<checksum>[0-9a-f]+) [\*\-] (?P<path>.*)$")
        result = {}
        for line in out.splitlines():
            match = regex.match(line)
            if not match:
                continue

            checksum = match.groupdict()["checksum"]
            path = match.groupdict()["path"]

            if suffixes:
                found = False
                for suffix in suffixes:
                    if path.endswith(suffix):
                        found = True
                        break
                if not found:
                    continue

            result[path] = checksum
        return result

    def lfs_cat_file(self, filename: str, ref: str = "HEAD"):
        """
        A generator function that returns chunks of bytes of the requested file.
        """
        with subprocess.Popen(["git", "cat-file", "--filters", f"{ref}:{filename}"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, cwd=self.abspath) as proc:
            assert proc.stdout is not None
            while True:
                # 1MiB chunks are probably a good balance between memory consumption and performance
                data = proc.stdout.read(1024**2)
                if not data:
                    break
                yield data

    # FILES

    def add(self, files: List[str]):
        self._run_git(["add", *files])

    def commit(self, msg, *, allow_empty: bool = False, amend: bool = False, no_edit: bool = False):
        cmd = ["commit", "-m", msg]
        if allow_empty:
            cmd += ["--allow-empty"]
        if amend:
            cmd += ["--amend"]
        if no_edit:
            cmd += ["--no-edit"]
        self._run_git(cmd)

    def push(self, remote: Optional[str] = None, branch: Optional[str] = None, *, set_upstream: Optional[str] = None, force: bool = False):
        cmd = ["push"]
        if force:
            cmd += ["--force"]
        if set_upstream:
            cmd += ["--set-upstream"]
        if remote:
            cmd += [remote]
            if branch:
                cmd += [branch]
        self._run_git(cmd)

    def ls_tree(self, ref: str = "HEAD") -> List[Dict]:
        regex = re.compile(r"^(?P<mode>[0-9]+) (?P<object_type>[a-z]+) (?P<commit>[a-f0-9]+) +(?P<size>[\d-]+)\t+(?P<path>.+)$")
        lines = self._run_git(["ls-tree", "-r", "--long", ref]).splitlines()

        # map mode to a human readable file type
        file_types = {
            "040000": "directory",
            "100644": "file",
            "100755": "file",
            "120000": "symlink",
            "160000": "submodule",
        }

        result = []
        for line in lines:
            match = regex.match(line)
            if not match:
                continue

            data = match.groupdict()

            # add human readable file type
            data["file_type"] = file_types.get(data["mode"], None)

            # convert size to int
            if data["size"] == "-":
                data["size"] = None
            else:
                data["size"] = int(data["size"])

            result.append(data)

        return result

    def ls_files(self, ref: str = "HEAD", suffixes: Optional[List[str]] = None) -> Dict[str, str]:
        out = self._run_git(["ls-tree", "-r", "--format=%(objectname) %(path)", ref])
        regex = re.compile(r"^(?P<checksum>[0-9a-f]+) (?P<path>.*)$")
        result = {}
        for line in out.splitlines():
            match = regex.match(line)
            if not match:
                continue

            checksum = match.groupdict()["checksum"]
            path = match.groupdict()["path"]

            if suffixes:
                found = False
                for suffix in suffixes:
                    if path.endswith(suffix):
                        found = True
                        break
                if not found:
                    continue

            result[path] = checksum
        return result

    def diff(self, ref_old: str, ref_new: str, src_prefix: Optional[str] = None, dst_prefix: Optional[str] = None) -> Iterator[bytes]:
        cmd = ["git", "diff", ref_old, ref_new]

        if src_prefix:
            src_prefix = src_prefix.rstrip("/") + "/"
            cmd += [f"--src-prefix={src_prefix}"]

        if dst_prefix:
            dst_prefix = dst_prefix.rstrip("/") + "/"
            cmd += [f"--dst-prefix={dst_prefix}"]

        # 1MiB chunks are probably a good balance between memory consumption and performance
        chunk_size = 1024**2
        with subprocess.Popen(cmd, stdout=subprocess.PIPE, cwd=self.abspath) as proc:
            assert proc.stdout is not None
            while True:
                # read a chunk of data, make sure it ends with a newline
                # so we don't have to deal with split utf-8 characters and incomplete escape sequences later
                chunk = proc.stdout.read(chunk_size)
                chunk += proc.stdout.readline()
                if not chunk:
                    break
                yield chunk

    def status(self, *, porcelain: bool = False, untracked_files: bool = False):
        cmd = ["status", "--renames"]
        if untracked_files:
            cmd += ["--untracked-files"]
        if porcelain:
            cmd += ["--porcelain"]
        return self._run_git(cmd)

    def clean(self):
        """
        Remove untracked files from the working tree.
        This includes also gitingored files.
        """
        cmd = ["clean", "-d", "-x", "--force"]
        return self._run_git(cmd)

    # SUBMODULES

    def get_submodules(self, ref: str = "") -> dict:
        SUBMODULE_RE = re.compile(r"^submodule\.(?P<submodule>[^=]*)\.(?P<key>[^\.=]*)=(?P<value>.*)$")

        ref = ref or "HEAD"
        result = {}

        try:
            lines = self._run_git(["config", "--blob", f"{ref}:.gitmodules", "--list"], mute_stderr=True).splitlines()
        except subprocess.CalledProcessError:
            # .gitmodules file is missing
            return {}

        for line in lines:
            match = SUBMODULE_RE.match(line)
            if not match:
                continue
            submodule = match.groupdict()["submodule"]
            key = match.groupdict()["key"]
            value = match.groupdict()["value"]
            #if key == "url":
            #    assert value.startswith("../../")
            submodule_entry = result.setdefault(submodule, {})
            submodule_entry[key] = value

        if self.is_bare or ref:
            # querying a bare git repo or a specified ref
            submodules = self.ls_tree(ref)
            submodules = [i for i in submodules if i["file_type"] == "submodule"]
            for i in submodules:
                if i["file_type"] != "submodule":
                    continue
                submodule = i["path"]
                result[submodule]["commit"] = i["commit"]
                result[submodule]["status"] = " "

        else:
            # querying working tree
            STATUS_RE = re.compile(r"^(?P<status>.)(?P<commit>[a-f0-9]+) (?P<submodule>[^ ]+)\s*(\((?P<description>.*)\))?$")
            lines = self._run_git(["submodule", "status"], use_topdir=True).splitlines()
            for line in lines:
                match = STATUS_RE.match(line)
                if not match:
                    continue
                submodule = match.groupdict()["submodule"]
                commit = match.groupdict()["commit"]
                status = match.groupdict()["status"]
                result[submodule]["commit"] = commit
                result[submodule]["status"] = status

        remote_url = self.get_remote_url()
        for submodule_entry in result.values():
            url = submodule_entry["url"]
            if not url.startswith("../"):
                submodule_entry["clone_url"] = url
                continue
 
            clone_url = self.urljoin(remote_url, submodule_entry["url"])
            owner, repo = self.get_owner_repo_from_url(clone_url)
            submodule_entry["clone_url"] = clone_url
            submodule_entry["owner"] = owner
            submodule_entry["repo"] = repo

        return result

    # COMMITS
    def get_commit_range(self, old_rev: str, new_rev: str, *, not_all: bool = False) -> List[str]:
        """
        Returns a list of commit SHAs in old_rev..new_rev range.
        This is frequently used in git hooks.

        :param not_all: Add --not --all parameters to skip all commits that are part of any other branch.
        """
        if self.is_null_rev(new_rev):
            # branch deletion
            return []

        if self.is_null_rev(old_rev):
            # new branch
            args = ["rev-list", new_rev]
        else:
            # branch update
            args = ["rev-list", f"{old_rev}..{new_rev}"]

        if not_all:
            args += ["--not", "--all"]

        output = self._run_git(args)
        return output.splitlines() if output else []
