import shutil
from abc import ABC, abstractmethod
from enum import Enum
from pathlib import Path
from typing import Any, Dict

import pytest

from functional2.testlib.fixtures.formatter import BalancedTemplater


class Fileish(ABC):
    """
    Baseclass, which allows files to be copied declaratively
    """

    @abstractmethod
    def copy_to(self, path: Path, origin: Path) -> None:
        """
        Copies this file to the given TempDir
        :param path: TempDir for the test
        :param origin: Directory the tests originates in. Used to adjust relative paths
        """
        pass


class _ByContentFileish(Fileish, ABC):
    def __init__(self, mode: int | None = None):
        self.mode = mode

    @abstractmethod
    def get_content(self, origin: Path) -> str:
        """
        Returns the content, which should be present in the current path
        :return: content as a string
        """
        pass

    def copy_to(self, path: Path, origin: Path) -> None:
        path.write_text(self.get_content(origin))
        if self.mode is not None:
            path.chmod(self.mode)


class File(_ByContentFileish):
    def __init__(self, file_contents: str, mode: int | None = None):
        """
        Declares a file by its content
        :param file_contents: content of the file as a string
        :param mode: Optionally change the mode of the file (e.g. to executable)
        """
        super().__init__(mode)
        self.file_contents = file_contents

    def get_content(self, _: Path) -> str:
        return self.file_contents


class CopyFile(Fileish):
    def __init__(self, source: str):
        """
        Declares a file as a copy of an existing file
        :param source: Path to the file to be copied
        """
        self.source = source

    def copy_to(self, path: Path, origin: Path):
        shutil.copyfile(origin / self.source, path)


class CopyTree(Fileish):
    def __init__(self, tree_base: str):
        """
        Declares a folder as a copy of an existing folder
        :param tree_base: base folder of the tree being copied
        """
        self.tree_base = tree_base

    def copy_to(self, path: Path, origin: Path):
        shutil.copytree(origin / self.tree_base, path, dirs_exist_ok=True)


class CopyTemplate(_ByContentFileish):
    def __init__(self, template: str, values: Dict[str, Any], mode: int | None = None):
        """
        Declares a file as an initiated version of the given file template
        :param template: source template's file name. Parameters formatted as `{key_name}` are replaced by corresponding values
        :param values: dictionary of key_name and value to be replaced in the template.
        :param mode: Optionally change the mode of the file (e.g. to executable)
        """
        self.template = template
        self.values = values
        self.content: str | None = None

        super().__init__(mode)

    def get_content(self, origin: Path) -> str:
        template_path = origin / self.template
        template_content = template_path.read_text()

        self.content = BalancedTemplater(template_content).substitute(**self.values)

        return self.content


class RelativeTo(str, Enum):
    TEST = "test"
    """
    Base for the given path is the directory of the test
    """
    TARGET = "target"
    """
    Base for the given path is the directory of the target / where all files are created
    """
    ROOT = "root"
    """
    Base for the given path is the root folder (/)
    """
    SELF = "self"
    """
    No base path is given, used to create relative symlinks (e.g. `"../test"`)
    """


class Symlink(Fileish):
    def __init__(self, source: str, relative_to: RelativeTo = RelativeTo.TARGET):
        """
        Declares a file as a symlink to a different location

        NOTE: due to limitations of symlinks on Windows, tests using this might be flakey and fail!!
        :param source: Path to the source where the symlink should be pointing
        :raise ValueError: When the given source is an absolute Path, but relative to wasn't set to ROOT
        """
        self.source = source
        self.relative_to = relative_to

    def copy_to(self, path: Path, origin: Path):
        if self.relative_to is not RelativeTo.ROOT and Path(self.source).is_absolute():
            msg = "absolute paths are only supported when using RelativeTo.ROOT"
            raise ValueError(msg)
        match self.relative_to:
            case RelativeTo.TEST:
                base = origin
            case RelativeTo.TARGET:
                base = path.parent
            case RelativeTo.ROOT:
                base = Path("/")
            case RelativeTo.SELF:
                base = None
            case _:
                msg = f"unknown relativity {self.relative_to}"
                raise ValueError(msg)
        target_path = base / self.source if base is not None else Path(self.source)
        path.symlink_to(target_path)


type FileDeclaration = Dict[str, Fileish | "FileDeclaration"]


def _init_files(files: FileDeclaration, tmp_path: Path, request: pytest.FixtureRequest) -> None:
    """
    This internal function is needed because one cannot call a fixture directly since pytest 4.0
    """

    for name, definition in files.items():
        destination = tmp_path / name
        if isinstance(definition, Fileish):
            definition.copy_to(destination, request.path.parent)
        else:
            # Initialize subdirectory
            destination.mkdir()
            _init_files(definition, destination, request)


@pytest.fixture
def files(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
    """
    Initializes the given files into the TempDir of the test.
    This ensures all necessary files and only those are present
    To use this add `@pytest.mark.parametrize("files", [list_of_your_files_to_test, more_files_to_test], indirect=True)` above your test.
    The test is run once for each of the sets of files provided as the second argument.
    Each Set of files should be of the :py:type:`FileDeclaration` type

    :param tmp_path: TempDir of the test
    :param request: Fixture information provided by pytest, used to parametrize the files
    :return: Path to where the files were created
    """
    _init_files(request.param, tmp_path, request)
    return tmp_path
