"""
Custom exception hierarchy for ferro_ta.

Exception classes
-----------------
FerroTAError       — Base class for all ferro_ta exceptions.
FerroTAValueError  — Raised for invalid parameter values (e.g. timeperiod < 1).
FerroTAInputError  — Raised for invalid input arrays (e.g. mismatched lengths, wrong dtype, unexpected NaN/Inf when strict mode is used).

All custom exceptions inherit from both the ferro_ta base and the corresponding
built-in exception (ValueError) so that existing ``except ValueError`` clauses
continue to work after upgrading.

Error codes
-----------
Every exception carries a ``code`` attribute (e.g. ``"FTERR001"``) for
programmatic handling:

    FTERR001  — Invalid parameter value (FerroTAValueError)
    FTERR002  — Invalid input array (FerroTAInputError)
    FTERR003  — Input array too short (FerroTAInputError)
    FTERR004  — Input arrays have mismatched lengths (FerroTAInputError)
    FTERR005  — Input array contains NaN or Inf (FerroTAInputError, strict mode)
    FTERR006  — General Rust-bridge error (FerroTAValueError or FerroTAInputError)

Examples
--------
>>> from ferro_ta.core.exceptions import FerroTAError, FerroTAValueError, FerroTAInputError
>>> raise FerroTAValueError("timeperiod must be >= 1, got 0")
Traceback (most recent call last):
    ...
ferro_ta.exceptions.FerroTAValueError: [FTERR001] timeperiod must be >= 1, got 0
>>> try:
...     raise FerroTAValueError("bad value")
... except FerroTAValueError as exc:
...     print(exc.code)
FTERR001

NaN / Inf policy
----------------
By default ferro_ta **propagates** NaN and Inf in input arrays — output values
that depend on a NaN/Inf input will themselves be NaN/Inf.  No exception is
raised for NaN or Inf values in the input data.  If you need strict mode, call
:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them.
"""

from __future__ import annotations

from typing import NoReturn

# ---------------------------------------------------------------------------
# Error code registry
# ---------------------------------------------------------------------------

#: Maps each ``FerroTAError`` subclass to its default error code.
ERROR_CODES: dict[str, str] = {
    "FerroTAError": "FTERR000",
    "FerroTAValueError": "FTERR001",
    "FerroTAInputError": "FTERR002",
}

# Well-known codes for specific error kinds
_CODE_TOO_SHORT = "FTERR003"
_CODE_LENGTH_MISMATCH = "FTERR004"
_CODE_NOT_FINITE = "FTERR005"
_CODE_RUST_BRIDGE = "FTERR006"

# Code descriptions (for reference and programmatic inspection)
ERROR_CODE_DESCRIPTIONS: dict[str, str] = {
    "FTERR000": "General ferro_ta error (base class fallback)",
    "FTERR001": "Invalid parameter value",
    "FTERR002": "Invalid input array",
    "FTERR003": "Input array too short",
    "FTERR004": "Input arrays have mismatched lengths",
    "FTERR005": "Input array contains NaN or Inf (strict mode)",
    "FTERR006": "Rust-bridge error (re-raised from Rust ValueError)",
}


class FerroTAError(Exception):
    """Base class for all ferro_ta exceptions.

    Attributes
    ----------
    code : str
        A short error code string (e.g. ``"FTERR001"``) for programmatic
        handling.  The code is included at the beginning of the exception
        message.
    suggestion : str | None
        Optional human-readable suggestion for how to fix the error.
    """

    code: str = "FTERR000"
    suggestion: str | None = None

    def __init__(
        self,
        message: str,
        *,
        code: str | None = None,
        suggestion: str | None = None,
    ) -> None:
        self.code = code or type(self).code
        self.suggestion = suggestion
        full_msg = f"[{self.code}] {message}"
        if suggestion:
            full_msg = f"{full_msg}\n  Suggestion: {suggestion}"
        super().__init__(full_msg)


class FerroTAValueError(FerroTAError, ValueError):
    """Raised when a parameter value is out of the accepted range.

    Examples: ``timeperiod < 1``, ``fastperiod >= slowperiod`` for MACD.

    Default error code: ``FTERR001``.
    """

    code = "FTERR001"


class FerroTAInputError(FerroTAError, ValueError):
    """Raised when one or more input arrays are invalid.

    Examples: mismatched lengths for open/high/low/close, wrong dtype that
    cannot be coerced to float64.

    Default error code: ``FTERR002``.
    """

    code = "FTERR002"


# ---------------------------------------------------------------------------
# Finer-grained exception subclasses (added in 1.2.0).
#
# These are drop-in compatible with the base classes: every subclass still
# inherits from ``FerroTAError`` and ``ValueError``, so existing user code
# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working.
# The subclasses exist so users can catch *specific* failure modes without
# string-matching on the error message.
# ---------------------------------------------------------------------------


class InvalidPeriodError(FerroTAValueError):
    """Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range.

    Default error code: ``FTERR001``.
    """


class InsufficientDataError(FerroTAInputError):
    """Input array is shorter than the minimum required for the indicator.

    Default error code: ``FTERR003``.
    """

    code = "FTERR003"


