"""Automatic ephemeris determination for eclipsing binary light curves.

1. BLS (Box Least Squares) locates the dominant eclipse signal and gives an
   initial period, reference time t0, eclipse duration and depth.
2. PDM (Phase Dispersion Minimisation) refines the period on a fine grid
   around the BLS solution.
3. Phase-folding at the refined period locates the secondary eclipse and
   estimates its position and width in phase units.

The public interface is:
    concat_sectors  -- per-sector normalisation and concatenation
    auto_ephemeris  -- full BLS -> PDM -> secondary eclipse determination
"""

import numpy as np
from astropy.timeseries import BoxLeastSquares
from scipy.optimize import curve_fit
import logging

logger = logging.getLogger('mono_cbp.utils.ephemeris')


# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------

def concat_sectors(sector_data):
    """Concatenate light-curve segments from multiple sectors.

    Each sector is median-normalised independently before concatenation so
    that inter-sector flux offsets do not bias the period search.

    Args:
        sector_data (list of tuple): Each element is ``(time, flux, flux_err)``
            where all three are 1-D array-like.

    Returns:
        tuple: ``(time, flux, flux_err)`` concatenated arrays sorted by time.
    """
    times, fluxes, errors = [], [], []

    for t, f, e in sector_data:
        t = np.asarray(t, dtype=np.float64)
        f = np.asarray(f, dtype=np.float64)
        e = np.asarray(e, dtype=np.float64)

        ok = np.isfinite(t) & np.isfinite(f) & np.isfinite(e)

        med = np.median(f[ok])
        if med == 0.0 or not np.isfinite(med):
            logger.warning("Sector median is zero or non-finite; skipping normalisation.")
            med = 1.0

        times.append(t[ok])
        fluxes.append(f[ok] / med)
        errors.append(e[ok] / med)

    time_all    = np.concatenate(times)
    flux_all    = np.concatenate(fluxes)
    flux_err_all = np.concatenate(errors)

    order = np.argsort(time_all)
    return time_all[order], flux_all[order], flux_err_all[order]


