#!/usr/bin/python3
# markdown_export.py: write one smart-named Markdown file per conversation (summary + full
# transcript embedded), into a user-chosen backup folder, so the Mac app's record of your day
# survives outside the app and is portable to anything that reads text. The Mac-side answer to the
# iOS "Backup to a folder" feature (Capture/BackupService.swift + ConversationExporter.markdown).
#
# Reads the organizer's diary.json (engine/organize.py) for the conversation grouping + summaries,
# and each clip's sidecar (.organized/<file>.json) for the full diarized transcript. Write-only into
# the backup folder; never deletes a recording. Incremental: a small manifest in the backup folder
# records what's already written (by content hash) so re-runs only write new or changed conversations.
#
#   markdown_export.py --json   [--rec-dir DIR] [--out DIR]
#       Report what a backup would do (counts, folder state) for the UI. No writes.
#   markdown_export.py --backup --rec-dir DIR --out DIR [--audio]
#       Write one .md per conversation into DIR; with --audio, copy each conversation's clips too.
#   markdown_export.py --conv-key KEY --rec-dir DIR
#       Render ONE conversation as Markdown ({ok, text, filename, title}) for the per-card Export
#       action. Read-only: no writes, no manifest, no backup folder involved.
#
# Filename: "YYYY-MM-DD HH-mm <title> <id8>.md", matching the iOS export scheme (a short id keeps
# two same-minute, same-title conversations from overwriting each other). Pure stdlib (no pip deps).
import sys, os, json, re, hashlib, shutil, datetime

HOME = os.path.expanduser("~")
DEFAULT_REC = os.path.join(HOME, "Recordings/Epiphani")
MANIFEST_NAME = ".epiphani-backup.json"   # written in the backup folder; tracks what's already saved
APP_FOOTER = "*Recorded on device with RecapMuse (Mac). Private to this Mac.*"
MAX_MANIFEST = 5000                       # cap the manifest; trim oldest-first when exceeded


# ----------------------------------------------------------------------------- load
def load_diary(rec_dir):
    p = os.path.join(rec_dir, "diary.json")
    with open(p, encoding="utf-8") as f:   # caller handles OSError/ValueError visibly
        return json.load(f)


def load_sidecar(rec_dir, fname):
    """The per-clip meta (.organized/<file>.json) carries the full diarized transcript."""
    p = os.path.join(rec_dir, ".organized", fname + ".json")
    try:
        with open(p, encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError):
        return None


# ----------------------------------------------------------------------------- naming
def _sanitize(title, limit=60):
    """A filesystem-safe, human-readable slice of the title for the filename."""
    t = re.sub(r"[\\/:*?\"<>|\n\r\t]+", " ", str(title or "")).strip()
    t = re.sub(r"\s+", " ", t)
    if len(t) > limit:
        t = t[:limit].rsplit(" ", 1)[0].strip() or t[:limit].strip()
    return t or "Conversation"


def _first_clip_file(conv):
    clips = conv.get("clips") or []
    return clips[0].get("file", "") if clips else ""


def _parse_clip_dt(fname):
    """Recording filenames are 'YYYY-MM-DD_HH-MM-SS.aac' -> a datetime, or None."""
    m = re.match(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})", fname or "")
    return datetime.datetime(*[int(x) for x in m.groups()]) if m else None


def conv_identity(day, conv):
    """A stable short id (8 hex) from the day + first clip + title; collision-safe filename suffix."""
    seed = "%s|%s|%s" % (day.get("date", ""), _first_clip_file(conv), conv.get("title", ""))
    return hashlib.md5(seed.encode("utf-8")).hexdigest()[:8]


def conv_filename(day, conv):
    dt = _parse_clip_dt(_first_clip_file(conv))
    if dt:
        stamp = dt.strftime("%Y-%m-%d %H-%M")
    else:
        # Fall back to the day's date with no clock when the clip name can't be parsed.
        stamp = day.get("date", "undated")
    return "%s %s %s.md" % (stamp, _sanitize(conv.get("title", "Conversation")), conv_identity(day, conv))


