"""
Wavelet band decomposition (CWT + synchrosqueezed variant), ported near-verbatim from
c:\\DataS\\InstrumentSimulator\\backend\\app\\wavelet.py (project "Macro Causal Lab").

No project-specific dependencies — pure numpy/ssqueezepy, safe to call from any service.
"""
import logging
import warnings

import numpy as np
from ssqueezepy import cwt, icwt
from ssqueezepy.utils.cwt_utils import center_frequency

logger = logging.getLogger(__name__)

MIN_SCALES_PER_BAND = 4

# Fixed period (days) lower-bounds for bands 0..5, independent of the analysis
# window's length. Without this, dividing [scales.min(), scales.max()] into
# num_levels *equal* log-spaced buckets means "band i" covers a totally
# different real-world period range depending on how much history is fed in
# (a longer window resolves longer scales, which shifts every intermediate
# boundary upward) - so the same band index is not comparable across window
# lengths. Anchoring to absolute day-periods keeps "band i" meaning the same
# oscillation whether the window is 3 months or 3 years; only the last band's
# upper bound stays open, since a longer window can genuinely resolve slower
# cycles a short one cannot.
CANONICAL_PERIOD_EDGES_DAYS = [0.3, 1.5, 4.0, 12.0, 35.0, 90.0]


def _period_to_log_scale_fn(wavelet, scales, n, num_calib_points=16):
    """Build an interpolation from log(period_days) to log(scale) by sampling
    center_frequency at a handful of scales (not every scale - it's not
    vectorized and can be slow over hundreds/thousands of scales).
    """
    log_scales_full = np.log(scales)
    calib_log_scales = np.linspace(log_scales_full.min(), log_scales_full.max(), min(num_calib_points, len(scales)))
    calib_periods = np.array([1 / center_frequency(wavelet, scale=float(np.exp(s)), N=n) for s in calib_log_scales])
    calib_log_periods = np.log(calib_periods)

    def period_to_log_scale(period_days):
        return float(np.interp(np.log(period_days), calib_log_periods, calib_log_scales))

    return period_to_log_scale


