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

"""Define interface of the file backend."""

import contextlib
import logging
from abc import ABC, abstractmethod
from collections.abc import Generator
from functools import cached_property
from pathlib import Path
from typing import Any, Generic, Protocol, TypeVar

import requests

from debusine.db.models import File, FileInStore, FileStore
from debusine.server.file_backend.models import FileBackendConfiguration
from debusine.utils import NotSupportedError, extract_generic_type_arguments

logger = logging.getLogger(__name__)

FBC = TypeVar("FBC", bound=FileBackendConfiguration)


class FileBackendStream(Protocol):
    """The interface returned by :py:meth:`FileBackendInterface.get_stream`."""

    def read(self, length: int = ..., /) -> bytes:
        """Read bytes from the file."""

    def fileno(self) -> int:
        """Return the file descriptor number, if available."""


class FileBackendInterface(Generic[FBC], ABC):
    """Interface to operate files in a storage."""

    db_store: FileStore

    # Class used as the in-memory representation of file backend
    # configuration.
    _configuration_type: type[FBC]

    def __init_subclass__(cls, **kwargs: Any) -> None:
        """Register configuration type for a subclass."""
        super().__init_subclass__(**kwargs)

        # The configuration type, computed by introspecting the type
        # argument used to specialize this generic class.
        [cls._configuration_type] = extract_generic_type_arguments(
            cls, FileBackendInterface
        )

    @cached_property
    def configuration(self) -> FBC:
        """Return the configuration for this file backend."""
        return self._configuration_type(**self.db_store.configuration)

    @abstractmethod
    def get_url(self, fileobj: File) -> str | None:
        """Return a URL pointing to the content when possible or None."""
        raise NotImplementedError()

    @abstractmethod
    def get_local_path(self, fileobj: File) -> Path | None:
        """Return the path to a local copy of the file when possible or None."""
        raise NotImplementedError()

    def remove_file(self, fileobj: File) -> None:
        """
        Remove the file from the FileStore.

        This removes the copy of the file identified by ``fileobj`` from the
        underlying storage and also the FileInStore entry indicating the
        availability of said file in the FileStore.
        """
        try:
            FileInStore.objects.get(store=self.db_store, file=fileobj).delete()
        except FileInStore.DoesNotExist:
            pass

        self._remove_file(fileobj)

    @abstractmethod
    def _remove_file(self, fileobj: File) -> None:
        """Remove the file pointed by fileobj from the underlying storage."""
        raise NotImplementedError()

    @abstractmethod
    def add_file(self, local_path: Path, fileobj: File | None = None) -> File:
        """
        Copy local_path to underlying storage and register it in the FileStore.

        The optional ``fileobj`` provides the File used to identify the content
        available in local_path.

        The file may already exist in the store.  In that case the upload is
        for the purpose of proving that the user has access to the file
        contents and not merely its hash; this method must check whether the
        file contents are the same as those already in the store, and
        otherwise raise an exception rather than overwriting the existing
        file.
        """
        raise NotImplementedError()

    @contextlib.contextmanager
    def get_stream(
        self, fileobj: File
    ) -> Generator[FileBackendStream, None, None]:
        """
        Return a file-like object that can be read.

        Try using the file from get_local_path(). Do not support RemoteFiles
        yet.
        """
        if (local_path := self.get_local_path(fileobj)) is not None:
            with open(local_path, "rb") as f:
                yield f
        elif (url := self.get_url(fileobj)) is not None:
            with requests.get(url, stream=True) as r:
                r.raw.decode_content = True
                yield r.raw
        else:
            raise NotSupportedError(
                "This FileBackend doesn't support streaming"
            )
            # Note: this could fit a "always-redirect" remote file backend
