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

"""Test views for package archives."""

import datetime as dt
import hashlib
from abc import ABC
from operator import attrgetter
from pathlib import PurePath
from typing import ClassVar, overload

from debian.copyright import parse_multiline
from debian.deb822 import Deb822
from django.conf import settings
from django.test import override_settings
from django.utils import timezone
from django.utils.cache import has_vary_header
from pgpy import PGPKey, PGPKeyring
from pgpy.constants import EllipticCurveOID, PubKeyAlgorithm
from rest_framework import status

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.assets.models import KeyPurpose
from debusine.db.models import Artifact, Collection, CollectionItem
from debusine.db.playground import scenarios
from debusine.test.django import TestCase, TestResponseType


class ArchiveViewTests(TestCase, ABC):
    """Helper methods for testing archive views."""

    playground_memory_file_store = False
    scenario = scenarios.DefaultContext()

    archive: ClassVar[Collection]
    suites: ClassVar[dict[str, Collection]]

    # Path used for some tests of generic behaviour in each subclass.
    example_path: str
    # Are paths tested here normally mutable?
    expect_mutable_no_archive: bool
    expect_mutable: bool

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common test data."""
        super().setUpTestData()
        cls.archive = cls.scenario.workspace.get_singleton_collection(
            user=cls.scenario.user, category=CollectionCategory.ARCHIVE
        )
        cls.suites = {
            name: cls.playground.create_collection(
                name, CollectionCategory.SUITE, workspace=cls.scenario.workspace
            )
            for name in ("bookworm", "trixie")
        }

    @overload
    def format_snapshot(self, snapshot: None) -> None: ...

    @overload
    def format_snapshot(self, snapshot: dt.datetime) -> str: ...

    def format_snapshot(self, snapshot: dt.datetime | None) -> str | None:
        """Format a snapshot timestamp as a URL segment."""
        return None if snapshot is None else snapshot.strftime("%Y%m%dT%H%M%SZ")

    def archive_file_get(
        self,
        *,
        workspace_name: str | None = None,
        snapshot: dt.datetime | None = None,
        path: str,
    ) -> TestResponseType:
        """Get a file from an archive."""
        fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_PRIMARY_FQDN
        scope_name = self.scenario.scope.name
        if workspace_name is None:
            workspace_name = self.scenario.workspace.name
        url = f"/{scope_name}/{workspace_name}"
        if snapshot is not None:
            url += "/" + self.format_snapshot(snapshot)
        url += f"/{path}"
        return self.client.get(url, HTTP_HOST=fqdn)

    def assert_has_caching_headers(
        self, response: TestResponseType, *, mutable: bool
    ) -> None:
        self.assertEqual(
            response.headers["Cache-Control"],
            "max-age=1800, proxy-revalidate" if mutable else "max-age=31536000",
        )
        # https://github.com/typeddjango/django-stubs/pull/2704
        self.assertTrue(
            has_vary_header(response, "Authorization")  # type: ignore[arg-type]
        )

    def test_workspace_not_found(self) -> None:
        for snapshot in (None, timezone.now()):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(
                    workspace_name="nonexistent",
                    snapshot=snapshot,
                    path=self.example_path,
                )

                self.assertResponseProblem(
                    response,
                    "Workspace not found",
                    detail_pattern=(
                        "Workspace nonexistent not found in scope debusine"
                    ),
                    status_code=status.HTTP_404_NOT_FOUND,
                )
                self.assert_has_caching_headers(
                    response,
                    mutable=self.expect_mutable_no_archive and snapshot is None,
                )

    def test_archive_not_found(self) -> None:
        self.archive.child_items.all().delete()
        self.archive.delete()

        for snapshot in (None, timezone.now()):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(
                    snapshot=snapshot, path=self.example_path
                )

                self.assertResponseProblem(
                    response,
                    "Archive not found",
                    detail_pattern=(
                        f"No {CollectionCategory.ARCHIVE} collection found in "
                        f"{self.scenario.workspace}"
                    ),
                    status_code=status.HTTP_404_NOT_FOUND,
                )
                self.assert_has_caching_headers(
                    response,
                    mutable=self.expect_mutable_no_archive and snapshot is None,
                )


class ArchiveFileViewTests(ArchiveViewTests, ABC):
    """Helper methods for testing archive file views."""

    def create_repository_index(
        self, path: str, contents: bytes | None = None
    ) -> Artifact:
        """Create a minimal ``debian:repository-index`` artifact."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.REPOSITORY_INDEX,
            workspace=self.scenario.workspace,
            paths=[path] if contents is None else {path: contents},
            create_files=True,
        )
        return artifact

    def assert_response_ok(
        self, response: TestResponseType, *, contents: bytes, mutable: bool
    ) -> None:
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            b"".join(getattr(response, "streaming_content")), contents
        )
        self.assert_has_caching_headers(response, mutable=mutable)

    def test_file_not_found(self) -> None:
        for snapshot in (None, timezone.now()):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(
                    snapshot=snapshot, path=self.example_path
                )

                self.assertResponseProblem(
                    response,
                    "No FileInArtifact matches the given query.",
                    status_code=status.HTTP_404_NOT_FOUND,
                )
                self.assert_has_caching_headers(
                    response, mutable=self.expect_mutable and snapshot is None
                )


