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

"""Tests for file backends."""

import logging
from pathlib import Path
from typing import Optional
from unittest import mock

from django.conf import settings
from django.test import TestCase

from debusine.db.models import File, FileInStore, FileStore, Workspace
from debusine.db.tests.utils import _calculate_hash_from_data
from debusine.server.file_backend.interface import FileBackendInterface
from debusine.server.file_backend.local import LocalFileBackend
from debusine.test import TestHelpersMixin
from debusine.utils import NotSupportedError


class FakeFileBackend(FileBackendInterface):
    """Fake file backend to test methods in FileBackendInterface."""

    def __init__(self):
        """Initialize object."""
        super().__init__()

        self.db_store = FileStore.objects.create(
            name="Fake", backend="Fake", configuration={}
        )
        self.remove_file_calls = []

    def add_file(self, local_path, fileobj: Optional[File] = None) -> File:
        """Add file in the store."""
        fileobj = File.from_local_path(local_path)
        FileInStore.objects.get_or_create(
            store=self.db_store, file=fileobj, data={}
        )

        return fileobj

    def get_local_path(self, fileobj):  # noqa: U100
        """Return local path of fileobj."""
        return None

    def get_url(self, fileobj):  # noqa: U100
        """Return URL of fileobj."""
        return None

    def _remove_file(self, fileobj: File):
        # Ensure that the file is NOT in the DB before remove_file is called
        # The FileBackendInterface.remove_file should have removed it from the
        # DB before deleting the file from disk
        if FileInStore.objects.filter(
            store=self.db_store, file=fileobj
        ).exists():
            raise RuntimeError(
                "Attempted to remove file from disk but still is referenced "
                "by the FileInStore model"
            )  # pragma: no cover

        self.remove_file_calls.append(fileobj)


class FileBackendInterfaceTests(TestHelpersMixin, TestCase):
    """Tests for code of FileBackendInterface."""

    def setUp(self):
        """Initialize file backend to be tested."""
        self.file_backend = FakeFileBackend()

    def test_remove_file_not_in_file_backend(self):
        """
        remove_file() call _remove_file.

        Assert that when the file in disk is deleted from the backend
        the fileobj model is not deleted yet. Debusine can have
        orphaned files in disk (not referenced by the DB) but must not have
        files in DB that don't exist in disk.
        """
        fileobj = File.objects.create(hash_digest=b"test", size=15)

        self.file_backend.remove_file(fileobj)

        self.assertEqual(self.file_backend.remove_file_calls, [fileobj])

    def test_remove_file_in_file_backend(self):
        """remove_file() call _remove_file, delete file from FileInStore()."""
        fileobj = self.create_file_in_backend(self.file_backend)

        self.assertTrue(
            FileInStore.objects.filter(
                file=fileobj, store=self.file_backend.db_store
            ).exists()
        )

        self.file_backend.remove_file(fileobj)

        self.assertFalse(
            FileInStore.objects.filter(
                file=fileobj, store=self.file_backend.db_store
            ).exists()
        )

    def test_get_stream_non_local(self):
        """get_stream() without local URL: RuntimeError."""
        file = File()

        with self.assertRaisesRegex(
            NotSupportedError,
            "^This FileBackend doesn't support streaming$",
        ):
            self.file_backend.get_stream(file).__enter__()


