"""
.. autofunction:: estimate_order_of_convergence
.. autoclass:: EOCRecorder
.. autofunction:: stringify_eocs
.. autoclass:: PConvergenceVerifier
"""

from __future__ import annotations

import numbers

import numpy as np
from typing_extensions import override


# {{{ eoc estimation --------------------------------------------------------------

def estimate_order_of_convergence(abscissae, errors):
    r"""Assuming that abscissae and errors are connected by a law of the form

    .. math::

        \text{Error} =  \text{constant} \cdot \text{abscissa }^{\text{order}},

    this function finds, in a least-squares sense, the best approximation of
    constant and order for the given data set. It returns a tuple (constant, order).
    """
    assert len(abscissae) == len(errors)
    if len(abscissae) <= 1:
        raise RuntimeError("Need more than one value to guess order of convergence.")

    coefficients = np.polyfit(np.log10(abscissae), np.log10(errors), 1)
    return 10**coefficients[-1], coefficients[-2]


class EOCRecorder:
    """
    .. automethod:: add_data_point

    .. automethod:: estimate_order_of_convergence
    .. automethod:: order_estimate
    .. automethod:: max_error

    .. automethod:: pretty_print
    .. automethod:: write_gnuplot_file
    """

    def __init__(self) -> None:
        self.history: list[tuple[float, float]] = []

    def add_data_point(self, abscissa: float, error: float) -> None:
        if not (isinstance(abscissa, numbers.Number)
                or (isinstance(abscissa, np.ndarray) and abscissa.shape == ())):
            raise TypeError(
                    f"'abscissa' is not a scalar: '{type(abscissa).__name__}'")

        if not (isinstance(error, numbers.Number)
                or (isinstance(error, np.ndarray) and error.shape == ())):
            raise TypeError(f"'error' is not a scalar: '{type(error).__name__}'")

        self.history.append((abscissa, error))

    def estimate_order_of_convergence(self,
            gliding_mean: int | None = None,
            ) -> np.ndarray:
        abscissae = np.array([a for a, e in self.history])
        errors = np.array([e for a, e in self.history])

        # NOTE: in case any of the errors are exactly 0.0, which
        # can give NaNs in `estimate_order_of_convergence`
        emax: float = np.amax(errors)
        errors += (1 if emax == 0 else emax) * np.finfo(errors.dtype).eps

        size = len(abscissae)
        if gliding_mean is None:
            gliding_mean = size

        data_points = size - gliding_mean + 1
        result: np.ndarray = np.zeros((data_points, 2), float)
        for i in range(data_points):
            result[i, 0], result[i, 1] = estimate_order_of_convergence(
                abscissae[i:i+gliding_mean], errors[i:i+gliding_mean])
        return result

    def order_estimate(self) -> float:
        return self.estimate_order_of_convergence()[0, 1]

    def max_error(self) -> float:
        return max(err for absc, err in self.history)

    def _to_table(self, *,
            abscissa_label="h",
            error_label="Error",
            gliding_mean=2,
            abscissa_format="%s",
            error_format="%s",
            eoc_format="%s"):
        from pytools import Table

        tbl = Table()
        tbl.add_row((abscissa_label, error_label, "Running EOC"))

        gm_eoc = self.estimate_order_of_convergence(gliding_mean)
        for i, (absc, err) in enumerate(self.history):
            absc_str = abscissa_format % absc
            err_str = error_format % err
            if i < gliding_mean-1:
                eoc_str = ""
            else:
                eoc_str = eoc_format % (gm_eoc[i - gliding_mean + 1, 1])

            tbl.add_row((absc_str, err_str, eoc_str))

        if len(self.history) > 1:
            order = self.estimate_order_of_convergence()[0, 1]
            tbl.add_row(("Overall", "", eoc_format % order))

        return tbl

    def pretty_print(self, *,
            abscissa_label: str = "h",
            error_label: str = "Error",
            gliding_mean: int = 2,
            abscissa_format: str = "%s",
            error_format: str = "%s",
            eoc_format: str = "%s",
            table_type: str = "markdown") -> str:
        tbl = self._to_table(
                abscissa_label=abscissa_label, error_label=error_label,
                abscissa_format=abscissa_format,
                error_format=error_format,
                eoc_format=eoc_format,
                gliding_mean=gliding_mean)

        if table_type == "markdown":
            return tbl.github_markdown()
        if table_type == "latex":
            return tbl.latex()
        if table_type == "ascii":
            return str(tbl)
        if table_type == "csv":
            return tbl.csv()
        raise ValueError(f"unknown table type: {table_type}")

    @override
    def __str__(self):
        return self.pretty_print()

    def write_gnuplot_file(self, filename: str) -> None:
        with open(filename, "w") as outfile:
            outfile.writelines(f"{absc:f} {err:f}\n" for absc, err in self.history)
            result = self.estimate_order_of_convergence()
            const = result[0, 0]
            order = result[0, 1]
            outfile.write("\n")
            outfile.writelines(
                f"{absc:f} {const * absc**(-order):f}\n"
                for absc, _err in self.history)


def stringify_eocs(*eocs: EOCRecorder,
        names: tuple[str, ...] | None = None,
        abscissa_label: str = "h",
        error_label: str = "Error",
        gliding_mean: int = 2,
        abscissa_format: str = "%s",
        error_format: str = "%s",
        eoc_format: str = "%s",
        table_type: str = "markdown") -> str:
    """
    :arg names: a :class:`tuple` of names to use for the *error_label* of each
        *eoc*.
    """
    if names is not None and len(names) < len(eocs):
        raise ValueError(
                f"insufficient names: got {len(names)} names for "
                f"{len(eocs)} EOCRecorder instances")

    if names is None:
        names = tuple(f"{error_label} {i}" for i in range(len(eocs)))

    from pytools import merge_tables
    tbl = merge_tables(*[eoc._to_table(
        abscissa_label=abscissa_label, error_label=name,
        abscissa_format=abscissa_format,
        error_format=error_format,
        eoc_format=eoc_format,
        gliding_mean=gliding_mean)
        for name, eoc in zip(names, eocs, strict=True)
        ], skip_columns=(0,))

    if table_type == "markdown":
        return tbl.github_markdown()
    if table_type == "latex":
        return tbl.latex()
    if table_type == "ascii":
        return str(tbl)
    if table_type == "csv":
        return tbl.csv()
    raise ValueError(f"unknown table type: {table_type}")

# }}}


# {{{ p convergence verifier

class PConvergenceVerifier:
    def __init__(self):
        self.orders = []
        self.errors = []

    def add_data_point(self, order, error):
        self.orders.append(order)
        self.errors.append(error)

    @override
    def __str__(self):
        from pytools import Table
        tbl = Table()
        tbl.add_row(("p", "error"))

        for p, err in zip(self.orders, self.errors, strict=True):
            tbl.add_row((str(p), str(err)))

        return str(tbl)

    def __call__(self):
        orders = np.array(self.orders, np.float64)
        errors = np.abs(np.array(self.errors, np.float64))

        rel_change = np.diff(1e-20 + np.log10(errors)) / np.diff(orders)

        assert (rel_change < -0.2).all()

# }}}


# vim: foldmethod=marker
