import hashlib
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import Enum, auto
from functools import cached_property
from pathlib import Path
from typing import Any, Optional, Union


def make_list_unique(seq: list[Any]) -> list[Any]:
    return list(dict.fromkeys(seq))


class LibraryType(Enum):
    OBJECT = auto()
    INTERFACE = auto()


class IncludeScope(Enum):
    INTERFACE = auto()
    PUBLIC = auto()
    PRIVATE = auto()


class CMakeElement(ABC):
    tab_prefix = " " * 4

    @abstractmethod
    def to_string(self) -> str:
        pass

    def __str__(self) -> str:
        return self.to_string()


class CMakeContent(CMakeElement):
    def __init__(self, content: str) -> None:
        super().__init__()
        self.content = content

    def to_string(self) -> str:
        return self.content


class CMakeEmptyLine(CMakeElement):
    def to_string(self) -> str:
        return ""


class CMakeComment(CMakeElement):
    def __init__(self, comment: str) -> None:
        super().__init__()
        self.comment = comment

    def to_string(self) -> str:
        return f"# {self.comment}"


class CMakeProject(CMakeElement):
    def __init__(self, name: str) -> None:
        super().__init__()
        self.name = name

    def to_string(self) -> str:
        return f"project({self.name})"


class CMakeMinimumVersion(CMakeElement):
    def __init__(self, version: str) -> None:
        super().__init__()
        self.version = version

    def to_string(self) -> str:
        return f"cmake_minimum_required(VERSION {self.version})"


class CMakeAddLibrary(CMakeElement):
    def __init__(
        self,
        name: str,
        files: list[Path] | None = None,
        type: LibraryType | None = None,
        compile_options: list[str] | None = None,
    ) -> None:
        self.name = name
        self.files = files or []
        self.type = type or (LibraryType.OBJECT if self.files else LibraryType.INTERFACE)
        self.compile_options = compile_options or []

    @property
    def target_name(self) -> str:
        return f"{self.name}_lib"

    def to_string(self) -> str:
        content = f"add_library({self.target_name} {self.type.name} {self._get_files_string()})"
        if self.compile_options:
            content += "\n" + self._add_compile_options()
        return content

    def _get_files_string(self) -> str:
        return " ".join([file.as_posix() for file in self.files])

    def _add_compile_options(self) -> str:
        return f"target_compile_options({self.target_name} PRIVATE " + " ".join(self.compile_options) + ")"


@dataclass
class CMakeVariable(CMakeElement):
    name: str
    value: str
    cache: bool = False
    type: Optional[str] = None
    docstring: Optional[str] = None
    force: bool = False

    def to_string(self) -> str:
        arguments = [self.name, self.value]
        if self.cache:
            arguments.append("CACHE")
        if self.type:
            arguments.append(self.type)
        if self.docstring is not None:
            if self.docstring:
                arguments.append(self.docstring)
            else:
                arguments.append('""')
        if self.force:
            arguments.append("FORCE")
        return "set(" + " ".join(arguments) + ")"


class CMakePath:
    def __init__(
        self,
        path: Path,
        variable: Optional[str] = None,
        relative_path: Optional[Path] = None,
    ) -> None:
        super().__init__()
        self.path = path
        self.variable = variable
        self.relative_path = relative_path

    def to_cmake_element(self) -> Optional[CMakeElement]:
        return CMakeVariable(self.variable, self.to_path().as_posix()) if self.variable else None

    def to_string(self) -> str:
        if self.variable:
            path = f"${{{self.variable}}}"
        else:
            path = self.path.as_posix()
        if self.relative_path:
            path += f"/{self.relative_path.as_posix()}"
        return path

    def to_path(self) -> Path:
        return self.path / self.relative_path if self.relative_path else self.path

    def joinpath(self, path: str) -> "CMakePath":
        rel_path = self.relative_path / path if self.relative_path else Path(path)
        return CMakePath(self.path, self.variable, rel_path)

    def __str__(self) -> str:
        return self.to_string()

    def with_suffix(self, suffix: str) -> "CMakePath":
        if self.relative_path:
            new_relative_path = self.relative_path.with_suffix(suffix)
            return CMakePath(self.path, self.variable, new_relative_path)
        else:
            new_path = self.path.with_suffix(suffix)
            return CMakePath(new_path, self.variable, None)

    def __eq__(self, value: object) -> bool:
        if not isinstance(value, CMakePath):
            return False
        return self.path == value.path and self.relative_path == value.relative_path


class CMakeInclude(CMakeElement):
    def __init__(self, path: str | CMakePath) -> None:
        super().__init__()
        self.path = path

    def to_string(self) -> str:
        return f"include({self.path})"


