# 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 Iterator
from functools import cached_property
from pathlib import Path
from typing import Generic, IO, 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 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):
        """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):
        """
        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):
        """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.
        """
        raise NotImplementedError()

    @contextlib.contextmanager
    def get_stream(self, fileobj: File) -> Iterator[IO[bytes]]:
        """
        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