def band_decompose(
    values: list[float],
    dates: list[str],
    num_levels: int = 4,
    wavelet: str = "gmw",
) -> dict:
    x = np.asarray(values, dtype=float)
    n = len(x)
    if n < 32:
        raise ValueError("Serie trop courte pour une analyse ondelette (32 points minimum).")

    x_mean = float(x.mean())
    xc = x - x_mean

    Wx, scales = cwt(xc, wavelet=wavelet)
    log_scales = np.log(scales)

    period_to_log_scale = _period_to_log_scale_fn(wavelet, scales, n)
    period_edges = list(CANONICAL_PERIOD_EDGES_DAYS[:num_levels])
    while len(period_edges) < num_levels:
        period_edges.append(period_edges[-1] * 2.5)

    # scales[0] = smallest scale = highest frequency = shortest period (impulses/noise);
    # scales[-1] = largest scale = lowest frequency = longest period (slow trend).
    bands = []
    for i in range(num_levels):
        lo = period_to_log_scale(period_edges[i])
        hi = period_to_log_scale(period_edges[i + 1]) if i + 1 < len(period_edges) else log_scales.max()
        mask = (log_scales >= lo) & (log_scales <= hi if i == num_levels - 1 else log_scales < hi)
        scales_band = scales[mask]

        reconstruction_failed = False
        if len(scales_band) >= MIN_SCALES_PER_BAND:
            try:
                recon = icwt(Wx[mask, :], wavelet=wavelet, scales=scales_band, x_len=n)
            except Exception:
                # Root-caused: icwt's internal scaletype inference recursively splits
                # `scales` looking for a log/linear transition point (infer_scaletype ->
                # logscale_transition_idx), and on a masked SUBSET of the original CWT
                # scales that recursive sub-slice can come out too short, crashing with
                # "attempt to get argmax of an empty sequence" instead of a clean error.
                # Confirmed by reproducing it directly against ssqueezepy 0.6.6: retrying
                # with a regenerated, exactly log-uniform array over the same range/count
                # sidesteps the fragile inference entirely (trivially satisfies its "is
                # this log-spaced" check) and reliably recovers a real reconstruction —
                # verified on 5 independently reproduced failures, all recovered.
                try:
                    clean_scales = np.geomspace(scales_band.min(), scales_band.max(), len(scales_band))
                    recon = icwt(Wx[mask, :], wavelet=wavelet, scales=clean_scales, x_len=n)
                    logger.warning(
                        "band_decompose: icwt failed for band %d with original scales (n=%d, wavelet=%s) — "
                        "recovered using a regenerated log-uniform scale array", i, len(scales_band), wavelet,
                    )
                except Exception:
                    logger.warning(
                        "band_decompose: icwt failed for band %d (n=%d scales, wavelet=%s) even after "
                        "retry — falling back to a zero band", i, len(scales_band), wavelet, exc_info=True,
                    )
                    recon = np.zeros(n)
                    reconstruction_failed = True
            scale_lo, scale_hi = float(scales_band.min()), float(scales_band.max())
        else:
            # Too few scales in this bucket for a stable reconstruction; report a
            # flat zero band (its content ends up folded into the residual) so the
            # band list always stays num_levels long.
            logger.warning(
                "band_decompose: only %d scale(s) in band %d (< %d minimum) — falling back to a zero band",
                len(scales_band), i, MIN_SCALES_PER_BAND,
            )
            recon = np.zeros(n)
            reconstruction_failed = True
            scale_lo, scale_hi = float(np.exp(lo)), float(np.exp(hi))

        freq_at_scale_hi = center_frequency(wavelet, scale=scale_hi, N=n)
        freq_at_scale_lo = center_frequency(wavelet, scale=scale_lo, N=n)
        period_low_days = round(1 / freq_at_scale_lo, 1) if freq_at_scale_lo else None
        period_high_days = round(1 / freq_at_scale_hi, 1) if freq_at_scale_hi else None
        label = (
            f"{period_low_days}-{period_high_days}j"
            if period_low_days is not None and period_high_days is not None
            else f"bande {i + 1}"
        )
        bands.append(
            {
                "index": i,
                "label": label,
                "period_low_days": period_low_days,
                "period_high_days": period_high_days,
                "series": [round(float(v), 6) for v in recon],
                "reconstruction_failed": reconstruction_failed,
            }
        )

    reconstructed_total = x_mean + sum(np.array(band["series"]) for band in bands)
    residual = x - reconstructed_total

    return {
        "dates": dates,
        "original": [round(float(v), 6) for v in x],
        "mean": round(x_mean, 6),
        "bands": bands,
        "residual": [round(float(v), 6) for v in residual],
        "wavelet": wavelet,
    }


def windowed_band_decompose(
    values: list[float],
    dates: list[str],
    window_size: int,
    num_levels: int = 4,
    wavelet: str = "gmw",
) -> dict:
    """Decompose a long series by running band_decompose independently on
    consecutive slices of `window_size` points, then concatenating the
    results. Each slice is analyzed on its own terms (own local mean, own
    CWT scale range), so precision near the reconstructed curve stays high
    even over a long history; the trade-off is a visible discontinuity at
    each slice boundary, which is acceptable for this diagnostic view.
    """
    n = len(values)
    window_size = max(32, min(window_size, n)) if window_size else n
    if n <= window_size:
        result = band_decompose(values, dates, num_levels, wavelet)
        result["window_size"] = window_size
        result["chunks"] = 1
        return result

    all_dates: list[str] = []
    all_original: list[float] = []
    all_residual: list[float] = []
    band_series: list[list[float]] = [[] for _ in range(num_levels)]
    band_meta: list[dict | None] = [None] * num_levels
    band_failed: list[bool] = [False] * num_levels

    chunk_count = 0
    start = 0
    while start < n:
        end = min(start + window_size, n)
        if 0 < n - end < 32:
            # Don't leave a too-short trailing slice: fold the remainder in.
            end = n
        chunk = band_decompose(values[start:end], dates[start:end], num_levels, wavelet)
        chunk_count += 1
        all_dates.extend(chunk["dates"])
        all_original.extend(chunk["original"])
        all_residual.extend(chunk["residual"])
        for band in chunk["bands"]:
            band_series[band["index"]].extend(band["series"])
            band_meta[band["index"]] = band
            # OR across chunks: if any slice couldn't reconstruct this band, flag the
            # whole concatenated series as unreliable rather than losing that signal
            # when a later chunk happens to succeed and overwrites band_meta.
            band_failed[band["index"]] = band_failed[band["index"]] or band.get("reconstruction_failed", False)
        start = end

    bands = []
    for i in range(num_levels):
        meta = band_meta[i] or {"label": f"bande {i + 1}", "period_low_days": None, "period_high_days": None}
        bands.append(
            {
                "index": i,
                "label": meta["label"],
                "period_low_days": meta["period_low_days"],
                "period_high_days": meta["period_high_days"],
                "series": band_series[i],
                "reconstruction_failed": band_failed[i],
            }
        )

    return {
        "dates": all_dates,
        "original": all_original,
        "mean": round(float(np.mean(all_original)), 6) if all_original else 0.0,
        "bands": bands,
        "residual": all_residual,
        "wavelet": wavelet,
        "window_size": window_size,
        "chunks": chunk_count,
    }


