import contextlib
from collections.abc import Generator, Hashable, Mapping
from typing import Any

from .tcr_compare import isdunder
from .tcr_null import Undefined


def _check_strict(master: Mapping, slave: Mapping, *, recursive: bool) -> bool:
  for k, v in master.items():
    if k not in slave:
      return [k]
    if recursive and isinstance(v, Mapping) and isinstance(slave[k], Mapping):  # noqa: SIM102
      if (a := _check_strict(v, slave[k], recursive=recursive)) is not True:
        return [k, *a]
  return True


def dict_zip(dict1: Mapping, *dicts: Mapping) -> Generator[tuple[Hashable, Any], None, None]:
  """Zips dictionaries simillar to `zip()`, returns a generator which yields a tuple of (key, (value1, value2, value3)) for each key. This function does not support analogy of `zip()` non-strict mode, strict=True is implied (Each key must be in every single passed in dict)."""

  dicks = [dict1, *dicts]

  kys = dicks[0].keys()
  if any(kys != dick.keys() for dick in dicks):
    msg = 'Mismatched keys'
    raise ValueError(msg)

  for k, v in dicks[0].items():
    yield (k, (v, *(dick[k] for dick in dicks[1:])))


def merge_dicts(master: Mapping = None, slave: Mapping = None, *dicts: Mapping, recursive=True, strict=False) -> dict:
  """Merge dictionaries, made to prioritize the `master` dictionary and if key is not found there, then it takes from the `slave` dictionary.

  Optionally if `recursive=True`, then if the same key is a dict in both master and slave, merge dicts operation is performed on both of them.\\
  Optionally if `strict=True` it raises `ValueError` if there exists a key that is in master dictionary but not in slave dictionary.\\
  If there are more than 2 arguments passed in the list is reduced from back to front until two elements are left by the process of merge_dicts(master=list[-2], slave=list[-1])
  """
  merged = {}

  if master is None:
    master = {}
  if slave is None:
    slave = {}

  dicts = [master, slave, *dicts]

  while len(dicts) > 2:
    slave_ = dicts.pop()
    master_ = dicts[-1]
    dicts[-1] = merge_dicts(master_, slave_, recursive=recursive, strict=strict)

  master, slave = dicts

  if strict:
    a = _check_strict(master, slave, recursive=recursive)
    if a is not True:
      msg = f'Strict check failed: there exists a key ({".".join(a)}) that is in master dictionary but not in slave dictionary'
      raise ValueError(msg)

  for key in master:
    if key in slave:
      if isinstance(master[key], dict) and isinstance(slave[key], dict) and recursive:
        merged[key] = merge_dicts(master[key], slave[key], recursive=True)
      else:
        merged[key] = master[key]
    else:
      merged[key] = master[key]

  for key in slave:
    if key not in merged:
      merged[key] = slave[key]

  return merged


def clean_dunder_dict(
  __dict__: dict[str, Any],
  strategy: int = 1,
) -> dict:
  """Filter the __dict__ passed in (or any other str-keyed dict for that matter).

  Strategy types:
  - 0: Remove only __dunder__ keys
  - 1: Do whatever lower-number strategies do but also remove __double_underscore_prefixed keys (default)
  - 2: Do whatever lower-number strategies do but also remove _single_underscore_prefixed keys
  """

  if strategy == 0:
    return {x: y for x, y in __dict__.items() if not (x.startswith('__') and x.endswith('__'))}

  if strategy == 1:
    return {x: y for x, y in __dict__.items() if not x.startswith('__')}

  if strategy == 2:
    return {x: y for x, y in __dict__.items() if not x.startswith('_')}

  if strategy == -2:
    raise ValueError('Invalid strategy, choose 0, 1 or 2 (not literally `0-2` you fucking idiot)')

  raise ValueError('Invalid strategy, choose 0-2')


class DotDict(dict):
  """### dot.notation access to dictionary attributes.

  This will ignore any dunder attributes, and will not try to look them up in the underlying dict.

  ```py
  >>> d = {"a": 1, "b": 2}
  >>> dd = DotDict(d) # or DotDict({"a": ...})
  >>> dd.a
  1
  >>> dd.b
  2
  ```

  This will convert any Mapping that is not already a DotDict into a DotDict on access.

  Raises:
      - KeyError (tried to convert the dot-access to item-access but failed to find item)
      - AttributeError (the dot-accessed attribute was dunder, did not try to convert to item-access and said attribute was not found)
      - Other standard dict errors if any.
  """

  def __getattr__(self, attr):
    if isdunder(attr):
      return object.__getattribute__(self, attr)

    a = super().__getitem__(attr)

    if not isinstance(a, DotDict) and isinstance(a, Mapping):
      return DotDict(a)

    return a

  def __setattr__(self, attr, value):
    if isdunder(attr):
      return setattr(self, attr, value)

    super().__setitem__(attr, value)

  def __delattr__(self, attr):
    if isdunder(attr):
      return delattr(self, attr)

    super().__delitem__(attr)


class JSDict(dict):
  """Returns tcr.Undefined when a value is not found rather than erroring.

  In order to effectively replace the undefined value (tcr.Undefined) bitwise or it with any other value, (`tcr.Undefined | T -> T`).
  """

  def __getitem__(self, key):
    try:
      return super().__getitem__(key)
    except KeyError:
      return Undefined

  def __delitem__(self, __key: Any) -> None:
    with contextlib.suppress(KeyError):
      return super().__delitem__(__key)


class JSDotDict(DotDict, JSDict):
  """Combines tcr.DotDict and tcr.JSDict.

  (Allows for dot-access and returns tcr.Undefined when a value is not found rather than erroring.)

  For full description see the previously mentioned class doc strings.
  """
