import numpy as np
import json
import warnings
warnings.filterwarnings('ignore')

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

from scipy.optimize import curve_fit

# ─────────────────────────────────────────────
# 1.  Load data (generate synthetic if file not found)
# ─────────────────────────────────────────────
try:
    data = np.load('/Users/maxim.ziatdinov/Code/SciLink/test_adaptive_annealing_data/output_hard/temp_spectrum_0.npy')
    if data.ndim == 2:
        if data.shape[1] == 2:
            x, y = data[:, 0], data[:, 1]
        else:
            x, y = data[0], data[1]
    else:
        x = np.linspace(0, 100, len(data))
        y = data
except FileNotFoundError:
    print("Data file not found. Generating synthetic 3-peak spectrum for demonstration.")
    rng_data = np.random.default_rng(0)
    x = np.linspace(0, 100, 500)
    # Exponential baseline
    baseline_true = 0.3 * (1.0 - np.exp(-x / 15.0)) + 0.001 * x
    # Three peaks (Pseudo-Voigt-like)
    def pv_synth(x, A, mu, sigma, eta):
        gauss = np.exp(-0.5 * ((x - mu) / sigma) ** 2)
        lorentz = 1.0 / (1.0 + ((x - mu) / sigma) ** 2)
        return A * (eta * lorentz + (1.0 - eta) * gauss)
    peak1 = pv_synth(x, 1.2, 30.0, 0.6, 0.6) + pv_synth(x, 0.4, 30.0, 3.5, 0.5)
    peak2 = pv_synth(x, 0.9, 55.0, 0.7, 0.7) + pv_synth(x, 0.3, 55.0, 4.0, 0.4)
    peak3 = pv_synth(x, 1.0, 78.0, 0.5, 0.5) + pv_synth(x, 0.35, 78.0, 3.0, 0.6)
    noise = rng_data.normal(0, 0.015, size=len(x))
    y = baseline_true + peak1 + peak2 + peak3 + noise

x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
N = len(x)

print(f"Data loaded: {N} points, x=[{x.min():.3f}, {x.max():.3f}], y=[{y.min():.4f}, {y.max():.4f}]")

# ─────────────────────────────────────────────
# 2.  Helper: Pseudo-Voigt
# ─────────────────────────────────────────────
def pseudo_voigt(x, A, mu, sigma, eta):
    """Pseudo-Voigt: eta*Lorentzian + (1-eta)*Gaussian, area-normalised by A."""
    sigma = np.abs(sigma) + 1e-12
    gauss = np.exp(-0.5 * ((x - mu) / sigma) ** 2)
    lorentz = 1.0 / (1.0 + ((x - mu) / sigma) ** 2)
    return A * (eta * lorentz + (1.0 - eta) * gauss)

# ─────────────────────────────────────────────
# 3.  Full model (24 parameters)
# Baseline: b(x) = a0*(1 - exp(-x/tau)) + a1*x
# Parameters:
#   a0, tau, a1,
#   An1, Ab1, mu1, sn1, sb1, etn1, etb1,
#   An2, Ab2, mu2, sn2, sb2, etn2, etb2,
#   An3, Ab3, mu3, sn3, sb3, etn3, etb3
# ─────────────────────────────────────────────
def full_model(x, a0, tau, a1,
               An1, Ab1, mu1, sn1, sb1, etn1, etb1,
               An2, Ab2, mu2, sn2, sb2, etn2, etb2,
               An3, Ab3, mu3, sn3, sb3, etn3, etb3):
    tau_ = np.abs(tau) + 1e-6
    baseline = a0 * (1.0 - np.exp(-x / tau_)) + a1 * x

    p1 = pseudo_voigt(x, An1, mu1, sn1, etn1) + pseudo_voigt(x, Ab1, mu1, sb1, etb1)
    p2 = pseudo_voigt(x, An2, mu2, sn2, etn2) + pseudo_voigt(x, Ab2, mu2, sb2, etb2)
    p3 = pseudo_voigt(x, An3, mu3, sn3, etn3) + pseudo_voigt(x, Ab3, mu3, sb3, etb3)

    return baseline + p1 + p2 + p3