def rolling_causal_bands(
    values: list[float],
    dates: list[str],
    start_idx: int,
    lookback: int,
    num_levels: int = 4,
    wavelet: str = "gmw",
    step: int = 1,
) -> dict:
    """Walk-forward band decomposition with no look-ahead: the band value
    reported for day `t` is computed from a CWT run only on the trailing
    `lookback` points ending at `t` (never anything after `t`). This is
    deliberately much slower than `band_decompose` (one CWT per day instead
    of one for the whole range) - that cost is the point: a single
    whole-range CWT lets every point "see" the entire future through the
    transform's global/symmetric support, which makes any backtest built on
    it meaningless (the peaks/troughs it finds were computed with hindsight).
    `step` > 1 re-runs the CWT every `step` days and holds the last computed
    value in between, trading fidelity for speed on long ranges.
    """
    n = len(values)
    out_dates: list[str] = []
    out_original: list[float] = []
    out_mean: list[float] = []
    band_series: list[list[float]] = [[] for _ in range(num_levels)]
    band_labels = [f"bande {i + 1}" for i in range(num_levels)]
    band_failed = [False] * num_levels
    last_tip: list[float] | None = None
    last_tip_mean = 0.0
    recomputations = 0

    for t in range(start_idx, n):
        window_start = t - lookback + 1
        if window_start < 0:
            continue  # not enough trailing history yet for a full lookback window
        if last_tip is None or (t - start_idx) % step == 0:
            window_values = values[window_start:t + 1]
            window_dates = dates[window_start:t + 1]
            result = band_decompose(window_values, window_dates, num_levels, wavelet)
            last_tip = [band["series"][-1] for band in result["bands"]]
            # band_decompose de-means each trailing window before decomposing it (see its
            # own x_mean/xc) — reconstructing a day's price needs THAT window's mean added
            # back, not one fixed constant for the whole causal run (unlike band_decompose's
            # own single-window "mean" key): a lookback window slides forward every step, so
            # its mean drifts along with any real trend. Losing this here (never captured)
            # meant the frontend's mean+sum(bands) reconstruction was silently missing the
            # entire price level — bands.sum() alone centers on ~0, not the real price.
            last_tip_mean = result["mean"]
            band_labels = [band["label"] for band in result["bands"]]
            for i, band in enumerate(result["bands"]):
                band_failed[i] = band_failed[i] or band.get("reconstruction_failed", False)
            recomputations += 1
        out_dates.append(dates[t])
        out_original.append(values[t])
        out_mean.append(last_tip_mean)
        for i in range(num_levels):
            band_series[i].append(last_tip[i])

    bands = [
        {
            "index": i,
            "label": band_labels[i],
            "period_low_days": None,
            "period_high_days": None,
            "series": band_series[i],
            "reconstruction_failed": band_failed[i],
        }
        for i in range(num_levels)
    ]

    return {
        "dates": out_dates,
        "original": out_original,
        "mean": out_mean,  # per-day series (the trailing window's mean drifts) — NOT a
                            # single scalar like band_decompose's own "mean" key; callers
                            # must add mean[i] (not a bare `mean`) to bands.sum() per day
        "bands": bands,
        "wavelet": wavelet,
        "lookback": lookback,
        "step": step,
        "recomputations": recomputations,
    }