# ----------------------------------------------------------------------------- render
def _clip_transcript(rec_dir, clip):
    """(transcript_text, note) for one clip. note is a human reason when there is no transcript."""
    sc = load_sidecar(rec_dir, clip.get("file", ""))
    if sc is None:
        return "", "transcript not found"
    status = sc.get("status", "")
    txt = (sc.get("transcript") or "").strip()
    if txt:
        return txt, ""
    reason = {"silent": "silent stretch", "no_speech": "sound but no speech",
              "empty": "empty recording", "error": "could not transcribe"}.get(status, "no transcript")
    return "", reason


def conversation_markdown(rec_dir, day, conv):
    """Full Markdown for one conversation: title, when, summary, action items, full transcript."""
    title = (conv.get("title") or "Conversation").strip() or "Conversation"
    out = "# %s\n\n" % title
    out += "*%s*  \n" % (day.get("heading") or day.get("date", ""))
    meta = " · ".join(x for x in [conv.get("time_range", ""), conv.get("who_with", ""),
                                  (conv.get("category", "") or "").replace("_", " ")] if x)
    if meta:
        out += "%s\n\n" % meta
    else:
        out += "\n"

    summary = (conv.get("summary") or "").strip()
    if summary:
        out += "## Summary\n\n%s\n\n" % summary

    actions = [a for a in (conv.get("action_items") or []) if a]
    if actions:
        out += "## Action items\n\n"
        for a in actions:
            out += "- [ ] %s\n" % a
        out += "\n"

    out += "## Transcript\n\n"
    clips = conv.get("clips") or []
    wrote_any = False
    for c in clips:
        head = " — ".join(x for x in [c.get("time", ""), (c.get("title") or "").strip()] if x)
        dur = c.get("dur_min")
        if dur:
            head += "  _(%d min)_" % dur
        out += "### %s\n\n" % (head or "Clip")
        txt, note = _clip_transcript(rec_dir, c)
        if txt:
            out += "%s\n\n" % txt
            wrote_any = True
        else:
            out += "_(%s)_\n\n" % note
    if not clips:
        out += "_(no clips)_\n\n"

    out += "---\n%s\n" % APP_FOOTER
    return out, wrote_any


def one_conversation(rec_dir, key):
    """Render ONE conversation (looked up by its first clip's filename, the same key the Diary card,
    citations.py and share_export.py all use) as Markdown, without touching the backup folder or its
    manifest. This is the per-card "Export" action: the app hands the returned text and suggested
    filename to a save panel. Read-only."""
    diary_path = os.path.join(rec_dir, "diary.json")
    if not os.path.exists(diary_path):
        return {"ok": False, "error": "no diary yet (organize your recordings first)"}
    try:
        diary = load_diary(rec_dir)
    except (OSError, ValueError) as e:
        return {"ok": False, "error": "could not read diary.json: %s" % (str(e)[:120])}
    for day, conv in iter_conversations(diary):
        if _first_clip_file(conv) == key:
            # conversation_markdown returns (markdown, wrote_any_transcript); the flag rides along so
            # the app can say plainly that a conversation exported without any transcript in it.
            md, had_transcript = conversation_markdown(rec_dir, day, conv)
            return {"ok": True, "text": md, "hasTranscript": bool(had_transcript),
                    "filename": conv_filename(day, conv),
                    "title": (conv.get("title") or "Conversation").strip() or "Conversation"}
    return {"ok": False, "error": "conversation not found: %s" % key}


def _content_hash(md_body):
    return hashlib.md5(md_body.encode("utf-8")).hexdigest()


def iter_conversations(diary):
    """Yield (day, conv) for every conversation in the diary, newest day first (diary order)."""
    for day in (diary or {}).get("days", []):
        for conv in day.get("conversations", []):
            yield day, conv


