"""
ChitraGupt Cache Utility Tool.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

This module provides a type-static, JSON-like cache utility for handling
“.cache” files. It validates cache file paths, reads and writes cache content,
and formats the content with proper indentation.
"""

import json
import pathlib
import re
import shutil
import typing

import chitragupt.exceptions as exceptions

CacheKey = str
AnyIntStrFloatBool = typing.Union[int, str, float, bool]
WritableCache = typing.Dict[
    CacheKey, typing.Union[typing.List[AnyIntStrFloatBool], AnyIntStrFloatBool]
]
ReadableCache = typing.Dict[CacheKey, WritableCache]
BufferType = typing.TypeVar(
    "BufferType", str, bytes, typing.Dict[str, typing.Any], WritableCache
)

# =============================================================================
# Cache Path Creation
# =============================================================================
GLOBAL_CAHCHE: pathlib.Path = pathlib.Path("chitragupt/tmp/cache/global.cache")


class CachePathCreator(type(pathlib.Path())):
    """
    Validates and creates paths for cache files.

    Parameters
    ----------
    _path : str or pathlib.Path
        Path to the cache file.

    Raises
    ------
    ChitraGuptPathError
        If the path does not exist or does not have a “.cache” extension.
    """

    def __new__(cls, _path: typing.Union[str, pathlib.Path]) -> pathlib.Path:
        if not isinstance(_path, pathlib.Path):
            _path = pathlib.Path(_path)
        if not _path.exists():
            raise exceptions.ChitraGuptPathError(
                f"Path does not exist: {_path}"
            )
        if not _path.name.endswith(".cache"):
            raise exceptions.ChitraGuptPathError(
                f"Path is not a cache file: {_path}"
            )
        return _path


CachePath = typing.Union[pathlib.Path, CachePathCreator]