# ---------------------------------------------------------------------------
# Synchrosqueezed variant (opt-in, method="ssq"). Everything above this line
# is completely untouched by what follows: band_decompose, windowed_band_decompose
# and rolling_causal_bands are the exact same functions used when method="cwt"
# (the default), so any existing simulation re-run with its original config
# hits the same code path and produces byte-identical results as before.
#
# Rationale: plain cwt/icwt smears a single true frequency's energy across
# several neighboring scales (that's *why* band_decompose needs canonical period
# edges and a minimum-scales guard - the boundaries are fuzzy). Synchrosqueezing
# reassigns that smeared energy onto its estimated true instantaneous frequency
# before splitting into bands, which should reduce cross-band leakage. It also
# exposes two things plain cwt does not: per-band energy |Tx|^2 (how dominant
# that cycle is *right now*, independent of direction) and a dominant-frequency
# ridge (which cycle length is currently winning, tracked over time).
#
# Separate period edges from the cwt path's CANONICAL_PERIOD_EDGES_DAYS: with
# fs=1.0 (one sample = one day, matching our daily data), ssq_cwt has a hard
# Nyquist floor at exactly 2.0 days (max resolvable frequency = fs/2). The cwt
# path's edges start at 0.3 days - below that floor, so reusing them here would
# leave the fastest band permanently empty. This does mean "band 0" covers a
# different absolute range under ssq than under cwt; that's an inherent
# consequence of ssq's stricter frequency floor, not a rounding choice.
# ---------------------------------------------------------------------------

CANONICAL_PERIOD_EDGES_DAYS_SSQ = [2.0, 4.0, 10.0, 25.0, 60.0, 120.0]


def band_decompose_ssq(
    values: list[float],
    dates: list[str],
    num_levels: int = 4,
    wavelet: str = "gmw",
) -> dict:
    from ssqueezepy import issq_cwt, ssq_cwt
    from ssqueezepy.ridge_extraction import extract_ridges

    x = np.asarray(values, dtype=float)
    n = len(x)
    if n < 32:
        raise ValueError("Serie trop courte pour une analyse ondelette (32 points minimum).")

    x_mean = float(x.mean())
    xc = x - x_mean

    with warnings.catch_warnings():
        warnings.simplefilter("ignore", RuntimeWarning)
        # fs=1.0: one sample = one day, so ssq_freqs comes out directly in
        # cycles/day and 1/ssq_freqs is a period in days with no extra
        # calibration step (unlike band_decompose's scale->period conversion,
        # which needs _period_to_log_scale_fn precisely because plain cwt's
        # scale axis has no absolute unit by itself).
        Tx, _Wx, ssq_freqs, _scales = ssq_cwt(xc, wavelet=wavelet, fs=1.0)

    ssq_freqs = np.asarray(ssq_freqs)
    periods = 1.0 / ssq_freqs

    period_edges = list(CANONICAL_PERIOD_EDGES_DAYS_SSQ[:num_levels])
    while len(period_edges) < num_levels:
        period_edges.append(period_edges[-1] * 2.5)

    bands = []
    for i in range(num_levels):
        lo_period = period_edges[i]
        hi_period = period_edges[i + 1] if i + 1 < len(period_edges) else float(periods.max())
        mask = (periods >= lo_period) & (periods <= hi_period if i == num_levels - 1 else periods < hi_period)

        reconstruction_failed = False
        if mask.any():
            try:
                Tx_band = np.zeros_like(Tx)
                Tx_band[mask, :] = Tx[mask, :]
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore", RuntimeWarning)
                    recon = np.real(issq_cwt(Tx_band, wavelet=wavelet))
            except Exception:
                # Defensive, mirroring band_decompose: never let one band's edge
                # case fail the whole request — but log it and flag it (see
                # band_decompose's identical fix for why this used to be silent).
                logger.warning(
                    "band_decompose_ssq: issq_cwt failed for band %d (wavelet=%s) — "
                    "falling back to a zero band", i, wavelet, exc_info=True,
                )
                recon = np.zeros(n)
                reconstruction_failed = True
            energy = np.sum(np.abs(Tx[mask, :]) ** 2, axis=0)
        else:
            logger.warning("band_decompose_ssq: no frequencies fall in band %d — falling back to a zero band", i)
            recon = np.zeros(n)
            energy = np.zeros(n)
            reconstruction_failed = True

        period_low_days = round(float(lo_period), 1)
        period_high_days = round(float(hi_period), 1)
        bands.append(
            {
                "index": i,
                "label": f"{period_low_days}-{period_high_days}j",
                "period_low_days": period_low_days,
                "period_high_days": period_high_days,
                "series": [round(float(v), 6) for v in recon],
                "energy": [round(float(v), 8) for v in energy],
                "reconstruction_failed": reconstruction_failed,
            }
        )

    reconstructed_total = x_mean + sum(np.array(band["series"]) for band in bands)
    residual = x - reconstructed_total

    try:
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", RuntimeWarning)
            ridge_idxs = extract_ridges(Tx, ssq_freqs, n_ridges=1, bw=4)
        ridge_periods = 1.0 / ssq_freqs[ridge_idxs.ravel()]
        ridge_period_days = [round(float(v), 3) for v in ridge_periods]
    except Exception:
        ridge_period_days = [None] * n

    return {
        "dates": dates,
        "original": [round(float(v), 6) for v in x],
        "mean": round(x_mean, 6),
        "bands": bands,
        "residual": [round(float(v), 6) for v in residual],
        "wavelet": wavelet,
        "ridge_period_days": ridge_period_days,
    }