class ArchiveRootViewTests(ArchiveViewTests):
    """Test retrieving the root of an archive."""

    example_path = ""
    expect_mutable_no_archive = True
    expect_mutable = True

    def test_current(self) -> None:
        self.playground.create_collection(
            "not-exported",
            CollectionCategory.SUITE,
            workspace=self.scenario.workspace,
            data={"exported": False},
        )

        response = self.archive_file_get(path="")

        tree = self.assertResponseHTML(response)
        archive_suites = tree.xpath("//div[@id='archive-suites']")[0]
        self.assertTextContentEqual(
            archive_suites.p, "This archive contains the following suites:"
        )
        self.assertEqual(len(archive_suites.ul.li), len(self.suites))
        fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_FQDN
        workspace = self.scenario.workspace
        for li, suite in zip(
            archive_suites.ul.li,
            sorted(self.suites.values(), key=attrgetter("name")),
        ):
            self.assertTextContentEqual(li.p, f"{suite.name}: ")
            self.assertEqual(
                Deb822("".join(li.pre.itertext())),
                {
                    "Types": "deb",
                    "URIs": (
                        f"http://{fqdn}/{workspace.scope.name}/{workspace.name}"
                    ),
                    "Suites": suite.name,
                },
            )
        self.assert_has_caching_headers(response, mutable=True)

    def test_current_signing_keys(self) -> None:
        keys = [
            PGPKey.new(PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519).pubkey
            for _ in range(len(self.suites))
        ]
        sorted_suites = sorted(self.suites.values(), key=attrgetter("name"))
        for suite, key in zip(sorted_suites, keys):
            self.playground.create_asset_usage(
                resource=self.playground.create_signing_key_asset(
                    purpose=KeyPurpose.OPENPGP_REPOSITORY,
                    fingerprint=key.fingerprint,
                    public_key=str(key),
                    workspace=suite.workspace,
                ),
                workspace=suite.workspace,
            )
            suite.data["signing_keys"] = [key.fingerprint]
            suite.save()

        response = self.archive_file_get(path="")

        tree = self.assertResponseHTML(response)
        archive_suites = tree.xpath("//div[@id='archive-suites']")[0]
        self.assertTextContentEqual(
            archive_suites.p, "This archive contains the following suites:"
        )
        self.assertEqual(len(archive_suites.ul.li), len(self.suites))
        fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_FQDN
        workspace = self.scenario.workspace
        for li, suite, key in zip(archive_suites.ul.li, sorted_suites, keys):
            self.assertTextContentEqual(li.p, f"{suite.name}: ")
            suite_deb822 = Deb822("".join(li.pre.itertext()))
            self.assertCountEqual(
                suite_deb822.keys(), ["Types", "URIs", "Suites", "Signed-By"]
            )
            self.assertDictContainsAll(
                suite_deb822,
                {
                    "Types": "deb",
                    "URIs": (
                        f"http://{fqdn}/{workspace.scope.name}/{workspace.name}"
                    ),
                    "Suites": suite.name,
                },
            )
            self.assertEqual(
                PGPKeyring(
                    parse_multiline(suite_deb822["Signed-By"])
                ).fingerprints(),
                {key.fingerprint},
            )
        self.assert_has_caching_headers(response, mutable=True)

    def test_snapshot(self) -> None:
        now = timezone.now()
        one_month_ago = (now - dt.timedelta(days=30)).replace(microsecond=0)

        response = self.archive_file_get(snapshot=one_month_ago, path="")

        # We always list all exported suites in the workspace, regardless of
        # when they were added to the archive, so we don't bother testing
        # different snapshot times here.
        tree = self.assertResponseHTML(response)
        archive_suites = tree.xpath("//div[@id='archive-suites']")[0]
        self.assertTextContentEqual(
            archive_suites.p, "This archive contains the following suites:"
        )
        self.assertEqual(len(archive_suites.ul.li), len(self.suites))
        fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_FQDN
        workspace = self.scenario.workspace
        for li, suite in zip(
            archive_suites.ul.li,
            sorted(self.suites.values(), key=attrgetter("name")),
        ):
            self.assertTextContentEqual(li.p, f"{suite.name}: ")
            self.assertEqual(
                Deb822("".join(li.pre.itertext())),
                {
                    "Types": "deb",
                    "URIs": (
                        f"http://{fqdn}/{workspace.scope.name}/{workspace.name}"
                    ),
                    "Suites": suite.name,
                    "Snapshot": self.format_snapshot(one_month_ago),
                },
            )
        self.assert_has_caching_headers(response, mutable=False)

    def test_archive_empty(self) -> None:
        self.archive.child_items.all().delete()
        for suite in self.suites.values():
            suite.delete()

        for snapshot in (None, timezone.now()):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(snapshot=snapshot, path="")

                tree = self.assertResponseHTML(response)
                archive_suites = tree.xpath("//div[@id='archive-suites']")[0]
                self.assertTextContentEqual(
                    archive_suites, "This archive is empty."
                )
                self.assert_has_caching_headers(
                    response, mutable=snapshot is None
                )

    @override_settings(
        ALLOWED_HOSTS=["*"],
        DEBUSINE_DEBIAN_ARCHIVE_FQDN=["deb.example.com", "deb.example.org"],
        DEBUSINE_DEBIAN_ARCHIVE_PRIMARY_FQDN="deb.example.com",
    )
    def test_override_archive_fqdn(self) -> None:
        response = self.archive_file_get(path="")

        tree = self.assertResponseHTML(response)
        archive_suites = tree.xpath("//div[@id='archive-suites']")[0]
        workspace = self.scenario.workspace
        for li in archive_suites.ul.li:
            self.assertEqual(
                Deb822("".join(li.pre.itertext()))["URIs"],
                f"http://deb.example.com/"
                f"{workspace.scope.name}/{workspace.name}",
            )