def auto_ephemeris(
    time,
    flux,
    flux_err=None,
    period_range=(5, 30.0),
    n_periods=50_000,
    n_durations=20,
    pdm_fraction=0.05,
    pdm_n_fine=5_000,
    pdm_n_bins=20,
    sec_n_bins=50,
    detrend_window=None,
):
    """Automatically determine binary ephemeris from a concatenated light curve.

    Args:
        time (array-like): Time values (e.g. BTJD).  Should be the output of
            :func:`concat_sectors` or equivalent pre-processing.
        flux (array-like): Normalised flux (median ≈ 1).
        flux_err (array-like, optional): Flux uncertainties.  Uniform weights
            are used if ``None``.
        period_range (tuple): ``(min, max)`` trial period in days.
            Default ``(0.5, 30.0)``.
        n_periods (int): BLS period-grid size.  Default ``50_000``.
        n_durations (int): BLS duration-grid size.  Default ``20``.
        pdm_fraction (float): PDM searches ``± pdm_fraction x period_bls``
            around the BLS solution.  Default ``0.05`` (±5 %).  A wider
            range is needed when BLS errors exceed ~2%, e.g. for light
            curves with strong ellipsoidal variation.
        pdm_n_fine (int): Number of periods in the PDM fine grid.
            Default ``5_000``.
        pdm_n_bins (int): Phase bins for the PDM statistic.  Default ``20``.
        sec_n_bins (int): Phase bins when searching for the secondary eclipse.
            Default ``50``.
        detrend_window (float or None): Window length in days for a biweight
            sliding-window detrend applied to the flux before all analysis
            steps (BLS, PDM, eclipse timing, t0 refinement, secondary
            detection).  Removes slow astrophysical trends (e.g. ellipsoidal
            variation, reflection effect) that otherwise confuse period
            finding and secondary detection.  The biweight is robust so the
            narrow eclipse signal is preserved.  ``None`` (default) disables
            detrending.  A value of ~1–3 days works well for most targets;
            the window should be shorter than the dominant sinusoidal period
            (≈ P/2) but at least a few times the eclipse duration.

    Returns:
        dict: Eclipse parameters with keys matching :func:`~mono_cbp.utils.data.load_catalogue`:

            - ``'period'``     -- refined orbital period (days)
            - ``'bjd0'``       -- reference time of primary eclipse (same units as *time*)
            - ``'prim_pos'``   -- primary eclipse phase position (always ``0.0``)
            - ``'prim_width'`` -- primary eclipse width in phase units
            - ``'sec_pos'``    -- secondary eclipse phase position
            - ``'sec_width'``  -- secondary eclipse width in phase units
    """
    time = np.asarray(time, dtype=np.float64)
    flux = np.asarray(flux, dtype=np.float64)
    if flux_err is not None:
        flux_err = np.asarray(flux_err, dtype=np.float64)

    # ------------------------------------------------------------------
    # Optional detrend — removes slow astrophysical trends (ellipsoidal
    # variation, reflection effect) that confuse BLS, PDM, eclipse timing,
    # and secondary detection.  When detrend_window is set, flux_work is
    # the detrended flux and is used throughout ALL steps.  Eclipse depths
    # and shapes are preserved because the biweight is robust to the
    # narrow eclipse signal; the smooth baseline is divided out.
    # When detrend_window=None, flux_work = flux (no change).
    # ------------------------------------------------------------------
    flux_work = flux
    if detrend_window is not None:
        try:
            from wotan import flatten as _wotan_flatten
            flux_detrended = _wotan_flatten(
                time, flux, window_length=detrend_window, method='biweight'
            )
            # wotan returns NaN at edges; replace with 1.0 so BLS isn't affected
            nan_mask = ~np.isfinite(flux_detrended)
            flux_detrended[nan_mask] = 1.0
            flux_work = flux_detrended
            logger.info(
                f"Detrend: biweight window={detrend_window} d, "
                f"{nan_mask.sum()} edge NaNs replaced with 1.0"
            )
        except ImportError:
            logger.warning("wotan not available; skipping detrending")

    # Keep a reference to the detrended flux for the caller (= original if no detrend)
    flux_bls = flux_work

    # ------------------------------------------------------------------
    # Step 1: BLS — primary eclipse
    # ------------------------------------------------------------------
    period_bls, t0_bls, width_prim_days = _bls_primary(
        time, flux_work, flux_err, period_range, n_periods, n_durations
    )
    logger.info(
        f"BLS: period={period_bls:.6f} d, t0={t0_bls:.4f}, "
        f"duration={width_prim_days:.4f} d"
    )

    # ------------------------------------------------------------------
    # Step 2: PDM — refine period
    # ------------------------------------------------------------------
    period = _pdm_refine(time, flux_work, period_bls, pdm_fraction, pdm_n_fine, pdm_n_bins)
    # period = period_bls
    logger.info(f"PDM: period={period:.6f} d  (BLS was {period_bls:.6f} d)")

    # ------------------------------------------------------------------
    # Step 2.25: Eclipse-timing period refinement
    # ------------------------------------------------------------------
    # For short time baselines (few periods visible) BLS/PDM period accuracy
    # is limited.  Directly timing each primary eclipse centroid and fitting
    # a linear ephemeris is far more accurate in that regime.
    period = _refine_period_transits(time, flux_work, period, t0_bls, width_prim_days / period)

    # ------------------------------------------------------------------
    # Step 2.5: Refine t0 and primary width
    # ------------------------------------------------------------------
    width_bls_phase = width_prim_days / period

    # Centroid-based t0 refinement (shape-agnostic; robust for deep V-shaped eclipses)
    t0_refined = _refine_t0(time, flux_work, period, t0_bls, width_bls_phase)

    # Gaussian fit to primary for width (using refined t0 so eclipse is centred)
    phase_prim = ((time - t0_refined) / period) % 1.0
    _, prim_width_phase = _fit_gaussian_eclipse(
        phase_prim, flux_work, center_guess=0.0, width_guess=width_bls_phase, n_bins=200
    )
    logger.info(
        f"Primary width: Gaussian={prim_width_phase:.6f}, BLS={width_bls_phase:.6f} (phase)"
    )

    # ------------------------------------------------------------------
    # Step 3: Secondary eclipse
    # ------------------------------------------------------------------
    sec_pos, sec_width_phase = _find_secondary(
        time, flux_work, period, t0_refined, prim_width_phase, sec_n_bins
    )
    logger.info(
        f"Secondary: pos={sec_pos:.4f}, width={sec_width_phase:.4f} (phase)"
    )

    return {
        'period':          period,
        'bjd0':            t0_refined,
        'prim_pos':        0.0,
        'prim_width':      prim_width_phase,
        'sec_pos':         sec_pos,
        'sec_width':       sec_width_phase,
        'flux_detrended':  flux_bls,   # detrended flux used for BLS/PDM (= original if detrend_window=None)
    }


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

