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

"""Implementation of local file backend."""

import logging
import os
import shutil
import tempfile
from pathlib import Path

from django.conf import settings
from django.db import transaction

from debusine.db.models import File, FileInStore, FileStore
from debusine.server.file_backend.interface import FileBackendInterface
from debusine.server.file_backend.models import LocalFileBackendConfiguration


logger = logging.getLogger(__name__)


class LocalFileBackend(FileBackendInterface[LocalFileBackendConfiguration]):
    """Local file backend (in settings.DEBUSINE_STORE_DIRECTORY)."""

    def __init__(self, file_store: FileStore):
        """Initialize LocalFileBackend."""
        super().__init__()
        self.db_store = file_store

        if (base_directory := self.configuration.base_directory) is None:
            if file_store.name == "Default":
                base_directory = settings.DEBUSINE_STORE_DIRECTORY
            else:
                raise RuntimeError(
                    f'LocalFileBackend {file_store.name} configuration '
                    'requires "base_directory" setting'
                )

        self._base_directory = Path(base_directory)

    def base_directory(self) -> Path:
        """Return the base_directory of this backend."""
        return self._base_directory

    def get_local_path(self, fileobj: File) -> Path:
        """Return local_path for fileobj."""
        hash_hex = fileobj.hash_digest.hex()

        file_path = (
            self._base_directory
            / hash_hex[0:2]
            / hash_hex[2:4]
            / hash_hex[4:6]
            / f"{hash_hex}-{fileobj.size}"
        )

        return file_path

    @classmethod
    def _sync_file(cls, path: Path):
        """Flush file_path and its directory to disk."""
        # same approach (open mode) as sync from coreutils

        # coreutils include O_NONBLOCK to avoid blocking if the file
        # is a FIFO. Files in debusine are not FIFOs
        try:
            fd = os.open(path, os.O_RDONLY)
        except OSError as exc:
            logger.debug(  # noqa: G200
                "Could not open %s for flushing (%s)", path, exc
            )
            return

        os.fsync(fd)
        os.close(fd)

        if path.is_file():
            cls._sync_file(path.parent)

    @staticmethod
    def _create_subdirectories(base_directory: Path, subdirectory: Path):
        """
        Create the subdirectory in base_directory.

        :param base_directory: directory (must exist) to create the subdirectory
        :param subdirectory: such as "a/b/c", created in base_directory
        """
        accumulating = base_directory

        for part in subdirectory.parts:
            accumulating = accumulating / Path(part)
            accumulating.mkdir(exist_ok=True)

    @transaction.atomic
    def add_file(self, local_path: Path, fileobj: File | None = None) -> File:
        """Add local_path to the filestore. Reuse fileobj if size matches."""
        # fileobj.hash is not compared to avoid recalculating the hash
        # of the file
        if not fileobj:
            fileobj = File.from_local_path(local_path)
        elif fileobj.size != (size_in_disk := local_path.stat().st_size):
            raise ValueError(
                f"add_file file size mismatch. Path: {local_path} "
                f"Size in disk: {size_in_disk} "
                f"fileobj.size: {fileobj.size}"
            )

        file_in_store, created = FileInStore.objects.get_or_create(
            store=self.db_store, file=fileobj, data={}
        )

        if created:
            destination_file = self.get_local_path(fileobj)
            destination_directory = destination_file.parent

            self._create_subdirectories(
                self._base_directory,
                destination_directory.relative_to(self._base_directory),
            )

            # To make it easy to identify files that were not finished
            # copying: copy to a temp file + rename
            temporary_file = tempfile.NamedTemporaryFile(
                dir=destination_directory, suffix=".temp", delete=False
            )
            temporary_file.close()

            shutil.copy(local_path, temporary_file.name)
            os.rename(temporary_file.name, destination_file)

            self._sync_file(destination_file)

        return fileobj

    def get_url(self, fileobj: File) -> None:  # noqa: U100
        """Return None: no remote URL for a file in LocalFileBackend."""
        return None

    def _remove_file(self, fileobj: File):
        """Remove the file pointed by fileobj from the backend."""
        file_path = self.get_local_path(fileobj)
        file_path.unlink(missing_ok=True)
