"""Three-phase pipeline orchestrator. Never hard-fails on Phase A:
StemSeparationUnavailable -> bypass (master original mix); degraded
separation -> light mode. Intermediates written to work_dir for debugging."""
import os, json, re, time, traceback, warnings
from fractions import Fraction
import numpy as np
import soundfile as sf
from scipy.signal import resample_poly

from .stem_separator import separate as _sep_impl, StemSeparationUnavailable, cache_dir_for
from .stem_chains import process_stems
from .remixer import remix
from .hf_extension import extend_hf, detect_rolloff
from .reference_engine import load_profile, band_width_ratio
from .match_eq import apply_match_eq, correction_curve_db, spectral_deviation_db
from .mb_compressor import compress_multiband
from .saturation import glue_saturate
from .width import adjust_width
from .tp_limiter import limit
from .verify import verify_master

DEFAULT_TARGETS = {"lufs": -10.0}
PIPELINE_CACHE_ROOT = os.path.join("tmp", "pipeline")
PIPELINE_SR = 44100
_VALID_GENRE = re.compile(r"^[A-Za-z0-9 _-]+$")
RESIDUAL_EQ_MAX_ITER = 2
RESIDUAL_EQ_TOL_DB = 4.5
RESIDUAL_WIDTH_MAX_ITER = 3
RESIDUAL_WIDTH_TOL = 0.02   # tighter than the 0.05 gate floor: leaves margin for limiter time-reweighting
WIDTH_LIMIT_ROUNDS = 3      # width->limit closed-loop compensation rounds
WIDTH_POST_LIMIT_TOL = 0.03 # post-limiter acceptance, under the 0.05 gate floor


def _validate_genre(genre):
    if not _VALID_GENRE.match(genre):
        raise ValueError("invalid genre")


def _work_dir_for(path, genre, no_stems, light, root=PIPELINE_CACHE_ROOT):
    base = cache_dir_for(path, root=root)
    mode = "light" if light else "full"
    stems = "nostems" if no_stems else "stems"
    return os.path.join(base, f"{genre}_{mode}_{stems}")


def _standardize_sr(audio, sr):
    if sr == PIPELINE_SR:
        return audio, sr
    frac = Fraction(PIPELINE_SR, sr).limit_denominator(1000)
    return resample_poly(audio, frac.numerator, frac.denominator, axis=0), PIPELINE_SR


def _separate(path, progress=None):           # seam for tests
    return _sep_impl(path, progress=progress)


def _emit(progress, stage):
    if callable(progress):
        progress(stage)


def _band_idx(hz, freq):
    return int(np.where(hz == freq)[0][0])


def _apply_residual_eq(x, sr, ref, pre_rolloff):
    """Iterative residual EQ: no Gaussian (preserves narrow-band corrections)."""
    log = {"iterations": []}
    for i in range(RESIDUAL_EQ_MAX_ITER):
        hz, dev, max_band = spectral_deviation_db(x, sr, ref)
        i160, i16k = _band_idx(hz, 160), _band_idx(hz, 16000)
        _, corr = correction_curve_db(
            x, sr, ref, pre_rolloff, gaussian_sigma=0.0)
        entry = {
            "iter": i,
            "before": {"160hz": float(dev[i160]), "16000hz": float(dev[i16k]),
                       "max_band": max_band},
            "correction_db": {"160hz": float(corr[i160]),
                              "16000hz": float(corr[i16k])},
        }
        if max_band < RESIDUAL_EQ_TOL_DB:
            entry["stopped"] = "converged"
            log["iterations"].append(entry)
            break
        x = apply_match_eq(x, sr, ref, pre_extension_rolloff_hz=pre_rolloff,
                           gaussian_sigma=0.0)
        hz, dev, max_band = spectral_deviation_db(x, sr, ref)
        entry["after"] = {"160hz": float(dev[i160]), "16000hz": float(dev[i16k]),
                          "max_band": max_band}
        log["iterations"].append(entry)
        if max_band < RESIDUAL_EQ_TOL_DB:
            break
    return x, log


def _apply_residual_width(x, sr, ref):
    """Iterative width correction toward profile band ratios."""
    ref_w = ref["band_width_ratio"]
    log = {"iterations": []}
    for i in range(RESIDUAL_WIDTH_MAX_ITER):
        w = band_width_ratio(x, sr)
        devs = {k: float(abs(w[k] - ref_w[k])) for k in w}
        entry = {"iter": i, "before": devs}
        if all(devs[k] < RESIDUAL_WIDTH_TOL for k in devs):
            entry["stopped"] = "converged"
            log["iterations"].append(entry)
            break
        x = adjust_width(x, sr, ref)
        w = band_width_ratio(x, sr)
        devs = {k: float(abs(w[k] - ref_w[k])) for k in w}
        entry["after"] = devs
        log["iterations"].append(entry)
        if all(devs[k] < RESIDUAL_WIDTH_TOL for k in devs):
            break
    return x, log