def rolling_causal_bands_ssq(
    values: list[float],
    dates: list[str],
    start_idx: int,
    lookback: int,
    num_levels: int = 4,
    wavelet: str = "gmw",
    step: int = 1,
) -> dict:
    """Synchrosqueezed counterpart to rolling_causal_bands - identical
    walk-forward, no-look-ahead structure (same day-by-day trailing window,
    same step logic), just calling band_decompose_ssq instead of
    band_decompose. rolling_causal_bands itself is untouched.
    """
    n = len(values)
    out_dates: list[str] = []
    out_original: list[float] = []
    out_mean: list[float] = []
    band_series: list[list[float]] = [[] for _ in range(num_levels)]
    energy_series: list[list[float]] = [[] for _ in range(num_levels)]
    ridge_series: list[float | None] = []
    band_labels = [f"bande {i + 1}" for i in range(num_levels)]
    band_failed = [False] * num_levels
    last_tip: list[float] | None = None
    last_energy_tip: list[float] | None = None
    last_ridge_tip: float | None = None
    last_tip_mean = 0.0
    recomputations = 0

    for t in range(start_idx, n):
        window_start = t - lookback + 1
        if window_start < 0:
            continue
        if last_tip is None or (t - start_idx) % step == 0:
            window_values = values[window_start:t + 1]
            window_dates = dates[window_start:t + 1]
            result = band_decompose_ssq(window_values, window_dates, num_levels, wavelet)
            last_tip = [band["series"][-1] for band in result["bands"]]
            last_energy_tip = [band["energy"][-1] for band in result["bands"]]
            last_ridge_tip = result["ridge_period_days"][-1]
            # See rolling_causal_bands's identical fix for why this is a per-day series,
            # not a single scalar: each trailing window is de-meaned independently before
            # decomposition, and that mean drifts as the window slides forward.
            last_tip_mean = result["mean"]
            band_labels = [band["label"] for band in result["bands"]]
            for i, band in enumerate(result["bands"]):
                band_failed[i] = band_failed[i] or band.get("reconstruction_failed", False)
            recomputations += 1
        out_dates.append(dates[t])
        out_original.append(values[t])
        out_mean.append(last_tip_mean)
        for i in range(num_levels):
            band_series[i].append(last_tip[i])
            energy_series[i].append(last_energy_tip[i])
        ridge_series.append(last_ridge_tip)

    bands = [
        {
            "index": i,
            "label": band_labels[i],
            "period_low_days": None,
            "period_high_days": None,
            "series": band_series[i],
            "energy": energy_series[i],
            "reconstruction_failed": band_failed[i],
        }
        for i in range(num_levels)
    ]

    return {
        "dates": out_dates,
        "original": out_original,
        "mean": out_mean,  # per-day series — see rolling_causal_bands
        "bands": bands,
        "wavelet": wavelet,
        "lookback": lookback,
        "step": step,
        "recomputations": recomputations,
        "method": "ssq",
        "ridge_period_days": ridge_series,
    }


