#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program 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.
#
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
#
import shutil

from collections.abc import Iterator
from pathlib import Path
from typing import ClassVar

from ahriman.core.exceptions import CalledProcessError
from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output, utcnow, walk
from ahriman.models.changes import Changes
from ahriman.models.package import Package
from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.remote_source import RemoteSource
from ahriman.models.repository_paths import RepositoryPaths


class Sources(LazyLogging):
    """
    helper to download package sources (PKGBUILD etc...) and perform some operations with git

    Attributes:
        DEFAULT_BRANCH(str): (class attribute) default branch to process git repositories.
            Must be used only for local stored repositories, use RemoteSource descriptor instead for real packages
        DEFAULT_COMMIT_AUTHOR(tuple[str, str]): (class attribute) default commit author to be used if none set
        GITCONFIG(dict[str, str]): (class attribute) git config options to suppress annoying hints
    """

    DEFAULT_BRANCH: ClassVar[str] = "master"  # default fallback branch
    DEFAULT_COMMIT_AUTHOR: ClassVar[tuple[str, str]] = ("ahriman", "ahriman@localhost")
    GITCONFIG: ClassVar[dict[str, str]] = {
        "init.defaultBranch": DEFAULT_BRANCH,
    }

    @staticmethod
    def changes(source_dir: Path, last_commit_sha: str) -> Changes:
        """
        extract changes from the last known commit if available

        Args:
            source_dir(Path): local path to directory with source files
            last_commit_sha(str): last known commit hash

        Returns:
            Changes: changes from the last commit if available
        """
        instance = Sources()

        diff = None
        if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
            diff = instance.diff(source_dir, last_commit_sha)
        pkgbuild = instance.read(source_dir, "HEAD", Path("PKGBUILD"))

        return Changes(last_commit_sha, diff, pkgbuild)

    @staticmethod
    def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]:
        """
        extend existing PKGBUILD with repository architecture

        Args:
            sources_dir(Path): local path to directory with source files
            architecture(str): repository architecture

        Returns:
            list[PkgbuildPatch]: generated patch for PKGBUILD architectures if required
        """
        architectures = Pkgbuild.supported_architectures(sources_dir)
        if "any" in architectures:  # makepkg does not like when there is any other arch except for any
            return []
        architectures.add(architecture)
        return [PkgbuildPatch("arch", list(architectures))]

    @staticmethod
    def fetch(sources_dir: Path, remote: RemoteSource) -> str | None:
        """
        either clone repository or update it to origin/``remote.branch``

        Args:
            sources_dir(Path): local path to fetch
            remote(RemoteSource): remote target (from where to fetch)

        Returns:
            str | None: current commit sha if available
        """
        instance = Sources()
        # local directory exists and there is .git directory
        is_initialized_git = (sources_dir / ".git").is_dir()
        if is_initialized_git and not instance.has_remotes(sources_dir):
            # there is git repository, but no remote configured so far
            instance.logger.info("skip update at %s because there are no branches configured", sources_dir)
            return instance.head(sources_dir)

        branch = remote.branch or instance.DEFAULT_BRANCH
        if is_initialized_git:
            instance.logger.info("update HEAD to remote at %s using branch %s", sources_dir, branch)
            instance.fetch_until(sources_dir, branch=branch)
        elif remote.git_url is not None:
            instance.logger.info("clone remote %s to %s using branch %s", remote.git_url, sources_dir, branch)
            check_output(*instance.git(), "clone", "--quiet", "--depth", "1", "--branch", branch, "--single-branch",
                         remote.git_url, str(sources_dir), cwd=sources_dir.parent, logger=instance.logger)
        else:
            # it will cause an exception later
            instance.logger.error("%s is not initialized, but no remote provided", sources_dir)

        # and now force reset to our branch
        check_output(*instance.git(), "checkout", "--force", branch, cwd=sources_dir, logger=instance.logger)
        check_output(*instance.git(), "reset", "--quiet", "--hard", f"origin/{branch}",
                     cwd=sources_dir, logger=instance.logger)

        # move content if required
        # we are using full path to source directory in order to make append possible
        pkgbuild_dir = remote.pkgbuild_dir or sources_dir.resolve()
        instance.move((sources_dir / pkgbuild_dir).resolve(), sources_dir)

        return instance.head(sources_dir)

    @staticmethod
    def has_remotes(sources_dir: Path) -> bool:
        """
        check if there are remotes for the repository

        Args:
            sources_dir(Path): local path to git repository

        Returns:
            bool: ``True`` in case if there is any remote and false otherwise
        """
        instance = Sources()
        remotes = check_output(*instance.git(), "remote", cwd=sources_dir, logger=instance.logger)
        return bool(remotes)

    @staticmethod
    def init(sources_dir: Path) -> None:
        """
        create empty git repository at the specified path

        Args:
            sources_dir(Path): local path to sources
        """
        instance = Sources()
        if not (sources_dir / ".git").is_dir():
            # skip initializing in case if it was already
            check_output(*instance.git(), "init", "--quiet", "--initial-branch", instance.DEFAULT_BRANCH,
                         cwd=sources_dir, logger=instance.logger)

        # extract local files...
        files = ["PKGBUILD", ".SRCINFO"] + [str(path) for path in Pkgbuild.local_files(sources_dir)]
        instance.add(sources_dir, *files)
        # ...and commit them
        instance.commit(sources_dir)

    @staticmethod
    def load(sources_dir: Path, package: Package, patches: list[PkgbuildPatch], paths: RepositoryPaths) -> str | None:
        """
        fetch sources from remote and apply patches

        Args:
            sources_dir(Path): local path to fetch
            package(Package): package definitions
            patches(list[PkgbuildPatch]): optional patch to be applied
            paths(RepositoryPaths): repository paths instance

        Returns:
            str | None: current commit sha if available
        """
        instance = Sources()
        if (cache_dir := paths.cache_for(package.base)).is_dir() and cache_dir != sources_dir:
            # no need to clone whole repository, just copy from cache first
            shutil.copytree(cache_dir, sources_dir, dirs_exist_ok=True)
        last_commit_sha = instance.fetch(sources_dir, package.remote)

        patches.extend(instance.extend_architectures(sources_dir, paths.repository_id.architecture))
        for patch in patches:
            instance.patch_apply(sources_dir, patch)

        return last_commit_sha

    @staticmethod
    def patch_create(sources_dir: Path, *pattern: str) -> str:
        """
        create patch set for the specified local path

        Args:
            sources_dir(Path): local path to git repository
            *pattern(str): glob patterns

        Returns:
            str: patch as plain text
        """
        instance = Sources()
        instance.add(sources_dir, *pattern, intent_to_add=True)
        diff = instance.diff(sources_dir)
        return f"{diff}\n"  # otherwise, patch will be broken

    @staticmethod
    def push(sources_dir: Path, remote: RemoteSource, *pattern: str,
             commit_author: tuple[str, str] | None = None) -> None:
        """
        commit selected changes and push files to the remote repository

        Args:
            sources_dir(Path): local path to git repository
            remote(RemoteSource): remote target, branch and url
            *pattern(str): glob patterns
            commit_author(tuple[str, str] | None, optional): commit author if any (Default value = None)
        """
        instance = Sources()
        instance.add(sources_dir, *pattern)
        if not instance.commit(sources_dir, commit_author=commit_author):
            return  # no changes to push, just skip action

        git_url, branch = remote.git_source()
        check_output(*instance.git(), "push", "--quiet", git_url, branch, cwd=sources_dir, logger=instance.logger)

    def add(self, sources_dir: Path, *pattern: str, intent_to_add: bool = False) -> None:
        """
        track found files via git

        Args:
            sources_dir(Path): local path to git repository
            *pattern(str): glob patterns
            intent_to_add(bool, optional): record only the fact that it will be added later, acts as
                --intent-to-add git flag (Default value = False)
        """
        # glob directory to find files which match the specified patterns
        found_files: list[Path] = []
        for glob in pattern:
            found_files.extend(sources_dir.glob(glob))
        if not found_files:
            return  # no additional files found
        self.logger.info("found matching files %s", found_files)
        # add them to index
        args = ["--intent-to-add"] if intent_to_add else []
        check_output(*self.git(), "add", *args, *[str(fn.relative_to(sources_dir)) for fn in found_files],
                     cwd=sources_dir, logger=self.logger)

    def commit(self, sources_dir: Path, message: str | None = None,
               commit_author: tuple[str, str] | None = None) -> bool:
        """
        commit changes

        Args:
            sources_dir(Path): local path to git repository
            message(str | None, optional): optional commit message if any. If none set, message will be generated
                according to the current timestamp (Default value = None)
            commit_author(tuple[str, str] | None, optional): optional commit author if any (Default value = None)

        Returns:
            bool: ``True`` in case if changes have been committed and ``False`` otherwise
        """
        if not self.has_changes(sources_dir):
            return False  # nothing to commit

        if message is None:
            message = f"Autogenerated commit at {utcnow()}"
        args = ["--message", message]

        if commit_author is None:
            commit_author = self.DEFAULT_COMMIT_AUTHOR
        user, email = commit_author
        gitconfig = {
            "user.email": email,
            "user.name": user,
        }

        check_output(*self.git(gitconfig), "commit", "--quiet", *args, cwd=sources_dir, logger=self.logger)

        return True

    def diff(self, sources_dir: Path, sha: str | None = None) -> str:
        """
        generate diff from the current version and write it to the output file

        Args:
            sources_dir(Path): local path to git repository
            sha(str | None, optional): optional commit sha to calculate diff (Default value = None)

        Returns:
            str: patch as plain string
        """
        args = []
        if sha is not None:
            args.append(sha)
        return check_output(*self.git(), "diff", *args, cwd=sources_dir, logger=self.logger)

    def fetch_until(self, sources_dir: Path, *, branch: str | None = None, commit_sha: str | None = None,
                    max_depth: int = 10) -> str | None:
        """
        fetch repository until commit sha

        Args:
            sources_dir(Path): local path to git repository
            branch(str | None, optional): use specified branch (Default value = None)
            commit_sha(str | None, optional): commit hash to fetch. If none set, only one will be fetched
                (Default value = None)
            max_depth(int, optional): maximal amount of commits to fetch if ``commit_sha`` is set (Default value = 10)

        Returns:
            str | None: fetched ``commit_sha`` (if set) and ``None`` in case if commit wasn't found or
            ``commit_sha`` is not set
        """
        commit_sha = commit_sha or "HEAD"  # if none set we just fetch the last commit

        commits_count = 1
        while commits_count <= max_depth:
            command = self.git() + ["fetch", "--quiet", "--depth", str(commits_count)]
            if branch is not None:
                command += ["origin", branch]
            check_output(*command, cwd=sources_dir, logger=self.logger)  # fetch one more level

            try:
                # check if there is an object in current git directory
                check_output(*self.git(), "cat-file", "-e", commit_sha, cwd=sources_dir, logger=self.logger)
                return commit_sha  # found the required commit
            except CalledProcessError:
                commits_count += 1  # increase depth

        # no commits found at the requested depth
        return None

    def git(self, gitconfig: dict[str, str] | None = None) -> list[str]:
        """
        git command prefix

        Args:
            gitconfig(dict[str, str] | None, optional): additional git config flags if any (Default value = None)

        Returns:
            list[str]: git command prefix with valid default flags
        """
        gitconfig = gitconfig or {}

        def configuration_flags() -> Iterator[str]:
            for option, value in (self.GITCONFIG | gitconfig).items():
                yield "-c"
                yield f"{option}=\"{value}\""

        return ["git"] + list(configuration_flags())

    def has_changes(self, sources_dir: Path) -> bool:
        """
        check if there are changes in current git tree

        Args:
            sources_dir(Path): local path to git repository

        Returns:
            bool: ``True`` if there are uncommitted changes and ``False`` otherwise
        """
        # there is --exit-code argument to diff, however, there might be other process errors
        changes = check_output(*self.git(), "diff", "--cached", "--name-only", cwd=sources_dir, logger=self.logger)
        return bool(changes)

    def head(self, sources_dir: Path, ref_name: str = "HEAD") -> str:
        """
        extract HEAD reference for the current git repository

        Args:
            sources_dir(Path): local path to git repository
            ref_name(str, optional): reference name (Default value = "HEAD")

        Returns:
            str: HEAD commit hash
        """
        # we might want to parse git files instead though
        return check_output(*self.git(), "rev-parse", ref_name, cwd=sources_dir, logger=self.logger)

    def move(self, pkgbuild_dir: Path, sources_dir: Path) -> None:
        """
        move content from pkgbuild_dir to sources_dir

        Args:
            pkgbuild_dir(Path): path to directory with pkgbuild from which need to move
            sources_dir(Path): path to target directory
        """
        del self
        if pkgbuild_dir == sources_dir:
            return  # directories are the same, no need to move
        for src in walk(pkgbuild_dir):
            dst = sources_dir / src.relative_to(pkgbuild_dir)
            shutil.move(src, dst)

    def patch_apply(self, sources_dir: Path, patch: PkgbuildPatch) -> None:
        """
        apply patches if any

        Args:
            sources_dir(Path): local path to directory with git sources
            patch(PkgbuildPatch): patch to be applied
        """
        # create patch
        self.logger.info("apply patch %s from database at %s", patch.key, sources_dir)
        if patch.is_plain_diff:
            check_output(*self.git(), "apply", "--ignore-space-change", "--ignore-whitespace",
                         cwd=sources_dir, input_data=patch.serialize(), logger=self.logger)
        else:
            patch.write(sources_dir / "PKGBUILD")

    def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str | None:
        """
        read file content from the specified commit

        Args:
            sources_dir(Path): local path to git repository
            commit_sha(str): commit hash to read from
            path(Path): path to file inside the repository

        Returns:
            str | None: file content at specified commit if available
        """
        try:
            return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)
        except CalledProcessError:
            self.logger.exception("failed to read file %s at %s", path, commit_sha)
            return None
