"""Benchmark Toolsuite."""

from collections.abc import Callable
from timeit import Timer
from types import FunctionType
from typing import Any

try:
    import numpy as np  # noqa - oft-used pattern made available for conveneince
except ImportError:
    pass


class BenchMark(Timer):
    """Measure code execution time using :meth:`Timer.timeit`.

    A flexible interface for benchmarking with :class:`Timer`.

    Parameters
    ----------
    f
        A function, other callable, or executable code string to run and time.
    fargs
        Arguments to pass to `f`.
    fkwargs
        Keyword arguments to pass to `f`.
    s
        A setup function, other callable, or executable code string to run once to prepare the environment. This is excluded from timing results.
    sargs
        Arguments to pass to `s`.
    skwargs
        Keyword arguments to pass to `s`.
    items_processed
        Items processed per call, e.g. size of sequence `f` operates on. Default is 1.
    item_units
        Arbitrary string for printing result speed as: f"{item_units}/sec". Default "items".
    namespace
        Variable definition dictionary to include in runtime environment. If installed, Numpy is defined in the namespace as `np`, so no need to pass in.

    Examples
    --------
    >>> from time import sleep
    >>> from benchmarkme import BenchMark
    >>> bmrk = BenchMark(sleep, [0.1])
    >>> bmrk()
    Benchmark Results
    -----------------
    * Ran 2 trials 5 times.
    * Time of one independent trial: 1... msec
    * Best time of subsequent trials: 1... msec
    * Worst time of subsequent trials: 1... msec
    * Best speed: ... items / sec
    * Worst speed: ... items / sec
    <BLANKLINE>

    """

    def __init__(
        self,
        f: Callable | str,
        fargs: list[Any] | None = None,
        fkwargs: dict[str, Any] = None,
        s: str | None = None,
        sargs: list[Any] | None = None,
        skwargs: dict[str, Any] = None,
        items_processed: int | None = None,
        item_units: str | None = None,
        namespace: dict[str, Any] | None = None,
    ):
        fargstring = (
            ", ".join([(repr(a) if not isinstance(a, str) else a) for a in fargs])
            if fargs is not None
            else ""
        )
        fkwargstring = (
            ", ".join([f"{k}={v}" for k, v in fkwargs.items()])
            if fkwargs is not None
            else ""
        )
        sargstring = ", ".join([str(a) for a in sargs]) if sargs is not None else ""
        skwargstring = (
            ", ".join([f"{k}={v}" for k, v in skwargs.items()])
            if skwargs is not None
            else ""
        )

        if isinstance(f, FunctionType | Callable) or (
            isinstance(f, type) and issubclass(f, FunctionType | Callable)
        ):
            stmt = f"f({fargstring + fkwargstring})"
        elif isinstance(f, str):
            stmt = f
        else:
            raise ValueError("f must be a function, callable or str!")

        if isinstance(s, FunctionType | Callable) or (
            isinstance(s, type) and issubclass(s, FunctionType | Callable)
        ):
            setup_stmt = f"{s.__name__}({sargstring + skwargstring})"
        elif isinstance(s, str):
            setup_stmt = s
        else:
            setup_stmt = ""

        self.namespace = globals()
        self.namespace.update(locals())
        if namespace is not None:
            self.namespace.update(namespace)

        super().__init__(stmt=stmt, setup=setup_stmt, globals=self.namespace)
        self.trials = None
        self.item_units = "items" if item_units is None else item_units
        self.items_processed = 1 if items_processed is None else items_processed

    @staticmethod
    def _format_speed_(items_per_sec, precision=3):
        """Format time per item as speed with appropriate units.

        Parameters
        ----------
        items_per_sec
            Items processed per second to format as speed.
        precision
            Number of digits in measurement after decimal.

        Returns
        -------
        str
            Speed with units, e.g. "100.000 Mitems / sec".

        """
        iunits = {"G": 1e9, "M": 1e6, "k": 1e3, "": 1.0}
        iscales = [(iscale, iunit) for iunit, iscale in iunits.items()]
        iscales.sort(reverse=True)
        for iscale, iunit in iscales:
            if items_per_sec >= iscale:
                break

        itemfmt = "%.*g %s" % (precision, items_per_sec / iscale, iunit)
        return itemfmt

    @staticmethod
    def _format_time_(time, precision=3):
        """Format time with appropriate units.

        Parameters
        ----------
        time
            Time in seconds to format.
        precision
            Number of digits in measurement after decimal.

        """
        tunits = {"nsec": 1e-9, "usec": 1e-6, "msec": 1e-3, "sec": 1.0}
        tscales = [(tscale, tunit) for tunit, tscale in tunits.items()]
        tscales.sort(reverse=True)
        for tscale, tunit in tscales:
            if time >= tscale:
                break

        timefmt = "%.*g %s" % (precision, time / tscale, tunit)
        return timefmt

    def __call__(self, repeats=5):
        """Repeatedly time code execution for at least `maxtime` seconds.

        Parameters
        ----------
        duration
            Cumulative total test time for all runs. Default is 1 second.

        """
        trials = self.autorange()[0]
        time_first_call = self.timeit(1)
        time_cum_repeat = self.repeat(repeats, trials)
        best_per_trial = min(time_cum_repeat) / trials
        worst_per_trial = max(time_cum_repeat) / trials
        best_items_per_sec = self.items_processed / best_per_trial
        worst_items_per_sec = self.items_processed / worst_per_trial
        callfmt = self._format_time_(time_first_call)
        bestfmt = self._format_time_(best_per_trial)
        worstfmt = self._format_time_(worst_per_trial)
        bestitemfmt = self._format_speed_(best_items_per_sec)
        worstitemfmt = self._format_speed_(worst_items_per_sec)
        print("Benchmark Results")
        print("-----------------")
        print(f"* Ran {trials} trials {repeats} times.")
        print(f"* Time of one independent trial: {callfmt}")
        print(f"* Best time of subsequent trials: {bestfmt}")
        print(f"* Worst time of subsequent trials: {worstfmt}")
        print(f"* Best speed: {bestitemfmt}{self.item_units} / sec")
        print(f"* Worst speed: {worstitemfmt}{self.item_units} / sec")
        print()


def main():
    import doctest

    from benchmarkme import tools

    doctest.testmod(tools, verbose=True, optionflags=doctest.ELLIPSIS)


if __name__ == "__main__":
    main()