def _fit_gaussian_eclipse(phase, flux, center_guess, width_guess, n_bins=100):
    """Fit a Gaussian to phase-folded eclipse data to estimate width.

    Phases are internally shifted so the eclipse center sits at 0.5 before
    fitting, ensuring the eclipse is never split across the 0/1 phase boundary.
    The returned center is shifted back to the original phase convention.

    Args:
        phase (np.ndarray): Phase values (0 to 1)
        flux (np.ndarray): Normalized flux values
        center_guess (float): Initial guess for eclipse center in phase units
        width_guess (float): Initial guess for eclipse width in phase units
        n_bins (int): Number of bins for phase-folding. Default 100.

    Returns:
        tuple: (center, width_phase) where width_phase is the Gaussian FWHM
               Returns (center_guess, width_guess) if fit fails
    """
    # Always shift so the eclipse center sits at 0.5, avoiding wrap-around
    shift = 0.5 - center_guess
    phase_work = (phase + shift) % 1.0  # Eclipse now centred at 0.5

    # Bin the shifted phase-folded data
    bin_edges = np.linspace(0.0, 1.0, n_bins + 1)
    bin_mids  = 0.5 * (bin_edges[:-1] + bin_edges[1:])
    bin_means  = np.full(n_bins, np.nan)
    bin_counts = np.zeros(n_bins, dtype=int)

    for k in range(n_bins):
        in_bin = (phase_work >= bin_edges[k]) & (phase_work < bin_edges[k + 1])
        count = in_bin.sum()
        if count > 0:
            bin_means[k] = np.median(flux[in_bin])
            bin_counts[k] = count

    valid = np.isfinite(bin_means) & (bin_counts > 0)
    if valid.sum() < 5:
        logger.warning("Too few bins for Gaussian fit; using initial guess")
        return center_guess, width_guess

    phase_valid = bin_mids[valid]
    flux_valid  = bin_means[valid]

    # Define inverted Gaussian: baseline - amplitude * exp(...)
    def gaussian_dip(x, center, sigma, amplitude, baseline):
        return baseline - amplitude * np.exp(-0.5 * ((x - center) / sigma) ** 2)

    # Restrict fit to a window around the eclipse so that the many flat
    # out-of-eclipse bins don't overwhelm the few in-eclipse bins
    window = max(3.0 * width_guess, 3.0 / n_bins)  # At least 3 bins wide
    in_window = np.abs(phase_valid - 0.5) < window
    if in_window.sum() >= 5:
        phase_valid = phase_valid[in_window]
        flux_valid  = flux_valid[in_window]

    baseline_guess  = np.percentile(flux_valid, 90)  # Robust baseline within window
    amplitude_guess = max(baseline_guess - np.min(flux_valid), 1e-4)
    sigma_guess     = width_guess / 2.355  # FWHM → sigma

    try:
        popt, _ = curve_fit(
            gaussian_dip,
            phase_valid,
            flux_valid,
            p0=[0.5, sigma_guess, amplitude_guess, baseline_guess],
            bounds=(
                [0.4,  0.001, 0.0, 0.8],
                [0.6,  0.2,   1.0, 1.2],
            ),
            maxfev=2000,
        )

        center_work, sigma_fit, amplitude_fit, baseline_fit = popt

        if sigma_fit < 0.0004 or sigma_fit > 0.1:
            logger.warning(f"Gaussian sigma {sigma_fit:.4f} outside reasonable range; using guess")
            return center_guess, width_guess

        if amplitude_fit <= 0 or amplitude_fit > 0.5:
            logger.warning(f"Gaussian fit amplitude {amplitude_fit:.4f} unreasonable; using guess")
            return center_guess, width_guess

        # Width = 2 × FWHM.  For V-shaped eclipses FWHM ≈ 0.5 × total width,
        # so 2 × FWHM recovers the full first-to-last-contact duration.
        # This is robust regardless of eclipse depth or bin count, unlike
        # threshold-crossing approaches which break for shallow eclipses.
        width_fit = 2.0 * sigma_fit * 2.355  # 2 × FWHM

        if width_fit < 0.001 or width_fit > 0.4:
            logger.warning(f"Threshold width {width_fit:.4f} outside reasonable range; using guess")
            return center_guess, width_guess

        # Shift fitted center back to original phase convention
        center_fit = (center_work - shift) % 1.0
        logger.debug(f"Gaussian fit: center={center_fit:.4f}, threshold width={width_fit:.4f}")
        return float(center_fit), float(width_fit)

    except Exception as e:
        logger.warning(f"Gaussian fit failed: {e}; using initial guess")
        return center_guess, width_guess


