#!/usr/bin/env python3
"""Offline MP3 phrase analyzer + ShredBlob timing editor."""

from __future__ import annotations

import json
import os
import subprocess
import sys
import tkinter as tk
from tkinter import filedialog, messagebox, ttk

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ANALYZER_BIN = os.path.join(SCRIPT_DIR, "shredblob_analyze")
PRESET_FILENAME = "shredblob_tune.json"
DENDRITE_ONSET = 8.0

DEFAULTS = {
    "bpm": 92.0,
    "beat_sensitivity": 1.0,
    "narrative_duration": 45.0,
    "network_start": 11.0,
    "freed_bounce_duration": 7.0,
    "tunnel_duration": 13.0,
    "launch_duration": 10.0,
    "finale_white_duration": 3.2,
}

EVENT_COLORS = {
    "energy_spike": "#ff6644",
    "onset": "#44cc88",
    "silence": "#888899",
    "freq_modulation": "#cc88ff",
    "harmonic_change": "#88aaff",
    "dynamic_change": "#ffcc44",
}

PHASE_COLORS = {
    "solo": "#2a1810",
    "dendrites": "#3a2218",
    "network": "#1a2838",
    "freed": "#283018",
    "tunnel": "#182838",
    "launch": "#1a2048",
    "finale": "#eeeeee",
}


def find_analyzer() -> str:
    if os.path.isfile(ANALYZER_BIN) and os.access(ANALYZER_BIN, os.X_OK):
        return ANALYZER_BIN
    alt = os.path.join(os.getcwd(), "shredblob_analyze")
    if os.path.isfile(alt) and os.access(alt, os.X_OK):
        return alt
    return ANALYZER_BIN


def load_json(path: str) -> dict:
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def save_json(path: str, data: dict) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
        f.write("\n")


def clamp(v: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, v))


def load_preset_template() -> dict:
    """Full demo preset fields (colors, gains, …) for saves that only edit timings."""
    for name in ("shredblob_tune_defaults.json", "shredblob_tune.json"):
        path = os.path.join(SCRIPT_DIR, name)
        if os.path.isfile(path):
            try:
                return load_json(path)
            except (OSError, json.JSONDecodeError):
                continue
    return dict(DEFAULTS)


def song_transition_times(analysis: dict) -> list[float]:
    """Structural change points — used to place demo phases on the song."""
    dur = float(analysis.get("duration", 0))
    phrases = analysis.get("phrases", [])
    times: list[float] = []
    if len(phrases) >= 2:
        times.extend(float(p["start"]) for p in phrases[1:])
    if phrases:
        times.append(float(phrases[-1]["end"]))
    spikes = sorted(
        (e for e in analysis.get("events", []) if e.get("type") == "energy_spike"),
        key=lambda e: float(e.get("intensity", 0)),
        reverse=True,
    )
    for e in spikes[:10]:
        t = float(e["t"])
        if not any(abs(t - u) < 0.5 for u in times):
            times.append(t)
    for e in analysis.get("events", []):
        if e.get("type") == "silence" and float(e.get("intensity", 0)) > 0.2:
            t = float(e["t"])
            if not any(abs(t - u) < 0.45 for u in times):
                times.append(t)
    times.sort()
    deduped: list[float] = []
    for t in times:
        if not deduped or t - deduped[-1] > 0.5:
            deduped.append(t)
    if dur > 0 and (not deduped or deduped[-1] < dur * 0.92):
        deduped.append(dur)
    return deduped


def pick_phase_boundaries(transitions: list[float], duration: float, count: int = 5) -> list[float]:
    """Pick `count` phase boundaries spread across transitions (timings flexible)."""
    usable = [t for t in transitions if t > DENDRITE_ONSET + 0.4]
    if len(usable) < count:
        for i in range(1, count + 1):
            usable.append(duration * i / (count + 1))
        usable = sorted(set(usable))
    if len(usable) == 1:
        return [usable[0]] * count
    idxs = [int(round(i * (len(usable) - 1) / max(count - 1, 1))) for i in range(count)]
    return [usable[i] for i in idxs]


