import re
import sys
import uuid
from collections import defaultdict
from collections.abc import Collection, Iterator, Sequence
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import Any, Literal, SupportsIndex, overload

from cognite_toolkit._cdf_tk.cruds._resource_cruds.transformation import TransformationCRUD
from cognite_toolkit._cdf_tk.data_classes._module_directories import ModuleLocation
from cognite_toolkit._cdf_tk.exceptions import ToolkitValueError
from cognite_toolkit._cdf_tk.feature_flags import Flags

if sys.version_info >= (3, 11):
    from typing import Self
else:
    from typing_extensions import Self


@dataclass(frozen=True)
class BuildVariable:
    """This is an internal representation of a  build variable in a config.[env].file

    Args:
        key: The name of the variable.
        value: The value of the variable.
        is_selected: Whether the variable is selected by the user through Environment.selected
        location: The location for the variable which is used to determine the module(s) it belongs to

    """

    key: str
    value: str | int | float | bool | tuple[str | int | float | bool]
    is_selected: bool
    location: Path
    iteration: int | None = None

    @property
    def value_variable(self) -> str | int | float | bool | list[str | int | float | bool]:
        """Returns the value of the variable as a variable."""
        if isinstance(self.value, tuple):
            # Convert the tuple back to a list to make it JSON serializable
            return list(self.value)
        else:
            return self.value

    def dump(self) -> dict[str, Any]:
        return {
            "key": self.key,
            "value": self.value_variable,
            "is_selected": self.is_selected,
            "location": self.location.as_posix(),
        }

    @classmethod
    def load(cls, data: dict[str, Any]) -> Self:
        if isinstance(data["value"], list):
            # Convert the list to a tuple to make it hashable
            value = tuple(data["value"])
        else:
            value = data["value"]
        return cls(data["key"], value, data["is_selected"], Path(data["location"]))