def _refine_t0(time, flux, period, t0_init, width_phase):
    """Refine the reference transit time using the flux-weighted centroid of the
    primary eclipse dip.

    The centroid approach is shape-agnostic: it works for V-shaped, U-shaped or
    flat-bottomed eclipses without assuming a Gaussian profile.

    Args:
        time (np.ndarray): Time array.
        flux (np.ndarray): Normalised flux array.
        period (float): Orbital period (days).
        t0_init (float): Initial t0 estimate (same units as *time*).
        width_phase (float): Estimated primary eclipse width in phase units.

    Returns:
        float: Refined t0 (same units as *time*).
    """
    # Shift phases so primary eclipse sits at 0.5 (avoids wrap-around)
    phase = ((time - t0_init) / period) % 1.0
    phase_shifted = (phase + 0.5) % 1.0  # Primary now near 0.5

    # Select data within a 3x-width window around 0.5
    half_w = 1.5 * width_phase
    in_window = np.abs(phase_shifted - 0.5) < half_w

    if in_window.sum() < 3:
        logger.warning("Too few points in primary window for t0 refinement; using BLS t0")
        return t0_init

    phase_win = phase_shifted[in_window]
    flux_win  = flux[in_window]

    # Baseline from the 90th flux percentile (out-of-eclipse level within window)
    baseline = np.percentile(flux_win, 90)
    dip      = np.maximum(baseline - flux_win, 0.0)  # Only count downward excursions

    if dip.sum() == 0.0:
        logger.warning("No flux dip found for t0 refinement; using BLS t0")
        return t0_init

    # Flux-weighted centroid in shifted-phase space
    centroid_shifted = np.sum(phase_win * dip) / np.sum(dip)

    # Phase offset from 0.5 (the intended center)
    phase_offset = centroid_shifted - 0.5

    # Correct t0 so the centroid lands exactly at phase 0.0
    t0_refined = t0_init + phase_offset * period
    logger.info(
        f"t0 refinement: centroid offset = {phase_offset:.6f} phase "
        f"({phase_offset * period:.6f} d)"
    )
    return float(t0_refined)


