"""Fundamental Python utilities for data manipulation, type checking, and common operations"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_basic.ipynb.

# %% ../nbs/00_basic.ipynb 1
from __future__ import annotations

# %% auto 0
__all__ = ['empty', 'is_empty', 'AD', 'is_listy', 'is_listy_type', 'flatten', 'shorten', 'shortens', 'Runner', 'setattrs',
           'val_at', 'val_atpath', 'has_key', 'has_path', 'vals_atpath', 'vals_at', 'deep_in', 'pops_', 'pops_values_',
           'gets', 'update_', 'bundle_path', 'Kounter', 'simple_id', 'id_gen', 'WithCounterMeta']

# %% ../nbs/00_basic.ipynb
import importlib
import operator
import os
import pprint
import re
import sys
from binascii import hexlify
from inspect import Parameter
from pathlib import Path
from types import ModuleType
from typing import Any
from typing import Callable
from typing import DefaultDict
from typing import Hashable
from typing import Iterable
from typing import Literal
from typing import Mapping
from typing import MutableMapping
from typing import Self
from typing import Sequence
from typing import Type
from typing import TypeAlias
from typing import TypeVar

import fastcore.all as FC


# %% ../nbs/00_basic.ipynb
class Empty(type): __repr__ = __str__ = lambda self: 'empty'
class EmptyT(metaclass=Empty):...

# _EMPTY: TypeAlias = Parameter.empty
# EmptyT = Type[_EMPTY]

# %% ../nbs/00_basic.ipynb
empty = EmptyT

def is_empty(x) -> bool: return x is empty

# %% ../nbs/00_basic.ipynb
_VT = TypeVar('_VT')
# from `fastcore` + generics
class AD(dict[str, _VT]):
    "`dict` subclass that also provides access to keys as attrs"
    def __getattr__(self, k:str) -> _VT: return self[k] if k in self else FC.stop(AttributeError(k))  # type: ignore
    def __setattr__(self, k, v:_VT): (self.__setitem__, super().__setattr__)[k[0]=='_'](k,v)
    def __dir__(self) -> Iterable[str]: return super().__dir__() + list(self.keys())  # type: ignore
    def _repr_markdown_(self): return f'```json\n{pprint.pformat(self, indent=2)}\n```'
    def copy(self) -> Self: return type(self)(**self)

# %% ../nbs/00_basic.ipynb
def is_listy(x):
    return isinstance(x, Iterable) and not isinstance(x, (bytes, str))

def is_listy_type(x):
    return issubclass(x, Iterable) and not issubclass(x, (bytes, str))

# %% ../nbs/00_basic.ipynb
def flatten(o):
    "Concatenate all collections and items as a generator"
    for item in o:
        if not is_listy(item): yield item; continue
        try: yield from flatten(item)
        except TypeError: yield item

# %% ../nbs/00_basic.ipynb
def shorten(x:Any, mode:Literal['l', 'r', 'c']='l', limit=40, trunc='…', empty='') -> str:
    if len(s := str(x)) > limit:
        l, m, r = (
            (empty, trunc, s[-limit:]) if mode == 'l' else 
            (s[:limit], trunc, empty) if mode == 'r' else 
            (s[:(limit//2)-1], f" {trunc} ", s[-(limit//2-1):])
        )
        s = f'{l}{m}{r}'
    return s

def shortens(xs:Iterable[Any], mode:Literal['l', 'r', 'c']='l', limit=40, trunc='…', empty=''):
    for x in xs: yield shorten(x, mode, limit, trunc, empty)


# %% ../nbs/00_basic.ipynb
_FuncItem: TypeAlias = Callable | Sequence['_FuncItem']

def Runner(*fns: _FuncItem) -> Callable:
    """Return a function that runs callables `fns` in sequence with same arguments. 
    Only side-effects, no composition."""
    _fns: tuple[Callable, ...] = tuple(FC.flatten(fns))  # type: ignore
    if not _fns: return FC.noop
    if len(_fns) == 1: return _fns[0]
    def _(*args, **kwargs) -> None:
        for f in _fns: f(*args, **kwargs)
    return _

# %% ../nbs/00_basic.ipynb
def setattrs(dest, src, flds=''):
    "Set `flds` or keys() or dir() attributes from `src` into `dest`"
    g = dict.get if isinstance(src, dict) else getattr
    s = operator.setitem if isinstance(dest, MutableMapping) else setattr
    if flds: flds = re.split(r",\s*", flds)
    elif isinstance(src, dict): flds = src.keys()
    else: flds = (_ for _ in dir(src) if _[0] != '_')
    for fld in flds: s(dest, fld, g(src, fld))

# %% ../nbs/00_basic.ipynb
def val_at(o, attr: str, default: Any=empty, sep='.'):
    "Traverse nested `o` looking for attributes/items specified in dot-separated `attr`."
    if not isinstance(attr, str): raise TypeError(f'{attr=!r} is not a string')
    try:
        for a in attr.split(sep):
            try: o = o[a]
            except (TypeError, KeyError):
                try: o = o[int(a)]
                except (IndexError, TypeError, KeyError, ValueError): o = getattr(o, a)
    except AttributeError as e:
        return default if default is not empty else FC.stop(e)  # type: ignore
    return o

def val_atpath(o, *path: Any,  default: Any=empty):
    "Traverse nested `o` looking for attributes/items specified in `path`."
    try:
        for a in path:
            try: o = o[a]
            except (IndexError, TypeError, KeyError):
                try: o = o[int(a)]
                except (IndexError, TypeError, KeyError, ValueError): o = getattr(o, str(a))
    except (AttributeError, TypeError) as e:
        return default if default is not empty else FC.stop(e)  # type: ignore
    return o

_NF = object()

def has_key(o, attr: str, sep='.') -> bool:
    "Return `True` if nested dot-separated `attr` exists."
    return val_at(o, attr, default=_NF, sep=sep) is not _NF

def has_path(o, *path: Any) -> bool:
    "Return `True` if nested `path` exists."
    return val_atpath(o, path, default=_NF) is not _NF

# %% ../nbs/00_basic.ipynb
def _vals_atpath(o, *path: Any, filter_empty=False) -> empty | tuple[empty | object, ...] | object:
    try: 
        idx = path.index('*'); pre, pos = path[:idx], path[idx+1:]
    except ValueError:
        return a if (a := val_atpath(o, *path, default=_NF)) is not _NF else empty
    a = val_atpath(o, *pre, default=_NF) if pre else o
    if not pos: return a
    if a is _NF: return empty
    try: 
        res = tuple(map(lambda x: _vals_atpath(x, *pos, filter_empty=filter_empty), a))  # type: ignore
        if all(x is empty for x in res): return empty
        return tuple(filter(lambda x: x is not empty, res)) if filter_empty else res
    except (AttributeError, TypeError) as e: return empty

def vals_atpath(o, *path: Any, filter_empty=False) -> tuple[Any, ...]:
    "Return nested values-- or empty|(empty, ...)-- at `path` with wildcards '*' from `d`."
    if '*' not in path: return () if (res := val_atpath(o, *path, default=_NF)) is _NF else (res,)
    res = _vals_atpath(o, *path, filter_empty=filter_empty)
    return () if res is empty else res  # type: ignore

def _vals_at(o, path:str, filter_empty=False) -> empty | tuple[empty | object, ...] | object:
    pre, wc, pos = str(path).partition('*')
    if not wc and not pos: return a if (a := val_at(o, pre, _NF)) is not _NF else empty
    a = val_at(o, pre.rstrip('.'), _NF) if pre else o
    if not pos: return a
    if a is _NF: return empty
    try: 
        res = tuple(map(lambda x: _vals_at(x, pos.lstrip('.'), filter_empty=filter_empty), a))  # type: ignore
        if all(x is empty for x in res): return empty
        return tuple(filter(lambda x: x is not empty, res)) if filter_empty else res
    except TypeError: return empty

def vals_at(o, path:str, filter_empty=False) -> tuple[Any, ...]:
    "Return nested values-- or empty|(empty, ...)-- at `path` with wildcards '*' from `o`."
    if '*' not in path: return () if (res := val_at(o, path, _NF)) is _NF else (res,)
    res = _vals_at(o, path, filter_empty=filter_empty)
    return () if res is empty else res  # type: ignore


# %% ../nbs/00_basic.ipynb
def deep_in(o:Mapping|Iterable, val):
    "return True if val is in nested collections"
    if isinstance(o, Mapping):
        for v in o.values():
            if v == val or (is_listy(v) and deep_in(v, val)): return True
        return False
    elif isinstance(o, Iterable):
        if val in o: return True
        return any(deep_in(v, val) for v in o if is_listy(v))
    raise ValueError(f"deep_in: o must be a Mapping or Iterable, got {type(o)}")

# %% ../nbs/00_basic.ipynb
def pops_(d: dict, *ks: Hashable) -> dict:
    "Pop existing `ks` items from `d` in-place into a dictionary."
    return {k:d.pop(k) for k in ks if k in d}

# %% ../nbs/00_basic.ipynb
def pops_values_(d: dict, *ks: Hashable) -> tuple:
    "Pop existing `ks` items from `d` in-place into a tuple of values or `Parameter.empty` for missing keys."
    return tuple(d.pop(k, Parameter.empty) for k in ks)

# %% ../nbs/00_basic.ipynb
def gets(d: Mapping, *ks: Hashable):
    "Fetches `ks` values, or `Parameter.empty` for missing keys, from `d` into a tuple."
    return tuple(d.get(k, Parameter.empty) for k in ks)  # type: ignore

# %% ../nbs/00_basic.ipynb
def update_(dest=None, /, empty_value=None, **kwargs) -> Any:
    "Update `dest` in-place with `kwargs` whose values aren't `empty_value`"
    dest = dest if dest is not None else {}
    f = operator.setitem if isinstance(dest, MutableMapping) else setattr
    for k, v in filter(lambda x: x[1]!=empty_value, kwargs.items()): f(dest, k, v)
    return dest

# %% ../nbs/00_basic.ipynb
def _get_globals(mod: str):
    if hasattr(sys, '_getframe'):
        glb = sys._getframe(2).f_globals
    else:
        glb = sys.modules[mod].__dict__
    return glb

# %% ../nbs/00_basic.ipynb
def bundle_path(mod:str|ModuleType):
    "Return the path to the module's directory or current directory."
    if isinstance(mod, str): mod = importlib.import_module(mod)
    return Path(fn).parent if (fn := getattr(mod, '__file__', None)) else Path()

# %% ../nbs/00_basic.ipynb
class Kounter:
    def __init__(self): self.d = DefaultDict(int)
    def __call__(self, k): d = self.d; d[k] += 1; return self.d[k]

# %% ../nbs/00_basic.ipynb
def simple_id():
    return 'b'+hexlify(os.urandom(16), '-', 4).decode('ascii')

def id_gen():
    kntr = Kounter()
    def _(o:Any=None): 
        if o is None: return simple_id()
        # return f"{type(o).__name__}_{hash(o) if isinstance(o, Hashable) else kntr(type(o).__name__)}"
        return f"{type(o).__name__}_{kntr(type(o).__name__)}"
    return _

# %% ../nbs/00_basic.ipynb
class WithCounterMeta(FC.FixSigMeta):
    "Adds a `_cnt_` attribute to its classes and increments it for each new instance."
    _cnt_: int
    def __call__(cls, *args, **kwargs):
        res = super().__call__(*args, **kwargs)
        res._cnt_ = cls._cnt_
        cls._cnt_ += 1
        return res
    def __new__(cls, name, bases, dict):
        res = super().__new__(cls, name, bases, dict)
        res._cnt_ = 0
        return res
