"""
This module contains various helper functions and classes.
"""

from __future__ import annotations

import logging
import sys
from contextlib import nullcontext, suppress
from getpass import getpass
from math import log10

from collections import deque
from typing import TYPE_CHECKING, Any, TypeVar

if TYPE_CHECKING:
    from types import TracebackType

    from collections.abc import AsyncIterator, Callable, Iterator

__all__ = [
    "Cache",
    "NoLock",
    "NoneType",
    "OptCtx",
    "TimeOnlyFormatter",
    "acount",
    "byte2num",
    "count",
    "digits",
    "import_",
    "load_from_cfg",
    "num2byte",
    "num2id",
    "singleton",
    "split_arg",
]

NoneType = type(None)

NoLock = nullcontext()

T = TypeVar("T")


class OptCtx:
    """
    Optional context. Unlike `contextlib.nullcontext` this doesn't return a
    fixed value; instead it delegates to the wrapped context manager – if
    there is one.
    """

    def __init__(self, obj: object = None) -> None:
        self.obj: object = obj

    def __enter__(self) -> object:
        if self.obj is not None:
            return self.obj.__enter__()  # type: ignore[union-attr]  # duck typing
        return None

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> object:
        if self.obj is not None:
            return self.obj.__exit__(exc_type, exc_val, exc_tb)  # type: ignore[union-attr]  # duck typing
        return None

    async def __aenter__(self) -> object:
        if self.obj is not None:
            return await self.obj.__aenter__()  # type: ignore[union-attr]  # duck typing
        return None

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> object:
        if self.obj is not None:
            return await self.obj.__aexit__(exc_type, exc_val, exc_tb)  # type: ignore[union-attr]  # duck typing
        return None


def import_(name: str, off: int = 0) -> object:
    """
    Import a module and access an object in it.

    ``import_("a.b.c.d.e", 2)`` imports "a.b.c" and returns the e attribute
    of object d from it.
    """
    n = name.split(".")
    mn = ".".join(n[: -off if off else 99])
    try:
        res: object = __import__(mn)
        for nn in n[1:]:
            res = getattr(res, nn)
    except Exception:
        sys.modules.pop(mn, None)
        raise
    return res


def load_from_cfg(
    *a: Any,
    _cfg: dict[str, Any] | None = None,
    _attr: str = "server",
    _raise: bool = False,
    **k: Any,
) -> object | None:
    """
    A simple frontend to load a module, access a class/object from it,
    and call that with the config (and whchever other arguments you want to
    use).

    The module+object name is the "server" attribute (or @_attr).

    Args:
        _cfg: The configuration stanza to use. If not given, a ``cfg``
              argument must exist.
        _attr: The config entry that names the service to load.
               The default is ``"server"``.
        _raise: If no ``_attr`` exists, raise KeyError instead of returning `None`.

    """
    if _cfg is None:
        cfg = k["cfg"]
    else:
        cfg = _cfg
    try:
        name = cfg[_attr]
    except KeyError:
        if _raise:
            raise
        return None
    if isinstance(name, list | tuple):
        name, off = name
    else:
        off = 1
    m = import_(name, off=off)
    return m(*a, **k)  # type: ignore[operator]  # m is callable


def singleton(cls: Callable[[], T]) -> T:
    """Basic singleton decorator"""
    return cls()


class TimeOnlyFormatter(logging.Formatter):
    """A log formatter that doesn't show dates"""

    default_time_format: str = "%H:%M:%S"
    default_msec_format: str = "%s.%03d"


def count(it: Iterator[object]) -> int:
    """counts the length of an iterator"""
    n = 0
    for _ in it:
        n += 1
    return n


async def acount(it: AsyncIterator[object]) -> int:
    """counts the length of an async iterator"""
    n = 0
    async for _ in it:
        n += 1
    return n


class Cache:
    """
    A quick-and-dirty cache that keeps the last N entries of anything
    in memory so that ref and WeakValueDictionary don't lose them.

    Entries get refreshed when they're in the last third of the cache; as
    they're not removed, the actual cache size might only be 2/3rd of SIZE.
    """

    def __init__(self, size: int) -> None:
        self._size: int = size
        self._head: int = 0
        self._tail: int = 0
        self._attr: str = "_cache__pos"
        self._q: deque[object] = deque()

    def keep(self, entry: object) -> None:
        """Store an entry in the cache"""
        if getattr(entry, self._attr, -1) > self._tail + self._size / 3:
            return
        self._head += 1
        setattr(entry, self._attr, self._head)
        self._q.append(entry)
        self._flush()

    def _flush(self) -> None:
        while self._head - self._tail > self._size:
            self._q.popleft()
            self._tail += 1

    def resize(self, size: int) -> None:
        """Change the size of this cache."""
        self._size = size
        self._flush()

    def clear(self) -> None:
        """Clear the cache"""
        while self._head > self._tail:
            self._q.popleft()
            self._tail += 1


def digits(n: float, digits: float = 6) -> float:
    """
    Returns ``n`` rounded to ``digits`` significant digits. Default: 6.
    Ensures that the number doesn't carry nonsense precision or
    floating-point artefacts.

    >>> digits(123456789, 4)
    123400000
    >>> digits(math.pi, 4)
    3.142

    ``digits`` may be a fraction, in order to move the cut-off point to
    somewhere other than between 9.999 and 10.00.
    """
    return round(n, int(digits - 1 - log10(abs(n))))


def num2byte(num: int, length: int | None = None) -> bytes:
    """
    convert an unsigned integer to a bytestring
    """
    if length is None:
        length = (num.bit_length() + 7) // 8
    return num.to_bytes(length=length, byteorder="big")


def byte2num(data: bytes) -> int:
    """
    convert a bytestring to an unsigned integer
    """
    return int.from_bytes(data, byteorder="big")


def split_arg(p: str, kw: dict[str, Any]) -> None:
    """
    Split argument 'p' and add to dict 'kw'.

    'p' may be of the form x=y (y is added, possibly as an integer),
    x? (call getpass(x? )), x?=y (call getpass(y: )).
    """
    k: str
    v: str | int
    try:
        k, v = p.split("=", 1)
    except ValueError:
        if p[-1] == "?":
            k = p[:-1]
            v = getpass(k + "? ")
        else:
            raise
    else:
        if k[-1] == "?":
            k = k[:-1]
            v = getpass(v + ": ")
        with suppress(ValueError):
            v = int(v)
    kw[k] = v


_alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"


def num2id(number: int | object, alphabet: str = _alphabet) -> str:
    """
    Encode a number / object ID in base36 (by default).

    This code doesn't care about num2id(739172) or similar.

    To avoid these issues, pass an alphabet without vowels
    as the second parameter, e.g. :py:data:`moat.util.al_unique`.
    """
    if not isinstance(number, int):
        if isinstance(number, float | complex | str | bytes | bytearray):
            raise TypeError("number must be an object or integer")
        number = id(number)
    is_negative = number < 0
    number = abs(number)
    res = []

    while number:
        number, i = divmod(number, len(alphabet))
        res.append(alphabet[i])
    if is_negative:
        res.append("-")
    elif not res:
        return alphabet[0]
    res.reverse()

    return "".join(res)
