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

import logging
from pathlib import Path
from unittest import mock

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

from debusine.db.models import (
    Collection,
    File,
    FileInStore,
    FileStore,
    Workspace,
)
from debusine.db.tests.utils import _calculate_hash_from_data
from debusine.server.file_backend.local import LocalFileBackend
from debusine.test import TestHelpersMixin


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

    playground_memory_file_store = False

    def setUp(self) -> None:
        """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) -> None:
        """Test LocalFileBackend use setting's directory for Default store."""
        Collection.objects.filter(name="_").delete()
        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) -> None:
        """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) -> None:
        """
        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) -> None:
        """
        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)
        # 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) -> None:
        """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) -> None:
        """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(self.default_file_store.configuration["base_directory"])
            / hash_hex[0:2]
            / hash_hex[2:4]
            / hash_hex[4:6]
            / f"{hash_hex}-{file.size}",
        )

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

        url = self.file_backend.get_url(  # type: ignore[func-returns-value]
            fileobj
        )
        self.assertIsNone(url)

    def test_add_file_from_local_path_reuse_fileobj(self) -> None:
        """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) -> None:
        """
        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) -> None:
        """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) -> mock.MagicMock:
        """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) -> None:
        """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) -> None:
        """
        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_add_file_contents_mismatch(self) -> None:
        """File exists in the backend with same hash but different contents."""
        original_contents = b"This is a test"
        new_contents = b"Hash collision"  # same length as original_contents
        temp_file_path = self.create_temporary_file(contents=original_contents)
        fileobj = File.from_local_path(temp_file_path)
        self.file_backend.add_file(temp_file_path)

        self.assertEqual(File.objects.count(), 1)
        self.assertEqual(FileInStore.objects.count(), 1)

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

        expected_message = (
            f"add_file contents mismatch when trying to add "
            f"{fileobj.hash_digest.hex()}"
        )

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

        self.assertEqual(File.objects.count(), 1)
        self.assertEqual(FileInStore.objects.count(), 1)

    def test_directory_not_created_if_base_directory_do_not_exist(self) -> None:
        """
        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) -> None:
        """LocalFileBackend.base_directory() return the _base_directory."""
        self.assertEqual(
            self.file_backend.base_directory(),
            self.file_backend._base_directory,
        )

    def test_get_stream(self) -> None:
        """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) -> None:
        """LocalFileBackend._remove_file delete the file from the backend."""
        file = self.create_file_in_backend(
            self.file_backend,
        )

        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) -> None:
        """
        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) -> mock.MagicMock:
        """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) -> None:
        """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) -> None:
        """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)
