#!/usr/bin/env python3
"""HVN Voice Capture - export engine.

Pure file processing (no audio device touched -> can never disturb Wispr Flow).
Turns the rolling buffer of timestamped .aac segments into shareable, openable
recordings:

  * scans the buffer, SKIPPING empty/0-byte segments (launch churn leaves these),
  * auto-groups contiguous clips into "conversations" by time gaps
    (a gap longer than --gap minutes starts a new conversation), so the scattered
    "5 min here, 45 min there" spans surface as a pick-list,
  * stitches a chosen conversation (or an explicit clip list, or the last N minutes)
    into ONE file and transcodes to a format that opens natively on Mac + iPhone:
        m4a  = AAC in an MP4 container (default; small, double-clicks everywhere)
        mp3  = universal (bigger, plays literally anywhere)

Capture stays raw ADTS .aac (crash-safe); this only produces EXPORTS.

CLI:
  export_clips.py list  <buffer_dir> [--gap 5] [--json]
  export_clips.py export <buffer_dir> (--conv ID | --clips a,b,c | --last MINUTES)
                         [--format m4a|mp3] [--bitrate 128k] [--out PATH] [--gap 5]
"""
import sys, os, re, json, subprocess, datetime, shutil, argparse

TS_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})")
MIN_BYTES = 1024            # smaller than this = an empty rotation, skip it
DEFAULT_GAP_MIN = 5
DEFAULT_BITRATE = "128k"    # export bitrate (capture stays at its own setting)


def _which(name):
    p = shutil.which(name)
    if p:
        return p
    for c in ("/opt/homebrew/bin/", "/usr/local/bin/", "/usr/bin/"):
        if os.path.exists(c + name):
            return c + name
    return name  # last resort: hope it's on PATH at call time


FFMPEG = os.environ.get("HVN_FFMPEG") or _which("ffmpeg")
FFPROBE = os.environ.get("HVN_FFPROBE") or _which("ffprobe")


def parse_start(path):
    m = TS_RE.search(os.path.basename(path))
    if not m:
        return None
    try:
        return datetime.datetime(*(int(x) for x in m.groups()))
    except ValueError:
        return None


def probe_duration(path):
    """Seconds via ffprobe; falls back to a size estimate (64 kbps) if ffprobe is absent."""
    try:
        out = subprocess.run(
            [FFPROBE, "-v", "error", "-show_entries", "format=duration",
             "-of", "default=nokey=1:noprint_wrappers=1", path],
            capture_output=True, text=True, timeout=30)
        v = out.stdout.strip()
        if v:
            return float(v)
    except Exception:
        pass
    try:
        return os.path.getsize(path) * 8 / 64000.0
    except OSError:
        return 0.0


def scan(buffer_dir):
    clips = []
    for name in os.listdir(buffer_dir):
        if not name.lower().endswith(".aac"):
            continue
        path = os.path.join(buffer_dir, name)
        try:
            if os.path.getsize(path) < MIN_BYTES:
                continue
        except OSError:
            continue
        start = parse_start(path)
        if start is None:
            start = datetime.datetime.fromtimestamp(os.path.getmtime(path))
        clips.append({"path": path, "start": start, "dur": probe_duration(path)})
    clips.sort(key=lambda c: c["start"])
    return clips


def group(clips, gap_min=DEFAULT_GAP_MIN):
    """Group contiguous clips into conversations; a gap > gap_min starts a new one."""
    gap = gap_min * 60
    convs, cur = [], None
    for c in clips:
        end = c["start"] + datetime.timedelta(seconds=c["dur"])
        if cur and (c["start"] - cur["_end"]).total_seconds() <= gap:
            cur["clips"].append(c["path"]); cur["_end"] = max(cur["_end"], end)
            cur["dur"] += c["dur"]
        else:
            cur = {"id": len(convs) + 1, "start": c["start"], "_end": end,
                   "dur": c["dur"], "clips": [c["path"]]}
            convs.append(cur)
    for cv in convs:
        cv["end"] = cv.pop("_end")
    return convs


def _fmt_dur(s):
    s = int(s); h, m, sec = s // 3600, (s % 3600) // 60, s % 60
    return (f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}")