class SuiteRootViewTests(ArchiveViewTests):
    """Test retrieving the root of a suite."""

    example_path = "dists/bookworm"
    expect_mutable_no_archive = True
    expect_mutable = True

    def test_current(self) -> None:
        suite = self.suites["bookworm"]
        suite.data["components"] = ["main", "contrib", "non-free"]
        suite.data["architectures"] = ["amd64"]
        suite.data["release_fields"] = {"Description": "Something special"}
        suite.save()

        response = self.archive_file_get(path="dists/bookworm/")

        tree = self.assertResponseHTML(response)
        self.assertTextContentEqual(
            tree.xpath("//div[@id='suite-summary']")[0],
            "bookworm: Something special",
        )
        suite_architectures = tree.xpath("//div[@id='suite-architectures']")[0]
        self.assertEqual(
            [li.text for li in suite_architectures.ul.li], ["amd64"]
        )
        suite_release_fields = tree.xpath("//div[@id='suite-release-fields']")[
            0
        ]
        self.assertEqual(
            [
                (element.tag, element.text.strip())
                for element in suite_release_fields.dl.iterchildren()
            ],
            [("dt", "Description"), ("dd", "Something special")],
        )
        suite_apt = tree.xpath("//div[@id='suite-apt']")[0]
        fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_FQDN
        workspace = self.scenario.workspace
        self.assertEqual(
            Deb822("".join(suite_apt.pre.itertext())),
            {
                "Types": "deb",
                "URIs": (
                    f"http://{fqdn}/{workspace.scope.name}/{workspace.name}"
                ),
                "Suites": "bookworm",
                "Components": "main contrib non-free",
            },
        )
        self.assert_has_caching_headers(response, mutable=True)

    def test_current_signing_keys(self) -> None:
        suite = self.suites["bookworm"]
        keys = [
            PGPKey.new(PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519).pubkey
            for _ in range(2)
        ]
        for key in keys:
            self.playground.create_asset_usage(
                resource=self.playground.create_signing_key_asset(
                    purpose=KeyPurpose.OPENPGP_REPOSITORY,
                    fingerprint=key.fingerprint,
                    public_key=str(key),
                    workspace=suite.workspace,
                ),
                workspace=suite.workspace,
            )
        suite.data["signing_keys"] = [key.fingerprint for key in keys]
        suite.save()

        response = self.archive_file_get(path="dists/bookworm/")

        tree = self.assertResponseHTML(response)
        self.assertTextContentEqual(
            tree.xpath("//div[@id='suite-summary']")[0], "bookworm:"
        )
        self.assertFalse(tree.xpath("//div[@id='suite-architectures']"))
        self.assertFalse(tree.xpath("//div[@id='suite-release-fields']"))
        fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_FQDN
        workspace = self.scenario.workspace
        suite_apt = tree.xpath("//div[@id='suite-apt']")[0]
        suite_deb822 = Deb822("".join(suite_apt.pre.itertext()))
        self.assertCountEqual(
            suite_deb822.keys(), ["Types", "URIs", "Suites", "Signed-By"]
        )
        self.assertDictContainsAll(
            suite_deb822,
            {
                "Types": "deb",
                "URIs": (
                    f"http://{fqdn}/{workspace.scope.name}/{workspace.name}"
                ),
                "Suites": "bookworm",
            },
        )
        self.assertEqual(
            PGPKeyring(
                parse_multiline(suite_deb822["Signed-By"])
            ).fingerprints(),
            {key.fingerprint for key in keys},
        )
        self.assert_has_caching_headers(response, mutable=True)

    def test_snapshot(self) -> None:
        now = timezone.now()
        one_month_ago = (now - dt.timedelta(days=30)).replace(microsecond=0)
        self.archive.child_items.filter(
            category=CollectionCategory.SUITE, name="bookworm"
        ).update(created_at=one_month_ago)

        # We always show an exported suite, regardless of when it was added
        # to the archive, so we don't bother testing different snapshot
        # times here.
        response = self.archive_file_get(
            snapshot=one_month_ago, path="dists/bookworm/"
        )

        tree = self.assertResponseHTML(response)
        self.assertTextContentEqual(
            tree.xpath("//div[@id='suite-summary']")[0], "bookworm:"
        )
        self.assertFalse(tree.xpath("//div[@id='suite-architectures']"))
        self.assertFalse(tree.xpath("//div[@id='suite-release-fields']"))
        fqdn = settings.DEBUSINE_DEBIAN_ARCHIVE_FQDN
        suite_apt = tree.xpath("//div[@id='suite-apt']")[0]
        workspace = self.scenario.workspace
        self.assertEqual(
            Deb822("".join(suite_apt.pre.itertext())),
            {
                "Types": "deb",
                "URIs": (
                    f"http://{fqdn}/{workspace.scope.name}/{workspace.name}"
                ),
                "Suites": "bookworm",
                "Snapshot": self.format_snapshot(one_month_ago),
            },
        )
        self.assert_has_caching_headers(response, mutable=False)

    def test_suite_not_found(self) -> None:
        for snapshot in (None, timezone.now()):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(
                    snapshot=snapshot, path="dists/nonexistent/"
                )

                self.assertResponseProblem(
                    response,
                    "Suite not found",
                    status_code=status.HTTP_404_NOT_FOUND,
                )
                self.assert_has_caching_headers(
                    response, mutable=snapshot is None
                )

    def test_suite_not_exported(self) -> None:
        self.playground.create_collection(
            "not-exported",
            CollectionCategory.SUITE,
            workspace=self.scenario.workspace,
            data={"exported": False},
        )

        for snapshot in (None, timezone.now()):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(
                    snapshot=snapshot, path="dists/not-exported/"
                )

                self.assertResponseProblem(
                    response,
                    "Suite not found",
                    status_code=status.HTTP_404_NOT_FOUND,
                )
                self.assert_has_caching_headers(
                    response, mutable=snapshot is None
                )


