# 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 Collection commands."""

import re
from functools import partial
from typing import override
from unittest import mock

from debusine.artifacts.models import CollectionCategory
from debusine.client.commands.base import Command, DebusineCommand
from debusine.client.commands.collections import (
    CollectionCommand,
    SingleCollectionCommand,
)
from debusine.client.commands.tests.base import BaseCliTests
from debusine.client.models import CollectionData, CollectionDataNew


class CollectionCommandTests(BaseCliTests):
    """Tests for the :py:class:`CollectionCommand` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.data = CollectionData(
            id=42,
            name="sid",
            category=CollectionCategory.SUITE,
            full_history_retention_period=7,
            metadata_only_retention_period=14,
            data={"test": 2},
            links={
                "webui_self": "/c/42",
                "webui_category": "/c/suite",
            },
        )

    @Command.preserve_registry()
    def _command(self) -> CollectionCommand:
        class ConcreteCollectionCommand(CollectionCommand):
            """Version of CollectionCommand that can be instantiated."""

            @override
            def run(self) -> None:
                raise NotImplementedError()

        return self.command(ConcreteCollectionCommand, workspace="workspace")

    def test_markup_category(self) -> None:
        command = self._command()
        self.assertEqual(
            command.markup_category(self.data),
            "[link=https://debusine.debian.org/c/suite]debian:suite[/]",
        )
        self.data.category = CollectionCategory.WORKFLOW_INTERNAL
        self.assertEqual(
            command.markup_category(self.data), "debusine:workflow-internal"
        )

    def test_list_rich(self) -> None:
        command = self._command()
        with (
            mock.patch(
                "debusine.client.commands.collections.Table",
                return_value=(table := mock.MagicMock()),
            ),
            mock.patch(
                "debusine.client.commands.collections.rich.print"
            ) as rich_print,
        ):
            stderr, stdout = self.capture_output(
                partial(command._list_rich, [self.data])
            )

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

        self.assertEqual(table.add_row.call_count, 1)
        url = "https://debusine.debian.org/c/42"
        category_url = "https://debusine.debian.org/c/suite"
        call = table.add_row.call_args_list[0]
        self.assertEqual(call.args[0], "42")
        self.assertEqual(call.args[1], f"[link={url}]sid[/]")
        self.assertEqual(call.args[2], f"[link={category_url}]debian:suite[/]")
        self.assertEqual(call.args[3], "7")
        self.assertEqual(call.args[4], "14")

    def test_show_rich(self) -> None:
        command = self._command()
        with (
            mock.patch(
                "debusine.client.commands.collections.Table",
                return_value=(table := mock.MagicMock()),
            ),
            mock.patch(
                "debusine.client.commands.collections.rich.print"
            ) as rich_print,
            mock.patch("rich.json.JSON.from_data", side_effect=lambda x: x),
        ):
            stderr, stdout = self.capture_output(
                partial(command._show_rich, self.data)
            )

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

        self.assertEqual(table.add_row.call_count, 6)
        url = "https://debusine.debian.org/c/42"
        category_url = "https://debusine.debian.org/c/suite"
        rows = [
            call_args_list.args
            for call_args_list in table.add_row.call_args_list
        ]
        self.assertEqual(rows[0], ("ID:", "#42"))
        self.assertEqual(rows[1], ("Name:", f"[link={url}]sid[/]"))
        self.assertEqual(
            rows[2], ("Category:", f"[link={category_url}]debian:suite[/]")
        )
        self.assertEqual(rows[3], ("Full history retention:", "7 days"))
        self.assertEqual(rows[4], ("Metadata only retention:", "14 days"))
        self.assertEqual(rows[5], ("Data:", self.data.data))

    def test_show_rich_stats(self) -> None:
        self.data.stats = [
            {"type": "test", "category": "test", "active": True, "count": 2}
        ]
        command = self._command()
        with (
            mock.patch(
                "debusine.client.commands.collections.Table",
                return_value=(table := mock.MagicMock()),
            ),
            mock.patch(
                "debusine.client.commands.collections.rich.print"
            ) as rich_print,
            mock.patch("rich.json.JSON.from_data", side_effect=lambda x: x),
        ):
            stderr, stdout = self.capture_output(
                partial(command._show_rich, self.data)
            )

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

        self.assertEqual(table.add_row.call_count, 7)
        url = "https://debusine.debian.org/c/42"
        category_url = "https://debusine.debian.org/c/suite"
        rows = [
            call_args_list.args
            for call_args_list in table.add_row.call_args_list
        ]
        self.assertEqual(rows[0], ("ID:", "#42"))
        self.assertEqual(rows[1], ("Name:", f"[link={url}]sid[/]"))
        self.assertEqual(
            rows[2], ("Category:", f"[link={category_url}]debian:suite[/]")
        )
        self.assertEqual(rows[3], ("Full history retention:", "7 days"))
        self.assertEqual(rows[4], ("Metadata only retention:", "14 days"))
        self.assertEqual(rows[5], ("Data:", self.data.data))
        self.assertEqual(rows[6], ("Stats:", self.data.stats))


class SingleCollectionCommandTests(BaseCliTests):
    """Tests for the :py:class:`SingleCollectionCommand` class."""

    @Command.preserve_registry()
    def _command(
        self, category_or_fullname: str, name: str | None = None
    ) -> SingleCollectionCommand:
        class ConcreteCollectionCommand(SingleCollectionCommand):
            """Version of SingleCollectionCommand that can be instantiated."""

            @override
            def run(self) -> None:
                raise NotImplementedError()

        return self.command(
            ConcreteCollectionCommand,
            workspace="workspace",
            category_or_fullname=category_or_fullname,
            name=name,
        )

    def test_missing_name(self) -> None:
        for arg in ("debian:suite", "debian:archive", "invalid"):
            with self.subTest(arg=arg):
                command = self._command(arg)
                self.assertEqual(command.collection_category, arg)
                self.assertEqual(command.collection_name, "_")

    def test_full_name(self) -> None:
        for arg in (
            "sid@debian:suite",
            "_@debian:suite",
            "sid@debian:archive",
            "_@debian:archive",
        ):
            with self.subTest(arg=arg):
                command = self._command(arg)
                name, category = arg.split("@")
                self.assertEqual(command.collection_category, category)
                self.assertEqual(command.collection_name, name)

    def test_separate_name(self) -> None:
        for category, name in (
            ("debian:archive", "_"),
            ("debian:suite", "_"),
            ("debian:archive", "sid"),
            ("debian:suite", "sid"),
        ):
            with self.subTest(category=category, name=name):
                command = self._command(category, name)
                self.assertEqual(command.collection_category, category)
                self.assertEqual(command.collection_name, name)

    def test_name_given_twice(self) -> None:
        for category, name in (
            ("_@debian:archive", "_"),
            ("_@debian:suite", "_"),
            ("sid@debian:archive", "sid"),
            ("sid@debian:suite", "sid"),
            ("a@debian:suite", "b"),
            ("a@debian:suite", "b"),
        ):
            with self.subTest(category=category, name=name):
                stderr, _ = self.capture_output(
                    partial(self._command, category, name),
                    assert_system_exit_code=2,
                )
                self.assertRegex(
                    stderr,
                    "collection name is given"
                    f" both in `{re.escape(category)}`"
                    " and in the name argument",
                )


class ListTests(BaseCliTests):
    """Tests for the :py:class:`collections.List` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.data = CollectionData(
            id=42,
            name="sid",
            category=CollectionCategory.SUITE,
            full_history_retention_period=7,
            metadata_only_retention_period=14,
            data={"test": 2},
        )

    def test_list(self) -> None:
        command = self.create_command(
            ["collection", "list", "--workspace=workspace"]
        )
        assert isinstance(command, DebusineCommand)
        with (
            mock.patch.object(
                command.debusine, "collection_iter", return_value=[self.data]
            ) as collection_iter,
            mock.patch.object(command, "list") as list_,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        collection_iter.assert_called_once_with("workspace")
        list_.assert_called_once_with([self.data])


class ShowTests(BaseCliTests):
    """Tests for the :py:class:`collections.Show` class."""

    def test_show(self) -> None:
        data = CollectionData(
            id=42,
            name="sid",
            category=CollectionCategory.SUITE,
            full_history_retention_period=7,
            metadata_only_retention_period=14,
            data={"test": 2},
        )

        command = self.create_command(
            ["collection", "show", "--workspace=workspace", "category", "name"]
        )
        assert isinstance(command, DebusineCommand)
        with (
            mock.patch.object(
                command.debusine, "collection_get", return_value=data
            ) as collection_get,
            mock.patch.object(command, "show") as show,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        collection_get.assert_called_once_with(
            "workspace", "category", "name", with_stats=True
        )
        show.assert_called_once_with(data)


class CreateTests(BaseCliTests):
    """Tests for the :py:class:`collections.Create` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.sent = CollectionDataNew(
            name="sid",
            category=CollectionCategory.SUITE,
            data={},
        )

    def assertCreates(self, *args: str) -> None:
        """Collection_create with the given arguments returns self.sample."""
        command = self.create_command(
            [
                "collection",
                "create",
                "--workspace=workspace",
                CollectionCategory.SUITE,
                "sid",
                *args,
            ]
        )
        assert isinstance(command, DebusineCommand)

        created = CollectionData(id=42, **self.sent.model_dump())
        with (
            mock.patch.object(
                command.debusine, "collection_create", return_value=created
            ) as collection_create,
            mock.patch.object(command, "show") as show,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        collection_create.assert_called_once_with("workspace", self.sent)
        show.assert_called_once_with(created)

    def test_defaults(self) -> None:
        self.enterContext(self.patch_sys_stdin_read(""))
        self.assertCreates()

    def test_set_full_history_retention_period(self) -> None:
        self.enterContext(self.patch_sys_stdin_read(""))
        self.sent.full_history_retention_period = 1
        self.assertCreates("--full-history-retention-period=1")

    def test_unset_full_history_retention_period(self) -> None:
        self.enterContext(self.patch_sys_stdin_read(""))
        self.sent.full_history_retention_period = None
        self.assertCreates("--full-history-retention-period=-")

    def test_set_metadata_only_retention_period(self) -> None:
        self.enterContext(self.patch_sys_stdin_read(""))
        self.sent.metadata_only_retention_period = 1
        self.assertCreates("--metadata-only-retention-period=1")

    def test_unset_metadata_only_retention_period(self) -> None:
        self.enterContext(self.patch_sys_stdin_read(""))
        self.sent.metadata_only_retention_period = None
        self.assertCreates("--metadata-only-retention-period=-")

    def test_set_data(self) -> None:
        infile = self.create_temporary_file()
        infile.write_text("{'test': 7}")
        self.sent.data = {"test": 7}
        self.assertCreates(f"--data={infile}")

    def test_set_data_stdin(self) -> None:
        self.enterContext(self.patch_sys_stdin_read("{'test': 7}"))
        self.sent.data = {"test": 7}
        self.assertCreates("--data=-")

    def test_invalid_period(self) -> None:
        stderr, _ = self.capture_output(
            partial(
                self.create_command,
                [
                    "collection",
                    "create",
                    "--workspace=workspace",
                    "category",
                    "name",
                    "--full-history-retention-period=invalid",
                ],
            ),
            assert_system_exit_code=2,
        )
        self.assertRegex(
            stderr,
            r"error: retention period must be a number or '-'",
        )


class ManageTests(BaseCliTests):
    """Tests for the :py:class:`collections.Manage` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.sample = CollectionData(
            id=42,
            name="sid",
            category=CollectionCategory.SUITE,
            full_history_retention_period=7,
            metadata_only_retention_period=14,
            data={"test": 2},
        )
        self.edited = self.sample.model_copy()

    def assertManages(self, *args: str) -> None:
        """Call collection_update with the given arguments match self.edited."""
        command = self.create_command(
            [
                "collection",
                "manage",
                "--workspace=workspace",
                "category",
                "name",
                *args,
            ]
        )
        assert isinstance(command, DebusineCommand)

        with (
            mock.patch.object(
                command.debusine, "collection_get", return_value=self.sample
            ),
            mock.patch.object(
                command.debusine, "collection_update", return_value=self.edited
            ) as collection_update,
            mock.patch.object(command, "show") as show,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        collection_update.assert_called_once_with("workspace", self.edited)
        show.assert_called_once_with(self.edited)

    def test_rename(self) -> None:
        self.edited.name = "renamed"
        self.assertManages("--rename=renamed")

    def test_set_full_history_retention_period(self) -> None:
        self.edited.full_history_retention_period = 1
        self.assertManages("--full-history-retention-period=1")

    def test_unset_full_history_retention_period(self) -> None:
        self.edited.full_history_retention_period = None
        self.assertManages("--full-history-retention-period=-")

    def test_set_metadata_only_retention_period(self) -> None:
        self.edited.metadata_only_retention_period = 1
        self.assertManages("--metadata-only-retention-period=1")

    def test_unset_metadata_only_retention_period(self) -> None:
        self.edited.metadata_only_retention_period = None
        self.assertManages("--metadata-only-retention-period=-")

    def test_set_data(self) -> None:
        infile = self.create_temporary_file()
        infile.write_text("{'test': 7}")
        self.edited.data = {"test": 7}
        self.assertManages(f"--data={infile}")

    def test_set_data_stdin(self) -> None:
        self.enterContext(self.patch_sys_stdin_read("{'test': 7}"))
        self.edited.data = {"test": 7}
        self.assertManages("--data=-")

    def test_invalid_period(self) -> None:
        stderr, _ = self.capture_output(
            partial(
                self.create_command,
                [
                    "collection",
                    "manage",
                    "--workspace=workspace",
                    "category",
                    "name",
                    "--full-history-retention-period=invalid",
                ],
            ),
            assert_system_exit_code=2,
        )
        self.assertRegex(
            stderr,
            r"error: retention period must be a number or '-'",
        )


class DeleteTests(BaseCliTests):
    """Tests for the :py:class:`collections.Delete` class."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.sample = CollectionData(
            id=42,
            name="sid",
            category=CollectionCategory.SUITE,
            full_history_retention_period=7,
            metadata_only_retention_period=14,
            data={"test": 2},
            stats=[],
        )

    def test_delete(self, *args: str) -> None:
        command = self.create_command(
            [
                "collection",
                "delete",
                "--workspace=workspace",
                "category",
                "name",
                *args,
            ]
        )
        assert isinstance(command, DebusineCommand)

        with (
            mock.patch.object(
                command.debusine, "collection_get", return_value=self.sample
            ),
            mock.patch.object(
                command.debusine,
                "collection_delete",
            ) as collection_delete,
            mock.patch.object(command, "feedback") as feedback,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        collection_delete.assert_called_once_with("workspace", self.sample)
        feedback.assert_called_once_with("Collection deleted.")

    def test_delete_nonempty(self, *args: str) -> None:
        assert self.sample.stats is not None
        self.sample.stats = [
            {
                "type": "artifact",
                "category": "debusine:test",
                "active": True,
                "count": 1,
            },
            {
                "type": "artifact",
                "category": "debusine:test",
                "active": False,
                "count": 15,
            },
        ]
        command = self.create_command(
            [
                "collection",
                "delete",
                "--workspace=workspace",
                "category",
                "name",
                *args,
            ]
        )
        assert isinstance(command, DebusineCommand)

        with (
            mock.patch.object(
                command.debusine, "collection_get", return_value=self.sample
            ),
            mock.patch.object(
                command.debusine,
                "collection_delete",
            ) as collection_delete,
            mock.patch.object(command, "feedback") as feedback,
        ):
            stderr, stdout = self.capture_output(
                command.run, assert_system_exit_code=3
            )

        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr,
            "Collection is not empty"
            " (active debusine:test artifact: 1,"
            " deleted debusine:test artifact: 15):"
            " use --force to delete it.\n",
        )
        collection_delete.assert_not_called()
        feedback.assert_not_called()

    def test_delete_nonempty_force(self, *args: str) -> None:
        assert self.sample.stats is not None
        self.sample.stats += [
            {
                "type": "artifact",
                "category": "test",
                "active": True,
                "count": 1,
            },
            {
                "type": "artifact",
                "category": "test",
                "active": False,
                "count": 15,
            },
        ]
        command = self.create_command(
            [
                "collection",
                "delete",
                "--workspace=workspace",
                "category",
                "name",
                "--force",
                *args,
            ]
        )
        assert isinstance(command, DebusineCommand)

        with (
            mock.patch.object(
                command.debusine, "collection_get", return_value=self.sample
            ),
            mock.patch.object(
                command.debusine,
                "collection_delete",
            ) as collection_delete,
            mock.patch.object(command, "feedback") as feedback,
        ):
            stderr, stdout = self.capture_output(command.run)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        collection_delete.assert_called_once_with("workspace", self.sample)
        feedback.assert_called_once_with("Collection deleted.")