def _refine_period_transits(time, flux, period_approx, t0_approx, width_phase):
    """Refine the period from primary eclipse centroid timing.

    For data with few visible eclipses (1–3 periods covered), BLS/PDM period
    accuracy is inherently limited.  This function identifies each primary
    eclipse event in the data, computes a flux-weighted centroid time for
    each, then fits a linear ephemeris to the centroid sequence.  Even with
    only 2 eclipses this gives a period estimate of P = t₂ − t₁, which is
    more accurate than grid-based methods for short baselines.

    Returns ``period_approx`` unchanged if fewer than 2 eclipse events are
    detected.

    Args:
        time (np.ndarray): Time array.
        flux (np.ndarray): Normalised flux.
        period_approx (float): Initial period estimate (days).
        t0_approx (float): Initial t0 estimate (same units as *time*).
        width_phase (float): Primary eclipse width in phase units.

    Returns:
        float: Refined period (days).
    """
    # Assign each point to the nearest expected eclipse epoch
    epoch_float = (time - t0_approx) / period_approx
    epoch_int   = np.round(epoch_float).astype(int)
    phase_cent  = epoch_float - epoch_int  # Phase centred at 0 for each eclipse

    # Window must be large enough to capture eclipses despite period offsets.
    # The BLS period can be off by up to ~5%; after N epochs that shifts the
    # predicted eclipse by N×0.05 in phase.  Enforce a floor of 0.05 so that
    # eclipses are found even when the BLS duration is much narrower than the
    # true eclipse width (e.g. on light curves with strong sinusoidal variation).
    half_w     = max(3.0 * width_phase, 0.05)
    in_eclipse = np.abs(phase_cent) < half_w

    unique_epochs = np.unique(epoch_int[in_eclipse])
    if len(unique_epochs) < 2:
        logger.info(
            f"Eclipse timing: {len(unique_epochs)} primary eclipse(s) found; period unchanged"
        )
        return period_approx

    # Flux-weighted centroid time for each eclipse event.
    # Use a LOCAL baseline per epoch (median of out-of-eclipse data within
    # ±0.5 period of the predicted eclipse) rather than a global percentile.
    # This is robust against ellipsoidal variation and imperfect detrending:
    # the local median naturally captures the flux level at the eclipse's
    # specific orbital phase without being biased by variation elsewhere.
    centroids = []
    for ep in unique_epochs:
        in_epoch  = (epoch_int == ep)                  # All data in this period interval
        mask      = in_eclipse & in_epoch              # Eclipse window for this epoch
        oot       = in_epoch & ~in_eclipse             # Out-of-transit within same epoch

        if mask.sum() < 3:
            continue

        # Local baseline from out-of-eclipse data in the same period interval.
        # Fall back to the global 90th percentile if too few OOT points exist.
        if oot.sum() > 10:
            local_baseline = np.median(flux[oot])
        else:
            local_baseline = np.percentile(flux, 90)

        dip = np.maximum(local_baseline - flux[mask], 0.0)
        if dip.sum() == 0.0:
            logger.debug(f"Eclipse timing epoch {ep}: no dip found (local baseline={local_baseline:.4f})")
            continue

        # Require the eclipse to be significantly deeper than the local scatter
        local_noise = np.std(flux[oot]) if oot.sum() > 5 else 1e-3
        eclipse_depth = local_baseline - np.min(flux[mask])
        if eclipse_depth < 2.0 * local_noise:
            logger.debug(
                f"Eclipse timing epoch {ep}: depth {eclipse_depth:.4f} < 2σ={local_noise:.4f}; skipping"
            )
            continue

        centroid_t = np.sum(time[mask] * dip) / np.sum(dip)
        logger.debug(
            f"Eclipse timing epoch {ep}: centroid={centroid_t:.4f}, "
            f"depth={eclipse_depth:.4f}, noise={local_noise:.4f}"
        )
        centroids.append((ep, centroid_t))

    if len(centroids) < 2:
        logger.info(
            f"Eclipse timing: only {len(centroids)} usable eclipse(s); period unchanged"
        )
        return period_approx

    epochs_arr = np.array([c[0] for c in centroids], dtype=float)
    times_arr  = np.array([c[1] for c in centroids])

    # Linear fit: t_centroid = t0 + epoch × P  →  slope = P
    coeffs         = np.polyfit(epochs_arr, times_arr, 1)
    period_refined = float(abs(coeffs[0]))

    logger.info(
        f"Eclipse timing: centroids={[(c[0], round(c[1], 4)) for c in centroids]}, "
        f"fit P={period_refined:.6f} d  (was {period_approx:.6f} d)"
    )

    # Sanity check: refined period must stay within 10% of initial
    if abs(period_refined - period_approx) / period_approx > 0.10:
        logger.warning(
            f"Eclipse timing: refined period {period_refined:.4f} d deviates "
            f">10% from {period_approx:.4f} d; keeping original"
        )
        return period_approx

    return period_refined