def master_track(path, genre="other", no_stems=False, light=False,
                 out_dir=".", work_dir=None, profile_dir=None, progress=None):
    _validate_genre(genre)
    t0 = time.time()
    report = {"input": path, "genre": genre, "phases": {}}
    work_dir = work_dir or _work_dir_for(path, genre, no_stems, light)
    os.makedirs(work_dir, exist_ok=True)

    ref = (load_profile(genre, profile_dir) if profile_dir
           else load_profile(genre)) or {}
    genre_profile = dict(ref)
    target_lufs = float(ref.get("lufs", DEFAULT_TARGETS["lufs"]))

    audio, sr = sf.read(path, always_2d=True)
    audio, sr = _standardize_sr(audio, sr)
    separation_quality = None

    # ---- Phase A ----
    if no_stems:
        pre_master, report["phases"]["A"] = audio, "bypassed"
    else:
        try:
            _emit(progress, "A_separating")
            stems, sr, separation_quality = _separate(path, progress=progress)
            light = light or (separation_quality == "degraded")
            processed = process_stems(stems, sr, genre_profile, light=light)
            _emit(progress, "A_remix")
            pre_master = remix(processed, sr, genre_profile)
            report["phases"]["A"] = separation_quality if not light else "light"
        except StemSeparationUnavailable as e:
            pre_master, report["phases"]["A"] = audio, "bypassed"
            report["phase_a_error"] = str(e)

    pre_rolloff = detect_rolloff(pre_master, sr)
    report["pre_extension_rolloff_hz"] = pre_rolloff
    pre_master = extend_hf(pre_master, sr, rolloff_hz=pre_rolloff)
    sf.write(os.path.join(work_dir, "premaster.wav"), pre_master, sr)

    # ---- Phase B ----
    x = pre_master
    if ref.get("third_octave_db"):
        _emit(progress, "B_match_eq")
        x = apply_match_eq(x, sr, ref, pre_extension_rolloff_hz=pre_rolloff)
        report["phases"]["B_match_eq"] = "applied"
    else:
        report["phases"]["B_match_eq"] = "skipped (no profile)"
    _emit(progress, "B_multiband")
    x = compress_multiband(x, sr)
    _emit(progress, "B_glue")
    x = glue_saturate(x, amount=float(genre_profile.get("glue_amount", 0.07)))
    if ref.get("band_width_ratio"):
        _emit(progress, "B_width")
        x = adjust_width(x, sr, ref)
        report["phases"]["B_width"] = "applied"
    else:
        report["phases"]["B_width"] = "skipped (no profile)"
    # Residual tonal/width correction after dynamics — mb_comp/saturation re-tilt.
    if ref.get("third_octave_db"):
        _emit(progress, "B_residual_eq")
        x, eq_log = _apply_residual_eq(x, sr, ref, pre_rolloff)
        report["residual_eq"] = eq_log
        report["phases"]["B_residual_eq"] = "applied"
    else:
        report["phases"]["B_residual_eq"] = "skipped (no profile)"
    # Residual width runs BEFORE the limiter: linked limiting applies equal gain
    # to both channels per sample, so it cannot change S/(M+S) ratios except by
    # time-reweighting; post-limiter width raises true peaks and a re-trim then
    # destroys the LUFS/PLR targets. On hard-limited material the time-reweighting
    # drift reaches ~0.06 (GR lands on mono-heavy kick moments), so the width
    # target is closed-loop compensated: measure post-limiter drift, bias the
    # pre-limiter target the opposite way, redo width on the saved pre-limit
    # signal and re-limit.
    if ref.get("band_width_ratio"):
        _emit(progress, "B_residual_width")
        x_pre, true_tgt = x, ref["band_width_ratio"]
        comp_ref = dict(ref)
        rounds = []
        for rnd in range(WIDTH_LIMIT_ROUNDS):
            xw, w_log = _apply_residual_width(x_pre, sr, comp_ref)
            _emit(progress, "B_limit")
            x, lim_report = limit(xw, sr, target_lufs=target_lufs,
                                  ceiling_dbtp=-1.0)
            w_post = band_width_ratio(x, sr)
            drift = {k: float(w_post[k] - true_tgt[k]) for k in w_post}
            rounds.append({"round": rnd, "width_log": w_log,
                           "post_limit_drift": drift})
            if all(abs(v) < WIDTH_POST_LIMIT_TOL for v in drift.values()):
                break
            comp_ref["band_width_ratio"] = {
                k: float(np.clip(comp_ref["band_width_ratio"][k] - drift[k],
                                 0.0, 0.95))
                for k in true_tgt}
        report["residual_width"] = {"rounds": rounds}
        report["phases"]["B_residual_width"] = "applied"
    else:
        report["phases"]["B_residual_width"] = "skipped (no profile)"
        _emit(progress, "B_limit")
        x, lim_report = limit(x, sr, target_lufs=target_lufs, ceiling_dbtp=-1.0)
    report.update(lim_report)
    report["phases"]["B"] = "ok"

    base = os.path.splitext(os.path.basename(path))[0]
    out_path = os.path.join(out_dir, f"{base}_master.wav")
    sf.write(out_path, x, sr, subtype="PCM_24")
    report["output"] = out_path

    # ---- Phase C ----
    _emit(progress, "C_verify")
    if not ref:
        report["verification"] = {"skipped": "no profile"}
    else:
        try:
            report["verification"] = verify_master(
                x, sr, ref, input_audio=audio, separation_quality=separation_quality)
        except Exception as exc:
            tb = traceback.format_exc()
            warnings.warn(
                f"Phase C verify failed: {exc}\n{tb}",
                RuntimeWarning,
                stacklevel=2,
            )
            report["verification"] = {
                "pass": False,
                "error": str(exc),
                "traceback": tb,
            }

    report["elapsed_s"] = round(time.time() - t0, 1)
    with open(os.path.join(work_dir, "report.json"), "w") as fh:
        json.dump(report, fh, indent=1)
    with open(os.path.join(out_dir, f"{base}_master.report.json"), "w") as fh:
        json.dump(report, fh, indent=1)
    return out_path, report
