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

"""debusine-admin to report and fix inconsistencies in file stores (slow)."""

import datetime as dt
from typing import Any, NoReturn, override

from django.core.management import CommandParser
from django.db import transaction
from django.utils import timezone

from debusine.db.models.files import FileInStore, FileStore, FileStoreQuerySet
from debusine.django.management.debusine_base_command import DebusineBaseCommand
from debusine.server.file_backend.interface import FileBackendEntryInterface


class Command(DebusineBaseCommand):
    """Command to report and fix inconsistencies in file stores (slow)."""

    help = "Report and fix inconsistencies in Debusine's file stores (slow)."

    @override
    def add_arguments(self, parser: CommandParser) -> None:
        parser.add_argument(
            "--dry-run",
            help="Trial run: do not modify anything",
            action="store_true",
        )
        parser.add_argument(
            "--store",
            metavar="NAME",
            help="Only run on the specified store",
        )

    @override
    def handle(self, *args: Any, **options: Any) -> NoReturn:
        """Run the checks and fixes."""
        dry_run = options["dry_run"]
        store = options["store"]
        stores: FileStoreQuerySet[FileStore] = FileStore.objects.all()
        if store is not None:
            stores = stores.filter(name=store)
            if not stores.exists():
                self.stderr.write(f"FileStore {store!r} does not exist.\n")
                raise SystemExit(1)

        with transaction.atomic():
            self._delete_unreferenced_files(stores=stores, dry_run=dry_run)
        with transaction.atomic():
            missing_files_from_stores_correct = (
                self._report_missing_files_stores_referenced_by_the_db(
                    stores=stores
                )
            )
        raise SystemExit(int(not missing_files_from_stores_correct))

    def _delete_unreferenced_files(
        self, *, stores: FileStoreQuerySet[FileStore], dry_run: bool
    ) -> None:
        """Delete files in stores that are not referenced by the DB."""
        self.print_verbose("Checking orphan files from stores")
        one_day_ago = timezone.now() - dt.timedelta(days=1)

        # We consider only files older than a day old in order to allow a
        # grace period for files that are in the process of being created,
        # to avoid possible race conditions where this job deletes them
        # while we're still in the middle of creating references to them.
        # TODO: At the moment we can only get mtimes from some backends,
        # although this isn't an inherent limitation.
        for store in stores.filter(
            backend__in={
                FileStore.BackendChoices.LOCAL,
                FileStore.BackendChoices.S3,
            }
        ):
            established_entries_in_storage = set(
                store.get_backend_object().list_entries()
            )
            entries_in_db = self._entries_from_files_in_file_store_db(store)
            entries_to_delete = established_entries_in_storage - entries_in_db

            for entry_to_delete in entries_to_delete:
                mtime = entry_to_delete.get_mtime()
                if mtime is not None and mtime < one_day_ago:
                    if not dry_run:
                        entry_to_delete.remove()

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

    def _report_missing_files_stores_referenced_by_the_db(
        self, *, stores: FileStoreQuerySet[FileStore]
    ) -> bool:
        """
        Report DB referencing files that do not exist in storage.

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

        for store in stores.order_by("id"):
            # TODO: We should do this in a way that doesn't require holding
            # both sets in memory.
            entries_in_db = self._entries_from_files_in_file_store_db(store)
            entries_in_storage = set(store.get_backend_object().list_entries())
            orphan_entries = entries_in_db - entries_in_storage

            for orphan_entry in orphan_entries:
                self.stderr.write(
                    f"File in FileStore {store.name} but not in storage: "
                    f"{orphan_entry}"
                )

            ok = ok and (len(orphan_entries) == 0)

        return ok

    @staticmethod
    def _entries_from_files_in_file_store_db(
        store: FileStore,
    ) -> set[FileBackendEntryInterface[Any, Any]]:
        store_backend = store.get_backend_object()
        # TODO: This should probably also filter to files that are in any of
        # the scopes configured to use this store.  After `debusine-admin
        # scope manage --remove-file-store`, it's possible for the store to
        # contain files that are no longer in any of its scopes, and those
        # should be cleaned up.
        return {
            store_backend.get_entry(file_in_store.file)
            for file_in_store in FileInStore.objects.filter(
                store=store
            ).select_related("file")
        }