# Pure Lorentzian model (eta fixed to 1 for all PV components)
def full_model_lorentz(x, a0, tau, a1,
                       An1, Ab1, mu1, sn1, sb1,
                       An2, Ab2, mu2, sn2, sb2,
                       An3, Ab3, mu3, sn3, sb3):
    tau_ = np.abs(tau) + 1e-6
    baseline = a0 * (1.0 - np.exp(-x / tau_)) + a1 * x

    p1 = pseudo_voigt(x, An1, mu1, sn1, 1.0) + pseudo_voigt(x, Ab1, mu1, sb1, 1.0)
    p2 = pseudo_voigt(x, An2, mu2, sn2, 1.0) + pseudo_voigt(x, Ab2, mu2, sb2, 1.0)
    p3 = pseudo_voigt(x, An3, mu3, sn3, 1.0) + pseudo_voigt(x, Ab3, mu3, sb3, 1.0)

    return baseline + p1 + p2 + p3

# ─────────────────────────────────────────────
# 4.  Stage 1 – Estimate baseline from signal-free region
# ─────────────────────────────────────────────
mask_base = x < 15
if mask_base.sum() > 5:
    def baseline_only(x, a0, tau):
        tau_ = np.abs(tau) + 1e-6
        return a0 * (1.0 - np.exp(-x / tau_))
    try:
        p_base, _ = curve_fit(baseline_only, x[mask_base], y[mask_base],
                              p0=[y[mask_base].mean(), 10.0],
                              bounds=([0, 1], [y.max(), 30]),
                              method='trf', maxfev=5000)
        a0_est, tau_est = p_base
    except Exception:
        a0_est, tau_est = y[mask_base].mean() if mask_base.sum() > 0 else 0.1, 10.0
else:
    a0_est, tau_est = 0.1, 10.0

a1_est = 0.0  # initial estimate for linear term

print(f"Baseline estimate: a0={a0_est:.4f}, tau={tau_est:.4f}, a1={a1_est:.4f}")

# ─────────────────────────────────────────────
# 5.  Stage 2 – Locate peaks in baseline-subtracted data
# ─────────────────────────────────────────────
baseline_est = a0_est * (1.0 - np.exp(-x / (tau_est + 1e-6)))
y_sub = y - baseline_est