class DotCache:
    """
    Handles reading and writing to cache files.

    Parameters
    ----------
    path : CachePath
        Path to the cache file.

    Attributes
    ----------
    path : CachePath
        The validated cache file path.
    _file_handle : io.TextIOWrapper
        The file handle for reading/writing the cache file.
    """

    def __init__(self, path: CachePath) -> None:
        self.path: CachePath = path
        self._file_handle = self.path.open("r+", encoding="utf-8")

    def __enter__(self) -> "DotCache":
        """
        Enter the runtime context related to this object.

        Returns
        -------
        DotCache
            The cache object itself.
        """
        return self

    def __exit__(
        self, exc_type: typing.Any, exc_val: typing.Any, exc_tb: typing.Any
    ) -> None:
        """Exit the runtime context and close the file handle."""
        if not self._file_handle.closed:
            self._file_handle.close()

    def __del__(self) -> None:
        """Destructor to ensure the file handle is closed."""
        if not self._file_handle.closed:
            self._file_handle.close()

    @typing.overload
    def dict_format(self, buffer: str, indent: int = 4) -> str: ...

    @typing.overload
    def dict_format(self, buffer: bytes, indent: int = 4) -> bytes: ...

    @typing.overload
    def dict_format(
        self, buffer: typing.Dict[str, typing.Any], indent: int = 4
    ) -> str: ...

    def dict_format(
        self, buffer: BufferType, indent: int = 4
    ) -> typing.Union[str, bytes]:
        """
        Format the provided buffer as a properly indented JSON string.

        Parameters
        ----------
        buffer : str, bytes, or dict
            The content to format.
        indent : int, optional
            Number of spaces for indentation (default is 4).

        Returns
        -------
        str or bytes
            The formatted content. If the input was bytes, the output is bytes;
            otherwise, it is a string.
        """
        if isinstance(buffer, dict):
            result_str: str = json.dumps(buffer, indent=indent, sort_keys=True)
            return result_str
        elif isinstance(buffer, str):
            try:
                loaded = json.loads(buffer)
            except json.JSONDecodeError:
                # If not valid JSON, return the original string.
                return buffer
            return json.dumps(loaded, indent=indent, sort_keys=True)
        elif isinstance(buffer, bytes):
            try:
                loaded = json.loads(buffer.decode("utf-8"))
            except json.JSONDecodeError:
                return buffer
            result_str = json.dumps(loaded, indent=indent, sort_keys=True)
            return result_str.encode("utf-8")
        else:
            raise TypeError("Unsupported buffer type for formatting.")

    def fetch(self) -> str:
        """
        Read the entire content of the cache file.

        Returns
        -------
        str
            The content of the cache file.
        """
        self._file_handle.seek(0)
        return self._file_handle.read()

    def keys(self) -> typing.List[CacheKey]:
        """
        Extract all cache keys from the file content.

        Returns
        -------
        List[CacheKey]
            List of keys found in the cache file.
        """
        data: str = self.fetch()
        return re.findall(r"(\w+)\s*=", data)

    def get_writable_cache(self, key: CacheKey) -> WritableCache:
        """
        Retrieve cache content for a specific key and parse it as a dictionary.

        Parameters
        ----------
        key : CacheKey
            The key whose cache content to retrieve.

        Returns
        -------
        WritableCache
            A dictionary with the key and its parsed cache content.
            If parsing fails or the key is not found, returns the key with an
            empty string.
        """
        data: str = self.fetch()
        pattern: str = rf"{re.escape(key)}\s*=\s*(\{{.*?\}})"
        match = re.search(pattern, data, re.DOTALL)
        if match:
            json_str: str = match.group(1)
            try:
                parsed = json.loads(json_str)
                return {key: parsed}
            except json.JSONDecodeError:
                return {key: ""}
        return {key: ""}

    @typing.overload
    def format(self, content: WritableCache) -> WritableCache: ...

    @typing.overload
    def format(self) -> None: ...

    def format(
        self, content: typing.Optional[WritableCache] = None
    ) -> typing.Optional[WritableCache]:
        """
        Format the cache content by filtering out keys with empty values.

        Parameters
        ----------
        content : WritableCache, optional
            The cache content to format.

        Returns
        -------
        WritableCache or None
            The formatted cache content with empty values removed, or None if no
            content is provided.
        """
        return (
            {k: v for k, v in content.items() if v}
            if content is not None
            else None
        )

    def write(self, key: CacheKey, cache_content: WritableCache) -> None:
        """
        Write or update the cache content for a specific key in the cache file.

        Parameters
        ----------
        key : CacheKey
            The key to write or update.
        cache_content : WritableCache
            The cache content to write for the key.
        """
        data: str = self.fetch()
        formatted_json: str = self.dict_format(cache_content)
        formatted_line: str = f"{key} = {formatted_json}"
        pattern: str = rf"{re.escape(key)}\s*=\s*\{{.*?\}}"

        if re.search(pattern, data, re.DOTALL):
            updated_data: str = re.sub(
                pattern, formatted_line, data, flags=re.DOTALL
            )
        else:
            updated_data = (
                f"{data.strip()}\n{formatted_line}"
                if data.strip()
                else formatted_line
            )

        self._file_handle.seek(0)
        self._file_handle.write(updated_data)
        self._file_handle.truncate()

    def delete_key(self, key: CacheKey) -> None:
        """
        Delete a specific key and its cache content from the file.

        Parameters
        ----------
        key : CacheKey
            The key to be deleted.

        Raises
        ------
        KeyError
            If the key does not exist in the cache.
        """
        data: str = self.fetch()
        pattern: str = rf"^{re.escape(key)}\s*=\s*\{{.*?\}}\n?"
        if not re.search(pattern, data, re.MULTILINE | re.DOTALL):
            raise KeyError(f"Key '{key}' not found in cache.")
        updated_data: str = re.sub(
            pattern, "", data, flags=re.MULTILINE | re.DOTALL
        )
        self._file_handle.seek(0)
        self._file_handle.write(updated_data)
        self._file_handle.truncate()

    def exists(self, key: CacheKey) -> bool:
        """
        Check if a specific key exists in the cache.

        Parameters
        ----------
        key : CacheKey
            The key to check.

        Returns
        -------
        bool
            True if the key exists, False otherwise.
        """
        return key in self.keys()

    def update_key(
        self,
        key: CacheKey,
        new_value: typing.Union[dict, list, int, float, bool, str],
    ) -> None:
        """
        Update the cache content for a specific key by merging with the existing content.

        If both the existing content and the new value are dictionaries,
        they are merged. Otherwise, the new value overwrites the existing content.

        Parameters
        ----------
        key : CacheKey
            The key to update.
        new_value : dict, list, int, float, bool, or str
            The new value to update the key with.
        """
        existing_cache: WritableCache = self.get_writable_cache(key)
        existing_value: typing.Any = existing_cache.get(key, None)
        if isinstance(existing_value, dict) and isinstance(new_value, dict):
            existing_value.update(new_value)
            merged_value: typing.Union[dict, list, int, float, bool, str] = (
                existing_value
            )
        else:
            merged_value = new_value
        self.write(key, {key: merged_value})  # type: ignore

    def clear(self) -> None:
        """
        Clear the entire cache file.

        This method removes all content from the cache file.
        """
        self._file_handle.seek(0)
        self._file_handle.truncate()
        self._file_handle.write("")
        self._file_handle.flush()

    def read_all(self) -> ReadableCache:
        """
        Read and parse the entire cache file into a dictionary.

        Returns
        -------
        ReadableCache
            A dictionary mapping each cache key to its parsed content.
        """
        result: ReadableCache = {}
        for key in self.keys():
            cache_data = self.get_writable_cache(key)
            result.update(cache_data)  # type: ignore
        return result

    def search_keys(self, regex_pattern: str) -> typing.List[CacheKey]:
        """
        Search for keys matching a regular expression pattern.

        Parameters
        ----------
        regex_pattern : str
            The regular expression pattern to match keys.

        Returns
        -------
        List[CacheKey]
            A list of keys that match the given pattern.
        """
        pattern = re.compile(regex_pattern)
        return [key for key in self.keys() if pattern.search(key)]

    def __getitem__(self, key: CacheKey) -> typing.Any:
        """
        Retrieve the value associated with the given key using dictionary-like access.

        Parameters
        ----------
        key : CacheKey
            The key to retrieve.

        Returns
        -------
        Any
            The value associated with the key.

        Raises
        ------
        KeyError
            If the key is not found or its value is empty.
        """
        return self.get_value(key)

    def get_value(self, key: CacheKey) -> typing.Any:
        """
        Get the value associated with the specified key.

        Parameters
        ----------
        key : CacheKey
            The key to retrieve the value for.

        Returns
        -------
        Any
            The value associated with the key.

        Raises
        ------
        KeyError
            If the key does not exist or the value is empty.
        """
        cache_data: WritableCache = self.get_writable_cache(key)
        value = cache_data.get(key, "")
        if value == "":
            raise KeyError(f"Key '{key}' not found or empty in cache.")
        return value

    def __setitem__(self, key: CacheKey, value: typing.Any) -> None:
        """
        Set the value for the given key using dictionary-like assignment.

        Parameters
        ----------
        key : CacheKey
            The key to set.
        value : Any
            The value to assign to the key.
        """
        self.write(key, {key: value})

    def __delitem__(self, key: CacheKey) -> None:
        """
        Delete the specified key from the cache using dictionary-like deletion.

        Parameters
        ----------
        key : CacheKey
            The key to delete.

        Raises
        ------
        KeyError
            If the key is not found in the cache.
        """
        self.delete_key(key)

    def set_value(self, key: CacheKey, value: typing.Any) -> None:
        """
        Set the value for a given key.

        Parameters
        ----------
        key : CacheKey
            The key to set.
        value : Any
            The value to assign.
        """
        self.write(key, {key: value})

    def append_value(self, key: CacheKey, value: typing.Any) -> None:
        """
        Append a value to a list stored at the specified key.

        Parameters
        ----------
        key : CacheKey
            The key whose value should be a list.
        value : Any
            The value to append.

        Raises
        ------
        TypeError
            If the current value is not a list.
        KeyError
            If the key does not exist.
        """
        current_value = self.get_value(key)
        if not isinstance(current_value, list):
            raise TypeError(f"Value at key '{key}' is not a list.")
        current_value.append(value)
        self.write(key, {key: current_value})

    def rename_key(self, old_key: CacheKey, new_key: CacheKey) -> None:
        """
        Rename a key in the cache.

        Parameters
        ----------
        old_key : CacheKey
            The current key name.
        new_key : CacheKey
            The new key name.

        Raises
        ------
        KeyError
            If the old key does not exist or the new key already exists.
        """
        if self.exists(new_key):
            raise KeyError(f"New key '{new_key}' already exists in cache.")
        data: str = self.fetch()
        pattern: str = rf"(^|\n){re.escape(old_key)}\s*=\s*(\{{.*?\}})(?=\n|$)"
        match = re.search(pattern, data, re.MULTILINE | re.DOTALL)
        if not match:
            raise KeyError(f"Old key '{old_key}' not found in cache.")
        new_line: str = f"{new_key} = {match.group(2)}"
        updated_data: str = re.sub(
            pattern,
            f"\n{new_line}",
            data,
            count=1,
            flags=re.MULTILINE | re.DOTALL,
        )
        self._file_handle.seek(0)
        self._file_handle.write(updated_data.strip())
        self._file_handle.truncate()

    def backup(self, backup_path: typing.Union[str, pathlib.Path]) -> None:
        """
        Create a backup of the cache file.

        Parameters
        ----------
        backup_path : str or pathlib.Path
            The file path where the backup should be stored.
        """
        backup_file: pathlib.Path = (
            pathlib.Path(backup_path)
            if not isinstance(backup_path, pathlib.Path)
            else backup_path
        )
        shutil.copy(self.path, backup_file)

    def restore(self, backup_path: typing.Union[str, pathlib.Path]) -> None:
        """
        Restore the cache file from a backup.

        Parameters
        ----------
        backup_path : str or pathlib.Path
            The backup file path to restore from.

        Raises
        ------
        FileNotFoundError
            If the backup file does not exist.
        """
        backup_file: pathlib.Path = (
            pathlib.Path(backup_path)
            if not isinstance(backup_path, pathlib.Path)
            else backup_path
        )
        if not backup_file.exists():
            raise FileNotFoundError(
                f"Backup file '{backup_file}' does not exist."
            )
        if not self._file_handle.closed:
            self._file_handle.close()
        shutil.copy(backup_file, self.path)
        self._file_handle = self.path.open("r+", encoding="utf-8")

    def validate_cache(self) -> bool:
        """
        Validate the structure of the cache file.

        Returns
        -------
        bool
            True if all entries in the cache file adhere to the expected format and contain valid JSON,
            False otherwise.
        """
        data: str = self.fetch()
        lines = [line.strip() for line in data.splitlines() if line.strip()]
        pattern = re.compile(r"^\w+\s*=\s*\{.*\}$")
        for line in lines:
            if not pattern.match(line):
                return False
            try:
                _, json_part = line.split("=", 1)
                json.loads(json_part.strip())
            except (ValueError, json.JSONDecodeError):
                return False
        return True

    def update_all(
        self, update_func: typing.Callable[[CacheKey, typing.Any], typing.Any]
    ) -> None:
        """
        Apply a transformation function to all cache entries and update them.

        Parameters
        ----------
        update_func : Callable[[CacheKey, Any], Any]
            A function that takes a key and its current value, and returns the updated value.
        """
        all_cache: ReadableCache = self.read_all()
        new_cache: ReadableCache = {
            key: update_func(key, value) for key, value in all_cache.items()
        }
        self.clear()
        for key, updated_value in new_cache.items():
            self.write(key, {key: updated_value})  # type: ignore

    def sync(self) -> None:
        """Synchronize the file by flushing the file handle."""
        self._file_handle.flush()
        self._file_handle.seek(0)
