import pathlib as p
import shelve
import shutil
import string
from collections.abc import Callable, Iterator
from typing import Any, Self

ALLOWED_CHARACTERS = string.ascii_letters + string.digits + "!&#'^~$,.%`{}[]();@_-+="  # Allowed characters in DB ID

DISALLOWED_SEQUENCES = (
	"PRN",
	"CON",
	"AUX",
	"NUL",
	"COM1",
	"COM2",
	"COM3",
	"COM4",
	"COM5",
	"COM6",
	"COM7",
	"COM8",
	"COM9",
	"COM0",
	"LPT1",
	"LPT2",
	"LPT3",
	"LPT4",
	"LPT5",
	"LPT6",
	"LPT7",
	"LPT8",
	"LPT9",
	"LPT0",
)


def c(a):
	return f"\x1b[{a}m"


A = f"""
{c("208")}class {c("39")}DB{c("7")}({c}{c(39)}tcr.ShelveDB{c("7")}){c}{c(231)}:
  {c(7)}directory {c(208)}= {c(46)}"/VALID/path/to/db/dir/" {c(8)}# Or pathlib.Path object{c}
"""[1:-1]


class ShelveDB(dict):
	"""### Wrapper for shelve module databases.

	```py
	class DB(tcr.ShelveDB):
	  # REQUIRED:
	  directory = "/VALID/path/to/db/dir/" # Or pathlib.Path object
	  # OPTIONAL:
	  defaults = {"r": list}
	```
	### Defaults:
	If key "r" is accessed but it's not found, return `list()` (that'd be a `[]`, not the `list` type itself of course!) and set the value at that key to it, so the next time it's looked up a value will be present, unless deleted or overwritten in the meantime.
	Values must be any zero-argument callables. (functions, classes/types, etc.).
	This is to avoid mutability problems, for each key a new instance is created.

	## Raises:
	  - ValueError: provided alnum_id is invalid.
	  - RuntimeError: You did not or incorrectly set up the class declaration (shown above).
	  - NotADirectoryError: The directory path you provided points to a file, not a directory or nothing.
	"""

	directory: str | p.Path = None
	__directory: p.Path
	s: shelve.Shelf
	defaults: dict[Any, Callable[[], Any]] = None

	def __init__(self, alnum_id: str | int) -> None:
		if isinstance(self.directory, str | p.Path):
			if isinstance(self.directory, str):
				self.directory = p.Path(self.directory)
			self.__directory = self.directory
		else:
			raise RuntimeError("Set up the db as follows:\n" + A)  # noqa: TRY004

		if self.__directory.is_file():
			raise NotADirectoryError("Provide a path to a directory or a nonexistent path " "(a directory will be created if it doesn't exist).")

		self.__directory.mkdir(exist_ok=True, parents=True)

		if self.defaults is None:
			self.defaults = {}

		if not all(callable(v) for v in self.defaults.values()):
			raise RuntimeError("All values of defaults must be callable.")

		if not all(char in ALLOWED_CHARACTERS for char in str(alnum_id)):
			raise ValueError(f"alnum_id must be any of those characters: {ALLOWED_CHARACTERS}")

		if str(alnum_id) in DISALLOWED_SEQUENCES:
			raise ValueError(f'alnum_id must not be any of theese: {", ".join(DISALLOWED_SEQUENCES)}')

		self.alnum_id = str(alnum_id)
		alnum_dirpath = self.__directory / self.alnum_id
		alnum_dirpath.mkdir(exist_ok=True)
		self.s = shelve.open(alnum_dirpath / self.alnum_id)

		super().__init__(self.s)

	def __getitem__(self, __key: Any) -> Any:
		try:
			return super().__getitem__(__key)
		except KeyError:
			if __key in self.defaults:
				val = self.defaults[__key]()
				self[__key] = val
				return val
			else:
				raise

	def __setitem__(self, __key: Any, __value: Any) -> None:
		self.s[__key] = __value
		self.s.sync()
		return super().__setitem__(__key, __value)

	def __delitem__(self, __key: Any) -> None:
		del self.s[__key]
		self.s.sync()
		return super().__delitem__(__key)

	def __del__(self) -> None:
		try:
			self.s.close()
		except:  # noqa: E722
			return

	@property
	def d(self):
		"""Convert the underlying shelf into a dictionary (make a copy as dict)."""
		return dict(self.s)  # .copy()

	def clear(self) -> None:
		"""Clear both shelf and the underlying dictionary."""
		self.s.clear()
		self.s.sync()
		super().clear()

	def update(self, **kwargs: Any) -> None:
		"""Update both the shelf and the underlying dictionary."""
		self.s.update(**kwargs)
		self.s.sync()
		super().update(**kwargs)

	def copy(self) -> dict:
		"""Relinquishes the database connection and returns the underlying's dict copy."""
		return super().copy()

	def pop(self, *args, **kwargs) -> Any:
		super().pop(*args, **kwargs)
		res = self.s.pop(*args, **kwargs)
		self.s.sync()
		return res

	def popitem(self) -> tuple[Any, Any]:
		res = self.s.popitem()
		super().pop(res[0])
		self.s.sync()
		return res

	def setdefault(self, key, default=None):
		super().setdefault(key, default)
		res = self.s.setdefault(key, default)
		self.s.sync()
		return res

	def get_directory(self) -> p.Path:
		return self.__directory

	def get_path(self) -> p.Path:
		return self.get_directory() / self.alnum_id

	def drop_db(self) -> None:
		"""Close the shelf and delete the underlying system directory of this database instance."""
		self.s.close()
		shutil.rmtree(self.__directory / self.alnum_id)

	@classmethod
	def iter_all(cls) -> Iterator[tuple[str, Self]]:
		for path in p.Path(cls.directory).iterdir():
			yield path.name, cls(path.name)

	@classmethod
	def iter_all_paths(cls) -> Iterator[p.Path]:
		yield from p.Path(cls.directory).iterdir()

	@classmethod
	def iter_all_path_names(cls) -> Iterator[str]:
		for path in p.Path(cls.directory).iterdir():
			yield path.name

	@classmethod
	def iter_all_shelves(cls) -> Iterator[Self]:
		for path in p.Path(cls.directory).iterdir():
			yield cls(path.name)

	@classmethod
	def exists(cls, alnum_id: str | int) -> bool:
		"""Check if the given `alnum_id` is already in use (if a database directory exists and is a directory and is not empty)."""
		folder = p.Path(cls.directory) / str(alnum_id)
		return folder.is_dir() and (len(list(folder.iterdir())) > 0)  # Counting empty directories as non existent because theres no data
