# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Debusine task configuration repository management."""

import difflib
import logging
import shutil
import subprocess
from collections import defaultdict
from collections.abc import Generator, Iterable
from pathlib import Path
from typing import Any, Literal, Self, TypeAlias, assert_never, override

import yaml

from debusine.artifacts.models import DebusineTaskConfiguration
from debusine.client.models import StrictBaseModel, TaskConfigurationCollection


class Item(DebusineTaskConfiguration):
    """DebusineTaskConfiguration with path and position never set."""

    path: None = None
    position: None = None


def as_item(value: DebusineTaskConfiguration) -> Item:
    """Convert a DebusineTaskConfiguration to an Item."""
    serialized = value.model_dump()
    serialized.pop("path", None)
    serialized.pop("position", None)
    return Item.model_validate(serialized)


class InvalidRepository(Exception):
    """The local repository is not valid or does not match the remote one."""


class PullStats:
    """Statistics for a pull operation."""

    __slots__ = "added", "deleted", "unchanged", "updated"

    def __init__(
        self,
        added: int = 0,
        updated: int = 0,
        deleted: int = 0,
        unchanged: int = 0,
    ) -> None:
        """Initialize a new PullStats."""
        self.added = added
        self.updated = updated
        self.deleted = deleted
        self.unchanged = unchanged

    @override
    def __str__(self) -> str:
        return (
            f"{self.added} added, "
            f"{self.updated} updated, "
            f"{self.deleted} deleted, "
            f"{self.unchanged} unchanged"
        )

    @override
    def __repr__(self) -> str:
        return (
            f"PullStats(added={self.added}, updated={self.updated},"
            f" deleted={self.deleted}, unchanged={self.unchanged})"
        )

    def __iadd__(self, other: "PullStats") -> Self:
        """Add values from another PullStats."""
        self.added += other.added
        self.updated += other.updated
        self.deleted += other.deleted
        self.unchanged += other.unchanged
        return self

    @override
    def __eq__(self, other: Any) -> bool:
        if not isinstance(other, PullStats):
            return NotImplemented

        return (self.added, self.updated, self.deleted, self.unchanged) == (
            other.added,
            other.updated,
            other.deleted,
            other.unchanged,
        )

    def has_changes(self) -> bool:
        """Check if anything was changed."""
        return self.added > 0 or self.updated > 0 or self.deleted > 0


class Manifest(StrictBaseModel):
    """Repository metadata."""

    workspace: str
    collection: TaskConfigurationCollection


class EntryFile:
    """A file with task configuration entries."""

    relpath: Path
    entries: list[Item]

    def __init__(self, relpath: Path) -> None:
        """Create an empty EntryFile."""
        self.relpath = relpath
        self.entries = []

    def load(self, root: Path) -> None:
        """Load contents from the file system."""
        path = root / self.relpath
        try:
            with path.open() as fd:
                data = yaml.safe_load(fd)

            if isinstance(data, list):
                for item in data:
                    item.pop("path", None)
                    item.pop("position", None)
                    self.entries.append(Item.model_validate(item))
            else:
                data.pop("path", None)
                data.pop("position", None)
                self.entries.append(Item.model_validate(data))
        except Exception as e:
            e.add_note(f"source: {self.relpath}")
            raise

    def write(self, root: Path) -> None:
        """
        Write the contents to a file.

        If the file already exists and its contents would be unchanged, it is
        left untouched.
        """
        # Load the old contents
        old = EntryFile(self.relpath)
        try:
            old.load(root)
        except FileNotFoundError:
            pass
        else:
            # If they are unchanged, do nothing
            if self.entries == old.entries:
                return

        # Else rewrite the file
        data: list[dict[str, Any]] | dict[str, Any]
        if len(self.entries) == 1:
            data = self.entries[0].model_dump(mode="json")
        else:
            data = [e.model_dump(mode="json") for e in self.entries]
        path = root / self.relpath
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("wt") as fd:
            yaml.safe_dump(data, fd)

    def diff(self, other: "EntryFile") -> PullStats:
        """Compute change statistics."""
        matcher = difflib.SequenceMatcher(
            a=[e.model_dump_json() for e in self.entries],
            b=[e.model_dump_json() for e in other.entries],
            autojunk=False,
        )
        stats = PullStats()
        tag: Literal["replace", "delete", "insert", "equal"]
        for tag, i1, i2, j1, j2 in matcher.get_opcodes():
            match tag:
                case "replace":
                    # a[i1:i2] should be replaced by b[j1:j2]
                    ra = i2 - i1
                    rb = j2 - j1
                    if ra > rb:
                        stats.deleted += ra - rb
                        stats.updated += rb
                    elif ra < rb:
                        stats.added += rb - ra
                        stats.updated += ra
                    else:
                        stats.updated += ra
                case "delete":
                    # a[i1:i2] should be deleted
                    stats.deleted += i2 - i1
                case "insert":
                    # b[j1:j2] should be inserted at a[i1:i1]
                    stats.added += j2 - j1
                case "equal":
                    # a[i1:i2] == b[j1:j2] (the sub-sequences are equal)
                    stats.unchanged += i2 - i1
                case _ as unreachable:
                    assert_never(unreachable)

        # Note: updated is now representing items being replaced in the same
        # file position. We do not have the concept of an entry being updated
        # anymore based on its identifying information, because we now accept
        # multiple entries in a file that match the same task information
        return stats


