# 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.project import settings
from debusine.server.management.commands.monthly_cleanup import Command
from debusine.server.management.commands.tests import call_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))
