"""Auto-tune the loop finder's settings against the grade rubric (#19).

The closed feedback loop PLN asked for: generate candidate loops with a given
settings profile → grade them on the full quality rubric (grade.py: seam, zc, dc,
bar-consistency, level, bass-mono) → score the profile → search for the settings
that maximise quality. Auto-tune instead of hand-guessing the composite weights.

Objective (v1): the mean full-rubric grade of the finder's selected candidates.
The finder's OWN score uses struct/novel/seam/zc/tempo; the grade adds dc, level,
bass-mono and bar-consistency — features the finder doesn't see — so tuning the
weights to maximise grade pulls in signal beyond the finder's own metrics. (Caveat:
seam/zc appear in both, a partial confound; recall-vs-provenance-GT is the
anti-gaming complement — pluggable as a second objective later.)

Build-the-katana-first: validated on existing stems; runs over the demo corpus
once those are fetched+separated.
"""
import random
import statistics
from pathlib import Path

import numpy as np

from engine import grade as G
from engine import loops as L

# the knobs we sweep — composite weights (loops.W) + window shape
DEFAULT_SPACE = {
    "struct": [0.2, 0.4, 0.6],
    "novel": [0.1, 0.2, 0.3],
    "seam": [0.2, 0.3, 0.4],
    "zc": [0.05, 0.1, 0.2],
    "tempo_penalty": [0.1, 0.2, 0.3],
    "bars": [(2, 4), (4, 8), (2, 4, 8)],
    "top_n": [6, 8],
}
_WEIGHT_KEYS = ("struct", "novel", "seam", "zc", "tempo_penalty")


def load_stem(path) -> tuple[np.ndarray, int, tuple]:
    """Preload (mono audio, sr, beat-grid) once — the grid is weight-independent,
    so we pay PLP/onset detection a single time per stem, then sweep cheaply."""
    import librosa
    y, sr = librosa.load(str(path), sr=None, mono=True)
    return y, sr, L.beat_grid(y, sr)


def evaluate(stems: list[tuple], settings: dict) -> dict:
    """Run the finder with `settings` over preloaded stems, grade every candidate,
    return aggregate quality metrics. `stems` = list of (y, sr, grid)."""
    weights = {k: settings[k] for k in _WEIGHT_KEYS if k in settings}
    bars = tuple(settings.get("bars", (2, 4, 8)))
    top_n = int(settings.get("top_n", 8))

    grades, finder_scores, n_cand = [], [], 0
    for y, sr, grid in stems:
        cands = L.analyze_stem(y, sr, bars=bars, top_n=top_n, grid=grid, weights=weights)
        n_cand += len(cands)
        for c in cands:
            sl = y[int(c.start_s * sr):int(c.end_s * sr)]
            if sl.size < 8:
                continue
            grades.append(G.grade_array(sl, sr).grade)
            finder_scores.append(c.score)

    mean_g = float(statistics.fmean(grades)) if grades else 0.0
    # coverage term: penalise profiles that surface almost nothing
    coverage = min(1.0, n_cand / (len(stems) * 4 or 1))
    return {
        "objective": round(mean_g * (0.5 + 0.5 * coverage), 4),
        "mean_grade": round(mean_g, 4),
        "n_candidates": n_cand,
        "n_graded": len(grades),
        "coverage": round(coverage, 3),
    }


def _sample(method: str, n: int, space: dict, rng: random.Random) -> list[dict]:
    keys = list(space)
    if method == "grid":
        import itertools
        combos = list(itertools.product(*(space[k] for k in keys)))
        rng.shuffle(combos)
        return [dict(zip(keys, c)) for c in combos[:n]]
    return [{k: rng.choice(space[k]) for k in keys} for _ in range(n)]


def search(stems: list[tuple], *, method="random", n=20, space=None, seed=0) -> list[dict]:
    """Search the settings space; return configs ranked by objective (best first).
    Always includes the current finder defaults (loops.W) as a baseline row."""
    space = space or DEFAULT_SPACE
    rng = random.Random(seed)
    configs = _sample(method, n, space, rng)
    baseline = {**L.W, "bars": (2, 4, 8), "top_n": 8}
    results = []
    for s in [baseline] + configs:
        results.append({"settings": s, **evaluate(stems, s)})
    results[0]["is_baseline"] = True
    results.sort(key=lambda r: -r["objective"])
    return results