class CMakeIncludeDirectories(CMakeElement):
    def __init__(self, paths: list[CMakePath]) -> None:
        super().__init__()
        self.paths = paths

    def to_string(self) -> str:
        return "\n".join(["include_directories(", *self._add_tabulated_paths(self.paths), ")"])

    def _add_tabulated_paths(self, paths: list[CMakePath]) -> list[str]:
        return [self._add_tabulated_path(path) for path in paths]

    def _add_tabulated_path(self, path: CMakePath) -> str:
        return self.tab_prefix + path.to_string()


class CMakeTargetIncludeDirectories(CMakeElement):
    def __init__(self, target_name: str, paths: list[CMakePath], scope: IncludeScope = IncludeScope.PRIVATE) -> None:
        super().__init__()
        self.target_name = target_name
        self.paths = paths
        self.scope = scope

    def to_string(self) -> str:
        if not self.paths:
            return ""
        paths_str = " ".join(path.to_string() for path in self.paths)
        return f"target_include_directories({self.target_name} {self.scope.name} {paths_str})"


@dataclass
class CMakeAddExecutable(CMakeElement):
    name: str
    sources: list[Union[str, CMakePath, CMakeAddLibrary]]
    libraries: list[str] = field(default_factory=list)
    compile_options: list[str] = field(default_factory=list)
    link_options: list[str] = field(default_factory=list)
    exclude_from_all: bool = False

    def to_string(self) -> str:
        content = self._add_executable()
        if self.libraries:
            # add target_link_libraries
            content += "\n" + self._add_target_link_libraries()
        if self.compile_options:
            content += "\n" + self._add_compile_options()
        if self.link_options:
            content += "\n" + self._add_link_options()
        return content

    def _add_executable(self) -> str:
        arguments = [self.name]
        if self.exclude_from_all:
            arguments.append("EXCLUDE_FROM_ALL")
        arguments.extend(self._get_sources())
        return "add_executable(" + " ".join(arguments) + ")"

    def _add_target_link_libraries(self) -> str:
        return "target_link_libraries(" + " ".join([self.name, *self.libraries]) + ")"

    def _get_sources(self) -> list[str]:
        return [self._get_source(source) for source in self.sources]

    def _get_source(self, source: str | CMakePath | CMakeAddLibrary) -> str:
        """
        Get the source as string.

        - str: return as is
        - CMakePath: return the path as string
        - CMakeAddLibrary: return the target objects
        """
        if isinstance(source, CMakeAddLibrary):
            return f"$<TARGET_OBJECTS:{source.target_name}>"
        else:
            return str(source)

    def _add_compile_options(self) -> str:
        return f"target_compile_options({self.name} PRIVATE " + " ".join(self.compile_options) + ")"

    def _add_link_options(self) -> str:
        return f"target_link_options({self.name} PRIVATE " + " ".join(self.link_options) + ")"


@dataclass
class CMakeSetTargetProperties(CMakeElement):
    target: str
    properties: dict[str, str | CMakePath]

    def to_string(self) -> str:
        if not self.properties:
            return ""

        props = []
        for key, value in self.properties.items():
            props.extend([key, str(value)])

        return f"set_target_properties({self.target} PROPERTIES {' '.join(props)})"


class CMakeCommand(CMakeElement):
    def __init__(self, command: str | CMakePath, arguments: list[str | CMakePath]) -> None:
        super().__init__()
        self.command = command
        self.arguments = arguments

    def to_string(self) -> str:
        result = f"COMMAND {self.command}"
        if self.arguments:
            args_str = " ".join(str(arg) for arg in self.arguments)
            result += f" {args_str}"
        return result


class CMakeExecuteProcess(CMakeElement):
    def __init__(self, description: str, commands: list[CMakeCommand]) -> None:
        super().__init__()
        self.description = description
        self.commands = commands

    def to_string(self) -> str:
        content = [CMakeComment(self.description), "execute_process("]
        content.extend(self._get_commands())
        content.extend(
            [
                f"{self.tab_prefix}RESULT_VARIABLE result",
                ")",
                "if(result)",
                f"{self.tab_prefix}message(FATAL_ERROR '{self.description} failed: ${{result}}')",
                "endif()",
            ]
        )
        return "\n".join(str(line) for line in content)

    def _get_commands(self) -> list[str]:
        return [self.tab_prefix + str(command) for command in self.commands]


class CMakeDepends(CMakeElement):
    def __init__(self, depends: Sequence[str | CMakePath]) -> None:
        super().__init__()
        self.depends = depends

    def to_string(self) -> str:
        return f"{self.tab_prefix}DEPENDS {' '.join(str(depend) for depend in self.depends)}"


