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

"""Unit tests for task models."""

import re
from datetime import datetime, timezone
from typing import get_args
from unittest import TestCase

import debusine.tasks.models as task_models
from debusine.artifacts.models import ArtifactCategory


class TaskTypeTests(TestCase):
    """Tests for TaskTypes (to exercise classproperty)."""

    def test_choices(self) -> None:
        """Test that choices returns the expected list."""
        TaskTypes = task_models.TaskTypes
        self.assertEqual(
            TaskTypes.choices,
            [
                (TaskTypes.WORKER, TaskTypes.WORKER),
                (TaskTypes.SERVER, TaskTypes.SERVER),
                (TaskTypes.INTERNAL, TaskTypes.INTERNAL),
                (TaskTypes.WORKFLOW, TaskTypes.WORKFLOW),
                (TaskTypes.SIGNING, TaskTypes.SIGNING),
            ],
        )


class LookupTests(TestCase):
    """Tests for collection item lookups."""

    def test_string(self) -> None:
        """Single-item lookups accept strings."""
        self.assertIsInstance(
            "debian@debian:archive",
            get_args(task_models.LookupSingle),
        )

    def test_integer(self) -> None:
        """Single-item lookups accept integers."""
        self.assertIsInstance(1, get_args(task_models.LookupSingle))

    def test_dict(self) -> None:
        """Multiple-item lookups accept dicts."""
        raw_lookup = {"collection": "debian@debian:archive"}
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict(),
            {
                "__root__": (
                    {
                        "collection": "debian@debian:archive",
                        "child_type": "artifact",
                        "category": None,
                        "name_matcher": None,
                        "data_matchers": (),
                    },
                )
            },
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_child_type(self) -> None:
        """`child_type` is accepted."""
        raw_lookup = {
            "collection": "debian@debian:archive",
            "child_type": "collection",
        }
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict(),
            {
                "__root__": (
                    {
                        "collection": "debian@debian:archive",
                        "child_type": "collection",
                        "category": None,
                        "name_matcher": None,
                        "data_matchers": (),
                    },
                )
            },
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_category(self) -> None:
        """`child_type` is accepted."""
        raw_lookup = {
            "collection": "bookworm@debian:suite",
            "category": ArtifactCategory.SOURCE_PACKAGE,
        }
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict(),
            {
                "__root__": (
                    {
                        "collection": "bookworm@debian:suite",
                        "child_type": "artifact",
                        "category": ArtifactCategory.SOURCE_PACKAGE,
                        "name_matcher": None,
                        "data_matchers": (),
                    },
                )
            },
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_name_matcher(self) -> None:
        """A `name` lookup is accepted."""
        raw_lookup = {"collection": "debian@debian:archive", "name": "foo"}
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict()["__root__"][0]["name_matcher"],
            task_models.CollectionItemMatcher(
                kind=task_models.CollectionItemMatcherKind.EXACT, value="foo"
            ),
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_name_matcher_rejects_exact(self) -> None:
        """`name__exact` is rejected."""
        with self.assertRaisesRegex(
            ValueError, "Unrecognized matcher: name__exact"
        ):
            task_models.LookupMultiple.parse_obj(
                {"collection": "debian@debian:archive", "name__exact": "foo"}
            )

    def test_name_matcher_lookup(self) -> None:
        """Lookups such as `name__contains` are accepted."""
        raw_lookup = {
            "collection": "debian@debian:archive",
            "name__contains": "foo",
        }
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict()["__root__"][0]["name_matcher"],
            task_models.CollectionItemMatcher(
                kind=task_models.CollectionItemMatcherKind.CONTAINS, value="foo"
            ),
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_name_matcher_rejects_extra_segments(self) -> None:
        """A `name` lookup followed by two segments is rejected."""
        with self.assertRaisesRegex(
            ValueError, "Unrecognized matcher: name__foo__bar"
        ):
            task_models.LookupMultiple.parse_obj(
                {"collection": "debian@debian:archive", "name__foo__bar": "foo"}
            )

    def test_name_matcher_conflict(self) -> None:
        """Conflicting name lookups are rejected."""
        with self.assertRaisesRegex(
            ValueError,
            re.escape(
                "Conflicting matchers: ['name', 'name__contains', "
                "'name__endswith', 'name__startswith']"
            ),
        ):
            task_models.LookupMultiple.parse_obj(
                {
                    "collection": "debian@debian:archive",
                    "name": "foo",
                    "name__contains": "foo",
                    "name__endswith": "foo",
                    "name__startswith": "foo",
                }
            )

    def test_data_matcher(self) -> None:
        """A `data__KEY` lookup is accepted."""
        raw_lookup = {
            "collection": "debian@debian:archive",
            "data__package": "foo",
        }
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict()["__root__"][0]["data_matchers"],
            (
                (
                    "package",
                    task_models.CollectionItemMatcher(
                        kind=task_models.CollectionItemMatcherKind.EXACT,
                        value="foo",
                    ),
                ),
            ),
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_data_matcher_rejects_exact(self) -> None:
        """`data__KEY__exact` is rejected."""
        with self.assertRaisesRegex(
            ValueError, "Unrecognized matcher: data__package__exact"
        ):
            task_models.LookupMultiple.parse_obj(
                {
                    "collection": "debian@debian:archive",
                    "data__package__exact": "foo",
                }
            )

    def test_data_matcher_lookup(self) -> None:
        """Lookups such as `data__KEY__contains` are accepted."""
        raw_lookup = {
            "collection": "debian@debian:archive",
            "data__package__contains": "foo",
            "data__version__startswith": "1.",
        }
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict()["__root__"][0]["data_matchers"],
            (
                (
                    "package",
                    task_models.CollectionItemMatcher(
                        kind=task_models.CollectionItemMatcherKind.CONTAINS,
                        value="foo",
                    ),
                ),
                (
                    "version",
                    task_models.CollectionItemMatcher(
                        kind=task_models.CollectionItemMatcherKind.STARTSWITH,
                        value="1.",
                    ),
                ),
            ),
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_data_matcher_rejects_extra_segments(self) -> None:
        """A `data` lookup followed by three segments is rejected."""
        with self.assertRaisesRegex(
            ValueError, "Unrecognized matcher: data__package__foo__bar"
        ):
            task_models.LookupMultiple.parse_obj(
                {
                    "collection": "debian@debian:archive",
                    "data__package__foo__bar": "foo",
                }
            )

    def test_data_matcher_conflict(self) -> None:
        """Conflicting data lookups are rejected."""
        with self.assertRaisesRegex(
            ValueError,
            re.escape(
                "Conflicting matchers: ['data__package', "
                "'data__package__contains', 'data__package__endswith', "
                "'data__package__startswith']"
            ),
        ):
            task_models.LookupMultiple.parse_obj(
                {
                    "collection": "debian@debian:archive",
                    "data__package": "foo",
                    "data__package__contains": "foo",
                    "data__package__endswith": "foo",
                    "data__package__startswith": "foo",
                }
            )

    def test_list(self) -> None:
        """Multiple-item lookups accept lists."""
        raw_lookup = [
            {"collection": "debian@debian:archive"},
            {"collection": "kali@debian:archive"},
            "123@artifacts",
            124,
        ]
        lookup = task_models.LookupMultiple.parse_obj(raw_lookup)
        self.assertEqual(
            lookup.dict(),
            {
                "__root__": (
                    {
                        "collection": "debian@debian:archive",
                        "child_type": "artifact",
                        "category": None,
                        "name_matcher": None,
                        "data_matchers": (),
                    },
                    {
                        "collection": "kali@debian:archive",
                        "child_type": "artifact",
                        "category": None,
                        "name_matcher": None,
                        "data_matchers": (),
                    },
                    "123@artifacts",
                    124,
                )
            },
        )
        self.assertEqual(lookup.export(), raw_lookup)

    def test_wrong_type(self) -> None:
        """Multiple-item lookups only accept dicts or lists."""
        with self.assertRaisesRegex(
            ValueError,
            "Lookup of multiple collection items must be a dictionary or a "
            "list",
        ):
            task_models.LookupMultiple.parse_obj("foo")