class RemoteTaskConfigurationRepository:
    """Task configuration repository populated from a remote server."""

    #: Store an alias to the Manifest type
    Manifest: TypeAlias = Manifest

    def __init__(self, manifest: Manifest) -> None:
        """Initialize with a manifest."""
        #: Repository metadata
        self.manifest = manifest
        #: Task configuration entries
        self.entries: list[DebusineTaskConfiguration] = []

    @classmethod
    def from_items(
        cls, manifest: Manifest, items: Iterable[DebusineTaskConfiguration]
    ) -> Self:
        """Load contents from a stream of items."""
        res = cls(manifest)
        res.entries.extend(items)
        return res


class LocalTaskConfigurationRepository:
    """Task configuration repository stored in the local file system."""

    #: Store an alias to the Manifest type
    Manifest: TypeAlias = Manifest

    def __init__(self, root: Path) -> None:
        """Initialize an empty repository."""
        #: Repository metadata
        self.manifest: Manifest | None
        #: Task configuration entries
        self.files: dict[Path, EntryFile] = {}

        #: On-disk root
        self.root = root
        #: Path of the manifest file
        self.manifest_path = self.root / "MANIFEST"

        self.reload()

    def reload(self) -> None:
        """Load/reload contents."""
        self.manifest = None
        self.files = {}

        #: Load manifest from disk
        if self.manifest_path.exists():
            self.manifest = self.read_manifest(self.root)

        #: Load items from disk
        for relpath in self.iter_files(self.root):
            entry_file = EntryFile(relpath)
            entry_file.load(self.root)
            self.files[relpath] = entry_file

    def iter_entries(self) -> Generator[DebusineTaskConfiguration]:
        """
        Return the list of items in the repository.

        Items are annotated with file and position information.
        """
        for entry_file in self.files.values():
            for position, entry in enumerate(entry_file.entries):
                e = DebusineTaskConfiguration.model_validate(entry.model_dump())
                e.path = entry_file.relpath.as_posix()
                e.position = position
                yield e

    def is_dirty(self) -> bool:
        """Check if the git repository is dirty."""
        return False

    def git_commit(self) -> str | None:
        """Return the hash of the current git commit."""
        return None

    def has_commit(self, commit: str) -> bool:  # noqa: ARG002, U100
        """Check if the given commit hash is in our history."""
        return False

    def write_manifest(self) -> None:
        """Write repository manifest from disk."""
        if self.manifest is None:
            raise InvalidRepository("Repository manifest has not been set")
        try:
            with self.manifest_path.open("wt") as fd:
                yaml.safe_dump(self.manifest.model_dump(), fd)
        except Exception as exc:
            exc.add_note(f"manifest: {self.manifest_path}")
            raise

    def _merge_manifest(self, manifest: Manifest) -> None:
        if self.manifest is None:
            self.manifest = manifest.model_copy()
        elif (
            self.manifest.workspace != manifest.workspace
            or self.manifest.collection.id != manifest.collection.id
            or self.manifest.collection.name != manifest.collection.name
        ):
            raise InvalidRepository(
                "repo to pull refers to collection"
                f" {manifest.workspace}/{manifest.collection.name}"
                f" ({manifest.collection.id})"
                " while the checkout has collection"
                f" {self.manifest.workspace}/{self.manifest.collection.name}"
                f" ({self.manifest.collection.id})"
            )
        else:
            self.manifest.collection.data = manifest.collection.data.copy()

    def find_in_repo(
        self, entry: DebusineTaskConfiguration
    ) -> Generator[DebusineTaskConfiguration]:
        """
        Try to find the given entry in the repo.

        The entry is matched by exact contents.

        Generates all matching entries, annotated with path and position
        information.
        """
        item = as_item(entry)
        for entry_file in self.files.values():
            for idx, file_item in enumerate(entry_file.entries):
                if item == file_item:
                    found = entry.model_copy()
                    found.path = entry_file.relpath.as_posix()
                    found.position = idx
                    yield found

    def _pull(
        self,
        repo: RemoteTaskConfigurationRepository,
        logger: logging.Logger,
        preserve_deleted: bool = True,
    ) -> PullStats:
        """
        Pull changes from another repository.

        :param repo: repository to pull from
        :param logger: logger to use to give user feedback about what changed
        :param preserve_deleted: if True, items deleted in repo will be
               preserved in self; if False, items deleted in repo will be
               deleted in self.
        """
        # Divide the entries that have a path from those that do not
        pathless: list[DebusineTaskConfiguration] = []
        # And dispatch entries by path
        by_path: dict[Path, list[DebusineTaskConfiguration]] = defaultdict(list)
        for entry in repo.entries:
            # Try to fill path information by looking up the entry in the local
            # repository
            if entry.path is None:
                found = False
                for match in self.find_in_repo(entry):
                    found = True
                    assert match.path is not None
                    by_path[Path(match.path)].append(match)
                if not found:
                    # Ensure entries without path also do not have a position
                    entry.position = None
                    pathless.append(as_item(entry))
            else:
                by_path[Path(entry.path)].append(entry)

        # Dispatch pathless entries in new.yaml
        if pathless:
            new_file = by_path[Path("new.yaml")]
            new_file.extend(pathless)

        # Build EntryFile instances from dispatched entries
        new_files: dict[Path, EntryFile] = {}
        for relpath, entries in by_path.items():
            position_when_unset = max(e.position or 0 for e in entries) + 1
            entries.sort(
                key=lambda e: (
                    e.position
                    if e.position is not None
                    else position_when_unset
                )
            )

            entry_file = EntryFile(relpath)
            entry_file.entries.extend(as_item(e) for e in entries)
            new_files[relpath] = entry_file

        stats = PullStats()
        self._merge_manifest(repo.manifest)

        # Replace repository contents in memory
        old_files = self.files
        self.files = new_files

        # Compute and show differences
        for relpath in old_files.keys() & new_files.keys():
            old = old_files[relpath]
            new = new_files[relpath]
            file_stats = old.diff(new)
            stats += file_stats
            if file_stats.has_changes():
                logger.info("%s: %s", relpath, file_stats)

        for relpath in new_files.keys() - old_files.keys():
            new = new_files[relpath]
            logger.info(
                "%s: new file with %d entries", relpath, len(new.entries)
            )
            stats.added += len(new.entries)

        for relpath in list(old_files.keys() - new_files.keys()):
            old = old_files[relpath]
            action = (
                "preserved from deletion" if preserve_deleted else "deleted"
            )
            logger.info(
                "%s: file with %d entries %s", relpath, len(old.entries), action
            )
            stats.deleted += len(old.entries)
            if preserve_deleted:
                self.files[relpath] = old

        # Write out results to filesystem
        self.write_manifest()
        self.write_entries()

        return stats

    def clean_empty_dirs(self, deleted_paths: list[Path]) -> None:
        """Remove leftover empty directories after file deletions."""
        # Compute the set of all affected parent directories
        parents: set[Path] = set()
        for path in deleted_paths:
            parents.update(path.parents[:-1])

        # Sort by descending number of path components
        todo = sorted(parents, key=lambda p: len(p.parents), reverse=True)

        # Delete all parents if empty
        for path in todo:
            try:
                (self.root / path).rmdir()
            except OSError:
                pass

    def write_entries(self) -> None:
        """Write entries to disk."""
        # List files that need to be deleted
        deleted_paths = [
            relpath
            for relpath in self.iter_files(self.root)
            if relpath not in self.files
        ]

        # Update workdir to match entries
        for entry in self.files.values():
            entry.write(self.root)

        # Delete files that do not correspond to entries
        for path in deleted_paths:
            (self.root / path).unlink()
        self.clean_empty_dirs(deleted_paths)

    def pull(
        self,
        repo: RemoteTaskConfigurationRepository,
        logger: logging.Logger,
    ) -> PullStats:
        """
        Pull changes from another repository.

        :param repo: repository to pull from
        :param logger: logger to use to give user feedback about what changed
        """
        return self._pull(repo, logger, preserve_deleted=True)

    @classmethod
    def iter_files(cls, root: Path) -> Generator[Path]:
        """Generate a sequence of relative paths for all yaml files in path."""
        for cur_root, _dirs, files in root.walk():
            for name in files:
                if not name.endswith(".yaml"):
                    continue
                yield (cur_root / name).relative_to(root)

    @classmethod
    def is_checkout(cls, root: Path) -> bool:
        """Check if there is a task configuration checkout at path."""
        return (root / "MANIFEST").exists()

    @classmethod
    def read_manifest(cls, root: Path) -> Manifest:
        """Load manifest data from the repo at the given path."""
        path = root / "MANIFEST"
        try:
            with path.open() as fd:
                data = yaml.safe_load(fd)
            return Manifest(**data)
        except Exception as exc:
            exc.add_note(f"manifest: {path}")
            raise

    @classmethod
    def from_path(cls, root: Path) -> "LocalTaskConfigurationRepository":
        """Instantiate a local repository from a path."""
        if GitTaskConfigurationRepository.is_git(root):
            return GitTaskConfigurationRepository(root)
        else:
            return LocalTaskConfigurationRepository(root)