# ----------------------------------------------------------------------------- manifest
def load_manifest(out_dir):
    p = os.path.join(out_dir, MANIFEST_NAME)
    try:
        with open(p, encoding="utf-8") as f:
            m = json.load(f)
        if isinstance(m, dict) and isinstance(m.get("entries"), list):
            return m
    except (OSError, ValueError):
        pass
    return {"schema": "epiphani.backup/v1", "entries": []}


def save_manifest(out_dir, manifest):
    # Trim oldest-first (ordered list) so the cap never silently drops the most recent saves.
    entries = manifest.get("entries", [])
    if len(entries) > MAX_MANIFEST:
        manifest["entries"] = entries[-MAX_MANIFEST:]
    p = os.path.join(out_dir, MANIFEST_NAME)
    with open(p, "w", encoding="utf-8") as f:
        json.dump(manifest, f, ensure_ascii=False, indent=2)


def _writable(out_dir):
    """Catch an unwritable folder immediately (matches the iOS pre-flight write test)."""
    try:
        os.makedirs(out_dir, exist_ok=True)
        probe = os.path.join(out_dir, ".epiphani-writetest")
        with open(probe, "w") as f:
            f.write("ok")
        os.remove(probe)
        return True, ""
    except OSError as e:
        return False, str(e)[:200]


# ----------------------------------------------------------------------------- backup
def backup(rec_dir, out_dir, with_audio=False):
    """Write one .md per new/changed conversation into out_dir. Returns a JSON-able report."""
    ok, why = _writable(out_dir)
    if not ok:
        return {"ok": False, "error": "Backup folder is not writable: %s" % why}
    try:
        diary = load_diary(rec_dir)
    except (OSError, ValueError) as e:
        return {"ok": False, "error": "No diary to back up yet (%s)." % (str(e)[:120])}

    manifest = load_manifest(out_dir)
    seen = {e.get("key"): e for e in manifest.get("entries", []) if e.get("key")}
    wrote, skipped, errors = [], [], []

    for day, conv in iter_conversations(diary):
        key = "%s/%s" % (day.get("date", ""), conv_identity(day, conv))
        try:
            body, has_transcript = conversation_markdown(rec_dir, day, conv)
        except Exception as e:                       # visible, never a silent pass
            errors.append({"conversation": conv.get("title", ""), "error": str(e)[:200]})
            continue
        chash = _content_hash(body)
        prior = seen.get(key)
        fname = conv_filename(day, conv)
        md_path = os.path.join(out_dir, fname)

        # The .md is current iff it's unchanged and still on disk (a deleted backup re-writes).
        md_current = bool(prior and prior.get("hash") == chash
                          and os.path.exists(os.path.join(out_dir, prior.get("file", fname))))
        # Audio is still pending if the caller wants it and no prior run completed the copy cleanly.
        audio_pending = with_audio and not (prior and prior.get("audio_done"))
        if md_current and not audio_pending:
            skipped.append(fname)
            continue

        if not md_current:
            try:
                with open(md_path, "w", encoding="utf-8") as f:
                    f.write(body)
            except OSError as e:
                errors.append({"conversation": conv.get("title", ""), "error": str(e)[:200]})
                continue

        audio_ok = True
        audio_files = (prior or {}).get("audio", [])
        audio_done = (prior or {}).get("audio_done", False)
        if with_audio:
            audio_ok, audio_files = _copy_audio(rec_dir, out_dir, day, conv)
            audio_done = audio_ok      # "tried, no hard error" — unavailable sources don't block

        entry = {"key": key, "file": fname, "hash": chash, "title": conv.get("title", ""),
                 "date": day.get("date", ""), "has_transcript": has_transcript,
                 "audio": audio_files, "audio_done": audio_done, "saved_at": _now_iso()}
        # Do NOT mark done if the audio copy hit a real error: flag it so the next run retries.
        if with_audio and not audio_ok:
            entry["audio_incomplete"] = True
            errors.append({"conversation": conv.get("title", ""), "error": "audio copy incomplete"})
        seen[key] = entry
        (wrote if not md_current else skipped).append(fname)

    manifest["entries"] = list(seen.values())
    manifest["last_backup"] = _now_iso()
    try:
        save_manifest(out_dir, manifest)
    except OSError as e:
        errors.append({"conversation": "(manifest)", "error": str(e)[:200]})

    return {"ok": True, "out": out_dir, "wrote": wrote, "skipped": skipped,
            "errors": errors, "wrote_count": len(wrote), "skipped_count": len(skipped),
            "error_count": len(errors)}