def _turning_points(series: list[float], smooth_days: int) -> list[tuple[int, int]]:
    """Indices (and new direction, +1/-1) where the sign of the `smooth_days`-lag slope of
    `series` flips. Using the raw day-to-day slope would flag noise as a "reversal"; lagging
    over a few days smooths that out."""
    turns: list[tuple[int, int]] = []
    prev_sign = 0
    for t in range(smooth_days, len(series)):
        slope = series[t] - series[t - smooth_days]
        sign = 1 if slope > 0 else (-1 if slope < 0 else 0)
        if sign != 0:
            if prev_sign != 0 and sign != prev_sign:
                turns.append((t, sign))
            prev_sign = sign
    return turns


def _average_cycle_days(turn_points: list[tuple[int, int]]) -> float | None:
    """Empirical average gap (in days) between consecutive SAME-direction turning points —
    peak-to-peak or trough-to-trough, i.e. one full oscillation — measured from the actual
    reconstructed series rather than assumed from the band's theoretical period_low/high
    labels (which describe the wavelet scale bucket, not necessarily the real cycle length
    this specific window's price action produced). None if there aren't at least 2
    same-direction turns to measure a gap from."""
    peaks = [t for t, d in turn_points if d == -1]    # a downturn = just passed a peak
    troughs = [t for t, d in turn_points if d == 1]   # an upturn = just passed a trough
    gaps = [seq[i + 1] - seq[i] for seq in (peaks, troughs) for i in range(len(seq) - 1)]
    return sum(gaps) / len(gaps) if gaps else None