class EnumTests(TestCase):
    """Tests for SystemImageBuild task data."""

    def test_enums_str(self):
        """Test enum stringification."""
        for enum_cls in (
            task_models.AutopkgtestNeedsInternet,
            task_models.BlhcFlags,
            task_models.DiskImageFormat,
            task_models.LintianFailOnSeverity,
            task_models.MmDebstrapVariant,
            task_models.SbuildBuildComponent,
            task_models.SystemBootstrapRepositoryCheckSignatureWith,
            task_models.SystemBootstrapRepositoryType,
            task_models.SystemBootstrapOptionsVariant,
        ):
            with self.subTest(enum_cls=enum_cls):
                for el in enum_cls:
                    with self.subTest(el=el):
                        self.assertEqual(str(el), el.value)


class LintianDataTests(TestCase):
    """Tests for Lintian task data."""

    def test_input_validation(self):
        """Test LintianInput validation."""
        task_models.LintianInput(source_artifact=1)
        task_models.LintianInput(binary_artifacts=[1])
        task_models.LintianInput(binary_artifacts=[1, 2])

        error_msg = "One of source_artifact or binary_artifacts must be set"
        with self.assertRaisesRegex(ValueError, error_msg):
            task_models.LintianInput()
        with self.assertRaisesRegex(ValueError, error_msg):
            task_models.LintianInput(binary_artifacts=[])