def _bls_primary(time, flux, flux_err, period_range, n_periods, n_durations):
    """Run BLS to find the dominant (primary) eclipse signal.

    Returns:
        tuple: ``(period, t0, duration_days)``
    """
    bls = BoxLeastSquares(time, flux, dy=flux_err)

    periods = np.geomspace(period_range[0], period_range[1], n_periods)

    # Duration grid: scale with typical periods in the search range
    # Use geometric mean as reference to handle wide period ranges
    period_ref = np.sqrt(period_range[0] * period_range[1])
    dur_min = max(0.01 * period_ref, 1.0 / 24)  # 1% of ref period, at least 1 hour
    dur_max = min(0.12 * period_ref, 0.8 * period_range[0])  # 12% of ref, but < min period
    durations = np.linspace(dur_min, dur_max, n_durations)

    result = bls.power(periods, durations)

    best = int(np.argmax(result.power))
    return (
        float(result.period[best]),
        float(result.transit_time[best]),
        float(result.duration[best]),
    )


def _pdm_refine(time, flux, period_bls, pdm_fraction, n_fine, n_bins):
    """Refine the BLS period using Phase Dispersion Minimisation.

    The PDM statistic theta = mean(s_k**2) / S**2 where s_k**2 is the variance within
    phase bin k and S**2 is the total variance of the flux. theta is minimised at
    the true period.

    Uses ``np.bincount`` to compute within-bin variance without Python loops
    over bins, keeping runtime practical for large datasets.

    Returns:
        float: Refined period (days)
    """
    p_lo = (1.0 - pdm_fraction) * period_bls
    p_hi = (1.0 + pdm_fraction) * period_bls
    periods = np.linspace(p_lo, p_hi, n_fine)

    flux2 = flux ** 2
    S2 = np.var(flux)
    thetas = np.ones(n_fine)

    for i, P in enumerate(periods):
        phase = ((time - time[0]) / P) % 1.0
        bin_idx = np.floor(phase * n_bins).astype(int)
        bin_idx = np.clip(bin_idx, 0, n_bins - 1)

        counts = np.bincount(bin_idx, minlength=n_bins)
        sum_f  = np.bincount(bin_idx, weights=flux,  minlength=n_bins)
        sum_f2 = np.bincount(bin_idx, weights=flux2, minlength=n_bins)

        valid = counts > 1
        if not valid.any():
            continue

        # Within-bin variance: E[x²] − E[x]²
        safe_counts = np.where(counts > 0, counts, 1)
        bin_var = np.where(
            valid,
            sum_f2 / safe_counts - (sum_f / safe_counts) ** 2,
            0.0,
        )
        thetas[i] = bin_var[valid].mean() / S2

    return float(periods[np.argmin(thetas)])