class CMakeByproducts(CMakeElement):
    def __init__(self, byproducts: list[CMakePath]) -> None:
        super().__init__()
        self.byproducts = byproducts

    def to_string(self) -> str:
        return f"{self.tab_prefix}BYPRODUCTS {' '.join(str(byproduct) for byproduct in self.byproducts)}"


class CMakeBuildEvent(Enum):
    PRE_BUILD = auto()
    PRE_LINK = auto()
    POST_BUILD = auto()


@dataclass
class CMakeCustomCommand(CMakeElement):
    description: str
    commands: list[CMakeCommand]
    outputs: Optional[list[CMakePath]] = None
    depends: Optional[Sequence[str | CMakePath]] = None
    working_directory: Optional[CMakePath] = None
    build_event: Optional[CMakeBuildEvent] = None
    byproducts: Optional[list[CMakePath]] = None
    target: Optional[str] = None
    command_expand_lists: bool = False

    @cached_property
    def id(self) -> str:
        """Combine the first 12 characters of the sha256 hash of the description with the commands hash for uniqueness."""
        desc_hash = hashlib.sha256(self.description.encode("utf-8")).hexdigest()[:12]
        commands_str = "".join(str(command) for command in self.commands)
        commands_hash = hashlib.sha256(commands_str.encode("utf-8")).hexdigest()[:12]
        return f"cmd_{desc_hash}_{commands_hash}"

    def to_string(self) -> str:
        content = [CMakeComment(self.description), "add_custom_command("]
        if self.target:
            content.append(f"{self.tab_prefix}TARGET {self.target}")
        if self.build_event:
            content.append(f"{self.tab_prefix}{self.build_event.name}")
        if self.outputs:
            content.extend(self._get_outputs())
        if self.depends:
            content.append(CMakeDepends(self.depends).to_string())
        content.extend(self._get_commands())
        if self.byproducts:
            content.append(CMakeByproducts(self.byproducts).to_string())
        if self.working_directory:
            content.append(f"{self.tab_prefix}WORKING_DIRECTORY {self.working_directory.to_string()}")
        if self.command_expand_lists:
            content.append(f"{self.tab_prefix}COMMAND_EXPAND_LISTS")

        content.append(")")
        return "\n".join(str(line) for line in content)

    def _get_outputs(self) -> list[str]:
        if not self.outputs:
            return []
        return [f"{self.tab_prefix}OUTPUT {' '.join([output.to_string() for output in self.outputs])}"]

    def _get_commands(self) -> list[str]:
        return [self.tab_prefix + str(command) for command in self.commands]


@dataclass
class CMakeCustomTarget(CMakeElement):
    def __init__(
        self,
        name: str,
        description: str,
        commands: list[CMakeCommand],
        depends: Optional[Sequence[str | CMakePath]] = None,
        default_target: bool = False,
        byproducts: Optional[list[CMakePath]] = None,
    ) -> None:
        super().__init__()
        self.name = name
        self.description = description
        self.depends = depends
        self.commands = commands
        self.default_target = default_target
        self.byproducts = byproducts

    def to_string(self) -> str:
        add_to_all_target = "ALL" if self.default_target else ""
        content = [
            CMakeComment(self.description),
            f"add_custom_target({self.name} {add_to_all_target}",
        ]
        content.extend(self._get_commands())
        if self.depends:
            content.append(CMakeDepends(self.depends).to_string())
        if self.byproducts:
            content.append(CMakeByproducts(self.byproducts).to_string())
        content.append(")")
        return "\n".join(str(line) for line in content)

    def _get_commands(self) -> list[str]:
        return [self.tab_prefix + str(command) for command in self.commands]


class CMakeAddSubdirectory(CMakeElement):
    """
    Add_subdirectory(source_dir [binary_dir] [EXCLUDE_FROM_ALL] [SYSTEM]).

    https://cmake.org/cmake/help/latest/command/add_subdirectory.html#add-subdirectory
    """

    def __init__(self, source_dir: CMakePath, build_dir: Optional[CMakePath] = None) -> None:
        super().__init__()
        self.source_dir = source_dir
        self.build_dir = build_dir

    def to_string(self) -> str:
        arguments = [self.source_dir.to_string()]
        if self.build_dir:
            arguments.append(self.build_dir.to_string())
        return "add_subdirectory(" + " ".join(arguments) + ")"


class CMakeListAppend(CMakeElement):
    def __init__(self, variable: str, values: list[str]) -> None:
        super().__init__()
        self.variable = variable
        self.values = values

    def to_string(self) -> str:
        return f"list(APPEND {self.variable} {self._get_values_string()})"

    def _get_values_string(self) -> str:
        return " ".join(self.values)


class CMakeEnableTesting(CMakeElement):
    def __init__(self) -> None:
        super().__init__()

    def to_string(self) -> str:
        return "enable_testing()"