class LocalFileBackendTests(TestHelpersMixin, TestCase):
    """Tests for LocalFileBackend."""

    def setUp(self):
        """Initialize file backend to be tested."""
        self.default_file_store = FileStore.default()
        self.file_backend = LocalFileBackend(self.default_file_store)

    def test_init_set_base_directory_default(self):
        """Test LocalFileBackend use setting's directory for Default store."""
        Workspace.objects.all().delete()
        self.default_file_store.delete()

        file_store = FileStore.objects.create(
            name=self.default_file_store.name, configuration={}
        )
        local_file_backend = LocalFileBackend(file_store)

        self.assertEqual(
            local_file_backend._base_directory,
            Path(settings.DEBUSINE_STORE_DIRECTORY),
        )

    def test_init_set_base_directory_from_config(self):
        """Test LocalFileBackend use the store configuration base_directory."""
        directory = "/tmp"
        file_store = FileStore.objects.create(
            name="tmp", configuration={"base_directory": directory}
        )
        local_file_backend = LocalFileBackend(file_store)

        self.assertEqual(local_file_backend._base_directory, Path(directory))

    def test_init_set_base_directory_raise_runtime_error(self):
        """
        Test LocalFileBackend raise RuntimeError: no base_directory setting.

        Only the store named "Default" use the
        settings.DEBUSINE_STORE_DIRECTORY by default.
        """
        store_name = "temporary"
        file_store = FileStore.objects.create(name=store_name, configuration={})

        with self.assertRaisesRegex(
            RuntimeError,
            f'LocalFileBackend {store_name} configuration requires '
            '"base_directory" setting',
        ):
            LocalFileBackend(file_store)

    def test_init_base_directory(self):
        """
        Test LocalFileBackend use base_directory from LocalStore configuration.

        Add a file into the backend and check that the correct path was used.
        """
        local_store_directory = self.create_temporary_directory()

        local_store = FileStore.default()
        local_store.configuration["base_directory"] = (
            local_store_directory.as_posix()
        )
        local_store.save()

        file_backend = LocalFileBackend(local_store)

        fileobj = self.create_file_in_backend(
            file_backend, remove_file_from_backend=False
        )
        # No need to schedule deletion of the file because
        # the local_store_directory is cleaned up

        fileobj_path = file_backend.get_local_path(fileobj)

        self.assertTrue(
            str(fileobj_path.parent).startswith(
                local_store_directory.as_posix()
            )
        )

    def test_get_local_path_for_added_file(self):
        """LocalFileBackend.from_local_path return the already existing file."""
        temp_file_path = self.create_temporary_file()

        fileobj = File.from_local_path(temp_file_path)

        self.assertEqual(File.from_local_path(temp_file_path), fileobj)

    def test_get_local_path(self):
        """LocalFileBackend.get_local_path for a file return the file path."""
        temp_file_path = self.create_temporary_file()

        hash_hex = File.calculate_hash(temp_file_path).hex()

        file = File.from_local_path(temp_file_path)

        self.assertEqual(
            self.file_backend.get_local_path(file),
            Path(settings.DEBUSINE_STORE_DIRECTORY)
            / hash_hex[0:2]
            / hash_hex[2:4]
            / hash_hex[4:6]
            / f"{hash_hex}-{file.size}",
        )

    def test_get_url(self):
        """LocalFileBackend.get_url return None."""
        fileobj = self.create_file_in_backend(self.file_backend)

        self.assertIsNone(self.file_backend.get_url(fileobj))

    def test_add_file_from_local_path_reuse_fileobj(self):
        """LocalFileBackend.add_file reuse the fileobj."""
        temp_file_path = self.create_temporary_file(contents=b"This is a test")

        fileobj = File.from_local_path(temp_file_path)

        fileobj_before_id = fileobj.id

        added_fileobj = self.file_backend.add_file(
            temp_file_path, fileobj=fileobj
        )

        self.assertEqual(added_fileobj.id, fileobj_before_id)
        self.assertTrue(
            FileInStore.objects.filter(
                store=self.file_backend.db_store, file=added_fileobj
            ).exists()
        )

    def test_add_file_from_local_path_fileobj_size_do_not_match(self):
        """
        LocalFileBackend.add_file with fileobj not reused: size mismatch.

        Exception is raised, file is not added in the backend.
        """
        original_contents = b"This is a test"
        new_contents = b"Different content length"

        temp_file_path = self.create_temporary_file(contents=original_contents)
        fileobj = File.from_local_path(temp_file_path)

        self.assertEqual(FileInStore.objects.count(), 0)

        with open(temp_file_path, "wb") as f:
            f.write(new_contents)

        expected_message = (
            "^add_file file size mismatch. Path: "
            f"{temp_file_path} Size in disk: {len(new_contents)} "
            f"fileobj.size: {len(original_contents)}$"
        )

        with self.assertRaisesRegex(ValueError, expected_message):
            self.file_backend.add_file(temp_file_path, fileobj=fileobj)

        # No file was added in the File table
        self.assertEqual(File.objects.count(), 1)

        # No file was added in the FileInStore (wouldn't be possible
        # since no file was added anyway)
        self.assertEqual(FileInStore.objects.count(), 0)

    def test_add_file_from_local_path_fileobj(self):
        """LocalFileBackend.add_file copy and return the file to the backend."""
        mocked_fsync = self.patch_os_fsync()
        contents = b"this is a test2"
        temp_file_path = self.create_temporary_file(contents=contents)

        # Create a file where the final file will be
        # This situation can happen if debusine copies a file into the
        # backend but the database is not updated (e.g. server crash)
        # Debusine, at a later point, might copy a file into the backend.
        # Let's test that the file is overwritten correctly
        fileobj_temp = File()
        fileobj_temp.hash_digest = _calculate_hash_from_data(contents)
        fileobj_temp.size = len(contents)
        fileobj_temp_path = self.file_backend.get_local_path(fileobj_temp)
        fileobj_temp_path.parent.mkdir(parents=True, exist_ok=True)

        # Writes the contents twice. To test that debusine will overwrite
        # the file
        fileobj_temp_path.write_bytes(contents * 2)
        self.addCleanup(fileobj_temp_path.unlink)

        # Add file in the backend
        file = self.file_backend.add_file(temp_file_path)

        # The fileobj returned has the expected size...
        self.assertEqual(file.size, len(contents))

        # ...and expected hash...
        self.assertEqual(file.hash_digest, _calculate_hash_from_data(contents))

        # The file has been properly copied
        file_in_file_backend_hash = File.calculate_hash(
            self.file_backend.get_local_path(file)
        )

        self.assertEqual(file.hash_digest, file_in_file_backend_hash)
        self.assertTrue(
            FileInStore.objects.filter(
                store=self.file_backend.db_store, file=file
            ).exists()
        )
        mocked_fsync.assert_called()

    def patch_shutil_copy_raise_filenotfound(self):
        """Patch shutil.copy: raise FileNotFoundError if called."""
        patcher = mock.patch("shutil.copy", autospec=True)
        mocked_copy = patcher.start()
        mocked_copy.side_effect = FileNotFoundError
        self.addCleanup(patcher.stop)

        return mocked_copy

    def test_add_file_from_local_path_file_cannot_be_copied(self):
        """LocalFileBackend.add_file fail to copy file: no File is created."""
        temp_file_path = self.create_temporary_file(contents=b"test")

        self.patch_shutil_copy_raise_filenotfound()

        try:
            self.file_backend.add_file(temp_file_path)
        except FileNotFoundError:
            # shutil.copy() raised FileNotFoundError:
            # in production this could be any error: FileNotFoundError
            # because the file is gone when it tried to copy it, no space to
            # finish the copy, wrong permissions,
            # Only if the file is copied should then be referenced in the DB
            pass

        # The File in db was not created
        self.assertFalse(File.objects.all().exists())

    def test_add_file_for_an_added_file(self):
        """
        LocalFileBackend.add_file add a file that is already in the backend.

        Assert that shutil.copy is not called.
        """
        fileobj = self.create_file_in_backend(self.file_backend)

        mocked_copy = self.patch_shutil_copy_raise_filenotfound()

        # In this test we pretend to add the file from the backend into the
        # backend. Usually it would be from outside the backend directory.
        file_path = self.file_backend.get_local_path(fileobj)

        self.assertEqual(
            self.file_backend.add_file(file_path, fileobj), fileobj
        )
        mocked_copy.assert_not_called()

    def test_directory_not_created_if_base_directory_do_not_exist(self):
        """
        A FileStore points to a base directory that does not exist.

        LocalFileBackend.add_file() does not create a directory: it raise
        FileNotFoundError: the base_directory must exist.
        """
        does_not_exist = self.create_temporary_directory()
        does_not_exist.rmdir()

        file_store = FileStore.objects.create(
            name="Testing",
            backend=FileStore.BackendChoices.LOCAL,
            configuration={"base_directory": str(does_not_exist)},
        )
        local_file_backend = LocalFileBackend(file_store)

        file_to_add = self.create_temporary_file()

        with self.assertRaises(FileNotFoundError):
            local_file_backend.add_file(file_to_add)

        # The directory must not exist, it is not created
        self.assertFalse(does_not_exist.is_dir())

    def test_base_directory(self):
        """LocalFileBackend.base_directory() return the _base_directory."""
        self.assertEqual(
            self.file_backend.base_directory(),
            self.file_backend._base_directory,
        )

    def test_get_stream(self):
        """LocalFileBackend.get_stream return a stream with file contents."""
        contents = b"this is a test"
        file = self.create_file_in_backend(self.file_backend, contents)

        with self.file_backend.get_stream(file) as f:
            self.assertEqual(f.read(), contents)

    def test_remove_file(self):
        """LocalFileBackend._remove_file delete the file from the backend."""
        file = self.create_file_in_backend(
            self.file_backend, remove_file_from_backend=False
        )

        self.assertTrue(self.file_backend.get_local_path(file).exists())

        self.file_backend._remove_file(file)

        self.assertFalse(self.file_backend.get_local_path(file).exists())

    def test_remove_file_did_not_exist(self):
        """
        LocalFileBackend.remove_file try to delete a file that did not exist.

        This could happen if remove_file() was called concurrently.
        """
        file = self.create_file_in_backend(self.file_backend)
        self.file_backend.get_local_path(file).unlink()

        self.file_backend._remove_file(file)
        self.assertTrue(True)  # No exception was raised

    def patch_os_fsync(self):
        """Return mocked os.fsync."""
        patcher = mock.patch("os.fsync", autospec=True)
        mocked_fsync = patcher.start()
        self.addCleanup(patcher.stop)

        return mocked_fsync

    def test_sync_file(self):
        """LocalFileBackend._sync_file sync the file and its directory."""
        mocked_fsync = self.patch_os_fsync()

        temporary_file = self.create_temporary_file()

        LocalFileBackend._sync_file(temporary_file)

        # Called twice: one to os.fsync the file and again for the directory
        self.assertEqual(mocked_fsync.call_count, 2)

    def test_sync_file_does_not_exist(self):
        """LocalFileBackend._sync_file log warning: file does not exist."""
        temporary_file = self.create_temporary_file()
        temporary_file.unlink()

        expected_message = f"Could not open {temporary_file} for flushing"

        with self.assertLogsContains(
            expected_message, logger="debusine", level=logging.DEBUG
        ):
            LocalFileBackend._sync_file(temporary_file)