class BuildVariables(tuple, Sequence[BuildVariable]):
    """This is an internal representation of the build variables in a config.[env].file

    The motivation for this class is to provide helper functions for the user to interact with the build variables.
    """

    # Subclassing tuple to make the class immutable. BuildVariables is expected to be initialized and
    # then used as a read-only object.
    def __new__(cls, collection: Collection[BuildVariable], source_path: Path | None = None) -> Self:
        # Need to override __new__ to as we are subclassing a tuple:
        #   https://stackoverflow.com/questions/1565374/subclassing-tuple-with-multiple-init-arguments
        return super().__new__(cls, tuple(collection))

    def __init__(self, collection: Collection[BuildVariable], source_path: Path | None = None) -> None:
        super().__init__()
        self.source_path = source_path

    @cached_property
    def selected(self) -> "BuildVariables":
        return BuildVariables([variable for variable in self if variable.is_selected])

    @classmethod
    def load_raw(
        cls,
        raw_variable: dict[str, Any],
        available_modules: set[Path],
        selected_modules: set[Path] | None = None,
        source_path: Path | None = None,
    ) -> Self:
        """Loads the variables from the user input."""
        variables = []
        to_check: list[tuple[Path, int | None, dict[str, Any]]] = [(Path(""), None, raw_variable)]
        while to_check:
            path, iteration, subdict = to_check.pop()
            for key, value in subdict.items():
                subpath = path / key
                if subpath in available_modules and isinstance(value, dict):
                    to_check.append((subpath, None, value))
                elif subpath in available_modules and isinstance(value, list):
                    if Flags.MODULE_REPEAT.is_enabled():
                        for no, module_variables in enumerate(value, 1):
                            if not isinstance(module_variables, dict):
                                raise ToolkitValueError(f"Variables under a module must be a dictionary: {subpath}.")
                            to_check.append((subpath, no, module_variables))
                    else:
                        raise ToolkitValueError(
                            f"Variables under a module cannot be a list: {subpath}. Please use a dictionary/mapping."
                        )
                elif isinstance(value, dict):
                    # Remove this check to support variables with dictionary values.
                    continue
                else:
                    hashable_values = tuple(value) if isinstance(value, list) else value
                    is_selected = selected_modules is None or path in selected_modules
                    variables.append(BuildVariable(key, hashable_values, is_selected, path, iteration))

        return cls(variables, source_path=source_path)

    @classmethod
    def load(cls, data: list[dict[str, Any]]) -> Self:
        """Loads the variables from a dictionary."""
        return cls([BuildVariable.load(variable) for variable in data])

    def get_module_variables(self, module: ModuleLocation) -> "list[BuildVariables]":
        """Gets the variables for a specific module."""
        variables_by_key_by_iteration: dict[int | None, dict[str, list[BuildVariable]]] = defaultdict(
            lambda: defaultdict(list)
        )
        for variable in self:
            if variable.location == module.relative_path or variable.location in module.parent_relative_paths:
                variables_by_key_by_iteration[variable.iteration][variable.key].append(variable)

        base_variables: dict[str, list[BuildVariable]] = variables_by_key_by_iteration.pop(None, {})
        variable_sets: list[dict[str, list[BuildVariable]]]
        if variables_by_key_by_iteration:
            # Combine each with the base variables
            variable_sets = []
            for _, variables_by_key in sorted(variables_by_key_by_iteration.items(), key=lambda x: x[0] or 0):
                for key, build_variable in base_variables.items():
                    variables_by_key[key].extend(build_variable)
                variable_sets.append(variables_by_key)
        else:
            variable_sets = [base_variables]

        return [
            BuildVariables(
                [
                    # We select the variable with the longest path to ensure that the most specific variable is selected
                    max(variables, key=lambda v: len(v.location.parts))
                    for variables in variable_set.values()
                ],
                source_path=self.source_path,
            )
            for variable_set in variable_sets
        ]

    @overload
    def replace(self, content: str, file_path: Path | None = None, use_placeholder: Literal[False] = False) -> str: ...

    @overload
    def replace(
        self, content: str, file_path: Path | None = None, use_placeholder: Literal[True] = True
    ) -> tuple[str, dict[str, BuildVariable]]: ...

    def replace(
        self, content: str, file_path: Path | None = None, use_placeholder: bool = False
    ) -> str | tuple[str, dict[str, BuildVariable]]:
        # Extract file suffix from path, default to .yaml if not provided
        file_suffix = file_path.suffix if file_path and file_path.suffix else ".yaml"

        variable_by_placeholder: dict[str, BuildVariable] = {}
        for variable in self:
            if not use_placeholder:
                replace = variable.value_variable
            else:
                replace = f"VARIABLE_{uuid.uuid4().hex[:8]}"
                variable_by_placeholder[replace] = variable

            _core_pattern = rf"{{{{\s*{variable.key}\s*}}}}"
            if file_suffix == ".sql":
                # For SQL files, convert lists to SQL-style tuples
                if isinstance(replace, list):
                    replace = self._format_list_as_sql_tuple(replace)
                content = re.sub(_core_pattern, str(replace), content)
            elif file_suffix in {".yaml", ".yml", ".json"}:
                # Check if this is a transformation file (ends with Transformation.yaml/yml)
                is_transformation_file = file_path is not None and f".{TransformationCRUD.kind}." in file_path.name
                # Check if variable is within a query field (SQL context)
                is_in_query_field = self._is_in_query_field(content, variable.key)

                # For lists in query fields, use SQL-style tuples
                # For transformation files, ensure SQL conversion is applied to query property variables
                if is_transformation_file and is_in_query_field and isinstance(replace, list):
                    replace = self._format_list_as_sql_tuple(replace)
                    # Use simple pattern for SQL context (no YAML quoting needed)
                    content = re.sub(_core_pattern, str(replace), content)
                else:
                    # Preserve data types for YAML
                    pattern = _core_pattern
                    if isinstance(replace, str) and (replace.isdigit() or replace.endswith(":")):
                        replace = f'"{replace}"'
                        pattern = rf"'{_core_pattern}'|{_core_pattern}|" + rf'"{_core_pattern}"'
                    elif replace is None:
                        replace = "null"
                    content = re.sub(pattern, str(replace), content)
            else:
                # For other file types, use simple string replacement
                content = re.sub(_core_pattern, str(replace), content)
        if use_placeholder:
            return content, variable_by_placeholder
        else:
            return content

    @staticmethod
    def _is_transformation_file(file_path: Path) -> bool:
        """Check if the file path indicates a transformation YAML file.

        Transformation files are YAML files in the "transformations" folder.

        Args:
            file_path: The file path to check

        Returns:
            True if the file is a transformation YAML file
        """
        # Check if path contains "transformations" folder and ends with .yaml/.yml
        path_str = file_path.as_posix().lower()
        return "transformations" in path_str and file_path.suffix.lower() in {".yaml", ".yml"}

    @staticmethod
    def _format_list_as_sql_tuple(replace: list[Any]) -> str:
        """Format a list as a SQL-style tuple string.

        Args:
            replace: The list to format

        Returns:
            SQL tuple string, e.g., "('A', 'B', 'C')" or "()" for empty lists
        """
        if not replace:
            # Empty list becomes empty SQL tuple
            return "()"
        else:
            # Format list as SQL tuple: ('A', 'B', 'C')
            formatted_items = []
            for item in replace:
                if item is None:
                    formatted_items.append("NULL")
                elif isinstance(item, str):
                    formatted_items.append(f"'{item}'")
                else:
                    formatted_items.append(str(item))
            return f"({', '.join(formatted_items)})"

    @staticmethod
    def _is_in_query_field(content: str, variable_key: str) -> bool:
        """Check if a variable is within a query field in YAML.

        Assumes query is a top-level property. This detects various YAML formats:
        - query: >-
        - query: |
        - query: "..."
        - query: ...
        """
        lines = content.split("\n")
        variable_pattern = rf"{{{{\s*{re.escape(variable_key)}\s*}}}}"
        in_query_field = False

        for line in lines:
            # Check if this line starts a top-level query field
            query_match = re.match(r"^query\s*:\s*(.*)$", line)
            if query_match:
                in_query_field = True
                query_content_start = query_match.group(1).strip()

                # Check if variable is on the same line as query: declaration
                if re.search(variable_pattern, line):
                    return True

                # If query content starts on same line (not a block scalar), check it
                if query_content_start and not query_content_start.startswith(("|", ">", "|-", ">-", "|+", ">+")):
                    if re.search(variable_pattern, query_content_start):
                        return True
                continue

            # Check if we're still in the query field
            if in_query_field:
                # If we hit another top-level property, we've exited the query field
                if re.match(r"^\w+\s*:", line):
                    in_query_field = False
                    continue

                # We're still in the query field, check for variable
                if re.search(variable_pattern, line):
                    return True

        return False

    # Implemented to get correct type hints
    def __iter__(self) -> Iterator[BuildVariable]:
        return super().__iter__()

    @overload
    def __getitem__(self, index: SupportsIndex) -> BuildVariable: ...

    @overload
    def __getitem__(self, index: slice) -> Self: ...

    def __getitem__(self, index: SupportsIndex | slice, /) -> "BuildVariable | BuildVariables":
        if isinstance(index, slice):
            return BuildVariables(super().__getitem__(index))
        return super().__getitem__(index)

    def dump(self) -> list[dict[str, Any]]:
        return [variable.dump() for variable in self]