from scipy.ndimage import uniform_filter1d
y_smooth = uniform_filter1d(y_sub, size=max(1, N // 50))

residual_search = y_smooth.copy()
peak_centers = []
peak_heights = []
for _ in range(3):
    idx = np.argmax(residual_search)
    peak_centers.append(x[idx])
    peak_heights.append(y_smooth[idx])
    width_suppress = max(1, N // 20)
    lo = max(0, idx - width_suppress)
    hi = min(N, idx + width_suppress)
    residual_search[lo:hi] = -np.inf

peak_centers = sorted(peak_centers)
peak_heights_dict = {}
for c in peak_centers:
    idx = np.argmin(np.abs(x - c))
    peak_heights_dict[c] = max(y_smooth[idx], 0.01)

mu1_est, mu2_est, mu3_est = peak_centers
A1_est = peak_heights_dict[mu1_est]
A2_est = peak_heights_dict[mu2_est]
A3_est = peak_heights_dict[mu3_est]

print(f"Peaks found at: {mu1_est:.2f}, {mu2_est:.2f}, {mu3_est:.2f}")
print(f"Peak heights: {A1_est:.4f}, {A2_est:.4f}, {A3_est:.4f}")

# ─────────────────────────────────────────────
# 6.  Bounds definition (24 params with a1)
# ─────────────────────────────────────────────
x_range = x.max() - x.min()
mu_tol = x_range * 0.15

lb = [
    0,    1,   -0.1,    # a0, tau, a1
    0, 0, mu1_est - mu_tol, 0.1, 1.5, 0, 0,
    0, 0, mu2_est - mu_tol, 0.1, 1.5, 0, 0,
    0, 0, mu3_est - mu_tol, 0.1, 1.5, 0, 0,
]
ub = [
    y.max()*2, 30, 0.1,  # a0, tau, a1
    y.max()*5, y.max()*5, mu1_est + mu_tol, 1.5, 8, 1, 1,
    y.max()*5, y.max()*5, mu2_est + mu_tol, 1.5, 8, 1, 1,
    y.max()*5, y.max()*5, mu3_est + mu_tol, 1.5, 8, 1, 1,
]
bounds = (lb, ub)

# Bounds for pure Lorentzian model (18 params: a0, tau, a1 + 5 per peak)
lb_lor = [
    0,    1,   -0.1,
    0, 0, mu1_est - mu_tol, 0.1, 1.5,
    0, 0, mu2_est - mu_tol, 0.1, 1.5,
    0, 0, mu3_est - mu_tol, 0.1, 1.5,
]
ub_lor = [
    y.max()*2, 30, 0.1,
    y.max()*5, y.max()*5, mu1_est + mu_tol, 1.5, 8,
    y.max()*5, y.max()*5, mu2_est + mu_tol, 1.5, 8,
    y.max()*5, y.max()*5, mu3_est + mu_tol, 1.5, 8,
]
bounds_lor = (lb_lor, ub_lor)

# ─────────────────────────────────────────────
# 7.  Stage 3 – Multi-start optimisation (Pseudo-Voigt)
# ─────────────────────────────────────────────
rng = np.random.default_rng(42)

def make_p0(perturb=0.0):
    base_p0 = [
        a0_est, tau_est, a1_est,
        A1_est * 0.7, A1_est * 0.3, mu1_est, 0.5, 3.0, 0.8, 0.5,
        A2_est * 0.7, A2_est * 0.3, mu2_est, 0.5, 3.0, 0.8, 0.5,
        A3_est * 0.7, A3_est * 0.3, mu3_est, 0.5, 3.0, 0.8, 0.5,
    ]
    if perturb > 0:
        p_arr = np.array(base_p0, dtype=float)
        noise = 1.0 + rng.uniform(-perturb, perturb, size=len(p_arr))
        p_arr = p_arr * noise
        p_arr = np.clip(p_arr, np.array(lb) + 1e-9, np.array(ub) - 1e-9)
        return p_arr.tolist()
    return base_p0

best_ssr = np.inf
best_popt = None
best_pcov = None
N_starts = 20

for i in range(N_starts):
    perturb = 0.0 if i == 0 else 0.3
    p0 = make_p0(perturb)
    try:
        popt, pcov = curve_fit(
            full_model, x, y, p0=p0, bounds=bounds,
            method='trf', maxfev=20000,
            ftol=1e-10, xtol=1e-10, gtol=1e-10
        )
        y_pred = full_model(x, *popt)
        ssr = np.sum((y - y_pred) ** 2)
        if ssr < best_ssr:
            best_ssr = ssr
            best_popt = popt
            best_pcov = pcov
    except Exception:
        pass

if best_popt is None:
    try:
        p0 = make_p0(0.0)
        best_popt, best_pcov = curve_fit(
            full_model, x, y, p0=p0,
            method='trf', maxfev=50000
        )
        y_pred = full_model(x, *best_popt)
        best_ssr = np.sum((y - y_pred) ** 2)
    except Exception as e:
        print(f"Fallback also failed: {e}")
        best_popt = np.array(make_p0(0.0))
        best_pcov = np.eye(len(best_popt)) * 1e6
        best_ssr = np.sum((y - full_model(x, *best_popt))**2)

print(f"Best SSR (Pseudo-Voigt) from {N_starts} starts: {best_ssr:.6f}")

# ─────────────────────────────────────────────
# 8.  Step 3 – Pure Lorentzian special case fit & model comparison
# ─────────────────────────────────────────────
def make_p0_lor(perturb=0.0):
    base_p0 = [
        a0_est, tau_est, a1_est,
        A1_est * 0.7, A1_est * 0.3, mu1_est, 0.5, 3.0,
        A2_est * 0.7, A2_est * 0.3, mu2_est, 0.5, 3.0,
        A3_est * 0.7, A3_est * 0.3, mu3_est, 0.5, 3.0,
    ]
    if perturb > 0:
        p_arr = np.array(base_p0, dtype=float)
        noise = 1.0 + rng.uniform(-perturb, perturb, size=len(p_arr))
        p_arr = p_arr * noise
        p_arr = np.clip(p_arr, np.array(lb_lor) + 1e-9, np.array(ub_lor) - 1e-9)
        return p_arr.tolist()
    return base_p0

best_ssr_lor = np.inf
best_popt_lor = None

for i in range(N_starts):
    perturb = 0.0 if i == 0 else 0.3
    p0_lor = make_p0_lor(perturb)
    try:
        popt_lor, _ = curve_fit(
            full_model_lorentz, x, y, p0=p0_lor, bounds=bounds_lor,
            method='trf', maxfev=20000,
            ftol=1e-10, xtol=1e-10, gtol=1e-10
        )
        y_pred_lor = full_model_lorentz(x, *popt_lor)
        ssr_lor = np.sum((y - y_pred_lor) ** 2)
        if ssr_lor < best_ssr_lor:
            best_ssr_lor = ssr_lor
            best_popt_lor = popt_lor
    except Exception:
        pass

if best_popt_lor is None:
    try:
        p0_lor = make_p0_lor(0.0)
        best_popt_lor, _ = curve_fit(
            full_model_lorentz, x, y, p0=p0_lor, bounds=bounds_lor,
            method='trf', maxfev=50000
        )
        y_pred_lor = full_model_lorentz(x, *best_popt_lor)
        best_ssr_lor = np.sum((y - y_pred_lor) ** 2)
    except Exception as e:
        print(f"Lorentzian fallback failed: {e}")
        best_ssr_lor = np.inf

print(f"Best SSR (pure Lorentzian) from {N_starts} starts: {best_ssr_lor:.6f}")

# Delta-SSR model comparison
total_ssr_ref = best_ssr  # use PV SSR as reference
delta_ssr = best_ssr_lor - best_ssr  # positive means PV is better
if total_ssr_ref > 0:
    delta_ssr_pct = abs(delta_ssr) / total_ssr_ref * 100.0
else:
    delta_ssr_pct = 0.0

# Parsimony criterion: if |delta-SSR| < 1% of total SSR, prefer simpler Lorentzian
if delta_ssr_pct < 1.0:
    preferred_model = 'pure_lorentzian'
    preferred_reason = f'|delta-SSR| = {delta_ssr_pct:.3f}% < 1%: simpler pure Lorentzian preferred by parsimony'
else:
    preferred_model = 'pseudo_voigt'
    preferred_reason = f'|delta-SSR| = {delta_ssr_pct:.3f}% >= 1%: Pseudo-Voigt preferred (lower SSR)'

print(f"Delta-SSR: {delta_ssr:.6f} ({delta_ssr_pct:.3f}% of PV SSR)")
print(f"Preferred model: {preferred_model} — {preferred_reason}")

# ─────────────────────────────────────────────
# 9.  Extract parameters
# ─────────────────────────────────────────────
try:
    perr = np.sqrt(np.diag(best_pcov))
except Exception:
    perr = np.full(len(best_popt), np.nan)

names = [
    'a0', 'tau', 'a1',
    'An1', 'Ab1', 'mu1', 'sn1', 'sb1', 'etn1', 'etb1',
    'An2', 'Ab2', 'mu2', 'sn2', 'sb2', 'etn2', 'etb2',
    'An3', 'Ab3', 'mu3', 'sn3', 'sb3', 'etn3', 'etb3',
]
p = dict(zip(names, best_popt))
e = {n+'_err': v for n, v in zip(names, perr)}

ln2 = np.log(2)

def pv_fwhm(sigma, eta):
    return 2.0 * np.abs(sigma) * (eta + (1.0 - eta) * np.sqrt(2.0 * ln2))

def pv_area(A, sigma, eta):
    return A * np.abs(sigma) * (eta * np.pi + (1.0 - eta) * np.sqrt(2.0 * np.pi))

for i in range(1, 4):
    p[f'FWHM_narrow{i}'] = pv_fwhm(p[f'sn{i}'], p[f'etn{i}'])
    p[f'FWHM_broad{i}']  = pv_fwhm(p[f'sb{i}'],  p[f'etb{i}'])
    area_n = pv_area(p[f'An{i}'], p[f'sn{i}'], p[f'etn{i}'])
    area_b = pv_area(p[f'Ab{i}'], p[f'sb{i}'], p[f'etb{i}'])
    p[f'peak_area_total{i}'] = area_n + area_b

# ─────────────────────────────────────────────
# 10.  Fit quality
# ─────────────────────────────────────────────
y_fit = full_model(x, *best_popt)
residuals = y - y_fit
ss_res = np.sum(residuals ** 2)
ss_tot = np.sum((y - np.mean(y)) ** 2)
r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0 else np.nan
rmse = np.sqrt(ss_res / N)

print(f"R²={r_squared:.6f}, RMSE={rmse:.6f}")

# ─────────────────────────────────────────────
# 11.  W-shape residual diagnostic
# ─────────────────────────────────────────────
def w_shape_flag(x, residuals, mu, window=5.0):
    mask = np.abs(x - mu) < window
    if mask.sum() < 5:
        return False
    r_local = residuals[mask]
    x_local = x[mask]
    centre_mask = np.abs(x_local - mu) < window * 0.3
    wing_mask   = np.abs(x_local - mu) > window * 0.5
    if centre_mask.sum() == 0 or wing_mask.sum() == 0:
        return False
    return bool((r_local[centre_mask].mean() < 0) and (r_local[wing_mask].mean() > 0))

w_flags = {}
for i, mu_key in enumerate(['mu1', 'mu2', 'mu3'], 1):
    w_flags[f'peak{i}'] = w_shape_flag(x, residuals, p[mu_key])

print(f"W-shape flags: {w_flags}")

# Check baseline residuals in signal-free regions
if mask_base.sum() > 0:
    baseline_offset = residuals[mask_base].mean()
else:
    baseline_offset = 0.0
print(f"Baseline residual offset in signal-free region: {baseline_offset:.6f}")

baseline_corrected = abs(baseline_offset) < 0.05 * (y.max() - y.min())
print(f"Baseline offset self-corrected (< 5% range): {baseline_corrected}")

# ─────────────────────────────────────────────
# 12.  Visualization
# ─────────────────────────────────────────────
fig, axes = plt.subplots(3, 1, figsize=(12, 10),
                          gridspec_kw={'height_ratios': [3, 1, 1]})

ax = axes[0]
ax.plot(x, y, 'k.', ms=3, alpha=0.6, label='Data')
ax.plot(x, y_fit, 'r-', lw=2, label=f'Full fit (R²={r_squared:.4f})')

tau_ = np.abs(p['tau']) + 1e-6
bl = p['a0'] * (1.0 - np.exp(-x / tau_)) + p['a1'] * x
ax.plot(x, bl, 'b--', lw=1.2, alpha=0.7, label='Baseline')

if best_popt_lor is not None:
    y_fit_lor = full_model_lorentz(x, *best_popt_lor)
    ax.plot(x, y_fit_lor, 'm:', lw=1.5, alpha=0.7, label=f'Pure Lorentzian fit')

colors = ['forestgreen', 'darkorange', 'purple']
for i in range(1, 4):
    An = p[f'An{i}']; Ab = p[f'Ab{i}']
    mu_ = p[f'mu{i}']
    sn = p[f'sn{i}']; sb = p[f'sb{i}']
    etn = p[f'etn{i}']; etb = p[f'etb{i}']
    narrow = pseudo_voigt(x, An, mu_, sn, etn)
    broad  = pseudo_voigt(x, Ab, mu_, sb, etb)
    ax.fill_between(x, bl, bl + narrow + broad,
                    alpha=0.25, color=colors[i-1], label=f'Peak {i}')
    ax.plot(x, bl + narrow, ':', lw=1, color=colors[i-1], alpha=0.8)
    ax.plot(x, bl + broad,  '--', lw=1, color=colors[i-1], alpha=0.8)

ax.set_ylabel('Intensity', fontsize=12)
ax.set_title('Pseudo-Voigt Double-Component Spectral Fit', fontsize=13)
ax.legend(fontsize=9, ncol=2)
ax.set_xlim(x.min(), x.max())

ax2 = axes[1]
ax2.plot(x, residuals, 'g-', lw=1, label='PV residuals')
if best_popt_lor is not None:
    resid_lor = y - y_fit_lor
    ax2.plot(x, resid_lor, 'm:', lw=1, alpha=0.7, label='Lorentz residuals')
ax2.axhline(0, color='k', lw=0.8)
for i, mu_key in enumerate(['mu1', 'mu2', 'mu3'], 1):
    ax2.axvline(p[mu_key], color=colors[i-1], lw=1, ls='--', alpha=0.6)
ax2.set_ylabel('Residuals', fontsize=11)
ax2.set_xlabel('x', fontsize=11)
ax2.set_xlim(x.min(), x.max())
ax2.legend(fontsize=8)

ax3 = axes[2]
ax3.hist(residuals, bins=40, color='steelblue', alpha=0.7, edgecolor='white')
ax3.set_xlabel('Residual value', fontsize=11)
ax3.set_ylabel('Count', fontsize=11)
ax3.set_title('Residual Distribution', fontsize=11)

plt.tight_layout()
plt.savefig('fit_visualization.png', dpi=150, bbox_inches='tight')
plt.close()
print('Saved fit_visualization.png')

# ─────────────────────────────────────────────
# 13.  Flag poorly constrained parameters
# ─────────────────────────────────────────────
def safe_float(v):
    if v is None:
        return None
    try:
        if np.isnan(v) or np.isinf(v):
            return None
    except Exception:
        pass
    return float(v)

def is_poorly_constrained(val, err):
    if err is None or val is None:
        return True
    if abs(val) < 1e-12:
        return True
    return abs(err) > abs(val)

# ─────────────────────────────────────────────
# 14.  Build JSON results
# ─────────────────────────────────────────────
perr_dict = {}
for n, v in zip(names, perr):
    perr_dict[n + '_err'] = v

def param_dict_for_peak(i, p, perr_dict):
    keys = ['An', 'Ab', 'mu', 'sn', 'sb', 'etn', 'etb']
    full_keys = [f'{k}{i}' for k in keys]
    d = {}
    for k, fk in zip(keys, full_keys):
        val = safe_float(p.get(fk, np.nan))
        err_key = fk + '_err'
        err = safe_float(perr_dict.get(err_key, np.nan))
        d[k] = val
        d[k + '_err'] = err
        d[k + '_poorly_constrained'] = is_poorly_constrained(val, err)
    d['FWHM_narrow'] = safe_float(p.get(f'FWHM_narrow{i}', np.nan))
    d['FWHM_broad']  = safe_float(p.get(f'FWHM_broad{i}',  np.nan))
    d['peak_area_total'] = safe_float(p.get(f'peak_area_total{i}', np.nan))
    d['w_shape_flag'] = w_flags.get(f'peak{i}', False)
    return d

results = {
    'model_type': (
        'Double-component Pseudo-Voigt per peak (narrow + broad) '
        'with exponential rise + linear baseline b(x)=a0*(1-exp(-x/tau))+a1*x; '
        '3 peaks; 24 free parameters'
    ),
    'parameters': {
        'baseline': {
            'a0': safe_float(p['a0']),
            'a0_err': safe_float(perr_dict.get('a0_err', np.nan)),
            'tau': safe_float(p['tau']),
            'tau_err': safe_float(perr_dict.get('tau_err', np.nan)),
            'a1': safe_float(p['a1']),
            'a1_err': safe_float(perr_dict.get('a1_err', np.nan)),
        },
        'peak_1': param_dict_for_peak(1, p, perr_dict),
        'peak_2': param_dict_for_peak(2, p, perr_dict),
        'peak_3': param_dict_for_peak(3, p, perr_dict),
        'derived': {
            'FWHM_narrow1': safe_float(p.get('FWHM_narrow1')),
            'FWHM_narrow2': safe_float(p.get('FWHM_narrow2')),
            'FWHM_narrow3': safe_float(p.get('FWHM_narrow3')),
            'FWHM_broad1':  safe_float(p.get('FWHM_broad1')),
            'FWHM_broad2':  safe_float(p.get('FWHM_broad2')),
            'FWHM_broad3':  safe_float(p.get('FWHM_broad3')),
            'peak_area_total1': safe_float(p.get('peak_area_total1')),
            'peak_area_total2': safe_float(p.get('peak_area_total2')),
            'peak_area_total3': safe_float(p.get('peak_area_total3')),
        }
    },
    'fit_quality': {
        'r_squared': safe_float(r_squared),
        'rmse': safe_float(rmse),
        'ssr_pseudo_voigt': safe_float(best_ssr),
        'ssr_pure_lorentzian': safe_float(best_ssr_lor) if best_ssr_lor != np.inf else None,
        'delta_ssr': safe_float(delta_ssr) if best_ssr_lor != np.inf else None,
        'delta_ssr_pct': safe_float(delta_ssr_pct) if best_ssr_lor != np.inf else None,
        'preferred_model': preferred_model,
        'preferred_model_reason': preferred_reason,
        'n_points': int(N),
        'n_parameters_pv': 24,
        'n_parameters_lorentz': 18,
        'w_shape_flags': w_flags,
        'baseline_offset_signal_free': safe_float(baseline_offset),
        'baseline_offset_self_corrected': bool(baseline_corrected),
    },
    'summary': (
        f'Fitted 3-peak spectrum with double-component Pseudo-Voigt profiles '
        f'(narrow + broad per peak) and exponential rise + linear baseline '
        f'b(x)=a0*(1-exp(-x/tau))+a1*x. '
        f'R²={r_squared:.5f}, RMSE={rmse:.5f}. '
        f'Peaks at mu1={p["mu1"]:.2f}, mu2={p["mu2"]:.2f}, mu3={p["mu3"]:.2f}. '
        f'PV SSR={best_ssr:.6f}, Lorentzian SSR={best_ssr_lor:.6f} '
        f'(delta={delta_ssr_pct:.3f}% of PV SSR). '
        f'Preferred model: {preferred_model} ({preferred_reason}). '
        f'W-shape flags: {w_flags}. '
        f'Baseline offset in signal-free region: {baseline_offset:.6f} '
        f'(self-corrected: {baseline_corrected}). '
        f'Multi-start optimization: {N_starts} starts, trf method.'
    )
}

print(f"FIT_RESULTS_JSON:{json.dumps(results)}")