class SigningKeysViewTests(ArchiveViewTests):
    """Test retrieving signing keys for an archive or a suite."""

    example_path = "signing-keys.asc"
    expect_mutable_no_archive = True
    expect_mutable = True

    def test_archive(self) -> None:
        keys = [
            PGPKey.new(PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519).pubkey
            for _ in range(len(self.suites))
        ]
        for suite, key in zip(self.suites.values(), keys):
            self.playground.create_asset_usage(
                resource=self.playground.create_signing_key_asset(
                    purpose=KeyPurpose.OPENPGP_REPOSITORY,
                    fingerprint=key.fingerprint,
                    public_key=str(key),
                    workspace=suite.workspace,
                ),
                workspace=suite.workspace,
            )
            suite.data["signing_keys"] = [key.fingerprint]
            suite.save()

        response = self.archive_file_get(path="signing-keys.asc")

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.headers["Content-Type"],
            "application/pgp-keys; charset=utf-8",
        )
        self.assertEqual(
            PGPKeyring(response.content).fingerprints(),
            {key.fingerprint for key in keys},
        )
        self.assert_has_caching_headers(response, mutable=True)

    def test_suite(self) -> None:
        suite = self.suites["bookworm"]
        keys = [
            PGPKey.new(PubKeyAlgorithm.EdDSA, EllipticCurveOID.Ed25519).pubkey
            for _ in range(len(self.suites))
        ]
        for key in keys:
            self.playground.create_asset_usage(
                resource=self.playground.create_signing_key_asset(
                    purpose=KeyPurpose.OPENPGP_REPOSITORY,
                    fingerprint=key.fingerprint,
                    public_key=str(key),
                    workspace=suite.workspace,
                ),
                workspace=suite.workspace,
            )
        suite.data["signing_keys"] = [key.fingerprint for key in keys]
        suite.save()

        response = self.archive_file_get(path="dists/bookworm/signing-keys.asc")

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.headers["Content-Type"],
            "application/pgp-keys; charset=utf-8",
        )
        self.assertEqual(
            PGPKeyring(response.content).fingerprints(),
            {key.fingerprint for key in keys},
        )
        self.assert_has_caching_headers(response, mutable=True)