def _find_secondary(time, flux, period, t0, prim_width_phase, n_bins):
    """Locate the secondary eclipse via phase-folding.

    The primary eclipse region (± 3x half-width around phase 0) is masked
    before searching for the deepest remaining dip. The secondary position
    and width are estimated by fitting a Gaussian to the binned dip profile.

    Returns:
        tuple: ``(sec_pos, sec_width)`` both in phase units.
    """
    phase = ((time - t0) / period) % 1.0

    # Mask primary (with a generous safety buffer to handle underestimated width)
    # Mask 3× the estimated width to avoid picking up primary residuals
    half_w = 3.0 * prim_width_phase / 2.0
    prim_mask = (phase < half_w) | (phase > 1.0 - half_w)

    phase_out = phase[~prim_mask]
    flux_out  = flux[~prim_mask]

    if len(phase_out) < n_bins:
        logger.warning(
            "Insufficient data outside primary eclipse for secondary detection; "
            "defaulting to no secondary."
        )
        return 0.5, 0.0

    # Bin the out-of-eclipse phase-folded data
    bin_edges = np.linspace(0.0, 1.0, n_bins + 1)
    bin_mids  = 0.5 * (bin_edges[:-1] + bin_edges[1:])
    bin_means = np.full(n_bins, np.nan)

    for k in range(n_bins):
        in_bin = (phase_out >= bin_edges[k]) & (phase_out < bin_edges[k + 1])
        if in_bin.sum() > 0:
            bin_means[k] = np.median(flux_out[in_bin])

    # Mark bins that have actual data (not in masked region)
    valid = np.isfinite(bin_means)
    if valid.sum() < 3:
        logger.warning("Too few populated bins for secondary detection.")
        return 0.5, 0.0

    # 3-bin rolling mean to reduce shot noise (only on valid bins)
    # Use same indices as valid bins to avoid interpolation edge effects
    bin_means_smooth = bin_means.copy()
    for k in range(n_bins):
        if valid[k]:
            # Compute rolling mean using neighboring bins (if valid)
            neighbors = []
            for offset in [-1, 0, 1]:
                idx = k + offset
                if 0 <= idx < n_bins and valid[idx]:
                    neighbors.append(bin_means[idx])
            if neighbors:
                bin_means_smooth[k] = np.mean(neighbors)

    # Deepest bin → secondary position (only search among valid bins)
    valid_smooth = np.isfinite(bin_means_smooth)
    if not valid_smooth.any():
        logger.warning("No valid bins after smoothing.")
        return 0.5, 0.0

    sec_bin = int(np.nanargmin(np.where(valid_smooth, bin_means_smooth, np.inf)))
    sec_pos_initial = float(bin_mids[sec_bin])

    baseline  = np.median(flux_out)
    sec_depth = baseline - bin_means_smooth[sec_bin]

    if sec_depth <= 0:
        logger.info("No secondary dip detected; returning no secondary.")
        return 0.5, 0.0

    # Significance test: secondary must stand out from the bin-to-bin scatter.
    # With n_bins=50, the expected deepest noise bin is ~2.5–3 sigma, so a
    # threshold of 5× rejects all but genuine eclipses.
    bin_scatter = np.nanstd(bin_means[valid])
    snr = sec_depth / bin_scatter if bin_scatter > 0 else 0.0
    logger.info(f"Secondary candidate: pos={sec_pos_initial:.4f}, depth={sec_depth:.5f}, SNR={snr:.2f}")
    if snr < 0.9:
        logger.info(
            f"Secondary SNR {snr:.2f} < 0.9 (depth={sec_depth:.5f}, "
            f"bin_scatter={bin_scatter:.5f}); no significant secondary detected."
        )
        return 0.5, 0.0

    # Use Gaussian fit to refine secondary position and width
    # Initial width guess based on typical secondary eclipses
    width_guess = max(prim_width_phase, 0.02)  # At least 2% of phase

    sec_pos, sec_width_phase = _fit_gaussian_eclipse(
        phase_out, flux_out,
        center_guess=sec_pos_initial,
        width_guess=width_guess,
        n_bins=200,
    )

    # Guard against degenerate estimates
    if sec_width_phase <= 0 or sec_width_phase > 0.4:
        logger.warning(f"Secondary width {sec_width_phase:.4f} unreasonable; reporting no secondary")
        return 0.5, 0.0

    return sec_pos, sec_width_phase