class GitTaskConfigurationRepository(LocalTaskConfigurationRepository):
    """LocalTaskConfigurationRepository which is a git repository."""

    def git(
        self, args: list[str], check: bool = True
    ) -> subprocess.CompletedProcess[str]:
        """Run a git command in the repository and return its output."""
        cmd = ["git", "-C", self.root.as_posix(), *args]
        res = subprocess.run(cmd, capture_output=True, text=True)
        if check and res.returncode != 0:
            try:
                res.check_returncode()
            except Exception as e:
                e.add_note("stderr:\n" + res.stderr)
                raise
        return res

    @override
    def is_dirty(self) -> bool:
        res = self.git(["status", "--porcelain"], check=True)
        for line in res.stdout.splitlines():
            if line.endswith("/"):
                return True
            elif line.endswith(".yaml"):
                return True
            elif line[3:] == "MANIFEST":
                return True
        return False

    @override
    def git_commit(self) -> str | None:
        res = self.git(["rev-parse", "HEAD"], check=False)
        if res.returncode == 0:
            return res.stdout.strip()
        # rev-parse may fail if the git repo has just been initialized
        return None

    @override
    def has_commit(self, commit: str) -> bool:
        res = self.git(
            ["merge-base", "--is-ancestor", commit, "HEAD"], check=False
        )
        if res.returncode == 0:
            return True
        return False

    @override
    def pull(
        self,
        repo: RemoteTaskConfigurationRepository,
        logger: logging.Logger,
    ) -> PullStats:
        if self.is_dirty():
            raise InvalidRepository(
                "git working directory is dirty: please commit changes"
                " before pulling from debusine"
            )

        return self._pull(repo, logger, preserve_deleted=False)

    @classmethod
    def is_git(cls, path: Path) -> bool:
        """
        Check if the given path contains a git repository.

        If git is not installed, it returns False.
        """
        if (git := shutil.which("git")) is None:
            return False
        res = subprocess.run(
            [git, "-C", path.as_posix(), "rev-parse"], capture_output=True
        )
        return res.returncode == 0
