# Copyright 2024 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 in-memory file backend."""

import contextlib
import io
import logging
from collections import defaultdict
from collections.abc import Generator
from pathlib import Path

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 MemoryFileBackendConfiguration

logger = logging.getLogger(__name__)


class MemoryFileBackend(FileBackendInterface[MemoryFileBackendConfiguration]):
    """In-memory file backend (used for tests)."""

    # In-memory file storages, indexed by configuration names
    storages: dict[str, dict[str, bytes]] = defaultdict(dict)

    def __init__(self, file_store: FileStore) -> None:
        """Initialize MemoryFileBackend."""
        super().__init__()
        self.db_store = file_store
        self.storage = self.storages[self.configuration.name]

    def get_local_path(self, fileobj: File) -> None:  # noqa: U100
        """Return local_path for fileobj."""
        return None

    def _store_file(self, hash_hex: str, value: bytes) -> None:
        """Add/update a file in the storage."""
        # This is here mainly so it can be more easily mocked than a dict
        # assignment
        self.storage[hash_hex] = value

    @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={}
        )
        hash_hex = fileobj.hash_digest.hex()
        contents = local_path.read_bytes()
        if created:
            self._store_file(hash_hex, contents)
        elif self.storage[hash_hex] != contents:
            raise ValueError(
                f"add_file contents mismatch when trying to add {hash_hex}"
            )

        return fileobj

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

    def _remove_file(self, fileobj: File) -> None:
        """Remove the file pointed by fileobj from the backend."""
        hash_hex = fileobj.hash_digest.hex()
        self.storage.pop(hash_hex, None)

    @contextlib.contextmanager
    def get_stream(self, fileobj: File) -> Generator[io.BytesIO, None, None]:
        """Yield a file-like object that can be read."""
        hash_hex = fileobj.hash_digest.hex()
        with io.BytesIO(self.storage[hash_hex]) as out:
            yield out
