"""detect_distribution_type — Classify the distribution shape of a sample."""

import math
import numpy as np


def detect_distribution_type(values: list[float]) -> dict:
    """Classify the distribution shape of a numeric sample.

    Uses cardinality (number of distinct values), number of prominent modes,
    skewness, excess kurtosis, tail ratio (p99/p50) and log-skewness to assign
    one of: discrete, multimodal, heavy-tail, normal-ish, lognormal-ish,
    right-skewed, left-skewed, other, or too_few_samples (n < 30).

    A skew-only criterion mislabels discrete/ordinal and multimodal columns as
    "normal-ish" (e.g. a 6-level rating, or multimodal asset prices whose
    skewness happens to be small). To avoid that, cardinality and modality are
    checked *before* the symmetric normal-ish test:

      * ``n_unique <= 15`` -> "discrete" (ordinal / low-cardinality counts).
      * ``n_modes >= 2`` (with ``n >= 100``) -> "multimodal".

    The Jarque-Bera statistic and its p-value are computed from the already
    available skewness and excess kurtosis and reported in ``stats`` as a
    diagnostic signal. It is deliberately NOT used as a hard gate for the
    "normal-ish" label: with large samples Jarque-Bera rejects normality for
    trivially non-normal but perfectly bell-shaped columns, which would produce
    massive false negatives. Cardinality and modality, by contrast, are robust
    to sample size.

    Args:
        values: List of numeric values.

    Returns:
        Dict with keys:
          "type" (str): distribution label.
          "stats" (dict): {"n", "skew", "kurtosis", "tail_ratio", "log_skew",
              "n_unique", "n_modes", "jb_stat", "jb_pvalue"}.
    """
    n = len(values)
    if n < 30:
        return {"type": "too_few_samples", "stats": {"n": n}}

    arr = np.array(values, dtype=float)

    mean = float(np.mean(arr))
    std = float(np.std(arr, ddof=1))

    # Skewness
    if std == 0:
        skew = 0.0
    else:
        skew = float(np.mean(((arr - mean) / std) ** 3))

    # Excess kurtosis
    if std == 0:
        kurt = 0.0
    else:
        kurt = float(np.mean(((arr - mean) / std) ** 4)) - 3.0

    # Tail ratio: p99 / p50 (only meaningful when median != 0)
    p50 = float(np.percentile(arr, 50))
    p99 = float(np.percentile(arr, 99))
    tail_ratio = (p99 / p50) if p50 != 0 else math.nan

    # Log-skewness on positive values
    positives = arr[arr > 0]
    if len(positives) >= 30:
        log_arr = np.log(positives)
        log_mean = float(np.mean(log_arr))
        log_std = float(np.std(log_arr, ddof=1))
        if log_std == 0:
            log_skew = 0.0
        else:
            log_skew = float(np.mean(((log_arr - log_mean) / log_std) ** 3))
    else:
        log_skew = math.nan

    # Cardinality and modality (robust to sample size).
    n_unique = int(np.unique(arr).size)
    n_modes = _count_modes(arr)

    # Jarque-Bera statistic from the moments already computed. Under the null
    # of normality it follows a chi-squared distribution with 2 degrees of
    # freedom, whose survival function is exp(-x / 2).
    jb_stat = n / 6.0 * (skew ** 2 + (kurt ** 2) / 4.0)
    jb_pvalue = math.exp(-jb_stat / 2.0)

    stats = {
        "n": n,
        "skew": skew,
        "kurtosis": kurt,
        "tail_ratio": tail_ratio,
        "log_skew": log_skew,
        "n_unique": n_unique,
        "n_modes": n_modes,
        "jb_stat": jb_stat,
        "jb_pvalue": jb_pvalue,
    }

    # Classification logic. Cardinality and modality come first so a discrete or
    # multimodal column is never mislabeled "normal-ish" on the basis of a small
    # skewness alone.
    if n_unique <= 15:
        dist_type = "discrete"
    elif kurt > 3.0:
        dist_type = "heavy-tail"
    elif n >= 100 and n_modes >= 2:
        dist_type = "multimodal"
    elif abs(skew) <= 0.5 and abs(kurt) <= 1.0:
        dist_type = "normal-ish"
    elif (
        skew > 0.5
        and not math.isnan(log_skew)
        and abs(log_skew) <= 0.5
        and not math.isnan(tail_ratio)
        and tail_ratio > 2.0
    ):
        dist_type = "lognormal-ish"
    elif skew > 0.5:
        dist_type = "right-skewed"
    elif skew < -0.5:
        dist_type = "left-skewed"
    else:
        dist_type = "other"

    return {"type": dist_type, "stats": stats}


def _count_modes(values, prom_frac: float = 0.15, valley_frac: float = 0.6) -> int:
    """Count prominent modes separated by deep valleys in a histogram.

    A naive local-maximum count over a raw histogram is dominated by sampling
    noise, so this:

      1. Bins the data into ~sqrt(n) bins and applies a light triangular smooth.
      2. Keeps local maxima taller than ``prom_frac`` of the global peak.
      3. Merges two adjacent peaks unless the lowest point between them falls
         below ``valley_frac`` of the smaller peak (a genuine separating valley).

    Args:
        values: Numeric numpy array.
        prom_frac: Minimum peak height as a fraction of the tallest peak.
        valley_frac: Two peaks count as distinct modes only if the valley
            between them dips below this fraction of the smaller peak.

    Returns:
        Number of distinct modes (0 for an empty/degenerate sample).
    """
    arr = np.asarray(values, dtype=float)
    n = arr.size
    if n == 0:
        return 0
    n_bins = max(10, min(50, int(round(math.sqrt(n)))))
    counts, _ = np.histogram(arr, bins=n_bins)
    kernel = np.array([1.0, 2.0, 1.0])
    kernel /= kernel.sum()
    smooth = np.convolve(counts.astype(float), kernel, mode="same")

    peak_global = float(smooth.max())
    if peak_global <= 0:
        return 0
    threshold = peak_global * prom_frac

    peaks = []
    for i in range(len(smooth)):
        left = smooth[i - 1] if i > 0 else -1.0
        right = smooth[i + 1] if i < len(smooth) - 1 else -1.0
        if smooth[i] >= threshold and smooth[i] > left and smooth[i] >= right:
            peaks.append(i)
    if len(peaks) <= 1:
        return len(peaks)

    kept = [peaks[0]]
    for p in peaks[1:]:
        prev = kept[-1]
        valley = float(smooth[prev:p + 1].min())
        if valley <= valley_frac * min(smooth[prev], smooth[p]):
            kept.append(p)  # separated by a deep valley -> distinct mode
        elif smooth[p] > smooth[prev]:
            kept[-1] = p  # same mode, keep the taller peak
    return len(kept)
