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

"""debusine-admin for the monthly_cleanup."""

from collections.abc import Callable
from datetime import datetime, timedelta
from pathlib import Path

from django.utils import timezone

from debusine.db.models import FileInStore, FileStore, FileUpload
from debusine.project import settings
from debusine.server.management.debusine_base_command import DebusineBaseCommand


class Command(DebusineBaseCommand):
    """Command to report and fix inconsistencies."""

    help = "Report and fix inconsistencies in Debusine's DB and storage"

    def add_arguments(self, parser):
        """Add CLI arguments for the monthly_cleanup command."""
        parser.add_argument(
            "--dry-run",
            help="Trial run: do not modify anything",
            action="store_true",
        )

    def handle(self, *args, **options):
        """Run the checks and fixes."""
        dry_run = options["dry_run"]
        self._fix_files_store_directory(dry_run=dry_run)
        self._delete_empty_directories_in_the_store_directory(dry_run=dry_run)
        self._delete_unfinished_uploads(dry_run=dry_run)
        self._delete_orphan_files_from_upload_directory(dry_run=dry_run)
        missing_files_from_stores_correct = (
            self._report_missing_files_stores_referenced_by_the_db()
        )
        missing_files_from_upload_correct = (
            self._report_missing_files_referenced_by_db_file_upload()
        )

        raise SystemExit(
            int(
                not missing_files_from_stores_correct
                or not missing_files_from_upload_correct
            )
        )

    def _fix_files_store_directory(self, *, dry_run: bool):
        """Fix files in the store directory that are not used by the DB."""
        self.print_verbose("Checking orphan files from stores")
        store_directory_files = self._list_directory(
            settings.DEBUSINE_STORE_DIRECTORY,
            files_only=True,
            filters=[self._path_older_than_one_day],
        )

        store_db_files = self._file_paths_from_files_in_file_stores_db()

        files_to_delete = store_directory_files - store_db_files

        for file_to_delete in files_to_delete:
            if not dry_run:
                file_to_delete.unlink(missing_ok=True)

            self.print_verbose(f"Deleted {file_to_delete}")

    @staticmethod
    def _path_older_than_one_day(path: Path) -> bool:
        one_day_ago = datetime.now() - timedelta(days=1)
        file_modification_time = datetime.fromtimestamp(path.stat().st_mtime)
        return file_modification_time < one_day_ago

    def _delete_empty_directories_in_the_store_directory(
        self, *, dry_run: bool
    ):
        """
        Delete empty directories in the store directory.

        This method might leave new empty directories (by design).

        If the directory a/b/c is deleted: now the directory a/b might be empty.
        This method could then check if a/b is empty but:

        - mtime of a/b is the recent date
        - if it deleted a/b it could be that the LocalFileBackend is adding
           a file in a/b and could fail (since LocalFileBackend is creating
           the subdirectories incrementally).

        To fix this it could have a locking mechanism (DB based or file
        based).

        For now: empty directories might be eventually deleted but not
        "right now".
        """
        self.print_verbose("Checking empty directories")

        store_directory_directories = self._list_directory(
            settings.DEBUSINE_STORE_DIRECTORY,
            directories_only=True,
            filters=[self._path_older_than_one_day],
        )

        def path_length(directory):
            return len(str(directory))

        store_directories_sorted = sorted(
            store_directory_directories, key=path_length, reverse=True
        )

        for directory in store_directories_sorted:
            # The directory is empty (and was modified more than a day ago):
            # it can be deleted
            if not dry_run:
                try:
                    directory.rmdir()
                except OSError:
                    # Probably a file has been added between _list_directory()
                    # call and the rmdir()
                    continue
            self.print_verbose(f"Deleted empty directory {directory}")

    def _delete_unfinished_uploads(self, *, dry_run: bool):
        """Delete from DB and disk uploads that started more than 1 day ago."""
        self.print_verbose("Checking unfinished file uploads")

        one_day_ago = timezone.now() - timedelta(days=1)

        old_file_uploads = FileUpload.objects.filter(
            last_activity_at__lt=one_day_ago
        )

        for file_upload in old_file_uploads:
            if not dry_run:
                file_upload.delete()
                Path(file_upload.path).unlink(missing_ok=True)

            self.print_verbose(f"Deleted unfinished upload {file_upload}")

    def _delete_orphan_files_from_upload_directory(self, *, dry_run: bool):
        """Delete files from the upload directory that are not referenced."""
        self.print_verbose("Checking orphan files in upload directory")

        paths_in_disk = self._list_directory(
            settings.DEBUSINE_UPLOAD_DIRECTORY,
            filters=[self._path_older_than_one_day],
        )

        debusine_upload_directory = Path(settings.DEBUSINE_UPLOAD_DIRECTORY)

        paths_in_db = set()
        for path in FileUpload.objects.all().values_list("path", flat=True):
            paths_in_db.add(debusine_upload_directory / path)

        files_to_remove = paths_in_disk - paths_in_db

        for file_to_remove in files_to_remove:
            if not dry_run:
                file_to_remove.unlink(missing_ok=True)

            self.print_verbose(f"Deleted {file_to_remove}")

    def _report_missing_files_stores_referenced_by_the_db(self) -> bool:
        """
        Report DB referencing files that do not exist in disk.

        It cannot be fixed automatically.
        """
        self.print_verbose("Checking missing files from stores")

        files_in_disk = self._list_directory(
            settings.DEBUSINE_STORE_DIRECTORY, files_only=True
        )

        files_in_db = self._file_paths_from_files_in_file_stores_db()

        orphan_files = files_in_db - files_in_disk

        for orphan_file in orphan_files:
            self.stderr.write(f"Dangling file: {orphan_file}")

        return len(orphan_files) == 0

    @staticmethod
    def _file_paths_from_files_in_file_stores_db() -> set[Path]:
        files_in_db = set()

        for store in FileStore.objects.all():
            store_backend = store.get_backend_object()
            for file_in_store in FileInStore.objects.filter(store=store):
                files_in_db.add(
                    store_backend.get_local_path(file_in_store.file)
                )

        return files_in_db

    def _report_missing_files_referenced_by_db_file_upload(self):
        """Report DB referencing upload files that do not exist in disk."""
        self.print_verbose("Checking missing files from upload")

        files_in_disk = self._list_directory(settings.DEBUSINE_UPLOAD_DIRECTORY)

        files_in_db = set()
        for file_in_db in (
            FileUpload.objects.all()
            .order_by("path")
            .values_list("path", flat=True)
        ):
            files_in_db.add(
                Path(settings.DEBUSINE_UPLOAD_DIRECTORY) / file_in_db
            )

        orphan_files = files_in_db - files_in_disk

        for orphan_file in orphan_files:
            self.stderr.write(f"Dangling file: {orphan_file}")

        return len(orphan_files) == 0

    @staticmethod
    def _list_directory(
        directory: Path | str,
        *,
        files_only: bool = False,
        directories_only: bool = False,
        filters: list[Callable[[Path], bool]] | None = None,
    ) -> set[Path]:
        if files_only and directories_only:
            raise ValueError(
                'Parameters "files_only" and "directories_only" '
                'are incompatible'
            )

        final_filters = []

        if files_only:
            final_filters.append(lambda x: x.is_file())

        if directories_only:
            final_filters.append(lambda x: x.is_dir())

        if filters is not None:
            final_filters.extend(filters)

        # Pathlib.glob documentation: "Using the “**” pattern in large
        # directory trees may consume an inordinate amount of time."
        # Consider changing this with something else (e.g. os.walk; or
        # yielding paths and checking the DB per each file/set of files if
        # this becomes a problem).
        paths = Path(directory).glob("**/*")

        filtered_paths = set()

        for path in paths:
            if all(f(path) for f in final_filters):
                filtered_paths.add(path)

        return filtered_paths
