# 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.

"""Tests for client task configuration handling."""

import logging
import os
import shutil
import subprocess
import time as tm
from collections.abc import Collection
from pathlib import Path
from typing import Any, ClassVar, override
from unittest import SkipTest, mock
from unittest._log import _LoggingWatcher

import pydantic
import yaml

from debusine.artifacts.models import DebusineTaskConfiguration, TaskTypes
from debusine.client.models import TaskConfigurationCollection
from debusine.client.task_configuration import (
    EntryFile,
    GitTaskConfigurationRepository,
    InvalidRepository,
    Item,
    LocalTaskConfigurationRepository,
    Manifest,
    PullStats,
    RemoteTaskConfigurationRepository,
    as_item,
)
from debusine.test import TestCase


class Samples:
    """
    Simple holder for task configuration samples.

    You can assign to members and they will be preserved.

    Accessing a nonexistent member will create a unique one on the fly.
    """

    def add_config(
        self,
        name: str,
        *,
        task_type: TaskTypes = TaskTypes.WORKER,
        task_name: str = "noop",
        **kwargs: Any,
    ) -> DebusineTaskConfiguration:
        """Add a DebusineTaskConfiguration with known values."""
        entry = DebusineTaskConfiguration(
            task_type=task_type, task_name=task_name, **kwargs
        )
        setattr(self, name, entry)
        return entry

    def __getattr__(self, name: str) -> DebusineTaskConfiguration:
        """Instantiate a DebusineTaskConfiguration entry on the fly."""
        result = DebusineTaskConfiguration(template=name)
        setattr(self, name, result)
        return result


def items(*samples: DebusineTaskConfiguration) -> list[Item]:
    """Make a list of items from samples."""
    return [as_item(s) for s in samples]


def with_path(
    sample: DebusineTaskConfiguration, path: str, position: int
) -> DebusineTaskConfiguration:
    """Annotate a DebusineTaskConfiguration with path information."""
    return sample.model_copy(update={"path": path, "position": position})