class SystemBootstrapDataTests(TestCase):
    """Tests for SystemBootstrap task data."""

    def test_repository_validation(self):
        """Test SystemBootstrapRepository validation."""
        common_kwargs = {
            "mirror": "https://deb.debian.org/deb",
            "suite": "bookworm",
        }

        task_models.SystemBootstrapRepository(**common_kwargs)

        error_msg = "ensure this value has at least 1 items"
        with self.assertRaisesRegex(ValueError, error_msg):
            task_models.SystemBootstrapRepository(types=[], **common_kwargs)

        task_models.SystemBootstrapRepository(
            check_signature_with="external",
            keyring={"url": "https://example.com/keyring_file.txt"},
            **common_kwargs,
        )

        error_msg = (
            "repository requires 'keyring':"
            " 'check_signature_with' is set to 'external'"
        )
        with self.assertRaisesRegex(ValueError, error_msg):
            task_models.SystemBootstrapRepository(
                check_signature_with="external", **common_kwargs
            )

    def test_repository_validation_duplicate_list_items(self):
        """Test SystemBootstrapRepository validation of duplicate list items."""
        common_kwargs = {
            "mirror": "https://deb.debian.org/deb",
            "suite": "bookworm",
        }

        task_models.SystemBootstrapRepository(**common_kwargs)

        error_msg = "the list has duplicated items"
        with self.assertRaisesRegex(ValueError, error_msg):
            task_models.SystemBootstrapRepository(
                types=["deb", "deb"], **common_kwargs
            )
        with self.assertRaisesRegex(ValueError, error_msg):
            task_models.SystemBootstrapRepository(
                components=["main", "main"], **common_kwargs
            )


class AutopkgtestDataTests(TestCase):
    """Tests for Autopkgtest task data."""

    def test_validation(self):
        """Test AutopkgtestData validation."""
        common_kwargs = {
            "input": {
                "source_artifact": 1,
                "binary_artifacts": [1, 2],
            },
            "host_architecture": "amd64",
            "environment": 1,
        }

        task_models.AutopkgtestData(**common_kwargs)

        for field in ("global", "factor", "short", "install", "test", "copy"):
            with self.subTest(field=field):
                error_msg = (
                    f"timeout -> {field}\n"
                    f"  ensure this value is greater than or equal to 0"
                )
                with self.assertRaisesRegex(ValueError, error_msg):
                    task_models.AutopkgtestData(
                        timeout={field: -1}, **common_kwargs
                    )


class ImageCacheUsageLogEntryTests(TestCase):
    """Tests for ImageCacheUsageLogEntry."""

    def test_timestamp_validation_succeeds(self):
        """Test ImageCacheUsageLogEntry accepts aware timestamp."""
        task_models.ImageCacheUsageLogEntry(
            filename="foo", timestamp=datetime.now(timezone.utc)
        )

    def test_timestamp_validation_fails(self):
        """Test ImageCacheUsageLogEntry rejects naive timestamp."""
        with self.assertRaisesRegex(ValueError, "timestamp is TZ-naive"):
            task_models.ImageCacheUsageLogEntry(
                filename="foo", timestamp=datetime.now()
            )


class ImageCacheUsageLogTests(TestCase):
    """Tests for ImageCacheUsageLog."""

    def test_version_validation_succeeds(self):
        """Test ImageCacheUsageLog accepts version 1."""
        task_models.ImageCacheUsageLog(version=1)

    def test_version_validation_fails(self):
        """Test ImageCacheUsageLog rejects version 99."""
        with self.assertRaisesRegex(ValueError, "Unknown usage log version 99"):
            task_models.ImageCacheUsageLog(version=99)


class EventReactionsTests(TestCase):
    """Tests for EventReactions."""

    def test_deserialize(self):
        """Test basic deserialization."""
        task_models.EventReactions.parse_raw(
            """
            {
                "on_success": [
                    {
                        "action": "update-collection-with-artifacts",
                        "collection": "internal@collections",
                        "artifact_filters": {
                            "category": "debian:binary-package",
                            "data__deb_fields__Section": "python"
                        },
                        "name_template": "{package}_{version}",
                        "variables": {
                            "package": "deb_fields.Package",
                            "version": "deb_fields.Version"
                        }
                    }
                ],
                "on_failure": [
                    {
                        "action": "send-notification",
                        "channel": "admin-team",
                        "data": {
                            "cc": [ "qa-team@example.org " ],
                            "subject": "Work request ${work_request_id}"
                        }
                    },
                    {
                        "action": "send-notification",
                        "channel": "security-team"
                    }
                ]
            }
        """
        )