class PhraseEditorApp:
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.root.title("ShredBlob Phrase / Timing Editor")
        self.root.geometry("1100x720")

        self.analysis: dict | None = None
        self.mp3_path: str | None = None
        self.preset_path = os.path.join(SCRIPT_DIR, PRESET_FILENAME)
        self.preset_data: dict = load_preset_template()
        self.vars: dict[str, tk.DoubleVar] = {}
        self._drag_marker: str | None = None
        self._view_end = 60.0

        self._build_ui()
        self._load_preset_if_exists()

    def _build_ui(self) -> None:
        top = ttk.Frame(self.root, padding=6)
        top.pack(fill=tk.X)

        ttk.Button(top, text="Open MP3…", command=self.open_mp3).pack(side=tk.LEFT, padx=4)
        ttk.Button(top, text="Analyze", command=self.run_analysis).pack(side=tk.LEFT, padx=4)
        ttk.Button(top, text="Suggest from phrases", command=self.suggest_timings).pack(side=tk.LEFT, padx=4)
        ttk.Button(top, text="Load preset…", command=self.load_preset_dialog).pack(side=tk.LEFT, padx=4)
        ttk.Button(top, text="Save preset…", command=self.save_preset_dialog).pack(side=tk.LEFT, padx=4)

        self.status = ttk.Label(top, text="Load an MP3 and analyze to see events and phrases.")
        self.status.pack(side=tk.LEFT, padx=12)

        paned = ttk.Panedwindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=6, pady=4)

        left = ttk.Frame(paned, padding=4)
        paned.add(left, weight=3)

        self.canvas = tk.Canvas(left, bg="#0e0e12", highlightthickness=0, height=420)
        self.canvas.pack(fill=tk.BOTH, expand=True)
        self.canvas.bind("<Configure>", lambda _e: self.redraw())
        self.canvas.bind("<Button-1>", self.on_canvas_press)
        self.canvas.bind("<B1-Motion>", self.on_canvas_drag)
        self.canvas.bind("<ButtonRelease-1>", self.on_canvas_release)

        legend = ttk.Frame(left)
        legend.pack(fill=tk.X, pady=4)
        ttk.Label(legend, text="Drag vertical markers · orange ticks = beats · colored dots = events").pack(anchor=tk.W)

        right = ttk.Frame(paned, padding=6, width=280)
        paned.add(right, weight=1)

        ttk.Label(right, text="Phase timings (seconds)", font=("", 11, "bold")).pack(anchor=tk.W, pady=(0, 6))

        specs = [
            ("network_start", "Network start", 4, 30, 0.1),
            ("freed_bounce_duration", "Freed bounce", 2, 20, 0.1),
            ("tunnel_duration", "Tunnel", 5, 30, 0.1),
            ("launch_duration", "Launch", 4, 25, 0.1),
            ("finale_white_duration", "White fade", 1, 8, 0.1),
            ("narrative_duration", "Total duration", 20, 120, 0.5),
            ("bpm", "BPM (detected)", 60, 180, 1),
            ("beat_sensitivity", "Beat sensitivity", 0.2, 3.0, 0.05),
        ]
        for key, label, lo, hi, step in specs:
            default = DEFAULTS.get(key, 0.0)
            var = tk.DoubleVar(value=default)
            self.vars[key] = var
            row = ttk.Frame(right)
            row.pack(fill=tk.X, pady=3)
            ttk.Label(row, text=label, width=16).pack(side=tk.LEFT)
            spin = ttk.Spinbox(row, textvariable=var, from_=lo, to=hi, increment=step, width=8,
                               command=self.redraw)
            spin.pack(side=tk.RIGHT)
            var.trace_add("write", lambda *_a: self.redraw())

        ttk.Label(right, text="Phrases (from analysis)", font=("", 11, "bold")).pack(anchor=tk.W, pady=(16, 4))
        self.phrase_list = tk.Listbox(right, height=10, exportselection=False)
        self.phrase_list.pack(fill=tk.BOTH, expand=True)
        self.phrase_list.bind("<<ListboxSelect>>", self.on_phrase_select)

        info = ttk.Label(
            right,
            text=f"Dendrites fixed at {DENDRITE_ONSET:.0f}s in demo.\n"
                 "Suggest spreads phases across this song's\n"
                 "transitions — timings are not fixed.",
            justify=tk.LEFT,
        )
        info.pack(anchor=tk.W, pady=8)

    def _load_preset_if_exists(self) -> None:
        if os.path.isfile(self.preset_path):
            try:
                data = load_json(self.preset_path)
                self.apply_preset(data)
                self.status.config(text=f"Loaded preset {self.preset_path}")
            except (OSError, json.JSONDecodeError):
                pass

    def apply_preset(self, data: dict) -> None:
        self.preset_data = dict(data)
        for key, var in self.vars.items():
            if key in data:
                var.set(float(data[key]))
        mp3 = data.get("mp3") or data.get("audio_file")
        if mp3:
            preset_dir = os.path.dirname(os.path.abspath(self.preset_path)) or SCRIPT_DIR
            if os.path.isabs(mp3):
                resolved = mp3
            else:
                resolved = os.path.normpath(os.path.join(preset_dir, mp3))
            if os.path.isfile(resolved):
                self.mp3_path = resolved

    def preset_mp3_field(self, preset_path: str | None = None) -> str | None:
        if not self.mp3_path:
            return None
        target = preset_path or self.preset_path
        preset_dir = os.path.dirname(os.path.abspath(target)) or SCRIPT_DIR
        mp3_abs = os.path.abspath(self.mp3_path)
        try:
            rel = os.path.relpath(mp3_abs, preset_dir)
            if not rel.startswith(".."):
                return rel.replace("\\", "/")
        except ValueError:
            pass
        return mp3_abs

    def preset_dict(self, preset_path: str | None = None) -> dict:
        out = dict(self.preset_data)
        for key, var in self.vars.items():
            try:
                out[key] = float(var.get())
            except tk.TclError:
                pass
        mp3 = self.preset_mp3_field(preset_path)
        if mp3:
            out["mp3"] = mp3
        return out

    def phase_boundaries(self) -> dict[str, float]:
        total = float(self.vars["narrative_duration"].get())
        finale = float(self.vars["finale_white_duration"].get())
        launch = float(self.vars["launch_duration"].get())
        tunnel = float(self.vars["tunnel_duration"].get())
        freed = float(self.vars["freed_bounce_duration"].get())
        network = float(self.vars["network_start"].get())

        end = total
        finale_start = end - finale
        launch_start = finale_start - launch
        tunnel_start = launch_start - tunnel
        freed_start = tunnel_start - freed

        return {
            "dendrite": DENDRITE_ONSET,
            "network": network,
            "freed": freed_start,
            "tunnel": tunnel_start,
            "launch": launch_start,
            "finale": finale_start,
            "end": end,
        }

    def open_mp3(self) -> None:
        path = filedialog.askopenfilename(
            title="Select MP3",
            filetypes=[("MP3 audio", "*.mp3"), ("All files", "*.*")],
        )
        if path:
            self.mp3_path = path
            self.status.config(text=f"Selected: {os.path.basename(path)}")

    def run_analysis(self) -> None:
        if not self.mp3_path:
            messagebox.showinfo("Analyze", "Open an MP3 file first.")
            return
        analyzer = find_analyzer()
        if not os.path.isfile(analyzer):
            messagebox.showerror(
                "Analyzer missing",
                f"Build it first:\n  make -f Makefile_mp3\n\nExpected: {analyzer}",
            )
            return

        out_json = os.path.join(SCRIPT_DIR, ".shredblob_analysis_cache.json")
        try:
            subprocess.run(
                [analyzer, self.mp3_path, "-o", out_json],
                check=True,
                capture_output=True,
                text=True,
            )
            self.analysis = load_json(out_json)
        except subprocess.CalledProcessError as exc:
            messagebox.showerror("Analyze failed", exc.stderr or str(exc))
            return
        except (OSError, json.JSONDecodeError) as exc:
            messagebox.showerror("Analyze failed", str(exc))
            return

        dur = float(self.analysis.get("duration", 45))
        self._view_end = max(dur, float(self.vars["narrative_duration"].get()), 30.0)
        if float(self.vars["narrative_duration"].get()) < dur * 0.5:
            self.vars["narrative_duration"].set(min(dur, max(45.0, dur * 0.95)))

        tempo = self.analysis.get("tempo_bpm")
        if tempo and float(tempo) > 40:
            self.vars["bpm"].set(round(float(tempo), 1))

        self._refresh_phrase_list()
        n_beats = len(self.analysis.get("beats", []))
        n_phrases = len(self.analysis.get("phrases", []))
        self.status.config(
            text=f"Analyzed {dur:.1f}s · ~{tempo:.0f} BPM · {n_beats} beats · {n_phrases} phrases"
        )
        self.redraw()

    def _refresh_phrase_list(self) -> None:
        self.phrase_list.delete(0, tk.END)
        if not self.analysis:
            return
        for i, p in enumerate(self.analysis.get("phrases", [])):
            self.phrase_list.insert(
                tk.END,
                f"{i + 1}: {p['start']:.1f}–{p['end']:.1f}s  E={p['mean_energy']:.2f}  beats={p.get('beats', 0)}",
            )

    def suggest_timings(self) -> None:
        if not self.analysis:
            messagebox.showinfo("Suggest", "Run analysis first.")
            return

        duration = float(self.analysis.get("duration", 45))
        tempo = float(self.analysis.get("tempo_bpm") or self.vars["bpm"].get())
        if tempo > 40:
            self.vars["bpm"].set(round(tempo, 1))

        transitions = song_transition_times(self.analysis)
        # network, freed_start, tunnel_start, launch_start, finale_start
        b0, b1, b2, b3, b4 = pick_phase_boundaries(transitions, duration, count=5)

        beats = self.analysis.get("beats", [])
        if beats:
            def snap(t: float) -> float:
                return min(beats, key=lambda b: abs(b - t))
            b0, b1, b2, b3, b4 = snap(b0), snap(b1), snap(b2), snap(b3), snap(b4)

        b0 = clamp(b0, DENDRITE_ONSET + 0.5, duration * 0.35)
        b1 = clamp(b1, b0 + 3.0, duration * 0.55)
        b2 = clamp(b2, b1 + 2.0, duration * 0.78)
        b3 = clamp(b3, b2 + 2.0, duration * 0.92)
        b4 = clamp(b4, b3 + 1.0, duration - 1.5)

        self.vars["network_start"].set(round(b0, 2))
        self.vars["freed_bounce_duration"].set(round(max(2.0, b2 - b1), 1))
        self.vars["tunnel_duration"].set(round(max(3.0, b3 - b2), 1))
        self.vars["launch_duration"].set(round(max(3.0, b4 - b3), 1))
        self.vars["finale_white_duration"].set(round(max(1.5, duration - b4), 1))
        self.vars["narrative_duration"].set(round(duration, 1))
        self._view_end = duration

        n_trans = len([t for t in transitions if DENDRITE_ONSET < t < duration])
        self.status.config(
            text=f"Phases mapped to {n_trans} song transitions (re-timed to {duration:.0f}s)"
        )
        self.redraw()

    def on_phrase_select(self, _event=None) -> None:
        sel = self.phrase_list.curselection()
        if not sel or not self.analysis:
            return
        p = self.analysis["phrases"][sel[0]]
        start = float(p["start"])
        if start >= DENDRITE_ONSET:
            self.vars["network_start"].set(round(start, 2))
            self.redraw()

    def load_preset_dialog(self) -> None:
        path = filedialog.askopenfilename(
            initialdir=os.path.dirname(self.preset_path) or SCRIPT_DIR,
            filetypes=[("JSON preset", "*.json"), ("All files", "*.*")],
        )
        if path:
            try:
                self.preset_path = path
                self.apply_preset(load_json(path))
                self.status.config(text=f"Loaded {os.path.basename(path)}")
                self.redraw()
            except (OSError, json.JSONDecodeError, ValueError, tk.TclError) as exc:
                messagebox.showerror("Load failed", str(exc))

    def save_preset_dialog(self) -> None:
        initial_dir = os.path.dirname(os.path.abspath(self.preset_path)) or SCRIPT_DIR
        path = filedialog.asksaveasfilename(
            initialdir=initial_dir,
            initialfile=os.path.basename(self.preset_path),
            defaultextension=".json",
            filetypes=[("JSON preset", "*.json")],
        )
        if not path:
            return
        try:
            data = self.preset_dict(preset_path=path)
            save_json(path, data)
            self.preset_path = path
            self.preset_data = dict(data)
            self.status.config(text=f"Saved {os.path.basename(path)}")
        except (OSError, TypeError, ValueError) as exc:
            messagebox.showerror("Save failed", str(exc))

    def _time_to_x(self, t: float, width: int, margin: int = 48) -> float:
        end = max(self._view_end, 1.0)
        return margin + (t / end) * max(1, width - margin * 2)

    def _x_to_time(self, x: float, width: int, margin: int = 48) -> float:
        end = max(self._view_end, 1.0)
        return clamp((x - margin) / max(1, width - margin * 2) * end, 0.0, end)

    def redraw(self) -> None:
        c = self.canvas
        c.delete("all")
        w = c.winfo_width()
        h = c.winfo_height()
        if w < 10:
            return

        margin = 48
        bounds = self.phase_boundaries()
        end = max(self._view_end, bounds["end"], 1.0)

        regions = [
            ("solo", 0, DENDRITE_ONSET),
            ("dendrites", DENDRITE_ONSET, bounds["network"]),
            ("network", bounds["network"], bounds["freed"]),
            ("freed", bounds["freed"], bounds["tunnel"]),
            ("tunnel", bounds["tunnel"], bounds["launch"]),
            ("launch", bounds["launch"], bounds["finale"]),
            ("finale", bounds["finale"], bounds["end"]),
        ]
        y0, y1 = 28, h - 72
        for name, t0, t1 in regions:
            if t1 <= t0:
                continue
            x0 = self._time_to_x(t0, w, margin)
            x1 = self._time_to_x(t1, w, margin)
            c.create_rectangle(x0, y0, x1, y1, fill=PHASE_COLORS[name], outline="")

        if self.analysis:
            times = self.analysis.get("envelope_times", [])
            energy = self.analysis.get("envelope_energy", [])
            if times and energy:
                peak = max(energy) or 1.0
                pts = []
                for t, e in zip(times, energy):
                    x = self._time_to_x(float(t), w, margin)
                    y = y1 - (float(e) / peak) * (y1 - y0 - 8)
                    pts.extend([x, y])
                if len(pts) >= 4:
                    c.create_line(*pts, fill="#88bbff", width=1.5, smooth=True)

            for bt in self.analysis.get("beats", []):
                x = self._time_to_x(float(bt), w, margin)
                c.create_line(x, y1, x, y1 + 10, fill="#ff8844", width=1)

            for ev in self.analysis.get("events", []):
                x = self._time_to_x(float(ev["t"]), w, margin)
                color = EVENT_COLORS.get(ev.get("type", ""), "#aaaaaa")
                c.create_oval(x - 3, y0 - 8, x + 3, y0 - 2, fill=color, outline="")

            for i, p in enumerate(self.analysis.get("phrases", [])):
                x0 = self._time_to_x(float(p["start"]), w, margin)
                x1 = self._time_to_x(float(p["end"]), w, margin)
                c.create_line(x0, y0 - 14, x1, y0 - 14, fill="#668866", width=2)
                c.create_text((x0 + x1) / 2, y0 - 20, text=str(i + 1), fill="#99cc99", font=("", 8))

        markers = [
            ("network", bounds["network"], "#66aaff", "network"),
            ("freed", bounds["freed"], "#aacc66", "freed"),
            ("tunnel", bounds["tunnel"], "#66cccc", "tunnel"),
            ("launch", bounds["launch"], "#8888ff", "launch"),
            ("finale", bounds["finale"], "#ffffff", "finale"),
            ("end", bounds["end"], "#ff6666", "end"),
        ]
        for key, t, color, label in markers:
            x = self._time_to_x(t, w, margin)
            c.create_line(x, 8, x, h - 20, fill=color, width=2, dash=(4, 3) if key == "end" else ())
            c.create_text(x, 14, text=label, fill=color, font=("", 8), anchor=tk.N)

        c.create_line(margin, y1 + 18, w - margin, y1 + 18, fill="#444")
        for tick in range(0, int(end) + 1, max(1, int(end // 12))):
            x = self._time_to_x(tick, w, margin)
            c.create_line(x, y1 + 14, x, y1 + 22, fill="#666")
            c.create_text(x, y1 + 30, text=str(tick), fill="#888", font=("", 8))

    def _marker_at(self, x: float, _y: float) -> str | None:
        w = self.canvas.winfo_width()
        bounds = self.phase_boundaries()
        markers = {
            "network": bounds["network"],
            "freed": bounds["freed"],
            "tunnel": bounds["tunnel"],
            "launch": bounds["launch"],
            "finale": bounds["finale"],
            "end": bounds["end"],
        }
        best, best_d = None, 1e9
        for key, t in markers.items():
            mx = self._time_to_x(t, w)
            d = abs(x - mx)
            if d < 8 and d < best_d:
                best, best_d = key, d
        return best

    def on_canvas_press(self, event) -> None:
        self._drag_marker = self._marker_at(event.x, event.y)

    def on_canvas_drag(self, event) -> None:
        if not self._drag_marker:
            return
        w = self.canvas.winfo_width()
        t = self._x_to_time(event.x, w)
        b = self.phase_boundaries()

        if self._drag_marker == "network":
            t = clamp(t, DENDRITE_ONSET + 0.5, b["freed"] - 4.0)
            self.vars["network_start"].set(round(t, 2))
        elif self._drag_marker == "freed":
            t = clamp(t, b["network"] + 4.0, b["tunnel"] - 1.0)
            self.vars["freed_bounce_duration"].set(round(b["tunnel"] - t, 2))
        elif self._drag_marker == "tunnel":
            t = clamp(t, b["freed"] + 1.0, b["launch"] - 1.0)
            self.vars["tunnel_duration"].set(round(b["launch"] - t, 2))
        elif self._drag_marker == "launch":
            t = clamp(t, b["tunnel"] + 1.0, b["finale"] - 0.5)
            self.vars["launch_duration"].set(round(b["finale"] - t, 2))
        elif self._drag_marker == "finale":
            t = clamp(t, b["launch"] + 0.5, b["end"] - 0.3)
            self.vars["finale_white_duration"].set(round(b["end"] - t, 2))
        elif self._drag_marker == "end":
            t = clamp(t, b["finale"] + 0.3, max(self._view_end, t))
            self.vars["narrative_duration"].set(round(t, 2))
            self._view_end = max(self._view_end, t)

    def on_canvas_release(self, _event) -> None:
        self._drag_marker = None


if __name__ == "__main__":
    root = tk.Tk()
    app = PhraseEditorApp(root)
    if len(sys.argv) > 1 and sys.argv[1].lower().endswith(".mp3"):
        app.mp3_path = os.path.abspath(sys.argv[1])
        app.status.config(text=f"Selected: {os.path.basename(app.mp3_path)}")
        root.after(200, app.run_analysis)
    root.mainloop()
