import os
from pathlib import Path
import shutil
from typing import Any
import yaml


class BaseConfig:
    """Lightweight Self-Healing config object."""

    def __init__(
        self, path: Path | str | None = None, paths: list[Path | str] | None = None
    ) -> None:
        """Initialize the config object.

        Args:
            path:
                The path to the config file. If the file does not exist, it will be created.
            paths:
                A list of paths to search for the config file.
                If it is not found in any, the last one in the list is used for creation.
        """
        # Prioritise direct assignment
        if path is not None:
            # cover the case of BaseConfig(path=["a", "b"])
            if isinstance(path, (list, tuple)):
                self._paths = path
            else:
                self._paths = [path]
        elif paths is not None:
            if not isinstance(paths, (list, tuple)) or len(paths) == 0:
                raise ValueError(
                    f"`paths` (type {type(paths)}) must be a valid list of paths"
                )
            self._paths = paths
        else:
            raise ValueError("Either `path` or `paths` must be provided.")

        self._attributes = {}
        for k, v in self.__class__.__dict__.items():
            if isinstance(v, property):
                continue
            if hasattr(v, "__call__"):
                continue
            if not k.startswith("_"):
                self._attributes[k] = v
                setattr(self, k, DeferredValue(k))

        if self.path.exists():
            self._ensure_file_integrity()

    def __getattribute__(self, name: str) -> Any:
        """Proxy attribute access. If the item is deferred, return the get instead."""
        item = object.__getattribute__(self, name)
        if isinstance(item, DeferredValue):
            return self.read(item.value)
        else:
            return item

    def __getitem__(self, key: str) -> Any:
        """Proxy subscript access to read method."""
        return self.read(key)

    @property
    def filename(self) -> str:
        """Filename, excluding path."""
        return self.path.name

    @property
    def path(self) -> Path:
        """Path to the config file."""
        return self._find_path()

    @property
    def abspath(self) -> Path:
        """Absolute path to the config file."""
        return self.path.resolve()

    def _ensure_dir(self) -> None:
        """Ensure that the directory for the config file exists."""
        dir_path = self.path.parent
        if not dir_path.exists():
            dir_path.mkdir(parents=True, exist_ok=True)

    def _ensure_file_integrity(self) -> bool:
        """Ensure that all attributes are present in the config file."""
        # if the file does not exist, we can get away with just writing the defaults
        if not self.path.exists():
            self.write()
            return True

        data = self._read()
        modified = False
        for attr, default in self._attributes.items():
            if attr not in data:
                data[attr] = default
                modified = True
        if modified:
            self.write()
        return modified

    def _find_path(self) -> Path:
        """Dynamically find the path"""
        path_obj = None
        for path in self._paths:
            path_obj = Path(os.path.expandvars(str(path))).expanduser()
            if path_obj.exists():
                return path_obj
        if path_obj is None:
            raise FileNotFoundError(f"Path list is malformed: {self._paths}")
        return path_obj

    def _read(self) -> dict[str, Any]:
        """Read the config file and return its contents."""
        self._ensure_dir()
        with self.path.open("r") as f:
            data = yaml.safe_load(f)
        if isinstance(data, dict):
            return data
        # In the case of a broken file, back it up and create a default one
        target_path = self.path
        backup_name = f"{self.filename}.bk"
        print(
            f"WARNING: Config file {target_path} failed to load.\n\tBacking up the file to: {backup_name}...",
            end=" ",
        )
        try:
            shutil.move(target_path, backup_name)
        except:
            print("Error.")
            raise

        print("Done.")
        return self.write(path=Path(target_path))

    def read(self, attr: str) -> Any:
        """Read the config file and return its contents.

        If it does not exist, creates the file and fills it with default vaulues.
        """
        self._ensure_file_integrity()
        data = self._read()
        return data.get(attr)

    def write(self, path: Path | None = None) -> dict[str, Any]:
        """Write to the config, ignoring any existing values."""
        if path is None:
            path = self.abspath

        defaults = self._attributes.copy()
        if path.exists():
            defaults.update(self._read())
        self._ensure_dir()
        with path.open("w+") as f:
            yaml.dump(defaults, f)
        return defaults

    @property
    def attributes(self) -> list[str]:
        """List of attributes that are defined in this config."""
        return [attr for attr in self._attributes.keys()]


class DeferredValue:
    """Stub class for deferring value access."""

    __slots__ = ["_parent", "_value"]

    def __init__(self, value: str) -> None:
        """Create the stub.

        Args:
            value: The name of the variable to access.
        """
        if not isinstance(value, str):
            raise TypeError("Value target must be a string")

        self._value = value

    @property
    def value(self) -> str:
        return self._value
