# Copyright 2023 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 management command monthly_cleanup."""

import os
from datetime import datetime, timedelta
from pathlib import Path
from unittest import mock

from django.test import TestCase
from django.utils import timezone

from debusine.db.models import Artifact, FileInArtifact, FileStore, FileUpload
from debusine.django.management.tests import call_command
from debusine.project import settings
from debusine.server.management.commands.monthly_cleanup import Command
from debusine.test import TestHelpersMixin


class MonthlyCleanupCommandTests(TestHelpersMixin, TestCase):
    """Tests for monthly_cleanup command."""

    playground_memory_file_store = False

    def setUp(self):
        """Set common objects and settings."""
        # Using override_settings does not work (perhaps because it's a
        # lambda calculated setting)
        self.debusine_store_directory = str(self.create_temporary_directory())
        self.original_debusine_store_directory = (
            settings.DEBUSINE_STORE_DIRECTORY
        )
        settings.DEBUSINE_STORE_DIRECTORY = self.debusine_store_directory

        # Use the default FileStore using a new base_directory
        file_store = FileStore.default()
        file_store.configuration["base_directory"] = (
            settings.DEBUSINE_STORE_DIRECTORY
        )
        file_store.save()

        # Use a new directory for the uploads
        self.debusine_upload_directory = str(self.create_temporary_directory())
        self.original_upload_directory = settings.DEBUSINE_STORE_DIRECTORY
        settings.DEBUSINE_UPLOAD_DIRECTORY = self.debusine_upload_directory

    def tearDown(self):
        """Restore settings."""
        settings.DEBUSINE_STORE_DIRECTORY = (
            self.original_debusine_store_directory
        )
        settings.DEBUSINE_UPLOAD_DIRECTORY = self.original_upload_directory

    def test_delete_file_in_disk_not_in_store_dry_run(self):
        """
        Test monthly_cleanup find file in disk not in DB.

        Also test that is not finding a file part of an artifact.
        """
        artifact, _ = self.create_artifact(paths=["README"], create_files=True)

        orphan_file = self.create_temporary_file(
            directory=self.debusine_store_directory
        )
        self._set_modified_time_two_days_ago(orphan_file)

        stdout, stderr, exitcode = call_command(
            "monthly_cleanup", "--dry-run", verbosity=2
        )

        # The file was not deleted: dry-run was used
        self.assertTrue(orphan_file.exists())

        self.assertEqual(
            stdout,
            "Checking orphan files from stores\n"
            f"Deleted {orphan_file}\n"
            "Checking empty directories\n"
            "Checking unfinished file uploads\n"
            "Checking orphan files in upload directory\n"
            "Checking missing files from stores\n"
            "Checking missing files from upload\n",
        )
        self.assertEqual(stderr, "")
        self.assertEqual(exitcode, 0)

    @staticmethod
    def get_local_file_path(artifact: Artifact, path_in_artifact: str) -> Path:
        """Return the local file path for the path_in_artifact in Artifact."""
        fileobj = artifact.fileinartifact_set.get(path=path_in_artifact).file
        file_backend = FileStore.default().get_backend_object()
        path = file_backend.get_local_path(fileobj)
        assert path is not None
        return path

    def test_delete_file_in_disk_not_in_db(self):
        """
        Test monthly_cleanup find and delete file that is in disk but not in DB.

        Also test that file part of an artifact is not deleted.
        """
        path_in_artifact = "README"
        artifact, _ = self.create_artifact(
            paths=[path_in_artifact], create_files=True
        )

        temporary_file = self.create_temporary_file(
            directory=self.debusine_store_directory
        )
        self._set_modified_time_two_days_ago(temporary_file)
        stdout, stderr, exitcode = call_command("monthly_cleanup")

        # The file was deleted
        self.assertFalse(temporary_file.exists())

        # The file part of an artifact was not deleted
        local_file_path = self.get_local_file_path(artifact, path_in_artifact)
        self.assertTrue(local_file_path.exists())

        # No verbosity, no output
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exitcode, 0)

    def test_file_not_deleted_too_new(self):
        """Do not delete the file: it's too new."""
        temporary_file = self.create_temporary_file(
            directory=self.debusine_store_directory
        )
        call_command("monthly_cleanup")

        # The file was not deleted
        self.assertTrue(temporary_file.exists())

    def test_delete_empty_directory_dry_run(self):
        """Report delete an empty directory (but dry-run: does not delete)."""
        empty_directory = self.create_temporary_directory(
            directory=self.debusine_store_directory
        )
        self._set_modified_time_two_days_ago(empty_directory)
        self.create_artifact(paths=["README"], create_files=True)

        stdout, stderr, exitcode = call_command(
            "monthly_cleanup", "--dry-run", verbosity=2
        )

        self.assertEqual(
            stdout,
            "Checking orphan files from stores\n"
            "Checking empty directories\n"
            f"Deleted empty directory {empty_directory}\n"
            "Checking unfinished file uploads\n"
            "Checking orphan files in upload directory\n"
            "Checking missing files from stores\n"
            "Checking missing files from upload\n",
        )
        self.assertEqual(stderr, "")
        self.assertEqual(exitcode, 0)

        # Not deleted: --dry-run was used
        self.assertTrue(empty_directory.is_dir())

    def test_delete_empty_directory(self):
        """Delete an empty directory (and leave a non-empty directory)."""
        empty_dir = self.create_temporary_directory(
            directory=self.debusine_store_directory
        )
        self._set_modified_time_two_days_ago(empty_dir)
        path_in_artifact = "README"
        artifact, _ = self.create_artifact(
            paths=[path_in_artifact],
            create_files=True,
            skip_add_files_in_store=False,
        )

        stdout, stderr, exitcode = call_command("monthly_cleanup")

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

        self.assertFalse(empty_dir.is_dir())

        # Directory of the file in the artifact exists
        # The file part of an artifact was not deleted
        local_file_path = self.get_local_file_path(artifact, path_in_artifact)
        self.assertTrue(local_file_path.parent.is_dir())

    def test_delete_empty_directory_too_new(self):
        """Directory is not deleted: it's too new."""
        empty_dir = self.create_temporary_directory(
            directory=self.debusine_store_directory
        )
        call_command("monthly_cleanup")

        self.assertTrue(empty_dir.is_dir())

    def test_delete_directory_fails(self):
        """directory.rmdir() fails (file was probably added after the check)."""
        empty_dir = self.create_temporary_directory(
            directory=self.debusine_store_directory
        )
        self._set_modified_time_two_days_ago(empty_dir)

        rmdir_patcher = mock.patch("pathlib.Path.rmdir", autospec=True)
        rmdir_mocked = rmdir_patcher.start()
        rmdir_mocked.side_effect = OSError()
        self.addCleanup(rmdir_patcher.stop)
        stdout, stderr, exitcode = call_command("monthly_cleanup")

        rmdir_mocked.assert_called_once()

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

        self.assertTrue(empty_dir.is_dir())

    def test_delete_unfinished_uploads_no_delete_dry_run(self):
        """Unfinished, old FileUpload is not deleted: dry run."""
        artifact, _ = self.create_artifact(paths=["README"], create_files=True)
        file_in_artifact = FileInArtifact.objects.get(artifact=artifact)

        temporary_file = self.create_temporary_file(
            directory=self.debusine_upload_directory
        )

        file_upload = FileUpload.objects.create(
            file_in_artifact=file_in_artifact, path=temporary_file.name
        )
        file_upload.last_activity_at = timezone.now() - timedelta(days=2)
        file_upload.save()

        stdout, stderr, exitcode = call_command(
            "monthly_cleanup", "--dry-run", verbosity=2
        )

        self.assertEqual(
            stdout,
            "Checking orphan files from stores\n"
            "Checking empty directories\n"
            "Checking unfinished file uploads\n"
            f"Deleted unfinished upload {file_upload.id}\n"
            "Checking orphan files in upload directory\n"
            "Checking missing files from stores\n"
            "Checking missing files from upload\n",
        )
        self.assertEqual(stderr, "")
        self.assertEqual(exitcode, 0)

        # Check that the FileUpload row was not deleted
        self.assertQuerySetEqual(
            FileUpload.objects.filter(id=file_upload.id), [file_upload]
        )

    def test_delete_unfinished_upload_deleted(self):
        """Unfinished and old FileUpload and its file is deleted."""
        artifact, _ = self.create_artifact(paths=["README"], create_files=True)
        file_in_artifact = FileInArtifact.objects.get(artifact=artifact)

        file_upload = FileUpload.objects.create(
            file_in_artifact=file_in_artifact, path="some-file-path"
        )
        file_upload.last_activity_at = timezone.now() - timedelta(days=2)
        file_upload.path = Path(self.debusine_upload_directory) / "the-file"
        file_upload.save()

        stdout, stderr, exitcode = call_command("monthly_cleanup")

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

        # Check that the FileUpload row was not deleted
        self.assertQuerySetEqual(
            FileUpload.objects.filter(id=file_upload.id), []
        )

        # The file also got deleted
        self.assertFalse(Path(file_upload.path).exists())

    def test_delete_unfinished_upload_not_deleted_recent_activity(self):
        """Unfinished FileUpload is not deleted: recent activity."""
        artifact, _ = self.create_artifact(paths=["README"], create_files=True)
        file_in_artifact = FileInArtifact.objects.get(artifact=artifact)

        upload_temporary_file = self.create_temporary_file(
            directory=self.debusine_upload_directory
        )
        file_upload = FileUpload.objects.create(
            file_in_artifact=file_in_artifact, path=upload_temporary_file.name
        )

        call_command("monthly_cleanup")

        # FileUpload did not get deleted: recent activity
        self.assertQuerySetEqual(
            FileUpload.objects.filter(id=file_upload.id), [file_upload]
        )

    def test_delete_orphan_file_in_upload_directory_no_delete_dry_run(self):
        """File is orphan but is not deleted: --dry-run."""
        orphan_file = self.create_temporary_file(
            directory=self.debusine_upload_directory
        )
        self._set_modified_time_two_days_ago(orphan_file)
        stdout, stderr, exitcode = call_command(
            "monthly_cleanup", "--dry-run", verbosity=2
        )

        self.assertTrue(orphan_file.exists())

        self.assertEqual(
            stdout,
            "Checking orphan files from stores\n"
            "Checking empty directories\n"
            "Checking unfinished file uploads\n"
            "Checking orphan files in upload directory\n"
            f"Deleted {orphan_file}\n"
            "Checking missing files from stores\n"
            "Checking missing files from upload\n",
        )
        self.assertEqual(stderr, "")
        self.assertEqual(exitcode, 0)

    def test_delete_orphan_file_too_new(self):
        """File is orphan but not deleted: too new."""
        orphan_file = self.create_temporary_file(
            directory=self.debusine_upload_directory
        )
        stdout, stderr, exitcode = call_command("monthly_cleanup")

        self.assertTrue(orphan_file.exists())

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

    def test_delete_orphan_file(self):
        """File is orphan and deleted."""
        orphan_file = self.create_temporary_file(
            directory=self.debusine_upload_directory
        )
        self._set_modified_time_two_days_ago(orphan_file)
        call_command("monthly_cleanup")

        self.assertFalse(orphan_file.exists())

    def test_file_in_store_db_missing_from_disk(self):
        """File is referenced by the DB but not in disk in the store."""
        path_in_artifact = "README"
        artifact, _ = self.create_artifact(
            paths=[path_in_artifact], create_files=True
        )

        file_path = self.get_local_file_path(artifact, path_in_artifact)
        file_path.unlink()

        stdout, stderr, exitcode = call_command("monthly_cleanup")

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, f"Dangling file: {file_path}\n")
        self.assertEqual(exitcode, 1)

    def test_file_in_upload_db_missing_from_disk(self):
        """File is referenced by the DB but not in disk for uploads."""
        artifact, _ = self.create_artifact(paths=["README"], create_files=True)
        file_in_artifact = FileInArtifact.objects.get(artifact=artifact)

        file_path = "missing-file"
        FileUpload.objects.create(
            file_in_artifact=file_in_artifact, path=file_path
        )

        stdout, stderr, exitcode = call_command("monthly_cleanup")

        debusine_upload_directory = Path(self.debusine_upload_directory)

        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr,
            f"Dangling file: {debusine_upload_directory / file_path}\n",
        )
        self.assertEqual(exitcode, 1)

    def _create_files_and_directories(self):
        root = self.create_temporary_directory()

        files = {
            self.create_temporary_file(directory=root),
            self.create_temporary_file(directory=root),
        }
        directory = self.create_temporary_directory(directory=root)

        files.add(self.create_temporary_file(directory=directory))

        return {"root": root, "files": files, "directories": {directory}}

    def test_list_directory_files_and_directories_raise_error(self):
        """_list_directory raise ValueError for incompatible parameters."""
        with self.assertRaisesRegex(
            ValueError,
            'Parameters "files_only" and "directories_only" are incompatible',
        ):
            Command._list_directory("/", files_only=True, directories_only=True)

    def test_files_and_directories(self):
        """_list_directory without filters: return all files and directories."""
        files_and_directories = self._create_files_and_directories()

        self.assertEqual(
            Command._list_directory(files_and_directories["root"]),
            files_and_directories["files"]
            | files_and_directories["directories"],
        )

    def test_files_only(self):
        """_list_directory(files_only) return only files (no directories)."""
        files_and_directories = self._create_files_and_directories()

        self.assertEqual(
            Command._list_directory(
                files_and_directories["root"], files_only=True
            ),
            files_and_directories["files"],
        )

    def test_directories_only(self):
        """_list_directory(directories_only) return only directories."""
        files_and_directories = self._create_files_and_directories()

        self.assertEqual(
            Command._list_directory(
                files_and_directories["root"], directories_only=True
            ),
            files_and_directories["directories"],
        )

    def test_filter_nothing(self):
        """_list_directory with exclude all filter: return nothing."""
        files_and_directories = self._create_files_and_directories()

        self.assertEqual(
            Command._list_directory(
                files_and_directories["root"],
                filters=[lambda x: False],  # noqa: U100
            ),
            set(),
        )

    def test_filter_all(self):
        """_list_directory (only files, include all filter): return files."""
        files_and_directories = self._create_files_and_directories()

        self.assertEqual(
            Command._list_directory(
                files_and_directories["root"],
                files_only=True,
                filters=[lambda x: True],  # noqa: U100,
            ),
            files_and_directories["files"],
        )

    @staticmethod
    def _set_modified_time_two_days_ago(path: Path):
        two_days_ago = (datetime.now() - timedelta(days=2)).timestamp()
        os.utime(path, (two_days_ago, two_days_ago))