def simplify_dicts(values: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Given a list of dicts, returns the same with the common keys removed."""
    assert values

    # Start with a dict with the contents of the first dict
    same = values[0].copy()

    # Drop all elements that change in other dicts
    for value in values[1:]:
        for k, v in value.items():
            if same.get(k) != v:
                same.pop(k, None)

    result: list[dict[str, Any]] = []
    for value in values:
        result.append({k: v for k, v in value.items() if k not in same})  # noqa: PERF401
    return result


class TaskConfigurationTestCase(TestCase):
    """Common function for task configuration related tests."""

    logger: ClassVar[logging.Logger]

    @override
    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        cls.logger = logging.getLogger("debusine.client.tests")

    @override
    def setUp(self) -> None:
        super().setUp()
        self.root = self.create_temporary_directory()
        self.samples = Samples()

    def manifest(
        self,
        workspace: str = "System",
        pk: int = 42,
        name: str = "default",
        data: dict[str, Any] | None = None,
    ) -> Manifest:
        """Create a Manifest."""
        return Manifest(
            workspace=workspace,
            collection=TaskConfigurationCollection(
                id=pk, name=name, data=data or {}
            ),
        )

    def make_remote_repo(
        self, entries: list[DebusineTaskConfiguration] | None = None
    ) -> RemoteTaskConfigurationRepository:
        repo = RemoteTaskConfigurationRepository(self.manifest())
        repo.entries.extend(entries or [])
        return repo

    def write(
        self,
        relpath: Path,
        item: pydantic.BaseModel | Collection[pydantic.BaseModel],
    ) -> None:
        """Write an item to a file in self.root."""
        path = self.root / relpath
        path.parent.mkdir(exist_ok=True, parents=True)
        with path.open("wt") as fd:
            if isinstance(item, pydantic.BaseModel):
                yaml.safe_dump(item.model_dump(mode="json"), fd)
            else:
                yaml.safe_dump([i.model_dump(mode="json") for i in item], fd)

    def assertFileContents(
        self,
        relpath: Path,
        expected: pydantic.BaseModel | Collection[pydantic.BaseModel],
    ) -> None:
        """Check that path points to a YAML file with the given contents."""
        path = self.root / relpath
        with path.open() as fd:
            actual = yaml.safe_load(fd)
        if isinstance(expected, pydantic.BaseModel):
            assert actual == expected.model_dump(mode="json")
        else:
            assert simplify_dicts(actual) == simplify_dicts(
                [i.model_dump(mode="json") for i in expected]
            )

    def assertPullLog(self, log: _LoggingWatcher, lines: list[str]) -> None:
        """Check that the pull log contains the expected lines."""
        self.assertCountEqual(
            [x.removeprefix("INFO:debusine.client.tests:") for x in log.output],
            lines,
        )

    def list_files(self) -> list[Path]:
        """List files present in self.root."""
        res: list[Path] = []
        for cur_root, _dirs, files in self.root.walk():
            res += [(cur_root / name).relative_to(self.root) for name in files]
        return sorted(res)


class PullStatsTests(TestCase):
    """Test PullStats class."""

    def test_has_changes(self) -> None:
        for args, expected in (
            ((0, 0, 0, 0), False),
            ((1, 0, 0, 0), True),
            ((0, 1, 0, 0), True),
            ((0, 0, 1, 0), True),
            ((0, 0, 0, 1), False),
            ((1, 1, 1, 1), True),
        ):
            with self.subTest(args=args):
                stats = PullStats(*args)
                self.assertEqual(stats.has_changes(), expected)

    def test_eq(self) -> None:
        for first, second, expected in (
            (PullStats(), PullStats(), True),
            (PullStats(1), PullStats(1), True),
            (PullStats(1), PullStats(2), False),
            (PullStats(1, 1), PullStats(1, 2), False),
            (PullStats(1, 1, 1), PullStats(1, 1, 2), False),
            (PullStats(1, 1, 1, 1), PullStats(1, 1, 1, 2), False),
            (PullStats(1), 1, False),
        ):
            with self.subTest(first=repr(first), second=repr(second)):
                if expected:
                    self.assertEqual(first, second)
                else:
                    self.assertNotEqual(first, second)


class EntryFileTests(TaskConfigurationTestCase):
    """Tests for :py:class:`EntryFile`."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.relpath = Path("test.yaml")
        self.ef = EntryFile(self.relpath)

    def test_empty(self) -> None:
        self.assertEqual(self.ef.relpath, self.relpath)
        self.assertEqual(self.ef.entries, [])

    def test_load_single(self) -> None:
        sample = DebusineTaskConfiguration(template="test")
        self.write(self.relpath, sample)
        self.ef.load(self.root)
        self.assertEqual(self.ef.entries, items(sample))

    def test_load_multi(self) -> None:
        self.write(self.relpath, [self.samples.s1, self.samples.s2])
        self.ef.load(self.root)
        self.assertEqual(
            self.ef.entries, items(self.samples.s1, self.samples.s2)
        )

    def test_load_annotates_exceptions(self) -> None:
        with self.assertRaises(FileNotFoundError) as e:
            self.ef.load(self.root)
        self.assertEqual(e.exception.__notes__, ["source: test.yaml"])

    def test_load_strips_path_position(self) -> None:
        sample = DebusineTaskConfiguration(
            template="test1", path="foo.yaml", position=3
        )
        self.write(self.relpath, sample)
        self.ef.load(self.root)
        self.assertEqual(self.ef.entries, [Item(template="test1")])

    def test_write_single(self) -> None:
        self.ef.entries += items(self.samples.s)
        self.ef.write(self.root)
        self.assertFileContents(self.relpath, self.samples.s)

    def test_write_multi(self) -> None:
        self.ef.entries += items(self.samples.s2, self.samples.s1)
        self.ef.write(self.root)
        self.assertFileContents(
            self.relpath, [self.samples.s2, self.samples.s1]
        )

    def test_write_unchanged(self) -> None:
        sample = Item(template="test")
        self.write(self.relpath, sample)
        os.utime(self.root / self.relpath, (123456, 123456))

        self.ef.entries.append(sample)
        self.ef.write(self.root)
        st = (self.root / self.relpath).stat()
        self.assertEqual(st.st_mtime, 123456)

    def test_diff(self) -> None:
        ef1 = EntryFile(Path("file1"))
        ef1.entries += items(self.samples.s1, self.samples.s2, self.samples.s3)
        ef2 = EntryFile(Path("file2"))
        ef2.entries += items(
            self.samples.s1,
            self.samples.s2,
            self.samples.s4,
            self.samples.s5,
            self.samples.s6,
        )
        stats = ef1.diff(ef2)
        self.assertEqual(stats.added, 2)
        self.assertEqual(stats.deleted, 0)
        self.assertEqual(stats.updated, 1)
        self.assertEqual(stats.unchanged, 2)

    def test_diff_deleted(self) -> None:
        ef1 = EntryFile(Path("file1"))
        ef1.entries += items(self.samples.s1, self.samples.s2, self.samples.s3)
        ef2 = EntryFile(Path("file2"))
        ef2.entries += items(self.samples.s1)
        stats = ef1.diff(ef2)
        self.assertEqual(stats.added, 0)
        self.assertEqual(stats.deleted, 2)
        self.assertEqual(stats.updated, 0)
        self.assertEqual(stats.unchanged, 1)

    def test_diff_replace_with_shorter_sequence(self) -> None:
        ef1 = EntryFile(Path("file1"))
        ef1.entries += items(
            self.samples.s1, self.samples.s2, self.samples.s3, self.samples.s4
        )
        ef2 = EntryFile(Path("file2"))
        ef2.entries += items(self.samples.s1, self.samples.s5, self.samples.s4)
        stats = ef1.diff(ef2)
        self.assertEqual(stats.added, 0)
        self.assertEqual(stats.deleted, 1)
        self.assertEqual(stats.updated, 1)
        self.assertEqual(stats.unchanged, 2)


class RemoteTaskConfigurationRepositoryTestsBase(TaskConfigurationTestCase):
    """Tests for :py:class:`RemoteTaskConfigurationRepository`."""

    def test_from_items(self) -> None:
        repo = RemoteTaskConfigurationRepository.from_items(
            self.manifest(), [self.samples.s1, self.samples.s2, self.samples.s3]
        )
        self.assertEqual(repo.manifest, self.manifest())
        self.assertEqual(
            repo.entries, [self.samples.s1, self.samples.s2, self.samples.s3]
        )


class LocalTaskConfigurationRepositoryTestCase(TaskConfigurationTestCase):
    """Base for LocalTaskConfigurationRepository tests."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.path_manifest = Path("MANIFEST")

    def populate(
        self,
        manifest: Manifest | None = None,
        files: (
            dict[
                str | Path,
                DebusineTaskConfiguration | list[DebusineTaskConfiguration],
            ]
            | None
        ) = None,
    ) -> None:
        """Populate the local repository."""
        self.write(self.path_manifest, manifest or self.manifest())
        for path, entries in (files or {}).items():
            if isinstance(entries, DebusineTaskConfiguration):
                self.write(Path(path), as_item(entries))
            else:
                self.write(Path(path), items(*entries))


class LocalTaskConfigurationRepositoryTests(
    LocalTaskConfigurationRepositoryTestCase
):
    """Test LocalTaskConfigurationRepository class."""

    def make_local_repo(
        self,
    ) -> LocalTaskConfigurationRepository:
        """Create a LocalTaskConfigurationRepository with manifest."""
        return LocalTaskConfigurationRepository(self.root)

    def test_is_dirty(self) -> None:
        repo = self.make_local_repo()
        self.assertFalse(repo.is_dirty())

    def test_git_commit(self) -> None:
        repo = self.make_local_repo()
        self.assertIsNone(repo.git_commit())

    def test_has_commit(self) -> None:
        repo = self.make_local_repo()
        self.assertFalse(repo.has_commit("0" * 40))

    def test_save_manifest_annotates_exceptions(self) -> None:
        repo = self.make_local_repo()
        repo.manifest = self.manifest()
        (repo.root / "MANIFEST").mkdir()
        with self.assertRaises(OSError) as exc:
            repo.write_manifest()
        self.assertEqual(
            exc.exception.__notes__, [f"manifest: {repo.root}/MANIFEST"]
        )

    def test_init_empty_dir(self) -> None:
        repo = self.make_local_repo()
        self.assertEqual(repo.root, self.root)
        self.assertEqual(repo.manifest_path, self.root / "MANIFEST")
        self.assertIsNone(repo.manifest)
        self.assertEqual(repo.files, {})

    def test_init(self) -> None:
        self.populate(
            files={
                (path1 := Path("path1.yaml")): [self.samples.s1],
                (path2 := Path("path2.yaml")): [
                    self.samples.s2,
                    self.samples.s3,
                ],
            }
        )
        repo = self.make_local_repo()
        self.assertEqual(repo.root, self.root)
        self.assertEqual(repo.manifest_path, self.root / "MANIFEST")
        self.assertEqual(repo.manifest, self.manifest())
        self.assertCountEqual(repo.files.keys(), {path1, path2})
        self.assertEqual(repo.files[path1].entries, items(self.samples.s1))
        self.assertEqual(
            repo.files[path2].entries, items(self.samples.s2, self.samples.s3)
        )

    def test_iter_files(self) -> None:
        self.write(path1 := Path("path1.yaml"), [])
        self.write(path2 := Path("path2.yaml"), [])
        self.write(path3 := Path("path3/path.yaml"), [])
        self.assertEqual(
            set(LocalTaskConfigurationRepository.iter_files(self.root)),
            {path1, path2, path3},
        )

    def test_iter_files_ignore_non_yaml(self) -> None:
        (self.root / "README").write_text("Thank you for reading")
        (self.root / "data.json").write_text("{}")
        self.assertEqual(
            list(LocalTaskConfigurationRepository.iter_files(self.root)), []
        )

    def test_iter_entries(self) -> None:
        self.populate(
            files={
                "single.yaml": self.samples.s1,
                "multi.yaml": [self.samples.s2, self.samples.s3],
                "dup.yaml": [self.samples.s1, self.samples.s1],
            },
        )
        local_repo = self.make_local_repo()

        self.assertCountEqual(
            list(local_repo.iter_entries()),
            [
                with_path(self.samples.s1, "single.yaml", 0),
                with_path(self.samples.s2, "multi.yaml", 0),
                with_path(self.samples.s3, "multi.yaml", 1),
                with_path(self.samples.s1, "dup.yaml", 0),
                with_path(self.samples.s1, "dup.yaml", 1),
            ],
        )

    def test_write_manifest_unset(self) -> None:
        repo = self.make_local_repo()
        with self.assertRaisesRegex(
            InvalidRepository, "Repository manifest has not been set"
        ):
            repo.write_manifest()

    def test_write_manifest(self) -> None:
        repo = self.make_local_repo()
        repo.manifest = self.manifest()
        self.assertFalse(repo.manifest_path.exists())
        repo.write_manifest()
        self.assertTrue(repo.manifest_path.exists())
        self.assertFileContents(Path("MANIFEST"), repo.manifest)

    def test_write_entries(self) -> None:
        local_repo = self.make_local_repo()
        ef = EntryFile(Path("test.yaml"))
        local_repo.files[ef.relpath] = ef
        ef.entries = items(self.samples.s1, self.samples.s2)
        local_repo.write_entries()
        self.assertFileContents(ef.relpath, [self.samples.s1, self.samples.s2])

    def test_write_entries_removes_unknown(self) -> None:
        self.populate(
            files={
                (path_removed := Path("removed.yaml")): self.samples.s1,
                (path_kept := Path("kept.yaml")): self.samples.s2,
            }
        )
        local_repo = self.make_local_repo()
        del local_repo.files[path_removed]
        local_repo.write_entries()
        self.assertCountEqual(
            self.list_files(), [self.path_manifest, path_kept]
        )
        self.assertFileContents(path_kept, self.samples.s2)

    def test_pull_new_repo(self) -> None:
        server_repo = self.make_remote_repo([self.samples.s1, self.samples.s2])
        local_repo = self.make_local_repo()
        with self.assertLogs(self.logger) as log:
            stats = local_repo.pull(server_repo, self.logger)
        self.assertPullLog(log, ["new.yaml: new file with 2 entries"])
        self.assertEqual(stats, PullStats(added=2))
        self.assertCountEqual(
            self.list_files(),
            [
                path_manifest := Path("MANIFEST"),
                path_new := Path("new.yaml"),
            ],
        )
        self.assertFileContents(path_manifest, server_repo.manifest)
        self.assertFileContents(path_new, [self.samples.s1, self.samples.s2])

    def test_pull_new_repo_with_paths(self) -> None:
        self.samples.add_config("root", path="test.yaml")
        self.samples.add_config(
            "foo1", path="foo/test.yaml", position=1, subject="s1"
        )
        self.samples.add_config(
            "foo2", path="foo/test.yaml", position=2, subject="s2"
        )
        server_repo = self.make_remote_repo(
            [self.samples.root, self.samples.foo1, self.samples.foo2]
        )
        local_repo = self.make_local_repo()
        with self.assertLogs(self.logger) as log:
            stats = local_repo.pull(server_repo, self.logger)
        self.assertPullLog(
            log,
            [
                "test.yaml: new file with 1 entries",
                "foo/test.yaml: new file with 2 entries",
            ],
        )
        self.assertEqual(stats, PullStats(added=3))
        self.assertCountEqual(
            self.list_files(),
            [
                self.path_manifest,
                path_root := Path("test.yaml"),
                path_foo := Path("foo/test.yaml"),
            ],
        )
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertFileContents(path_root, as_item(self.samples.root))
        self.assertFileContents(
            path_foo, items(self.samples.foo1, self.samples.foo2)
        )

    def test_pull_no_remote_paths(self) -> None:
        server_repo = self.make_remote_repo([self.samples.s1, self.samples.s2])

        path_samples = Path("samples.yaml")
        self.populate(
            server_repo.manifest,
            files={path_samples: [self.samples.s1, self.samples.s2]},
        )
        local_repo = self.make_local_repo()

        with self.assertNoLogs(self.logger.name):
            stats = local_repo.pull(server_repo, self.logger)
        self.assertEqual(stats, PullStats(unchanged=2))
        self.assertCountEqual(
            self.list_files(),
            [self.path_manifest, path_samples],
        )
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertFileContents(
            path_samples, items(self.samples.s1, self.samples.s2)
        )

    def test_pull_append_pathless_to_existing_new_yaml(self) -> None:
        server_repo = self.make_remote_repo(
            [
                self.samples.add_config("p1", path="new.yaml", position=0),
                self.samples.add_config("p2", path="new.yaml", position=2),
                self.samples.s1,
                self.samples.s2,
            ]
        )

        path_manifest = Path("MANIFEST")
        path_new = Path("new.yaml")

        self.populate(
            manifest=server_repo.manifest, files={path_new: [self.samples.s1]}
        )
        local_repo = self.make_local_repo()

        with self.assertLogs(self.logger.name) as log:
            stats = local_repo.pull(server_repo, self.logger)
        self.assertPullLog(
            log, ["new.yaml: 3 added, 0 updated, 0 deleted, 1 unchanged"]
        )
        self.assertEqual(stats, PullStats(added=3, unchanged=1))
        self.assertCountEqual(
            self.list_files(),
            [path_manifest, path_new],
        )
        self.assertFileContents(path_manifest, server_repo.manifest)
        self.assertFileContents(
            path_new,
            items(
                self.samples.p1,
                self.samples.s1,
                self.samples.p2,
                self.samples.s2,
            ),
        )

    def test_pull_update(self) -> None:
        old = self.samples.add_config("s1", path="test.yaml", position=1)
        new = old.model_copy(update={"comment": "updated"})

        server_repo = self.make_remote_repo([new])

        assert old.path is not None
        path_test = Path(old.path)
        self.populate(manifest=server_repo.manifest, files={path_test: [old]})
        local_repo = self.make_local_repo()

        with self.assertLogs(self.logger) as log:
            stats = local_repo.pull(server_repo, self.logger)
        self.assertPullLog(
            log, ["test.yaml: 0 added, 1 updated, 0 deleted, 0 unchanged"]
        )
        self.assertEqual(stats, PullStats(updated=1))
        self.assertCountEqual(
            self.list_files(), [self.path_manifest, path_test]
        )
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertFileContents(path_test, as_item(new))

    def test_pull_update_preserves_mtime(self) -> None:
        self.samples.add_config("changed", path="changed.yaml", position=1)
        new = self.samples.changed.model_copy(update={"comment": "updated"})

        server_repo = self.make_remote_repo(
            [new, self.samples.add_config("unchanged", path="unchanged.yaml")]
        )

        path_changed = Path("changed.yaml")
        path_unchanged = Path("unchanged.yaml")
        self.populate(
            manifest=server_repo.manifest,
            files={
                path_changed: self.samples.changed,
                path_unchanged: self.samples.unchanged,
            },
        )
        oldtime = tm.time_ns() - 3600 * 1_000_000_000
        os.utime(self.root / path_changed, ns=(oldtime, oldtime))
        os.utime(self.root / path_unchanged, ns=(oldtime, oldtime))
        orig_stat_changed = (self.root / path_changed).stat()
        orig_stat_unchanged = (self.root / path_unchanged).stat()

        local_repo = self.make_local_repo()

        with self.assertLogs(self.logger) as log:
            stats = local_repo.pull(server_repo, self.logger)
        self.assertPullLog(
            log, ["changed.yaml: 0 added, 1 updated, 0 deleted, 0 unchanged"]
        )
        self.assertEqual(stats, PullStats(updated=1, unchanged=1))
        self.assertCountEqual(
            self.list_files(),
            [self.path_manifest, path_changed, path_unchanged],
        )
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertFileContents(path_changed, as_item(new))
        self.assertFileContents(path_unchanged, as_item(self.samples.unchanged))
        self.assertLess(
            orig_stat_changed.st_mtime_ns,
            (self.root / path_changed).stat().st_mtime_ns,
        )
        self.assertEqual(
            orig_stat_unchanged.st_mtime_ns,
            (self.root / path_unchanged).stat().st_mtime_ns,
        )

    def test_pull_update_manifest_collection_data(self) -> None:
        server_repo = self.make_remote_repo()
        server_repo.manifest = self.manifest(data={"test": True})

        path_manifest = Path("MANIFEST")
        self.write(path_manifest, self.manifest())
        local_repo = self.make_local_repo()

        with self.assertNoLogs(self.logger.name):
            stats = local_repo.pull(server_repo, self.logger)
        self.assertEqual(stats, PullStats())
        self.assertCountEqual(self.list_files(), [path_manifest])
        self.assertFileContents(path_manifest, server_repo.manifest)

    def test_pull_delete(self) -> None:
        server_repo = self.make_remote_repo()

        self.populate(
            manifest=server_repo.manifest,
            files={Path("path1.yaml"): self.samples.s1},
        )
        local_repo = self.make_local_repo()

        with self.assertLogs(self.logger) as log:
            stats = local_repo._pull(
                server_repo, self.logger, preserve_deleted=False
            )
        self.assertPullLog(log, ["path1.yaml: file with 1 entries deleted"])
        self.assertEqual(stats, PullStats(deleted=1))
        self.assertCountEqual(self.list_files(), [self.path_manifest])
        self.assertFileContents(self.path_manifest, server_repo.manifest)

    def test_pull_delete_preserve(self) -> None:
        server_repo = self.make_remote_repo()

        self.populate(
            manifest=server_repo.manifest,
            files={(path1 := Path("path1.yaml")): self.samples.s1},
        )
        local_repo = self.make_local_repo()

        with self.assertLogs(self.logger) as log:
            stats = local_repo._pull(
                server_repo, self.logger, preserve_deleted=True
            )
        self.assertPullLog(
            log, ["path1.yaml: file with 1 entries preserved from deletion"]
        )
        self.assertEqual(stats, PullStats(deleted=1))
        self.assertCountEqual(self.list_files(), [self.path_manifest, path1])
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertFileContents(path1, self.samples.s1)

    def test_pull_delete_in_bundle(self) -> None:
        server_repo = self.make_remote_repo([self.samples.s2])

        self.populate(
            manifest=server_repo.manifest,
            files={
                (path1 := Path("path1.yaml")): [
                    self.samples.s1,
                    self.samples.s2,
                ]
            },
        )
        local_repo = self.make_local_repo()

        with self.assertLogs(self.logger) as log:
            stats = local_repo._pull(
                server_repo, self.logger, preserve_deleted=False
            )
        self.assertPullLog(
            log, ["path1.yaml: 0 added, 0 updated, 1 deleted, 1 unchanged"]
        )
        self.assertEqual(stats, PullStats(deleted=1, unchanged=1))
        self.assertCountEqual(
            self.list_files(),
            [self.path_manifest, path1],
        )
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertFileContents(path1, as_item(self.samples.s2))

    def test_pull_delete_clean_empty_dirs(self) -> None:
        self.samples.add_config("keep", subject="keep")
        server_repo = self.make_remote_repo([self.samples.keep])

        self.populate(
            manifest=server_repo.manifest,
            files={
                Path("dir1/file.yaml"): self.samples.s1,
                (path_keep := Path("dir1/dir1/file.yaml")): self.samples.keep,
                Path("dir1/dir2/file.yaml"): self.samples.s2,
                Path("dir2/dir1/file.yaml"): self.samples.s3,
                Path("dir2/dir2/file.yaml"): self.samples.s4,
            },
        )
        local_repo = self.make_local_repo()
        with self.assertLogs(self.logger) as log:
            stats = local_repo._pull(
                server_repo, self.logger, preserve_deleted=False
            )
        self.assertPullLog(
            log,
            [
                "dir1/file.yaml: file with 1 entries deleted",
                "dir1/dir2/file.yaml: file with 1 entries deleted",
                "dir2/dir1/file.yaml: file with 1 entries deleted",
                "dir2/dir2/file.yaml: file with 1 entries deleted",
            ],
        )
        self.assertEqual(stats, PullStats(deleted=4, unchanged=1))
        self.assertCountEqual(
            self.list_files(), [self.path_manifest, path_keep]
        )
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertTrue((self.root / "dir1").exists())
        self.assertTrue((self.root / "dir1/dir1").exists())
        self.assertFalse((self.root / "dir1/dir2").exists())
        self.assertFalse((self.root / "dir2").exists())

    def test_preserve_all_matching_pathless_entries(self) -> None:
        self.samples.add_config("keep", subject="keep")
        server_repo = self.make_remote_repo([self.samples.keep])

        self.populate(
            manifest=server_repo.manifest,
            files={
                Path("path1.yaml"): self.samples.s1,
                Path("path2.yaml"): self.samples.s2,
                (path_keep1 := Path("keep1.yaml")): [
                    self.samples.keep,
                    self.samples.s3,
                ],
                (path_keep2 := Path("keep2.yaml")): [
                    self.samples.s4,
                    self.samples.keep,
                    self.samples.s5,
                ],
            },
        )
        local_repo = self.make_local_repo()
        with self.assertLogs(self.logger) as log:
            stats = local_repo._pull(
                server_repo, self.logger, preserve_deleted=False
            )
        self.assertPullLog(
            log,
            [
                "path1.yaml: file with 1 entries deleted",
                "path2.yaml: file with 1 entries deleted",
                "keep1.yaml: 0 added, 0 updated, 1 deleted, 1 unchanged",
                "keep2.yaml: 0 added, 0 updated, 2 deleted, 1 unchanged",
            ],
        )
        self.assertEqual(stats, PullStats(deleted=5, unchanged=2))
        self.assertCountEqual(
            self.list_files(), [self.path_manifest, path_keep1, path_keep2]
        )
        self.assertFileContents(self.path_manifest, server_repo.manifest)
        self.assertFileContents(path_keep1, as_item(self.samples.keep))
        self.assertFileContents(path_keep2, as_item(self.samples.keep))

    def test_pull_repo_manifest_mismatch(self) -> None:
        server_repo = self.make_remote_repo()

        path_manifest = Path("MANIFEST")
        self.write(path_manifest, self.manifest(pk=7))
        local_repo = self.make_local_repo()

        with self.assertRaisesRegex(
            InvalidRepository,
            r"repo to pull refers to collection System/default \(42\)"
            r" while the checkout has collection System/default \(7\)",
        ):
            local_repo.pull(server_repo, self.logger)

        self.assertCountEqual(self.list_files(), [Path("MANIFEST")])

    def test_is_checkout(self) -> None:
        self.assertFalse(
            LocalTaskConfigurationRepository.is_checkout(self.root)
        )
        (self.root / "MANIFEST").write_text("")
        self.assertTrue(LocalTaskConfigurationRepository.is_checkout(self.root))

    def test_read_manifest_annotates_exceptions(self) -> None:
        with self.assertRaises(OSError) as exc:
            LocalTaskConfigurationRepository.read_manifest(self.root)
        self.assertEqual(
            exc.exception.__notes__, [f"manifest: {self.root}/MANIFEST"]
        )


class GitTaskConfigurationRepositoryTests(
    LocalTaskConfigurationRepositoryTestCase
):
    """Test GitTaskConfigurationRepository class."""

    @override
    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        if shutil.which("git") is None:  # pragma: no cover
            raise SkipTest("git is not installed")

    @override
    def setUp(self) -> None:
        super().setUp()
        self.git("init", "-b", "main")
        self.git("config", "user.name", "Test User")
        self.git("config", "user.email", "test@example.com")

    def make_local_repo(
        self,
    ) -> GitTaskConfigurationRepository:
        """Create a LocalTaskConfigurationRepository with manifest."""
        return GitTaskConfigurationRepository(self.root)

    def git(self, *args: str) -> subprocess.CompletedProcess[str]:
        """Run a git command in the repository and return its output."""
        cmd = ["git", "-C", self.root.as_posix()]
        cmd.extend(args)
        return subprocess.run(
            cmd,
            cwd=self.root,
            capture_output=True,
            text=True,
            check=True,
            env={
                "GIT_CONFIG_GLOBAL": "/dev/null",
                "GIT_CONFIG_SYSTEM": "/dev/null",
            },
        )

    def commit(self, message: str = "Commit message") -> None:
        """Commit changes in self.root."""
        self.git("add", ".")
        self.git("commit", "-m", message)

    def test_from_path(self) -> None:
        repo1 = LocalTaskConfigurationRepository.from_path(self.root)
        self.assertIsInstance(repo1, GitTaskConfigurationRepository)
        repo2 = LocalTaskConfigurationRepository.from_path(
            self.create_temporary_directory()
        )
        self.assertIsInstance(repo2, LocalTaskConfigurationRepository)

    def test_git_error_annotates_exception(self) -> None:
        repo = self.make_local_repo()
        shutil.rmtree(self.root / ".git")
        with self.assertRaises(subprocess.CalledProcessError) as exc:
            repo.git(["rev-parse", "HEAD"], check=True)
        self.assertEqual(exc.exception.__notes__[0][:8], "stderr:\n")

    def test_is_git(self) -> None:
        self.assertTrue(GitTaskConfigurationRepository.is_git(self.root))
        self.assertFalse(
            GitTaskConfigurationRepository.is_git(
                self.create_temporary_directory()
            )
        )
        with mock.patch("shutil.which", return_value=None):
            self.assertFalse(GitTaskConfigurationRepository.is_git(self.root))

    def test_is_dirty_empty(self) -> None:
        repo = self.make_local_repo()
        self.assertFalse(repo.is_dirty())

    def test_is_dirty_uncommitted_manifest(self) -> None:
        repo = self.make_local_repo()
        repo.manifest_path.write_text("test")
        self.assertTrue(repo.is_dirty())

    def test_is_dirty_uncommitted_yaml(self) -> None:
        repo = self.make_local_repo()
        self.write(Path("config.yaml"), self.samples.s1)
        self.assertTrue(repo.is_dirty())

    def test_is_dirty_dir(self) -> None:
        repo = self.make_local_repo()
        (self.root / "testdir").mkdir()
        (self.root / "testdir" / "testfile").write_text("")
        self.assertTrue(repo.is_dirty())

    def test_is_dirty_uncommitted_other(self) -> None:
        repo = self.make_local_repo()
        self.write(Path("README"), self.samples.s1)
        self.assertFalse(repo.is_dirty())

    def test_git_commit_empty(self) -> None:
        repo = self.make_local_repo()
        self.assertIsNone(repo.git_commit())

    def test_git_commit(self) -> None:
        repo = self.make_local_repo()
        repo.manifest = self.manifest()
        repo.write_manifest()
        self.commit()
        self.assertFalse(repo.is_dirty())
        self.assertIsNotNone(repo.git_commit())

    def test_has_commit(self) -> None:
        repo = self.make_local_repo()
        repo.manifest = self.manifest()
        repo.write_manifest()
        self.commit()
        first_commit = repo.git_commit()
        assert first_commit is not None
        self.write(Path("sample1.yaml"), self.samples.s1)
        self.commit()
        second_commit = repo.git_commit()
        assert second_commit is not None
        self.write(Path("sample2.yaml"), self.samples.s2)
        self.commit()
        third_commit = repo.git_commit()
        assert third_commit is not None
        self.assertTrue(repo.has_commit(first_commit))
        self.assertTrue(repo.has_commit(second_commit))
        self.assertTrue(repo.has_commit(third_commit))

        missing_commit = (
            ("0" if first_commit[0] != "0" else "1")
            + ("0" if second_commit[1] != "0" else "1")
            + ("0" if third_commit[2] != "0" else "1")
            + first_commit[3:]
        )
        self.assertFalse(repo.has_commit(missing_commit))

    def test_pull_dirty(self) -> None:
        server_repo = self.make_remote_repo()
        self.write(Path("config.yaml"), self.samples.s1)
        local_repo = self.make_local_repo()
        with self.assertRaisesRegex(
            InvalidRepository,
            r"git working directory is dirty: please commit changes"
            r" before pulling from debusine",
        ):
            local_repo.pull(server_repo, self.logger)

    def test_pull(self) -> None:
        commit = "0" * 40
        server_repo = self.make_remote_repo()
        server_repo.manifest.collection.data["git_commit"] = commit
        self.write(Path("MANIFEST"), self.manifest(data={"git_commit": commit}))
        self.commit()
        local_repo = self.make_local_repo()
        with self.assertNoLogs(self.logger.name):
            local_repo.pull(server_repo, self.logger)