def _copy_audio(rec_dir, out_dir, day, conv):
    """Copy a conversation's clip audio into out_dir/audio/. Returns (ok, [relpaths]).

    A source that simply isn't there (e.g. retention already deleted the audio while keeping the
    transcript) is "unavailable", not a failure to retry; only a real copy error makes ok False so
    the next run retries it. The transcript .md is always saved regardless of audio.
    """
    audio_dir = os.path.join(out_dir, "audio")
    rel = []
    ok = True
    try:
        os.makedirs(audio_dir, exist_ok=True)
    except OSError:
        return False, rel
    base = os.path.splitext(conv_filename(day, conv))[0]
    for i, c in enumerate(conv.get("clips") or []):
        src = os.path.join(rec_dir, c.get("file", ""))
        if not os.path.exists(src):
            alt = os.path.join(rec_dir, "Saved", c.get("file", ""))   # kept clips live in Saved/
            src = alt if os.path.exists(alt) else src
        if not os.path.exists(src):
            continue                                                   # unavailable, not a retry
        dst_name = "%s [%d]%s" % (base, i + 1, os.path.splitext(c.get("file", ""))[1] or ".aac")
        dst = os.path.join(audio_dir, dst_name)
        try:
            if not os.path.exists(dst):
                shutil.copy2(src, dst)
            rel.append(os.path.join("audio", dst_name))
        except OSError:
            ok = False                                                 # real failure -> retry next run
    return ok, rel


# ----------------------------------------------------------------------------- status (for the UI)
def status(rec_dir, out_dir):
    """A no-write report the UI uses to render the backup panel."""
    try:
        diary = load_diary(rec_dir)
        conv_count = sum(len(d.get("conversations", [])) for d in diary.get("days", []))
        has_diary = True
    except (OSError, ValueError):
        conv_count, has_diary = 0, False

    out_set = bool(out_dir)
    backed_up, last_backup, pending = 0, "", conv_count
    if out_set and os.path.isdir(out_dir):
        m = load_manifest(out_dir)
        backed_up = len(m.get("entries", []))
        last_backup = m.get("last_backup", "")
        pending = max(0, conv_count - backed_up)
    return {"ok": True, "has_diary": has_diary, "out": out_dir, "out_set": out_set,
            "conversations": conv_count, "backed_up": backed_up,
            "pending": pending, "last_backup": last_backup}


# ----------------------------------------------------------------------------- util / cli
def _now_iso():
    return datetime.datetime.now().isoformat(timespec="seconds")


def _arg(argv, flag, default=None):
    return os.path.expanduser(argv[argv.index(flag) + 1]) if flag in argv and argv.index(flag) + 1 < len(argv) else default


def main(argv):
    rec_dir = _arg(argv, "--rec-dir", DEFAULT_REC)
    out_dir = _arg(argv, "--out", "")
    if "--conv-key" in argv:
        # not path-like: read the raw argv value, never _arg's expanduser
        i = argv.index("--conv-key")
        key = argv[i + 1] if i + 1 < len(argv) else ""
        sys.stdout.write(json.dumps(one_conversation(rec_dir, key), ensure_ascii=False))
        return
    if "--backup" in argv:
        if not out_dir:
            sys.stdout.write(json.dumps({"ok": False, "error": "No backup folder chosen."}))
            return
        rep = backup(rec_dir, out_dir, with_audio=("--audio" in argv))
        sys.stdout.write(json.dumps(rep, ensure_ascii=False))
        return
    # default: --json status report
    sys.stdout.write(json.dumps(status(rec_dir, out_dir), ensure_ascii=False))


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