# 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 the debusine Cli task configuration commands."""

import os
from functools import partial
from pathlib import Path
from typing import Any, ClassVar, override
from unittest import mock
from unittest.mock import Mock

import yaml

from debusine.artifacts.models import DebusineTaskConfiguration, TaskTypes
from debusine.client.commands.task_config import Pull
from debusine.client.commands.tests.base import BaseCliTests
from debusine.client.models import (
    TaskConfigurationCollection,
    TaskConfigurationCollectionUpdateResults,
)
from debusine.client.task_configuration import RemoteTaskConfigurationRepository


class CliTaskConfigPullPushTests(BaseCliTests):
    """Tests for Cli task-config-pull and -push."""

    config1: ClassVar[DebusineTaskConfiguration]
    config2: ClassVar[DebusineTaskConfiguration]
    template: ClassVar[DebusineTaskConfiguration]

    @override
    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        cls.config1 = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        cls.config2 = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            context="context",
            subject="subject",
        )
        cls.template = DebusineTaskConfiguration(
            template="template", comment="this is a template"
        )

    @override
    def setUp(self) -> None:
        super().setUp()
        # Working directory for the local collection
        self.workdir = self.create_temporary_directory()
        # Mocked debusine object
        build_debusine = self.patch_build_debusine_object()
        self.debusine = build_debusine.return_value

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

    def make_remote_repo(
        self, workspace: str = "System", pk: int = 42, name: str = "default"
    ) -> RemoteTaskConfigurationRepository:
        """Create a TaskConfigurationRepository with manifest."""
        return RemoteTaskConfigurationRepository(
            self.manifest(workspace=workspace, pk=pk, name=name)
        )

    def mock_fetch(self, repo: RemoteTaskConfigurationRepository) -> Mock:
        """Mock push_task_configuration_collection with a plausible return."""
        fetch = Mock(return_value=repo)
        self.debusine.fetch_task_configuration_collection = fetch
        return fetch

    def mock_push(
        self,
        added: int = 1,
        updated: int = 2,
        removed: int = 3,
        unchanged: int = 4,
    ) -> Mock:
        """Mock push_task_configuration_collection with a plausible return."""
        results = TaskConfigurationCollectionUpdateResults(
            added=added, updated=updated, removed=removed, unchanged=unchanged
        )
        push = Mock(return_value=results)
        self.debusine.push_task_configuration_collection = push
        return push

    def write_file(self, path: Path, data: dict[str, Any]) -> None:
        """Write out a yaml file in workdir."""
        with (self.workdir / path).open("wt") as fd:
            yaml.safe_dump(data, fd)

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

    def assertFileContents(self, path: Path, expected: dict[str, Any]) -> None:
        """Check that path points to a YAML file with the given contents."""
        with (self.workdir / path).open() as fd:
            actual = yaml.safe_load(fd)
        self.assertEqual(actual, expected)

    def test_pull_default_to_current_dir(self) -> None:
        orig_cwd = os.getcwd()
        try:
            os.chdir(self.workdir)
            command = self.create_command(["task-config", "pull"])
            assert isinstance(command, Pull)
            self.assertEqual(command.args.workdir, Path.cwd())
            self.assertEqual(command.workspace, "developers")
            self.assertEqual(command.collection, "default")
        finally:
            os.chdir(orig_cwd)

    def test_pull_workdir(self) -> None:
        command = self.create_command(
            ["task-config", "pull", "--workdir", self.workdir.as_posix()]
        )
        assert isinstance(command, Pull)
        self.assertEqual(command.args.workdir, self.workdir)
        self.assertEqual(command.workspace, "developers")
        self.assertEqual(command.collection, "default")

    def test_pull_workspace(self) -> None:
        command = self.create_command(
            [
                "task-config",
                "pull",
                "--workdir",
                self.workdir.as_posix(),
                "--workspace=workspace",
            ]
        )
        assert isinstance(command, Pull)
        self.assertEqual(command.args.workdir, self.workdir)
        self.assertEqual(command.workspace, "workspace")
        self.assertEqual(command.collection, "default")

    def test_pull_collection_name(self) -> None:
        command = self.create_command(
            [
                "task-config",
                "pull",
                "--workdir",
                self.workdir.as_posix(),
                "--workspace=workspace",
                "collection",
            ]
        )
        assert isinstance(command, Pull)
        self.assertEqual(command.args.workdir, self.workdir)
        self.assertEqual(command.workspace, "workspace")
        self.assertEqual(command.collection, "collection")

    def test_pull_workspace_must_match_checkout(self) -> None:
        self.write_file(
            Path("MANIFEST"),
            self.manifest(workspace="workspace", name="name").model_dump(),
        )
        stderr, stdout = self.capture_output(
            partial(
                self.create_command,
                [
                    "task-config",
                    "pull",
                    "--workdir",
                    self.workdir.as_posix(),
                    "--workspace=other_workspace",
                    "name",
                ],
            ),
            assert_system_exit_code=3,
        )
        self.assertEqual(
            stderr,
            "--workspace=other_workspace is provided,"
            " but the repository checked out refers to"
            " workspace 'workspace'\n",
        )
        self.assertEqual(stdout, "")

    def test_pull_collection_must_match_checkout_ok(self) -> None:
        self.write_file(
            Path("MANIFEST"),
            self.manifest(workspace="workspace", name="name").model_dump(),
        )
        stderr, stdout = self.capture_output(
            partial(
                self.create_command,
                [
                    "task-config",
                    "pull",
                    "--workdir",
                    self.workdir.as_posix(),
                    "--workspace=workspace",
                    "name",
                ],
            ),
        )
        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")

    def test_pull_collection_must_match_checkout_fail(self) -> None:
        self.write_file(
            Path("MANIFEST"),
            self.manifest(workspace="workspace", name="name").model_dump(),
        )
        stderr, stdout = self.capture_output(
            partial(
                self.create_command,
                [
                    "task-config",
                    "pull",
                    "--workdir",
                    self.workdir.as_posix(),
                    "--workspace=workspace",
                    "other_name",
                ],
            ),
            assert_system_exit_code=3,
        )
        self.assertEqual(
            stderr,
            "--collection=other_name is provided,"
            " but the repository checked out refers to collection 'name'\n",
        )
        self.assertEqual(stdout, "")

    def test_pull_new_repo(self) -> None:
        server_repo = self.make_remote_repo()
        server_repo.entries += [self.config1]
        self.mock_fetch(repo=server_repo)
        cli = self.create_cli(
            [
                "task-config",
                "pull",
                "--workspace=System",
                "--workdir",
                self.workdir.as_posix(),
            ]
        )
        with self.assertLogs("debusine.client.tests") as log:
            stderr, stdout = self.capture_output(cli.execute)

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertCountEqual(
            [x.removeprefix("INFO:debusine.client.tests:") for x in log.output],
            [
                "new.yaml: new file with 1 entries",
                "1 added, 0 updated, 0 deleted, 0 unchanged",
            ],
        )
        self.assertCountEqual(
            self.workdir_files(),
            [path_manifest := Path("MANIFEST"), path_new := Path("new.yaml")],
        )

        self.assertFileContents(
            path_manifest, server_repo.manifest.model_dump()
        )
        self.assertFileContents(path_new, self.config1.model_dump())

    def test_pull_existing_repo(self) -> None:
        server_repo = self.make_remote_repo()
        server_repo.entries += [self.config1, self.template]
        self.mock_fetch(repo=server_repo)
        self.write_file(
            path_manifest := Path("MANIFEST"), server_repo.manifest.model_dump()
        )
        self.write_file(
            path_config1 := Path("config.yaml"), self.config1.model_dump()
        )
        self.write_file(
            path_template := Path("template.yaml"), self.template.model_dump()
        )
        cli = self.create_cli(
            ["task-config", "pull", "--workdir", self.workdir.as_posix()]
        )
        with self.assertLogs("debusine.client.tests") as log:
            stderr, stdout = self.capture_output(cli.execute)

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertCountEqual(
            [x.removeprefix("INFO:debusine.client.tests:") for x in log.output],
            [
                "0 added, 0 updated, 0 deleted, 2 unchanged",
            ],
        )
        self.assertCountEqual(
            self.workdir_files(),
            [
                path_manifest,
                path_template,
                path_config1,
            ],
        )

        self.assertFileContents(
            path_manifest, server_repo.manifest.model_dump()
        )
        self.assertFileContents(path_template, self.template.model_dump())
        self.assertFileContents(path_config1, self.config1.model_dump())

    def test_pull_repo_manifest_mismatch(self) -> None:
        server_repo = self.make_remote_repo()
        server_repo.entries += [self.config2]
        self.mock_fetch(repo=server_repo)
        local_manifest = server_repo.manifest.model_dump()
        local_manifest["workspace"] = "other"
        self.write_file(path_manifest := Path("MANIFEST"), local_manifest)
        self.write_file(
            path_config1 := Path("config.yaml"), self.config1.model_dump()
        )
        cli = self.create_cli(
            [
                "task-config",
                "pull",
                "--workdir",
                self.workdir.as_posix(),
            ]
        )
        stderr, stdout = self.capture_output(
            cli.execute, assert_system_exit_code=3
        )

        self.assertEqual(
            stderr,
            "repo to pull refers to collection System/default (42)"
            " while the checkout has collection other/default (42)\n",
        )
        self.assertEqual(stdout, "")
        self.assertCountEqual(
            self.workdir_files(),
            [
                path_manifest,
                path_config1,
            ],
        )

    def test_push_default_to_current_dir(self) -> None:
        command = self.create_command(["task-config", "push"])
        self.assertEqual(command.args.workdir, Path.cwd())
        self.assertFalse(command.args.dry_run)
        self.assertFalse(command.args.force)

    def test_push_workdir(self) -> None:
        command = self.create_command(
            ["task-config", "push", "--workdir", self.workdir.as_posix()]
        )
        self.assertEqual(command.args.workdir, self.workdir)
        self.assertFalse(command.args.dry_run)
        self.assertFalse(command.args.force)

    def test_push_dry_run(self) -> None:
        command = self.create_command(
            [
                "task-config",
                "push",
                "--workdir",
                self.workdir.as_posix(),
                "--dry-run",
            ]
        )
        self.assertEqual(command.args.workdir, self.workdir)
        self.assertTrue(command.args.dry_run)
        self.assertFalse(command.args.force)

    def test_push_force(self) -> None:
        command = self.create_command(
            [
                "task-config",
                "push",
                "--workdir",
                self.workdir.as_posix(),
                "--force",
            ]
        )
        self.assertEqual(command.args.workdir, self.workdir)
        self.assertFalse(command.args.dry_run)
        self.assertTrue(command.args.force)

    def test_push_no_workdir(self) -> None:
        path = self.workdir / "does-not-exist"
        cli = self.create_cli(
            ["task-config", "push", "--workdir", path.as_posix()]
        )
        stderr, stdout = self.capture_output(
            cli.execute, assert_system_exit_code=3
        )
        self.assertEqual(stderr, f"{path} is not a repository\n")
        self.assertEqual(stdout, "")

    def test_push_no_manifest(self) -> None:
        cli = self.create_cli(
            ["task-config", "push", "--workdir", self.workdir.as_posix()]
        )
        stderr, stdout = self.capture_output(
            cli.execute, assert_system_exit_code=3
        )
        self.assertEqual(stderr, f"{self.workdir} is not a repository\n")
        self.assertEqual(stdout, "")

    def test_push_repo(self) -> None:
        server_repo = self.make_remote_repo()
        self.mock_fetch(repo=server_repo)
        push_method = self.mock_push()
        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        self.write_file(Path("config.yaml"), self.config1.model_dump())
        cli = self.create_cli(
            ["task-config", "push", "--workdir", self.workdir.as_posix()]
        )
        with self.assertLogs("debusine.client.tests") as log:
            stderr, stdout = self.capture_output(cli.execute)

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertCountEqual(
            [x.removeprefix("INFO:debusine.client.tests:") for x in log.output],
            [
                "Pushing data to server...",
                "1 added, 2 updated, 3 removed, 4 unchanged",
            ],
        )

        repo = push_method.call_args.kwargs["repo"]
        self.assertEqual(repo.manifest, self.manifest())
        self.assertEqual(
            list(repo.iter_entries()),
            [
                self.config1.model_copy(
                    update={"path": "config.yaml", "position": 0}
                )
            ],
        )
        self.assertFalse(push_method.call_args.kwargs["dry_run"])

    def test_push_repo_dry_run(self) -> None:
        server_repo = self.make_remote_repo()
        self.mock_fetch(repo=server_repo)
        push_method = self.mock_push()
        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        self.write_file(Path("config.yaml"), self.config1.model_dump())
        cli = self.create_cli(
            [
                "task-config",
                "push",
                "--workdir",
                self.workdir.as_posix(),
                "--dry-run",
            ]
        )
        with self.assertLogs("debusine.client.tests") as log:
            stderr, stdout = self.capture_output(cli.execute)

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertCountEqual(
            [x.removeprefix("INFO:debusine.client.tests:") for x in log.output],
            [
                "Pushing data to server (dry run)...",
                "1 added, 2 updated, 3 removed, 4 unchanged",
            ],
        )

        repo = push_method.call_args.kwargs["repo"]
        self.assertEqual(repo.manifest, self.manifest())
        self.assertEqual(
            list(repo.iter_entries()),
            [
                self.config1.model_copy(
                    update={"path": "config.yaml", "position": 0}
                )
            ],
        )
        self.assertTrue(push_method.call_args.kwargs["dry_run"])

    def test_push_repo_dirty(self) -> None:
        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        cli = self.create_cli(
            ["task-config", "push", "--workdir", self.workdir.as_posix()]
        )
        with mock.patch(
            "debusine.client.task_configuration"
            ".LocalTaskConfigurationRepository.is_dirty",
            return_value=True,
        ):
            stderr, stdout = self.capture_output(
                cli.execute, assert_system_exit_code=3
            )
        self.assertEqual(
            stderr,
            f"{self.workdir} has uncommitted changes:"
            " please commit them before pushing\n",
        )
        self.assertEqual(stdout, "")

    def test_push_repo_dirty_force(self) -> None:
        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        fetch_method = self.mock_fetch(repo=self.make_remote_repo())
        push_method = self.mock_push()
        cli = self.create_cli(
            [
                "task-config",
                "push",
                "--workdir",
                self.workdir.as_posix(),
                "--force",
            ]
        )
        with (
            mock.patch(
                "debusine.client.task_configuration"
                ".LocalTaskConfigurationRepository.is_dirty",
                return_value=True,
            ),
            self.assertLogs("debusine.client.tests") as log,
        ):
            stderr, stdout = self.capture_output(cli.execute)
        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        self.assertCountEqual(
            [
                x.removeprefix("WARNING:debusine.client.tests:").removeprefix(
                    "INFO:debusine.client.tests:"
                )
                for x in log.output
            ],
            [
                f"{self.workdir} has uncommitted changes:"
                " please commit them before pushing",
                "Pushing data to server...",
                "1 added, 2 updated, 3 removed, 4 unchanged",
            ],
        )
        fetch_method.assert_called()
        push_method.assert_called()

    def test_push_git_no_commit_on_server(self) -> None:
        local_commit = "0" * 40
        server_repo = self.make_remote_repo()
        self.mock_fetch(repo=server_repo)
        push_method = self.mock_push()
        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        with mock.patch(
            "debusine.client.task_configuration"
            ".LocalTaskConfigurationRepository.git_commit",
            return_value=local_commit,
        ):
            cli = self.create_cli(
                [
                    "task-config",
                    "push",
                    "--workdir",
                    self.workdir.as_posix(),
                ]
            )
            stderr, stdout = self.capture_output(cli.execute)

        self.assertEqual(
            stderr,
            (
                "Pushing data to server...\n"
                "1 added, 2 updated, 3 removed, 4 unchanged\n"
            ),
        )
        self.assertEqual(stdout, "")

        repo = push_method.call_args.kwargs["repo"]
        self.assertEqual(
            repo.manifest, self.manifest(data={"git_commit": local_commit})
        )

    def test_push_git_does_not_have_previous_commit(self) -> None:
        local_commit = "1" * 40
        server_commit = "0" * 40
        server_repo = self.make_remote_repo()
        server_repo.manifest.collection.data["git_commit"] = server_commit
        fetch_method = self.mock_fetch(repo=server_repo)
        push_method = self.mock_push()

        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        with (
            mock.patch(
                "debusine.client.task_configuration"
                ".LocalTaskConfigurationRepository.git_commit",
                return_value=local_commit,
            ),
            mock.patch(
                "debusine.client.task_configuration"
                ".LocalTaskConfigurationRepository.has_commit",
                return_value=False,
            ),
        ):
            cli = self.create_cli(
                [
                    "task-config",
                    "push",
                    "--workdir",
                    self.workdir.as_posix(),
                ]
            )
            stderr, stdout = self.capture_output(
                cli.execute, assert_system_exit_code=3
            )

        self.assertEqual(
            stderr,
            "server collection was pushed from commit"
            f" {server_commit} which is not known to {self.workdir}\n",
        )
        self.assertEqual(stdout, "")

        fetch_method.assert_called()
        push_method.assert_not_called()

    def test_push_git_does_not_have_previous_commit_force(self) -> None:
        local_commit = "1" * 40
        server_commit = "0" * 40
        server_repo = self.make_remote_repo()
        server_repo.manifest.collection.data["git_commit"] = server_commit
        fetch_method = self.mock_fetch(repo=server_repo)
        push_method = self.mock_push()

        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        with (
            mock.patch(
                "debusine.client.task_configuration"
                ".LocalTaskConfigurationRepository.git_commit",
                return_value=local_commit,
            ),
            mock.patch(
                "debusine.client.task_configuration"
                ".LocalTaskConfigurationRepository.has_commit",
                return_value=False,
            ),
            self.assertLogs("debusine.client.tests") as log,
        ):
            cli = self.create_cli(
                [
                    "task-config",
                    "push",
                    "--workdir",
                    self.workdir.as_posix(),
                    "--force",
                ]
            )
            stderr, stdout = self.capture_output(cli.execute)

        self.assertCountEqual(
            [
                x.removeprefix("WARNING:debusine.client.tests:").removeprefix(
                    "INFO:debusine.client.tests:"
                )
                for x in log.output
            ],
            [
                "server collection was pushed from commit"
                f" {server_commit} which is not known to {self.workdir}",
                "Pushing data to server...",
                "1 added, 2 updated, 3 removed, 4 unchanged",
            ],
        )

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")

        fetch_method.assert_called()
        push_method.assert_called()

    def test_push_git_has_previous_commit(self) -> None:
        local_commit = "0" * 40
        server_commit = "1" * 40
        server_repo = self.make_remote_repo()
        server_repo.manifest.collection.data["git_commit"] = server_commit
        self.mock_fetch(repo=server_repo)
        push_method = self.mock_push()

        self.write_file(Path("MANIFEST"), self.manifest().model_dump())
        with (
            mock.patch(
                "debusine.client.task_configuration"
                ".LocalTaskConfigurationRepository.git_commit",
                return_value=local_commit,
            ),
            mock.patch(
                "debusine.client.task_configuration"
                ".LocalTaskConfigurationRepository.has_commit",
                return_value=True,
            ),
        ):
            cli = self.create_cli(
                [
                    "task-config",
                    "push",
                    "--workdir",
                    self.workdir.as_posix(),
                ]
            )
            stderr, stdout = self.capture_output(cli.execute)

        self.assertEqual(
            stderr,
            (
                "Pushing data to server...\n"
                "1 added, 2 updated, 3 removed, 4 unchanged\n"
            ),
        )
        self.assertEqual(stdout, "")

        repo = push_method.call_args.kwargs["repo"]
        self.assertEqual(
            repo.manifest, self.manifest(data={"git_commit": local_commit})
        )