class DistsByHashFileViewTests(ArchiveFileViewTests):
    """Test retrieving ``by-hash`` files from ``dists/``."""

    example_path = "dists/bookworm/main/source/by-hash/SHA256/00"
    expect_mutable_no_archive = False
    expect_mutable = False

    def test_current(self) -> None:
        old_contents = b"Old Sources file\n"
        old_sha256 = hashlib.sha256(old_contents).hexdigest()
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Sources.xz", contents=old_contents),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
        )
        new_contents = b"New Sources file\n"
        new_sha256 = hashlib.sha256(new_contents).hexdigest()
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Sources.xz", contents=new_contents),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
            replace=True,
        )
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Packages.xz"),
            user=self.scenario.user,
            variables={"path": "main/binary-amd64/Packages.xz"},
        )
        self.suites["trixie"].manager.add_artifact(
            self.create_repository_index("Sources.xz"),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
        )

        response = self.archive_file_get(
            path=f"dists/bookworm/main/source/by-hash/SHA256/{old_sha256}"
        )

        self.assert_response_ok(response, contents=old_contents, mutable=False)

        response = self.archive_file_get(
            path=f"dists/bookworm/main/source/by-hash/SHA256/{new_sha256}"
        )

        self.assert_response_ok(response, contents=new_contents, mutable=False)

    def test_snapshot(self) -> None:
        one_month_ago = (timezone.now() - dt.timedelta(days=30)).replace(
            microsecond=0
        )
        self.archive.child_items.filter(
            category=CollectionCategory.SUITE
        ).update(created_at=one_month_ago)
        old_contents = b"Old Sources file\n"
        old_sha256 = hashlib.sha256(old_contents).hexdigest()
        old_item = self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Sources.xz", contents=old_contents),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
        )
        old_item.created_at = one_month_ago
        old_item.save()
        new_contents = b"New Sources file\n"
        new_sha256 = hashlib.sha256(new_contents).hexdigest()
        new_item = self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Sources.xz", contents=new_contents),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
            replace=True,
        )
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Packages.xz"),
            user=self.scenario.user,
            variables={"path": "main/binary-amd64/Packages.xz"},
        )
        self.suites["trixie"].manager.add_artifact(
            self.create_repository_index("Sources.xz"),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
        )

        for snapshot, sha256, expected_contents in (
            (old_item.created_at - dt.timedelta(seconds=1), old_sha256, None),
            (old_item.created_at - dt.timedelta(seconds=1), new_sha256, None),
            (old_item.created_at, old_sha256, old_contents),
            (old_item.created_at, new_sha256, None),
            (
                new_item.created_at - dt.timedelta(seconds=1),
                old_sha256,
                old_contents,
            ),
            (new_item.created_at - dt.timedelta(seconds=1), new_sha256, None),
            # Snapshot URLs don't include a microsecond component, so add a
            # second to avoid rounding problems.
            (
                new_item.created_at + dt.timedelta(seconds=1),
                old_sha256,
                old_contents,
            ),
            (
                new_item.created_at + dt.timedelta(seconds=1),
                new_sha256,
                new_contents,
            ),
        ):
            with self.subTest(
                snapshot=self.format_snapshot(snapshot), sha256=sha256
            ):
                response = self.archive_file_get(
                    snapshot=snapshot,
                    path=f"dists/bookworm/main/source/by-hash/SHA256/{sha256}",
                )

                if expected_contents is None:
                    self.assertResponseProblem(
                        response,
                        "No FileInArtifact matches the given query.",
                        status_code=status.HTTP_404_NOT_FOUND,
                    )
                else:
                    self.assert_response_ok(
                        response, contents=expected_contents, mutable=False
                    )

    def test_suite_not_exported(self) -> None:
        contents = b"Sources file\n"
        sha256 = hashlib.sha256(contents).hexdigest()
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Sources.xz", contents=contents),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
            replace=True,
        )

        response = self.archive_file_get(
            path=f"dists/bookworm/main/source/by-hash/SHA256/{sha256}"
        )

        self.assert_response_ok(response, contents=contents, mutable=False)

        self.suites["bookworm"].data["exported"] = False
        self.suites["bookworm"].save()

        response = self.archive_file_get(
            path=f"dists/bookworm/main/source/by-hash/SHA256/{sha256}"
        )

        self.assertResponseProblem(
            response,
            "No FileInArtifact matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )
        self.assert_has_caching_headers(response, mutable=False)


class DistsFileViewTests(ArchiveFileViewTests):
    """Test retrieving non-``by-hash`` files from ``dists/``."""

    example_path = "dists/bookworm/Release"
    expect_mutable_no_archive = True
    expect_mutable = True

    def test_current(self) -> None:
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index(
                "Release", contents=b"Old Release file\n"
            ),
            user=self.scenario.user,
            variables={"path": "Release"},
        )
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index(
                "Release", contents=b"New Release file\n"
            ),
            user=self.scenario.user,
            variables={"path": "Release"},
            replace=True,
        )
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Sources.xz"),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
        )
        self.suites["trixie"].manager.add_artifact(
            self.create_repository_index("Release"),
            user=self.scenario.user,
            variables={"path": "Release"},
        )

        response = self.archive_file_get(path="dists/bookworm/Release")

        self.assert_response_ok(
            response, contents=b"New Release file\n", mutable=True
        )

    def test_snapshot(self) -> None:
        one_month_ago = (timezone.now() - dt.timedelta(days=30)).replace(
            microsecond=0
        )
        self.archive.child_items.filter(
            category=CollectionCategory.SUITE
        ).update(created_at=one_month_ago)
        old_contents = b"Old Release file\n"
        old_item = self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Release", contents=old_contents),
            user=self.scenario.user,
            variables={"path": "Release"},
        )
        old_item.created_at = one_month_ago
        old_item.save()
        new_contents = b"New Release file\n"
        new_item = self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Release", contents=new_contents),
            user=self.scenario.user,
            variables={"path": "Release"},
            replace=True,
        )
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Sources.xz"),
            user=self.scenario.user,
            variables={"path": "main/source/Sources.xz"},
        )
        self.suites["trixie"].manager.add_artifact(
            self.create_repository_index("Release"),
            user=self.scenario.user,
            variables={"path": "Release"},
        )

        for snapshot, expected_contents in (
            (old_item.created_at - dt.timedelta(seconds=1), None),
            (old_item.created_at, old_contents),
            (new_item.created_at - dt.timedelta(seconds=1), old_contents),
            # Snapshot URLs don't include a microsecond component, so add a
            # second to avoid rounding problems.
            (new_item.created_at + dt.timedelta(seconds=1), new_contents),
        ):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(
                    snapshot=snapshot, path="dists/bookworm/Release"
                )

                if expected_contents is None:
                    self.assertResponseProblem(
                        response,
                        "No FileInArtifact matches the given query.",
                        status_code=status.HTTP_404_NOT_FOUND,
                    )
                else:
                    self.assert_response_ok(
                        response, contents=expected_contents, mutable=False
                    )

    def test_suite_not_exported(self) -> None:
        contents = b"Release file\n"
        self.suites["bookworm"].manager.add_artifact(
            self.create_repository_index("Release", contents=contents),
            user=self.scenario.user,
            variables={"path": "Release"},
            replace=True,
        )

        response = self.archive_file_get(path="dists/bookworm/Release")

        self.assert_response_ok(response, contents=contents, mutable=True)

        self.suites["bookworm"].data["exported"] = False
        self.suites["bookworm"].save()

        response = self.archive_file_get(path="dists/bookworm/Release")

        self.assertResponseProblem(
            response,
            "No FileInArtifact matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )
        self.assert_has_caching_headers(response, mutable=True)


class PoolFileViewTests(ArchiveFileViewTests):
    """Test retrieving files from ``pool/``."""

    example_path = "pool/main/h/hello/hello_1.0.dsc"
    expect_mutable_no_archive = True
    expect_mutable = False

    def test_current_source(self) -> None:
        for suite_name, component, name, version, paths in (
            (
                "bookworm",
                "main",
                "hello",
                "1.0",
                ["hello_1.0.dsc", "hello_1.0.tar.xz"],
            ),
            (
                "bookworm",
                "contrib",
                "hello",
                "1.1-1",
                [
                    "hello_1.1-1.dsc",
                    "hello_1.1-1.debian.tar.xz",
                    "hello_1.1.orig.tar.xz",
                ],
            ),
            (
                "bookworm",
                "contrib",
                "hello",
                "1.1-2",
                [
                    "hello_1.1-2.dsc",
                    "hello_1.1-2.debian.tar.xz",
                    "hello_1.1.orig.tar.xz",
                ],
            ),
            ("bookworm", "main", "base-files", "1.0", ["base-files_1.0.dsc"]),
            (
                "trixie",
                "main",
                "hello",
                "1.0",
                ["hello_1.0.dsc", "hello_1.0.tar.xz"],
            ),
        ):
            self.suites[suite_name].manager.add_artifact(
                self.playground.create_minimal_source_package_artifact(
                    name,
                    version,
                    paths={
                        path: f"Contents of {path}".encode() for path in paths
                    },
                    create_files=True,
                ),
                user=self.scenario.user,
                variables={"component": component, "section": "devel"},
            )

        for path in (
            "pool/main/h/hello/hello_1.0.dsc",
            "pool/main/h/hello/hello_1.0.tar.xz",
            "pool/contrib/h/hello/hello_1.1-1.dsc",
            "pool/contrib/h/hello/hello_1.1-1.debian.tar.xz",
            "pool/contrib/h/hello/hello_1.1-2.dsc",
            "pool/contrib/h/hello/hello_1.1-2.debian.tar.xz",
            "pool/contrib/h/hello/hello_1.1.orig.tar.xz",
        ):
            response = self.archive_file_get(path=path)

            self.assert_response_ok(
                response,
                contents=f"Contents of {PurePath(path).name}".encode(),
                mutable=False,
            )

    def test_current_binary(self) -> None:
        for (
            suite_name,
            component,
            srcpkg_name,
            srcpkg_version,
            name,
            version,
            architecture,
        ) in (
            ("bookworm", "main", "hello", "1.0", "libhello1", "1.0", "amd64"),
            ("bookworm", "main", "hello", "1.0", "libhello1", "1.0", "s390x"),
            ("bookworm", "main", "hello", "1.0", "libhello-doc", "1.0", "all"),
            (
                "bookworm",
                "contrib",
                "hello",
                "1.1",
                "libhello1",
                "1:1.1",
                "amd64",
            ),
            (
                "bookworm",
                "main",
                "base-files",
                "1.0",
                "base-files",
                "1.0",
                "amd64",
            ),
            ("trixie", "main", "hello", "1.0", "libhello1", "1.0", "amd64"),
        ):
            deb_path = f"{name}_{version.split(':')[-1]}_{architecture}.deb"
            self.suites[suite_name].manager.add_artifact(
                self.playground.create_minimal_binary_package_artifact(
                    srcpkg_name,
                    srcpkg_version,
                    name,
                    version,
                    architecture,
                    paths={deb_path: f"Contents of {deb_path}".encode()},
                    create_files=True,
                ),
                user=self.scenario.user,
                variables={
                    "component": component,
                    "section": "devel",
                    "priority": "optional",
                },
            )

        for path in (
            "pool/main/h/hello/libhello1_1.0_amd64.deb",
            "pool/main/h/hello/libhello1_1.0_s390x.deb",
            "pool/main/h/hello/libhello-doc_1.0_all.deb",
            "pool/contrib/h/hello/libhello1_1.1_amd64.deb",
        ):
            response = self.archive_file_get(path=path)

            self.assert_response_ok(
                response,
                contents=f"Contents of {PurePath(path).name}".encode(),
                mutable=False,
            )

    def test_snapshot_source(self) -> None:
        one_month_ago = (timezone.now() - dt.timedelta(days=30)).replace(
            microsecond=0
        )
        one_day_ago = (timezone.now() - dt.timedelta(days=1)).replace(
            microsecond=0
        )
        self.archive.child_items.filter(
            category=CollectionCategory.SUITE
        ).update(created_at=one_month_ago)
        items: list[CollectionItem] = []
        for version in ("1.1-1", "1.1-2", "1.1-3"):
            items.append(
                self.suites["bookworm"].manager.add_artifact(
                    self.playground.create_minimal_source_package_artifact(
                        "hello",
                        version,
                        paths={
                            path: f"Contents of {path}".encode()
                            for path in (
                                f"hello_{version}.dsc",
                                f"hello_{version.split('-')[0]}.orig.tar.xz",
                            )
                        },
                        create_files=True,
                    ),
                    user=self.scenario.user,
                    variables={"component": "main", "section": "devel"},
                )
            )
        items[0].created_at = one_month_ago
        items[0].removed_at = one_day_ago
        items[0].save()
        items[1].created_at = one_day_ago
        items[1].save()
        start = items[0].created_at - dt.timedelta(seconds=1)
        # Snapshot URLs don't include a microsecond component, so add a
        # second to avoid rounding problems.
        end = items[2].created_at + dt.timedelta(seconds=1)

        for snapshot, name, expected in (
            (start, "hello_1.1-1.dsc", False),
            (start, "hello_1.1-2.dsc", False),
            (start, "hello_1.1-3.dsc", False),
            (start, "hello_1.1.orig.tar.xz", False),
            (items[0].created_at, "hello_1.1-1.dsc", True),
            (items[0].created_at, "hello_1.1-2.dsc", False),
            (items[0].created_at, "hello_1.1-3.dsc", False),
            (items[0].created_at, "hello_1.1.orig.tar.xz", True),
            (items[1].created_at, "hello_1.1-1.dsc", False),
            (items[1].created_at, "hello_1.1-2.dsc", True),
            (items[1].created_at, "hello_1.1-3.dsc", False),
            (items[1].created_at, "hello_1.1.orig.tar.xz", True),
            (end, "hello_1.1-1.dsc", False),
            (end, "hello_1.1-2.dsc", True),
            (end, "hello_1.1-3.dsc", True),
            (end, "hello_1.1.orig.tar.xz", True),
        ):
            with self.subTest(
                snapshot=self.format_snapshot(snapshot), name=name
            ):
                response = self.archive_file_get(
                    snapshot=snapshot, path=f"pool/main/h/hello/{name}"
                )

                if expected:
                    self.assert_response_ok(
                        response,
                        contents=f"Contents of {name}".encode(),
                        mutable=False,
                    )
                else:
                    self.assertResponseProblem(
                        response,
                        "No FileInArtifact matches the given query.",
                        status_code=status.HTTP_404_NOT_FOUND,
                    )

    def test_suite_not_exported(self) -> None:
        contents = b"dsc file\n"
        self.suites["bookworm"].manager.add_artifact(
            self.playground.create_minimal_source_package_artifact(
                "hello",
                "1.0",
                paths={"hello_1.0.dsc": contents},
                create_files=True,
            ),
            user=self.scenario.user,
            variables={"component": "main", "section": "devel"},
        )

        response = self.archive_file_get(path="pool/main/h/hello/hello_1.0.dsc")

        self.assert_response_ok(response, contents=contents, mutable=False)

        self.suites["bookworm"].data["exported"] = False
        self.suites["bookworm"].save()

        response = self.archive_file_get(path="pool/main/h/hello/hello_1.0.dsc")

        self.assertResponseProblem(
            response,
            "No FileInArtifact matches the given query.",
            status_code=status.HTTP_404_NOT_FOUND,
        )
        self.assert_has_caching_headers(response, mutable=False)

    def test_wrong_component(self) -> None:
        self.suites["bookworm"].manager.add_artifact(
            self.playground.create_minimal_source_package_artifact(
                "hello", "1.0", paths=["hello_1.0.dsc"], create_files=True
            ),
            user=self.scenario.user,
            variables={"component": "main", "section": "devel"},
        )
        self.suites["bookworm"].manager.add_artifact(
            self.playground.create_minimal_binary_package_artifact(
                "hello",
                "1.0",
                "hello",
                "1.0",
                "amd64",
                paths=["hello_1.0_amd64.deb"],
                create_files=True,
            ),
            user=self.scenario.user,
            variables={
                "component": "main",
                "section": "devel",
                "priority": "optional",
            },
        )

        for name in ("hello_1.0.dsc", "hello_1.0_amd64.deb"):
            response = self.archive_file_get(
                path=f"pool/contrib/h/hello/{name}"
            )

            self.assertResponseProblem(
                response,
                "No FileInArtifact matches the given query.",
                status_code=status.HTTP_404_NOT_FOUND,
            )

    def test_wrong_source_prefix(self) -> None:
        self.suites["bookworm"].manager.add_artifact(
            self.playground.create_minimal_source_package_artifact(
                "hello", "1.0", paths=["hello_1.0.dsc"], create_files=True
            ),
            user=self.scenario.user,
            variables={"component": "main", "section": "devel"},
        )
        self.suites["bookworm"].manager.add_artifact(
            self.playground.create_minimal_binary_package_artifact(
                "hello",
                "1.0",
                "hello",
                "1.0",
                "amd64",
                paths=["hello_1.0_amd64.deb"],
                create_files=True,
            ),
            user=self.scenario.user,
            variables={
                "component": "main",
                "section": "devel",
                "priority": "optional",
            },
        )

        for name in ("hello_1.0.dsc", "hello_1.0_amd64.deb"):
            response = self.archive_file_get(path=f"pool/main/a/hello/{name}")

            self.assertResponseProblem(
                response,
                "No FileInArtifact matches the given query.",
                status_code=status.HTTP_404_NOT_FOUND,
            )


class TopLevelFileViewTests(ArchiveFileViewTests):
    """Test retrieving top-level files, not in any suite."""

    example_path = "README"
    expect_mutable_no_archive = True
    expect_mutable = True

    def test_current(self) -> None:
        social_contract_contents = (
            b'"Social Contract" with the Free Software Community\n'
        )
        artifact = self.create_repository_index(
            "social-contract.txt", contents=social_contract_contents
        )
        self.archive.manager.add_artifact(
            artifact,
            user=self.scenario.user,
            variables={"path": "doc/social-contract.txt"},
        )
        self.archive.manager.add_artifact(
            self.create_repository_index("README"),
            user=self.scenario.user,
            variables={"path": "README"},
        )

        response = self.archive_file_get(path="doc/social-contract.txt")

        self.assert_response_ok(
            response, contents=social_contract_contents, mutable=True
        )

    def test_snapshot(self) -> None:
        old_social_contract_contents = (
            b'"Social Contract" with the Free Software Community\n'
        )
        old_artifact = self.create_repository_index(
            "social-contract.txt", contents=old_social_contract_contents
        )
        old_item = self.archive.manager.add_artifact(
            old_artifact,
            user=self.scenario.user,
            variables={"path": "doc/social-contract.txt"},
        )
        old_item.created_at = (timezone.now() - dt.timedelta(days=30)).replace(
            microsecond=0
        )
        old_item.save()
        new_social_contract_contents = (
            b'"Social Contract" with the Free Software Community (v2)\n'
        )
        new_artifact = self.create_repository_index(
            "social-contract.txt", contents=new_social_contract_contents
        )
        new_item = self.archive.manager.add_artifact(
            new_artifact,
            user=self.scenario.user,
            variables={"path": "doc/social-contract.txt"},
            replace=True,
        )

        for snapshot, expected_contents in (
            (old_item.created_at - dt.timedelta(seconds=1), None),
            (old_item.created_at, old_social_contract_contents),
            (
                new_item.created_at - dt.timedelta(seconds=1),
                old_social_contract_contents,
            ),
            # Snapshot URLs don't include a microsecond component, so add a
            # second to avoid rounding problems.
            (
                new_item.created_at + dt.timedelta(seconds=1),
                new_social_contract_contents,
            ),
        ):
            with self.subTest(snapshot=self.format_snapshot(snapshot)):
                response = self.archive_file_get(
                    snapshot=snapshot, path="doc/social-contract.txt"
                )

                if expected_contents is None:
                    self.assertResponseProblem(
                        response,
                        "No FileInArtifact matches the given query.",
                        status_code=status.HTTP_404_NOT_FOUND,
                    )
                else:
                    self.assert_response_ok(
                        response, contents=expected_contents, mutable=False
                    )


# Avoid running tests from common base classes.
del ArchiveViewTests
del ArchiveFileViewTests