class LengthMismatchError(FerroTAInputError):
    """Two or more input arrays (e.g. OHLC) have different lengths.

    Default error code: ``FTERR004``.
    """

    code = "FTERR004"


class NumericConvergenceError(FerroTAValueError):
    """An iterative calculation failed to converge within tolerance.

    Raised by iterative pricing models (implied volatility root-finding,
    Newton-Raphson, etc.) when the maximum iteration count is exhausted.
    """


class InvalidInputError(FerroTAInputError):
    """Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape.

    Default error code: ``FTERR005``.
    """

    code = "FTERR005"


# Public aliases that match the names documented in the README and
# CHANGELOG [Unreleased] section.
FerroTaError = FerroTAError  # type: ignore[misc]

# ---------------------------------------------------------------------------
# Validation helpers (called by Python wrappers)
# ---------------------------------------------------------------------------


def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) -> None:
    """Raise :class:`FerroTAValueError` if *value* < *minimum*.

    Parameters
    ----------
    value:
        The period parameter to validate.
    name:
        Human-readable parameter name for the error message.
    minimum:
        Minimum acceptable value (default 1).

    Raises
    ------
    FerroTAValueError
        If ``value < minimum``.
    """
    if value < minimum:
        raise InvalidPeriodError(
            f"{name} must be >= {minimum}, got {value}",
            suggestion=f"Set {name}={minimum} or higher.",
        )


def check_equal_length(**arrays: object) -> None:
    """Raise :class:`FerroTAInputError` if the supplied arrays differ in length.

    Parameters
    ----------
    **arrays:
        Keyword arguments mapping name → array-like.  At least two arrays
        should be supplied for the check to be meaningful.

    Raises
    ------
    FerroTAInputError
        If any two arrays have different lengths.

    Examples
    --------
    >>> import numpy as np
    >>> from ferro_ta.core.exceptions import check_equal_length
    >>> check_equal_length(open=np.array([1.0]), close=np.array([1.0, 2.0]))
    Traceback (most recent call last):
        ...
    ferro_ta.exceptions.FerroTAInputError: ...
    """

    lengths = {}
    for name, arr in arrays.items():
        if hasattr(arr, "__len__"):
            lengths[name] = len(arr)  # type: ignore[arg-type]
        elif hasattr(arr, "shape"):
            lengths[name] = arr.shape[0]  # type: ignore[union-attr]

    if len(set(lengths.values())) > 1:
        detail = ", ".join(f"{k}={v}" for k, v in lengths.items())
        raise LengthMismatchError(
            f"All input arrays must have the same length. Got: {detail}",
            code=_CODE_LENGTH_MISMATCH,
            suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.",
        )


def check_finite(arr: object, name: str = "input") -> None:
    """Raise :class:`FerroTAInputError` if *arr* contains NaN or Inf.

    This is an *opt-in* strict-mode helper.  ferro_ta does **not** call this
    automatically — it is provided for users who want deterministic behaviour
    when their data may contain missing values.

    Parameters
    ----------
    arr:
        Array-like to check.
    name:
        Human-readable name used in the error message.

    Raises
    ------
    FerroTAInputError
        If any element of *arr* is NaN or Inf.
    """
    import numpy as np  # local import

    a = np.asarray(arr, dtype=np.float64)
    if not np.all(np.isfinite(a)):
        raise InvalidInputError(
            f"{name} contains NaN or Inf values. "
            "ferro_ta propagates NaN by default; call check_finite() only "
            "when you require all-finite inputs.",
            code=_CODE_NOT_FINITE,
            suggestion="Use numpy.nan_to_num() or dropna() to clean your data before passing it to ferro_ta.",
        )


def check_min_length(arr: object, min_len: int, name: str = "input") -> None:
    """Raise :class:`FerroTAInputError` if *arr* has length less than *min_len*.

    Parameters
    ----------
    arr:
        Array-like to check.
    min_len:
        Minimum required length.
    name:
        Human-readable name used in the error message.

    Raises
    ------
    FerroTAInputError
        If ``len(arr) < min_len``.
    """
    length = 0
    if hasattr(arr, "__len__"):
        length = len(arr)  # type: ignore[arg-type]
    elif hasattr(arr, "shape"):
        length = arr.shape[0]  # type: ignore[union-attr]
    if length < min_len:
        raise InsufficientDataError(
            f"{name} must have at least {min_len} elements, got {length}",
            code=_CODE_TOO_SHORT,
            suggestion=f"Provide at least {min_len} data points. Current length: {length}.",
        )


def _normalize_rust_error(err: ValueError) -> NoReturn:
    """Re-raise a Rust-originated ValueError as FerroTAValueError or FerroTAInputError.

    Used by Python wrappers so users can catch FerroTA* exceptions consistently.
    """
    msg = str(err).lower()
    if (
        "length" in msg
        or "same length" in msg
        or "array" in msg
        or "mismatch" in msg
        or "dimension" in msg
        or "1-d" in msg
    ):
        raise FerroTAInputError(str(err), code=_CODE_RUST_BRIDGE) from err
    raise FerroTAValueError(str(err), code=_CODE_RUST_BRIDGE) from err