def list_conversations(buffer_dir, gap_min=DEFAULT_GAP_MIN, as_json=False):
    convs = group(scan(buffer_dir), gap_min)
    if as_json:
        print(json.dumps([{"id": c["id"], "start": c["start"].isoformat(),
                           "end": c["end"].isoformat(), "duration": round(c["dur"], 1),
                           "clips": c["clips"]} for c in convs]))
        return convs
    if not convs:
        print("No recordings in the buffer yet.")
        return convs
    print(f"{len(convs)} conversation(s) in the buffer:\n")
    for c in convs:
        when = c["start"].strftime("%a %-I:%M%p").replace("AM", "am").replace("PM", "pm")
        print(f"  [{c['id']}] {when}  ·  {_fmt_dur(c['dur'])}  ·  {len(c['clips'])} clip(s)")
    return convs


def stitch_export(clip_paths, out_path, fmt="m4a", bitrate=DEFAULT_BITRATE):
    clip_paths = [p for p in clip_paths if os.path.exists(p) and os.path.getsize(p) >= MIN_BYTES]
    if not clip_paths:
        raise SystemExit("Nothing to export: no non-empty clips selected.")
    os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
    # Raw ADTS frames concatenate cleanly; then transcode once to the target container/codec.
    raw = out_path + ".concat.aac"
    with open(raw, "wb") as w:
        for p in clip_paths:
            with open(p, "rb") as r:
                shutil.copyfileobj(r, w)
    codec = "libmp3lame" if fmt == "mp3" else "aac"
    cmd = [FFMPEG, "-y", "-i", raw, "-c:a", codec, "-b:a", bitrate, out_path]
    r = subprocess.run(cmd, capture_output=True, text=True)
    try:
        os.remove(raw)
    except OSError:
        pass
    if r.returncode != 0:
        raise SystemExit("ffmpeg export failed:\n" + r.stderr.strip()[-800:])
    return out_path


def _default_out(buffer_dir, convs, fmt):
    label = convs[0]["start"] if convs else datetime.datetime.now()
    name = "Conversation " + label.strftime("%Y-%m-%d %H%M") + "." + fmt
    return os.path.join(buffer_dir, "Exports", name)


def main(argv):
    ap = argparse.ArgumentParser(prog="export_clips")
    sub = ap.add_subparsers(dest="cmd", required=True)
    pl = sub.add_parser("list"); pl.add_argument("buffer"); pl.add_argument("--gap", type=int, default=DEFAULT_GAP_MIN); pl.add_argument("--json", action="store_true")
    pe = sub.add_parser("export"); pe.add_argument("buffer")
    pe.add_argument("--conv", type=int); pe.add_argument("--clips"); pe.add_argument("--last", type=int)
    pe.add_argument("--format", choices=["m4a", "mp3"], default="m4a")
    pe.add_argument("--bitrate", default=DEFAULT_BITRATE); pe.add_argument("--out"); pe.add_argument("--gap", type=int, default=DEFAULT_GAP_MIN)
    a = ap.parse_args(argv)

    if a.cmd == "list":
        list_conversations(a.buffer, a.gap, a.json); return 0

    if a.clips:
        sel = [p for p in a.clips.split(",") if p.strip()]
        convs = group(scan(a.buffer), a.gap)
    elif a.last:
        cutoff = datetime.datetime.now() - datetime.timedelta(minutes=a.last)
        sel = [c["path"] for c in scan(a.buffer) if c["start"] >= cutoff]
        convs = []
    else:
        convs = group(scan(a.buffer), a.gap)
        if a.conv is None:
            raise SystemExit("Pick what to export: --conv ID (see `list`), --clips a,b,c, or --last MINUTES.")
        match = [c for c in convs if c["id"] == a.conv]
        if not match:
            raise SystemExit(f"No conversation #{a.conv}. Run `list` to see them.")
        sel = match[0]["clips"]; convs = match
    out = a.out or _default_out(a.buffer, convs, a.format)
    path = stitch_export(sel, out, a.format, a.bitrate)
    dur = probe_duration(path)
    print(f"Exported {len(sel)} clip(s) -> {path}  ({_fmt_dur(dur)}, {os.path.getsize(path)//1024} KB, {a.format})")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