def wavelet_reliability(
    values: list[float],
    dates: list[str],
    start_idx: int,
    lookback: int,
    num_levels: int = 4,
    wavelet: str = "gmw",
    method: str = "cwt",
    step: int = 1,
    smooth_days: int = 3,
    tolerance_pct: float = 0.10,
    min_confirm_horizon: int = 3,
) -> dict:
    """
    For every turning point a CAUSAL (walk-forward, no look-ahead) decomposition flags —
    i.e. what a live signal would have shown on that date, using only data available up to
    then — checks whether redoing the decomposition later still shows a same-direction
    reversal near the original date. The fraction confirmed is a per-band confidence score:
    CWT/SSQ reconstructions are well known to be least reliable right at the edge of the
    available data — exactly where a live "the band just turned" reading gets made.

    A fixed confirm horizon (e.g. always +10 days) is meaningless across bands whose natural
    oscillation period can range from ~1 day to several weeks: 10 days is many cycles for a
    fast band (trivially "confirmed", inflating its score) but less than half a cycle for a
    slow one (never has time to actually turn back, deflating its score) — the exact pattern
    that shows up as fast bands scoring ~100% and the slow band scoring ~10% for no real
    reliability reason. Instead, per band: measure its own historical average peak-to-peak
    (or trough-to-trough) period from the causal series, and use `avg_cycle * (1 +
    tolerance_pct)` as a MAXIMUM horizon — a same-direction reversal confirms the original one
    if it shows up ANYWHERE in the forward window [original date, original date + that
    horizon], not just near one specific point in it.
    """
    rolling_decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands
    batch_decompose = band_decompose_ssq if method == "ssq" else band_decompose

    causal = rolling_decomposer(
        values, dates, start_idx=start_idx, lookback=lookback,
        num_levels=num_levels, wavelet=wavelet, step=step,
    )
    causal_dates = causal["dates"]
    date_to_global = {d: i for i, d in enumerate(dates)}

    bands_out = []
    for band in causal["bands"]:
        turn_points = _turning_points(band["series"], smooth_days)
        avg_cycle_days = _average_cycle_days(turn_points)

        if avg_cycle_days is None:
            bands_out.append({
                "label": band["label"], "n_tested": 0, "n_confirmed": 0, "confidence_pct": None,
                "avg_cycle_days": None, "confirm_horizon": None, "turns": [],
            })
            continue

        confirm_horizon = max(min_confirm_horizon, round(avg_cycle_days * (1 + tolerance_pct)))

        tested = []
        for t, direction in turn_points:
            d_date = causal_dates[t]
            g_idx = date_to_global.get(d_date)
            if g_idx is None or g_idx + confirm_horizon >= len(values):
                continue  # not enough real future data yet to judge this one

            end_idx = g_idx + confirm_horizon
            window_start = end_idx - lookback + 1
            if window_start < 0:
                continue
            window_values = values[window_start:end_idx + 1]
            window_dates = dates[window_start:end_idx + 1]
            try:
                hindsight = batch_decompose(window_values, window_dates, num_levels, wavelet)
            except ValueError:
                continue
            h_series = hindsight["bands"][band["index"]]["series"]

            # Forward-only window [original date, original date + horizon] — the reversal
            # can be confirmed anywhere in it, not just near a specific point. window_values
            # was built to end exactly at end_idx = g_idx + confirm_horizon, so h_series's
            # last index already IS "original date + horizon"; no separate bound needed.
            d_pos = g_idx - window_start
            hindsight_turns = _turning_points(h_series, smooth_days)
            matches = [idx for idx, new_dir in hindsight_turns if idx >= d_pos and new_dir == direction]
            confirmed = bool(matches)
            # Actual delay = how many days after the original date the confirming reversal
            # showed up (earliest match) — compared against avg_cycle_days below to check
            # whether "one cycle" is actually a well-calibrated horizon, or systematically
            # too long/short relative to how quickly a real reversal actually confirms.
            actual_delay = None
            height_diff = None
            if confirmed:
                confirm_idx = min(matches)
                actual_delay = confirm_idx - d_pos
                # Direction agreeing doesn't mean the peak/trough had the same amplitude —
                # the live (causal) reading can be a real reversal but distorted in height by
                # edge effects. Compare the band's own value at the original causal turning
                # point against its value at the confirming point in the hindsight
                # reconstruction (same units as the band, e.g. price terms).
                height_diff = abs(band["series"][t] - h_series[confirm_idx])
            tested.append({
                "date": d_date, "confirmed": confirmed,
                "actual_delay_days": actual_delay, "height_diff": height_diff,
            })

        n_tested = len(tested)
        n_confirmed = sum(1 for t in tested if t["confirmed"])
        delays = [t["actual_delay_days"] for t in tested if t["actual_delay_days"] is not None]
        avg_actual_delay = round(sum(delays) / len(delays), 1) if delays else None
        calibration_gap = round(sum(abs(avg_cycle_days - d) for d in delays) / len(delays), 1) if delays else None
        heights = [t["height_diff"] for t in tested if t["height_diff"] is not None]
        avg_height_diff = round(sum(heights) / len(heights), 6) if heights else None
        bands_out.append({
            "label": band["label"],
            "n_tested": n_tested,
            "n_confirmed": n_confirmed,
            "confidence_pct": round(n_confirmed / n_tested * 100, 1) if n_tested else None,
            "avg_cycle_days": round(avg_cycle_days, 1),
            "confirm_horizon": confirm_horizon,
            "avg_actual_delay_days": avg_actual_delay,
            "calibration_gap_days": calibration_gap,
            "avg_height_diff": avg_height_diff,
            "turns": tested,
        })

    return {
        "bands": bands_out,
        "smooth_days": smooth_days,
        "tolerance_pct": tolerance_pct,
        "method": method,
    }
