# 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 Debusine CLI "collection relation" commands."""

import contextlib
import io
from typing import Any, override
from unittest import mock

import yaml

from debusine.artifacts.models import CollectionCategory
from debusine.client.commands import collection_relations
from debusine.client.commands.tests.base import BaseCliTests
from debusine.client.exceptions import DebusineError
from debusine.client.models import (
    CollectionIdentifierResponse,
    CollectionRelationResponse,
    CollectionRelationSetResponse,
    CollectionRelationType,
)
from debusine.utils.yaml import YamlEditor


class ListTests(BaseCliTests):
    """Tests for listing collection relations."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.collection_relation = CollectionRelationResponse(
            source_collection=CollectionIdentifierResponse(
                name="personal", category=CollectionCategory.SUITE
            ),
            target_collection=CollectionIdentifierResponse(
                name="trixie", category=CollectionCategory.SUITE
            ),
            type=CollectionRelationType.SUITE_REQUIRES,
            position=1,
        )

    def test_list(self) -> None:
        command = self.create_command(
            [
                "collection",
                "relation",
                "list",
                "--from",
                "personal@debian:suite",
                "--to",
                "trixie@debian:suite",
                "--type",
                CollectionRelationType.SUITE_REQUIRES,
                "--workspace",
                "workspace",
            ]
        )

        assert isinstance(command, collection_relations.List)

        with (
            mock.patch.object(
                command.debusine,
                "collection_relation_list",
                return_value=[self.collection_relation],
            ) as collection_relation_list_mock,
            mock.patch.object(command, "list") as list_,
        ):
            command.run()

        collection_relation_list_mock.assert_called_once_with(
            "workspace",
            source="personal@debian:suite",
            target="trixie@debian:suite",
            relation_type=CollectionRelationType.SUITE_REQUIRES,
        )

        list_.assert_called_once_with([self.collection_relation])

    def test_list_rich(self) -> None:
        """Test list_rich() method: mocks rich, assert is called as expected."""
        command = self.create_command(
            [
                "collection",
                "relation",
                "list",
                "--from",
                "personal@debian:suite",
                "--to",
                "trixie@debian:suite",
                "--type",
                CollectionRelationType.SUITE_REQUIRES,
                "--workspace",
                "workspace",
            ]
        )

        assert isinstance(command, collection_relations.List)

        with (
            mock.patch(
                "debusine.client.commands.collection_relations.Table",
                return_value=(table := mock.MagicMock()),
            ),
            mock.patch(
                "debusine.client.commands.collection_relations.rich.print"
            ) as rich_print,
        ):
            command._list_rich([self.collection_relation])

        rich_print.assert_called_once()

        table.add_row.assert_called_once_with(
            "personal@debian:suite",
            "trixie@debian:suite",
            CollectionRelationType.SUITE_REQUIRES,
            "1",
        )

    def test_invalid_format_collection_identifier(self) -> None:
        stderr = io.StringIO()

        with (
            self.assertRaisesRegex(SystemExit, "2"),
            contextlib.redirect_stderr(stderr),
        ):
            self.create_command(
                [
                    "collection",
                    "relation",
                    "list",
                    "--from",
                    "invalid",
                    "--to",
                    "trixie@debian:suite",
                    "--type",
                    CollectionRelationType.SUITE_REQUIRES,
                    "--workspace",
                    "workspace",
                ],
            )

        self.assertIn(
            "Invalid collection identifier: 'invalid'. "
            "Expected format is 'name@category'",
            stderr.getvalue(),
        )

    def test_invalid_collection_identifier(self) -> None:
        stderr = io.StringIO()

        with (
            self.assertRaisesRegex(SystemExit, "2"),
            contextlib.redirect_stderr(stderr),
        ):
            self.create_command(
                [
                    "collection",
                    "relation",
                    "list",
                    "--from",
                    "personal@invalid",
                    "--to",
                    "trixie@debian:suite",
                    "--type",
                    CollectionRelationType.SUITE_REQUIRES,
                    "--workspace",
                    "workspace",
                ],
            )

        self.assertIn(
            "Invalid collection identifier: 'personal@invalid'. "
            "Category must be one of: debian:archive, debian:environments, "
            "debian:package-build-logs, debian:qa-results, debian:suite, "
            "debusine:task-configuration, debusine:task-history, "
            "debusine:workflow-internal",
            stderr.getvalue(),
        )

    def test_invalid_collection_identifier_debusine_test(self) -> None:
        stderr = io.StringIO()

        with (
            self.assertRaisesRegex(SystemExit, "2"),
            contextlib.redirect_stderr(stderr),
        ):
            self.create_command(
                [
                    "collection",
                    "relation",
                    "list",
                    "--from",
                    "test@debusine:test",
                    "--to",
                    "trixie@debian:suite",
                    "--type",
                    CollectionRelationType.SUITE_REQUIRES,
                    "--workspace",
                    "workspace",
                ],
            )

        self.assertIn(
            "Invalid collection identifier: 'test@debusine:test'. "
            "Category must be one of: debian:archive, debian:environments, "
            "debian:package-build-logs, debian:qa-results, debian:suite, "
            "debusine:task-configuration, debusine:task-history, "
            "debusine:workflow-internal",
            stderr.getvalue(),
        )

    def test_invalid_collection_identifier_wrong_format(self) -> None:
        stderr = io.StringIO()

        with (
            self.assertRaisesRegex(SystemExit, "2"),
            contextlib.redirect_stderr(stderr),
        ):
            self.create_command(
                [
                    "collection",
                    "relation",
                    "list",
                    "--from",
                    "a@",
                    "--to",
                    "trixie@debian:suite",
                    "--type",
                    CollectionRelationType.SUITE_REQUIRES,
                    "--workspace",
                    "workspace",
                ],
            )

        self.assertIn(
            "Invalid collection identifier: 'a@'. "
            "Expected format is 'name@category'",
            stderr.getvalue(),
        )


class EditTests(BaseCliTests):
    """Tests for editing collection relations."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.source = "personal@debian:suite"
        self.relation_type = CollectionRelationType.SUITE_REQUIRES
        self.current_targets = [
            CollectionIdentifierResponse(
                name="trixie", category=CollectionCategory.SUITE
            ),
            CollectionIdentifierResponse(
                name="experimental", category=CollectionCategory.SUITE
            ),
        ]
        self.etag = '"87d154802e0803b953e5fee3e"'

    def create_edit_command(
        self, *args: str, relation_type: None | str = None
    ) -> collection_relations.Edit:
        if relation_type is None:
            relation_type = self.relation_type

        command = self.create_command(
            [
                "collection",
                "relation",
                "edit",
                self.source,
                relation_type,
                "--workspace",
                "workspace",
                *args,
            ]
        )
        assert isinstance(command, collection_relations.Edit)
        return command

    def current_relations(self) -> list[CollectionRelationResponse]:
        return [
            CollectionRelationResponse(
                source_collection=CollectionIdentifierResponse(
                    name="personal", category=CollectionCategory.SUITE
                ),
                target_collection=target,
                type=self.relation_type,
                position=index + 1,
            )
            for index, target in enumerate(self.current_targets)
        ]

    def capture_run_mock_api(
        self,
        *args: str,
        assert_system_exit_code: int | None = None,
        collection_relation_set_side_effect: Any | None = None,
    ) -> tuple[mock.MagicMock, mock.MagicMock, str, str]:
        command = self.create_edit_command(*args)

        if collection_relation_set_side_effect is not None:
            set_side_effect = [collection_relation_set_side_effect]
        else:
            set_side_effect = [
                CollectionRelationSetResponse(targets=self.current_targets)
            ]

        with (
            mock.patch.object(
                command.debusine,
                "collection_relation_list_for_edit",
                return_value=(self.current_relations(), self.etag),
            ) as collection_relation_list_mock,
            mock.patch.object(
                command.debusine,
                "collection_relation_set",
                side_effect=set_side_effect,
            ) as collection_relation_set_mock,
        ):
            stderr, stdout = self.capture_output(
                command.run, assert_system_exit_code=assert_system_exit_code
            )

        return (
            collection_relation_list_mock,
            collection_relation_set_mock,
            stderr,
            stdout,
        )

    def test_edit_incompatible_other_options(self) -> None:
        command = self.create_edit_command(
            "--append",
            "bookworm@debian:suite",
            "--edit",
            relation_type="suite-requires",
        )

        stderr, stdout = self.capture_output(
            command.run, assert_system_exit_code=1
        )

        self.assertEqual(
            stderr,
            "--edit cannot be combined with -"
            "-append, --prepend, --remove, or --set\n",
        )
        self.assertEqual(stdout, "")

    def test_no_change(self) -> None:
        list_, set_, stderr, stdout = self.capture_run_mock_api()

        self.assertEqual(stderr, "")
        self.assertEqual(stdout, "")
        list_.assert_called_once_with(
            "workspace",
            source=self.source,
            relation_type=self.relation_type,
        )
        set_.assert_not_called()

    def test_append(self) -> None:
        list_, set_, stderr, stdout = self.capture_run_mock_api(
            "--append", "bookworm@debian:suite"
        )

        self.assertEqual(stderr, "")
        list_.assert_called_once()
        set_.assert_called_once_with(
            "workspace",
            source=self.source,
            relation_type=self.relation_type,
            targets=[
                "trixie@debian:suite",
                "experimental@debian:suite",
                "bookworm@debian:suite",
            ],
            if_match=self.etag,
        )
        self.assertEqual(
            yaml.safe_load(stdout),
            {
                "targets": [
                    target.model_dump() for target in self.current_targets
                ]
            },
        )
        self.assertEqual(stderr, "")

    def test_prepend_remove_set(self) -> None:
        list_, set_, stderr, stdout = self.capture_run_mock_api(
            "--set",
            "bookworm@debian:suite",
            "trixie@debian:suite",
            "--prepend",
            "sid@debian:suite",
            "--remove",
            "trixie@debian:suite",
        )

        stdout_parsed = yaml.safe_load(stdout)

        self.assertEqual(
            stdout_parsed,
            {
                "targets": [
                    target.model_dump() for target in self.current_targets
                ]
            },
        )

        self.assertEqual(stderr, "")
        list_.assert_called_once()
        set_.assert_called_once_with(
            "workspace",
            source=self.source,
            relation_type=self.relation_type,
            targets=["sid@debian:suite", "bookworm@debian:suite"],
            if_match=self.etag,
        )

    def test_edit_no_changes(self) -> None:
        with mock.patch(
            "debusine.utils.yaml.YamlEditor.edit_apply_loop",
            autospec=True,
            return_value=None,
        ) as edit_apply_loop_mock:
            list_, set_, stderr, stdout = self.capture_run_mock_api("--edit")

        edit_apply_loop_mock.assert_called_once()

        # Listing called once
        list_.assert_called_once_with(
            "workspace",
            source="personal@debian:suite",
            relation_type=CollectionRelationType.SUITE_REQUIRES,
        )

        # No set called
        set_.assert_not_called()

        self.assertEqual(stderr, "")
        self.assertEqual(
            stdout, "- trixie@debian:suite\n- experimental@debian:suite\n"
        )

    def test_edit(self) -> None:
        edited_targets = ["another@debian:suite"]

        def fake_edit(editor: YamlEditor[list[Any]]) -> bool:
            editor.value = edited_targets
            return True

        with mock.patch(
            "debusine.utils.yaml.YamlEditor.edit",
            autospec=True,
            side_effect=fake_edit,
        ) as edit_mock:
            list_, set_, stderr, stdout = self.capture_run_mock_api("--edit")

        edit_mock.assert_called_once()

        list_.assert_called_once_with(
            "workspace",
            source="personal@debian:suite",
            relation_type=CollectionRelationType.SUITE_REQUIRES,
        )

        set_.assert_called_once_with(
            "workspace",
            source="personal@debian:suite",
            relation_type=CollectionRelationType.SUITE_REQUIRES,
            targets=["another@debian:suite"],
            if_match=self.etag,
        )

        self.assertEqual(
            yaml.safe_load(stdout),
            {
                "targets": [
                    target.model_dump() for target in self.current_targets
                ]
            },
        )
        self.assertEqual(stderr, "")

    def test_api_fail(self) -> None:
        _list, _set, stderr, stdout = self.capture_run_mock_api(
            "--prepend",
            "sid@debian:suite",
            "--remove",
            "trixie@debian:suite",
            "--set",
            "bookworm@debian:suite",
            "trixie@debian:suite",
            assert_system_exit_code=3,
            collection_relation_set_side_effect=DebusineError(title="Error DB"),
        )
        stderr_parsed = yaml.safe_load(stderr)

        self.assertEqual(stderr_parsed["error"]["title"], "Error DB")
        self.assertEqual(stdout, "")
