""" turtle

This file is part of Turtle.

Turtle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

Turtle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with Turtle. If not, see <https://www.gnu.org/licenses/>. 
"""
import os
import re
import tempfile
import pathlib
from enum import Enum
import pygit2
import turtlevcs

class PullAction(Enum):
    """ enum type for pull actions """
    FETCH_ONLY = 0
    FETCH_AND_MERGE = 1
    FETCH_AND_REBASE = 2

class CreateFrom(Enum):
    """ enum type from which, i.e. a branch will be created from """
    HEAD = 0
    BRANCH = 1
    COMMIT = 2
    WORKING_COPY = 3

class TurtleCallbacks(pygit2.RemoteCallbacks):
    """ callback class for remote actions """

    push_result_callback = None

    def push_update_reference(self, refname, message):
        self.push_result_callback(message)

    def transfer_progress(self, stats):
        # TODO show progress
        return super().transfer_progress(stats)


class TurtleBase():
    """ a base class which creates and contains a turtle object """
    path = None
    turtle = None

    def __init__(self, path, no_turtle=False):
        self.path = path
        if no_turtle is False:
            self.turtle = Turtle(path)

class Turtle():
    """ turtle who takes care of the git stuff """
    repo = None

    def __init__(self, path):
        self.repo = pygit2.Repository(path)

    def reload(self):
        """ reload pygit2 repository """
        self.repo = pygit2.Repository(self.repo.workdir)

    def get_repo_name(self):
        """ get the repository name (basically the local folder name) """
        # TODO is this the best way to find the "repository name"?
        try:
            name = self.repo.path
            name = name.removesuffix("/.git/")
            pieces = name.split("/")
            name = pieces[-1]
        except Exception:
            name = ""

        return name

    def get_current_commit_hex(self):
        """ get hex of the currently checked out commit """
        return self.repo.head.target.hex

    def get_current_branch_name(self):
        """ get name of the currently checked out branch """
        try:
            branch_name = self.repo.head.shorthand
        except Exception:
            # we do not have a branch name if there is no commit yet in a fresh repo
            branch_name = "-"
        return branch_name

    def get_commit_info(self, show_unversioned, show_ignored, amend=False):
        """ get relevant commit info """
        message = None

        untracked_files = "all"
        if not show_unversioned:
            untracked_files = "no"

        if amend:
            branch = self.repo[self.repo.head.target]
            message = branch.message

        status = self.repo.status(
            untracked_files=untracked_files, ignored=show_ignored)

        return status, message

    def add(self, files, deleted_files):
        """ add files """

        index = self.repo.index
        total = len(files)
        for file in files:
            index.add(file)

        total_deleted = len(deleted_files)
        for file in deleted_files:
            index.remove(file)

        if total > 0 or total_deleted > 0:
            index.write()

        return total + total_deleted

    def commit(self, message, amend, files, deleted_files):
        """ commit files """
        error = None
        is_merge = False
        merge_head = None
        total = 0

        try:
            ref = self.repo.head.name
            parents = [self.repo.head.target]
            is_merge, merge_head = self.is_merge_in_progress()
            if is_merge:
                parents.append(merge_head)
        except Exception:
            ref = "HEAD"
            parents = []
            amend = False

        index = self.repo.index

        for file in files:
            index.add(file)

        for file in deleted_files:
            index.remove(file)

        if self.repo.head_is_unborn:
            total = -1
        else:
            diff = self.repo.diff("HEAD", cached=True)
            total = diff.stats.files_changed

        if total > 0 or amend or self.repo.head_is_unborn:
            index.write()

            try:
                tree = index.write_tree()

                signature = turtlevcs.get_author(self.repo)

                if amend:
                    commit = self.repo[self.repo.head.target]
                    self.repo.amend_commit(
                        commit, "HEAD", None, signature, message, tree)
                else:
                    self.repo.create_commit(
                        ref, signature, signature, message, tree, parents)

                if is_merge:
                    self.repo.state_cleanup()
            except Exception as ex:
                total = -1
                error = str(ex)

        return total, error

    def get_push_info(self):
        """ get relevant push info """
        try:
            current_branch_name = self.repo.head.shorthand
        except Exception:
            current_branch_name = None

        try:
            current_branch = self.repo.branches.local[current_branch_name]
        except Exception:
            current_branch = None

        try:
            current_remote = current_branch.upstream.remote_name
        except Exception:
            current_remote = None

        return current_remote, current_branch_name, self.repo.branches.local, self.repo.remotes

    def get_commits_for_push(self, branch_name, remote_name):
        """ get commits to be pushed """

        commit_list = []
        branch_on_remote = False

        try:
            branch = self.repo.branches.get(branch_name)

            latest_commit_id = branch.target
            latest_commit = self.repo.revparse_single(latest_commit_id.hex)

            if remote_name is not None:
                try:
                    remote_branch = self.repo.branches.remote[remote_name + "/" + branch_name]
                    if remote_branch is not None:
                        branch_on_remote = True
                except Exception as _ex:
                    pass

                commit_list = self.repo.walk(
                    latest_commit.id, pygit2.GIT_SORT_TIME)

                # hide all commits existing on target remote from list
                for remote_branch_name in self.repo.branches.remote:
                    remote_branch = self.repo.branches[remote_branch_name]
                    if remote_branch.remote_name == remote_name:
                        first_commit = self.repo.revparse_single(remote_branch.name)
                        commit_list.hide(first_commit.id)
        except Exception as _ex:
            pass

        return commit_list, branch_on_remote

    def push(self, branch_name, remote_name, result_callback):
        """ push branch to remote """
        remote = self.repo.remotes[remote_name]
        branch = self.repo.branches[branch_name]
        refspec = f"{branch.name}:{branch.name}"
        keypair = turtlevcs.get_keypair()
        callbacks = TurtleCallbacks(credentials=keypair)
        callbacks.push_result_callback = result_callback
        remote.push([refspec], callbacks)

    def get_branch_upstream(self, branch_name):
        """ get the upstream branch to local branch, i.e. to check if it can be pulled """
        branch = self.repo.branches[branch_name]
        return branch.upstream

    def pull(self, branch_name, remote_name, pull_action):
        """ pull a branch from remote """
        title = None
        message = None
        if self.repo.head_is_detached:
            if pull_action is PullAction.FETCH_ONLY and len(self.repo.remotes) == 1:
                remote_name = self.repo.remotes[0].name
            else:
                raise Exception("HEAD is detached")

        else:
            if not branch_name:
                branch_name = self.repo.head.shorthand

            if not remote_name:
                branch = self.repo.branches[branch_name]
                remote_name = branch.upstream.remote_name

        remote = self.repo.remotes[remote_name]
        keypair = turtlevcs.get_keypair()
        callbacks = TurtleCallbacks(credentials=keypair)
        remote.fetch(callbacks=callbacks)

        if pull_action is not PullAction.FETCH_ONLY:
            branch = self.repo.branches[branch_name]
            merge_result, _ = self.repo.merge_analysis(branch.upstream.target)
            if merge_result & pygit2.GIT_MERGE_ANALYSIS_UP_TO_DATE:
                pass # do nothing

            elif merge_result & pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
                self.repo.checkout_tree(self.repo.get(branch.upstream.target))
                branch.set_target(branch.upstream.target)
                self.repo.head.set_target(branch.upstream.target)

            elif merge_result & pygit2.GIT_MERGE_ANALYSIS_NORMAL:
                if pull_action is PullAction.FETCH_AND_REBASE:
                    pass # TODO rebase
                else:
                    self.repo.merge(branch.upstream.target)

                    if self.repo.index.conflicts is None:
                        signature = turtlevcs.get_author(self.repo)
                        tree = self.repo.index.write_tree()
                        self.repo.create_commit(
                            "HEAD",
                            signature,
                            signature,
                            f"Merge remote branch {branch.upstream.shorthand}"
                            f" into branch {branch.shorthand}",
                            tree,
                            [self.repo.head.target, branch.upstream.target])
                        self.repo.state_cleanup()
                    else:
                        title = "Conflicts"
                        message = "Fix conflicts and then commit the result"

            else:
                message = "Merge failed"

        return message, title

    def log(self, all_branches=False, ignored_remotes=[], hide_local=False):
        """ get commit log of current branch """
        commit_list = []

        if all_branches:
            head_id = self.repo.head.target
            seen = []

            def add_target_to_list(branch, skip_head=True):
                nonlocal commit_list
                try:
                    target_id = branch.resolve().target
                except Exception as _ex:
                    target_id = head_id

                if skip_head and not hide_local and target_id == head_id:
                    return

                walker = self.repo.walk(target_id, pygit2.GIT_SORT_TOPOLOGICAL)
                for s in seen:
                    walker.hide(s)
                seen.append(target_id)
                commit_list_temp = []

                for commit in walker:
                    commit_list_temp.append(commit)

                if len(commit_list_temp) > 0:
                    commit_list = commit_list_temp + commit_list


            # add local branches
            if not hide_local:
                for branch_name in self.repo.branches.local:
                    branch = self.repo.branches[branch_name]
                    add_target_to_list(branch)

            # add remote branches
            for branch_name in self.repo.branches.remote:
                branch = self.repo.branches[branch_name]

                if branch.remote_name in ignored_remotes:
                    continue

                add_target_to_list(branch)

            if not hide_local:
                # try to add the head last, so it appears on top
                # will not be possible if head is part of a branch
                add_target_to_list(head_id, skip_head=False)

        else:
            latest_commit_id = self.repo.head.target
            latest_commit = self.repo.revparse_single(latest_commit_id.hex)
            walker = self.repo.walk(latest_commit.id, pygit2.GIT_SORT_TOPOLOGICAL)
            for commit in walker:
                commit_list.append(commit)

        return commit_list

    def get_full_file_path(self, file):
        """ get the full file path for a file in the repo """
        return pathlib.Path(self.repo.workdir) / file

    def get_relative_file_path(self, file):
        """ get the full file path for a file in the repo """
        return file.removeprefix(self.repo.workdir)

    def get_file_from_index(self, file):
        """ store content of file from index into a tmp file and return temp file object """
        file_path = file.split("/")[-1]
        temp = tempfile.NamedTemporaryFile(suffix=f"__INDEX_{file_path}")

        index = self.repo.index
        index.read()
        try:
            id = index[file].id
            blob = self.repo[id]

            temp.write(blob.data)
            temp.flush()
        except Exception as _ex:
            temp.close()

        return temp

    def get_file_from_commit(self, hex, file):
        """ store content of file from a commit into a tmp file and return temp file object """

        temp = None
        try:
            index = pygit2.Index()
            commit = self.repo.revparse_single(hex)

            file_path = file.split("/")[-1]
            temp = tempfile.NamedTemporaryFile(suffix=f"__{commit.short_id}_{file_path}")

            index.read_tree(commit.tree)
            id = index[file].id
            blob = self.repo[id]

            temp.write(blob.data)
            temp.flush()
        except Exception as _ex:
            if temp:
                temp.close()

        return temp

    def get_file_from_previous_commit(self, file):
        """ store content of file from previous commit into a tmp file and return temp file object """
        prev_commit = self.repo.revparse_single(self.repo.head.name)
        hex = prev_commit.hex

        return self.get_file_from_commit(hex, file)

    def get_branch_commit_dictionary(self, ignored_remotes=[], hide_local=False):
        """ get a dictionary [commit] = branch """
        branch_dict = {}

        def add_branch_to_dict(branch):
            if not isinstance(branch.target, str):
                if branch.target.hex in branch_dict:
                    branch_dict[branch.target.hex].append((branch.name, branch_name))
                else:
                    branch_dict[branch.target.hex] = [(branch.name, branch_name)]

        if not hide_local:
            for branch_name in self.repo.branches.local:
                add_branch_to_dict(self.repo.branches[branch_name])

        for branch_name in self.repo.branches.remote:
            branch = self.repo.branches[branch_name]
            if branch.remote_name not in ignored_remotes:
                add_branch_to_dict(branch)

        return branch_dict

    def get_tag_commit_dictionary(self, tag_dict={}):
        """ get a dictionary [commit] = tag """

        regex = re.compile('^refs/tags/')
        for ref_name in self.repo.references:
            if regex.match(ref_name):
                ref = self.repo.references[ref_name]
                commit = ref.peel()
                if commit.hex in tag_dict:
                    tag_dict[commit.hex].append((ref.name, ref.shorthand))
                else:
                    tag_dict[commit.hex] = [(ref.name, ref.shorthand)]

        return tag_dict

    def get_list_of_references(self):
        """ get list of all available references """
        ref_list = []

        for ref in self.repo.references:
            ref_list.append(ref)

        return ref_list

    def get_list_of_branches(self):
        """ get list of all available branches """
        branch_list = []
        for branch_name in self.repo.branches:
            branch = self.repo.branches[branch_name]
            if not isinstance(branch.target, str):
                branch_list.append(branch_name)

        return branch_list

    def checkout(
            self,
            name,
            new_branch_name=None,
            force=False,
            override=False):
        """ checkout a branch, tag or commit """

        strategy = pygit2.GIT_CHECKOUT_FORCE if force else pygit2.GIT_CHECKOUT_SAFE
        is_remote_branch = False

        if name.startswith("refs/"):
            branch_name = name.split("/", 2)[-1]
        else:
            branch_name = name
        is_branch = self.repo.lookup_branch(branch_name, pygit2.GIT_BRANCH_ALL)
        is_remote_branch = self.repo.lookup_branch(branch_name, pygit2.GIT_BRANCH_REMOTE)

        if is_branch:
            branch = self.repo.branches[branch_name]

            if is_remote_branch:
                new_branch_name = branch.shorthand.removeprefix(
                    branch.remote_name).removeprefix("/")

            ref = self.repo.lookup_reference(branch.name)
            self.repo.checkout(refname=ref, strategy=strategy)
        else:
            # tag or commit hash
            commit = self.repo.revparse_single(name)
            self.repo.checkout_tree(treeish=commit, strategy=strategy)
            self.repo.set_head(commit.id)

        if new_branch_name:
            commit = self.repo.revparse_single(self.repo.head.name)
            new_branch = self.repo.branches.create(new_branch_name, commit, override)
            if is_remote_branch:
                new_branch.upstream = self.repo.branches[branch_name]

            new_ref = self.repo.lookup_reference(new_branch.name)
            self.repo.checkout(refname=new_ref, strategy=strategy)

    def init_submodules(self, callbacks=None, repo=None):
        """ init all submodules recursively """
        # TODO not tested yet
        if repo is None:
            repo=self.repo
        if callbacks is None:
            keypair = turtlevcs.get_keypair()
            callbacks = TurtleCallbacks(credentials=keypair)

        submodules = repo.listall_submodules()
        repo.init_submodules(submodules=submodules)
        repo.update_submodules(submodules=submodules, callbacks=callbacks)
        for submodule_path in submodules:
            submodule = repo.lookup_submodule(submodule_path)
            submodule_repo = submodule.open()
            self.init_submodules(callbacks=callbacks, repo=submodule_repo)

    def create_tag(self, name, source, base, annotation=None):
        """ create a tag based on HEAD, branch or commit """
        commit_hex = base
        if source is CreateFrom.HEAD:
            commit_hex = self.repo.head.target.hex
        elif source is CreateFrom.BRANCH:
            commit_hex = self.repo.branches[base].target.hex

        if annotation is None:
            self.repo.references.create(f"refs/tags/{name}", commit_hex)
        else:
            self.repo.create_tag(
                name,
                commit_hex,
                pygit2.GIT_OBJ_COMMIT,
                self.repo.default_signature,
                annotation)

    def create_branch(self, name, source, base, checkout=False, force=False):
        """ create a branch based on HEAD, branch or commit """
        commit_hex = base
        if source is CreateFrom.HEAD:
            commit_hex = self.repo.head.target.hex
        elif source is CreateFrom.BRANCH:
            commit_hex = self.repo.branches[base].target.hex

        commit = self.repo.revparse_single(commit_hex)
        self.repo.branches.create(name, commit, force)

        if checkout:
            self.checkout(name, force=force)

    def delete_branch(self, name):
        """ delete a branch by name """
        self.repo.branches.delete(name)

    def delete_reference(self, name):
        """ delete a reference by name """
        self.repo.references.delete(name)

    def get_remotes(self):
        """ get remotes """
        remotes = self.repo.remotes

        return remotes

    def get_remote_url_by_name(self, name):
        """ get urls for remote """
        remote = self.repo.remotes[name]

        return remote.url, remote.push_url

    def update_remote(self, old_name, new_name, url, push_url):
        """ update a remote, if old_name is empty a new remote will be added """
        remotes = self.repo.remotes

        if old_name and len(old_name) > 0:
            if old_name != new_name:
                remotes.rename(old_name, new_name)
            remotes.set_url(new_name, url)
            if push_url is not None and len(push_url) > 0:
                remotes.set_push_url(new_name, push_url)
            else:
                try:
                    remotes.set_push_url(new_name, None)
                except Exception:
                    # we cannot unset an empty push url, ignore exception
                    pass
        else:
            remotes.create(new_name, url)
            if push_url is not None and len(push_url) > 0:
                remotes.set_push_url(new_name, push_url)

    def remove_remote(self, name):
        """ remove remote by name """
        self.repo.remotes.delete(name)

    def get_revision_by_commit(self, hex):
        """ get revision object by commit """
        return self.repo.revparse_single(hex)

    def get_revision_by_branch(self, name):
        """ get revision object by branch name """
        branch = self.repo.branches[name]
        hex = branch.target.hex
        return self.repo.revparse_single(hex)

    def get_submodules(self):
        """ get list of submodules """
        submodules = self.repo.listall_submodules()
        return submodules

    def add_submodule(self, repo, path):
        """ add a new submodule """
        keypair = turtlevcs.get_keypair()
        callbacks = TurtleCallbacks(credentials=keypair)
        self.repo.add_submodule(repo, path, callbacks=callbacks)

    def remove_path(self, path):
        """ remove a path from the index """
        index = self.repo.index
        index.read()
        index.remove(path)
        index.write()

    def get_conflicts(self, path=None):
        """ get list of conflicts """
        conflicts = []

        if self.repo.index.conflicts is not None:
            for conflict in self.repo.index.conflicts:
                if len(conflict) == 3:
                    conflicts.append((conflict[0], conflict[1], conflict[2]))

        return conflicts

    def get_file_from_current_index(self, path, name):
        """ store content of file from current index into a tmp file and return temp file object """
        try:
            index = self.repo.index[path]
            return self.get_files_from_index(index, name)
        except Exception as _ex:
            pass

        return None

    def get_files_from_index(self, index, name):
        """ store content of file from an index into a tmp file and return temp file object """

        temp = None
        try:
            file_path = index.path.split("/")[-1]
            temp = tempfile.NamedTemporaryFile(suffix=f"__{name}_{file_path}")

            blob = self.repo[index.id]

            temp.write(blob.data)
            temp.flush()
        except Exception:
            if temp:
                temp.close()

        return temp

    def is_merge_in_progress(self):
        """ check if a merge is currently in progress
         if so also return the merge_head """
        merge_path = self.repo.path + "MERGE_HEAD"
        try:
            merge_file = open(merge_path, "r", encoding="utf8")
            if merge_file is not None:
                merge_head = merge_file.readline().strip()
                is_merge = len(merge_head) > 0

                return is_merge, merge_head
        except Exception as _ex:
            pass
        return False, None

    def is_rebase_in_progress(self):
        """ check if a merge is currently in progress
         if so also return the merge_head """
        rebase_path = self.repo.path + "REBASE_HEAD"
        try:
            rebase_file = open(rebase_path, "r", encoding="utf8")
            if rebase_file is not None:
                rebase_head = rebase_file.readline().strip()
                is_rebase = len(rebase_head) > 0

                return is_rebase, rebase_head
        except Exception as _ex:
            pass
        return False, None

    def can_be_staged(self, path, raise_ex=False):
        """ check if a file can actually be staged, raises exception if not """
        if os.path.exists(path):
            status, _ = self.get_commit_info(True, False)

            file = self.get_relative_file_path(path)

            if file in status:
                has_wt_status, file_status = turtlevcs.has_worktree_status(status[file])
                if has_wt_status and file_status in [pygit2.GIT_STATUS_WT_MODIFIED, pygit2.GIT_STATUS_WT_NEW]:
                    return True, file_status == pygit2.GIT_STATUS_WT_NEW

        if raise_ex:
            raise Exception(f"The file '{file}' cannot be staged.")

        return False, False

    def stage_file(self, path):
        """ stage a single file """
        self.repo.index.add(path)
        self.repo.index.write()

    def stage_file_as_index(self, file_path, index_path):
        """ stage contents of a given file as a specific index, i.e. for staging hunks """
        with open(file_path, "r") as file:
            file_contents = file.read()
            new_id = self.repo.write(pygit2.GIT_OBJ_BLOB, file_contents)
            index = pygit2.IndexEntry(index_path, new_id, pygit2.GIT_FILEMODE_BLOB)
            self.repo.index.add(index)
            self.repo.index.write()

    def can_be_reverted(self, file_path, raise_ex=False):
        """ check if a file can actually be reverted """
        status, _ = self.get_commit_info(True, False)

        if file_path in status:
            _, wt_status = turtlevcs.has_worktree_status(status[file_path])
            return wt_status, wt_status == pygit2.GIT_STATUS_WT_NEW

        if raise_ex:
            raise Exception(f"The file '{file_path}' cannot be staged.")

        return False, False

    def revert(self, file_path):
        """ revert worktree changes for a given file """
        can_be_converted, wt_new = self.can_be_reverted(file_path)
        if can_be_converted:
            file = str(self.get_full_file_path(file_path))
            if wt_new:
                os.remove(file)
            else:
                index = self.repo.index
                file_id = index[file_path].id
                blob = self.repo[file_id]

                with open(file, "wb") as file:
                    file.write(blob.data)

    def can_be_unstaged(self, file_path, raise_ex=False):
        """ check if a file can actually be unstaged """
        if os.path.exists(file_path):
            status, _ = self.get_commit_info(False, False)

            file = self.get_relative_file_path(file_path)

            if file in status:
                if turtlevcs.has_index_status(status[file]):
                    return True

        if raise_ex:
            raise Exception(f"The file '{file}' cannot be unstaged.")

        return False

    def unstage(self, file_path):
        """ remove file from index """
        self.repo.index.read()
        self.repo.index.remove(file_path)
        try:
            obj = self.repo.revparse_single('HEAD').tree[file_path]
            self.repo.index.add(pygit2.IndexEntry(file_path, obj.id, obj.filemode))
        except Exception as _ex:
            pass # file might not have been in the last commit
        finally:
            self.repo.index.write()


def clone(url, path, recursive, bare, progress_callback):
    """ clone repository from url into local path """
    keypair = turtlevcs.get_keypair()
    callbacks = TurtleCallbacks(credentials=keypair)
    new_repo = pygit2.clone_repository(url, path, bare, callbacks=callbacks)

    if recursive:
        # TODO not tested yet
        turtle = Turtle(path)
        turtle.init_submodules()

    return new_repo

def init(path, bare):
    """ init a new repo """
    repo = pygit2.init_repository(
        path,
        bare,
        pygit2.GIT_REPOSITORY_INIT_MKPATH | pygit2.GIT_REPOSITORY_INIT_NO_REINIT)
    return repo
