"""Cache Protocol and built-in implementations for Python interpreter discovery."""

from __future__ import annotations

import json
import logging
from contextlib import contextmanager, suppress
from hashlib import sha256
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable

if TYPE_CHECKING:
    from collections.abc import Generator
    from pathlib import Path

_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)


@runtime_checkable
class ContentStore(Protocol):
    """A store for reading and writing cached content."""

    def exists(self) -> bool:
        """Return whether the cached content exists."""
        ...

    def read(self) -> dict | None:
        """Read the cached content, or ``None`` if unavailable or corrupt."""
        ...

    def write(self, content: dict) -> None:
        """
        Persist *content* to the store.

        :param content: interpreter metadata to cache.
        """
        ...

    def remove(self) -> None:
        """Delete the cached content."""
        ...

    @contextmanager
    def locked(self) -> Generator[None]:
        """Context manager that acquires an exclusive lock on this store."""
        ...


@runtime_checkable
class PyInfoCache(Protocol):
    """Cache interface for Python interpreter information."""

    def py_info(self, path: Path) -> ContentStore:
        """
        Return the content store for the interpreter at *path*.

        :param path: absolute path to a Python executable.
        """
        ...

    def py_info_clear(self) -> None:
        """Remove all cached interpreter information."""
        ...


class DiskContentStore:
    """JSON file-based content store with file locking."""

    def __init__(self, folder: Path, key: str) -> None:
        self._folder = folder
        self._key = key

    @property
    def _file(self) -> Path:
        return self._folder / f"{self._key}.json"

    def exists(self) -> bool:
        return self._file.exists()

    def read(self) -> dict | None:
        data, bad_format = None, False
        try:
            data = json.loads(self._file.read_text(encoding="utf-8"))
        except ValueError:
            bad_format = True
        except OSError:
            _LOGGER.debug("failed to read %s", self._file, exc_info=True)
        else:
            _LOGGER.debug("got python info from %s", self._file)
            return data
        if bad_format:
            with suppress(OSError):
                self.remove()
        return None

    def write(self, content: dict) -> None:
        self._folder.mkdir(parents=True, exist_ok=True)
        self._file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8")
        _LOGGER.debug("wrote python info at %s", self._file)

    def remove(self) -> None:
        with suppress(OSError):
            self._file.unlink()
        _LOGGER.debug("removed python info at %s", self._file)

    @contextmanager
    def locked(self) -> Generator[None]:
        from filelock import FileLock  # noqa: PLC0415

        lock_path = self._folder / f"{self._key}.lock"
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        with FileLock(str(lock_path)):
            yield


class DiskCache:
    """
    File-system based Python interpreter info cache (``<root>/py_info/4/<sha256>.json``).

    :param root: root directory for the on-disk cache.
    """

    def __init__(self, root: Path) -> None:
        self._root = root

    @property
    def _py_info_dir(self) -> Path:
        return self._root / "py_info" / "4"

    def py_info(self, path: Path) -> DiskContentStore:
        """
        Return the content store for the interpreter at *path*.

        :param path: absolute path to a Python executable.
        """
        key = sha256(str(path).encode("utf-8")).hexdigest()
        return DiskContentStore(self._py_info_dir, key)

    def py_info_clear(self) -> None:
        """Remove all cached interpreter information."""
        folder = self._py_info_dir
        if folder.exists():
            for entry in folder.iterdir():
                if entry.suffix == ".json":
                    with suppress(OSError):
                        entry.unlink()


class NoOpContentStore(ContentStore):
    """Content store that does nothing -- implements ContentStore protocol."""

    def exists(self) -> bool:  # noqa: PLR6301
        return False

    def read(self) -> dict | None:  # noqa: PLR6301
        return None

    def write(self, content: dict) -> None:
        pass

    def remove(self) -> None:
        pass

    @contextmanager
    def locked(self) -> Generator[None]:  # noqa: PLR6301
        yield


class NoOpCache(PyInfoCache):
    """Cache that does nothing -- implements PyInfoCache protocol."""

    def py_info(self, path: Path) -> NoOpContentStore:  # noqa: ARG002, PLR6301
        return NoOpContentStore()

    def py_info_clear(self) -> None:
        pass


__all__ = [
    "ContentStore",
    "DiskCache",
    "DiskContentStore",
    "NoOpCache",
    "NoOpContentStore",
    "PyInfoCache",
]
