#!/usr/bin/python3
# meta.py: per-conversation user overrides + a soft delete, mirroring the iOS ConversationMetaStore.
# The overrides the Mac needs today are "hidden" (exclude a conversation from the Diary and Recap),
# "pinned" (float it to the top of its day group), and "kept" (exempt its clips from the rolling
# retention eviction, the conversation-level twin of a raw clip's Save action).
# Each is keyed by the conversation's FIRST clip filename (stable while that clip exists) and stored
# in a sparse JSON map next to the recordings, so it survives a re-organize and moving the folder. The
# organizer never reads or writes this file; the app applies it when rendering.
#
#   meta.py --rec-dir DIR --json                      # -> {ok, hidden:[keys], pinned:[keys], kept:[keys]}
#   meta.py --rec-dir DIR --hide KEY --val 0|1         # -> set/clear the hidden flag for a conversation
#   meta.py --rec-dir DIR --pin KEY --val 0|1          # -> set/clear the pinned flag for a conversation
#   meta.py --rec-dir DIR --keep KEY --val 0|1         # -> set/clear "keep forever" for a conversation
#   meta.py --rec-dir DIR --delete-clip FILE           # -> soft-delete one clip (move to .trash, reversible)
#   meta.py --rec-dir DIR --delete-conv KEY            # -> soft-delete a whole conversation (every clip
#                                                      #    to .trash, reversible) and drop its overrides
# Pure stdlib.
import sys, os, json

HOME = os.path.expanduser("~")
DEFAULT_REC = os.path.join(HOME, "Recordings/Epiphani")


def _meta_path(rec_dir):
    return os.path.join(rec_dir, ".meta", "conversation_meta.json")


def load_meta(rec_dir):
    try:
        with open(_meta_path(rec_dir), encoding="utf-8") as f:
            obj = json.load(f)
            return obj if isinstance(obj, dict) else {}
    except (OSError, ValueError):
        return {}


def save_meta(rec_dir, meta):
    """Write atomically; prune entries that are all-default so the map stays sparse (iOS parity)."""
    pruned = {k: v for k, v in meta.items() if v and any(v.values())}
    d = os.path.join(rec_dir, ".meta")
    try:
        os.makedirs(d, exist_ok=True)
        tmp = _meta_path(rec_dir) + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(pruned, f, ensure_ascii=False)
        os.replace(tmp, _meta_path(rec_dir))
        return True
    except OSError:
        return False


def flag_keys(meta, flag):
    """Every conversation key carrying `flag` truthy. The one reader for hidden/pinned/kept."""
    return [k for k, v in meta.items() if isinstance(v, dict) and v.get(flag)]


def hidden_keys(meta):
    return flag_keys(meta, "hidden")


def kept_files(rec_dir, meta):
    """Every clip FILENAME belonging to a kept conversation.

    "Keep" is a flag, deliberately not a move into Saved/ the way a single raw clip's Keep works:
    organize.py scans only the buffer root, so moving a conversation's clips into Saved/ would make
    the conversation vanish from the Diary on the next organize sweep — the opposite of keeping it.
    The flag is instead honoured by both eviction paths (storage.py's cap eviction and main.swift's
    time-based purge), which is why this resolves the conversation key to its full clip list.

    Falls back to the keys themselves (each key IS a clip filename: the conversation's first) when
    diary.json cannot be read, so a kept conversation always protects at least its own first clip."""
    keys = flag_keys(meta, "kept")
    if not keys:
        return []
    out = set(keys)
    try:
        with open(os.path.join(rec_dir, "diary.json"), encoding="utf-8") as f:
            diary = json.load(f)
        for day in (diary or {}).get("days", []):
            for cv in day.get("conversations", []):
                clips = cv.get("clips") or []
                if clips and clips[0].get("file") in out:
                    for c in clips:
                        if c.get("file"):
                            out.add(c["file"])
    except (OSError, ValueError, AttributeError, TypeError):
        pass
    return sorted(out)


def all_flags(meta, rec_dir=None):
    """The full override map the app renders from, in one shape."""
    return {"hidden": flag_keys(meta, "hidden"),
            "pinned": flag_keys(meta, "pinned"),
            "kept": flag_keys(meta, "kept"),
            "keptFiles": kept_files(rec_dir, meta) if rec_dir else []}


def set_flag(rec_dir, key, flag, on):
    """Set one boolean override on one conversation. save_meta() prunes all-false entries, so
    clearing the last flag on a conversation removes its row rather than leaving a dead entry."""
    meta = load_meta(rec_dir)
    entry = meta.get(key) or {}
    entry[flag] = bool(on)
    meta[key] = entry
    ok = save_meta(rec_dir, meta)
    out = {"ok": ok}
    out.update(all_flags(load_meta(rec_dir), rec_dir))
    return out


def set_hidden(rec_dir, key, on):
    return set_flag(rec_dir, key, "hidden", on)


def delete_clip(rec_dir, fname):
    """Soft delete: move the clip (from the buffer root OR Saved/) and its sidecar into .trash/, so it
    leaves the Diary/Recent and the next organize sweep but stays recoverable. Never a hard delete."""
    base = os.path.basename(fname)
    if not base:
        return {"ok": False, "error": "no file"}
    trash = os.path.join(rec_dir, ".trash")
    try:
        os.makedirs(trash, exist_ok=True)
    except OSError as e:
        return {"ok": False, "error": str(e)[:120]}
    moved = []
    # the audio may be in the buffer root or in Saved/
    for src in (os.path.join(rec_dir, base), os.path.join(rec_dir, "Saved", base)):
        if os.path.exists(src):
            try:
                os.replace(src, os.path.join(trash, base))
                moved.append(base)
            except OSError:
                pass
            break
    sidecar = os.path.join(rec_dir, ".organized", base + ".json")
    if os.path.exists(sidecar):
        try:
            os.replace(sidecar, os.path.join(trash, base + ".json"))
            moved.append(base + ".json")
        except OSError:
            pass
    return {"ok": bool(moved), "moved": moved, "trash": trash}


def conv_clip_files(rec_dir, key):
    """Every clip filename in the conversation whose FIRST clip is `key`. Falls back to [key] when the
    diary cannot be read, so an action on a conversation always at least reaches its own first clip."""
    try:
        with open(os.path.join(rec_dir, "diary.json"), encoding="utf-8") as f:
            diary = json.load(f)
        for day in (diary or {}).get("days", []):
            for cv in day.get("conversations", []):
                clips = cv.get("clips") or []
                if clips and clips[0].get("file") == key:
                    return [c["file"] for c in clips if c.get("file")]
    except (OSError, ValueError, AttributeError, TypeError):
        pass
    return [key] if key else []


def delete_conversation(rec_dir, key):
    """Soft-delete a whole organized conversation: every clip and sidecar moves to .trash/ (reversible,
    never a hard delete), and its override entry is dropped so a stale pin/keep can't outlive it.
    The Diary card clears on the next organize sweep, exactly like a single-clip delete."""
    files = conv_clip_files(rec_dir, key)
    if not files:
        return {"ok": False, "error": "conversation not found: %s" % key}
    moved, failed = [], []
    for f in files:
        r = delete_clip(rec_dir, f)
        (moved if r.get("ok") else failed).append(f)
    meta = load_meta(rec_dir)
    if key in meta:
        del meta[key]
        save_meta(rec_dir, meta)
    out = {"ok": bool(moved), "moved": moved, "failed": failed, "clips": len(files)}
    out.update(all_flags(load_meta(rec_dir), rec_dir))
    return out


def main(argv):
    rec_dir = os.path.expanduser(argv[argv.index("--rec-dir") + 1]) if "--rec-dir" in argv else DEFAULT_REC

    def arg(name):
        return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else None

    for flagarg, flag in (("--hide", "hidden"), ("--pin", "pinned"), ("--keep", "kept")):
        if flagarg in argv:
            key = arg(flagarg) or ""
            val = (arg("--val") or "1") not in ("0", "false", "no", "off")
            sys.stdout.write(json.dumps(set_flag(rec_dir, key, flag, val), ensure_ascii=False))
            return
    if "--delete-clip" in argv:
        sys.stdout.write(json.dumps(delete_clip(rec_dir, arg("--delete-clip") or ""), ensure_ascii=False))
        return
    if "--delete-conv" in argv:
        sys.stdout.write(json.dumps(delete_conversation(rec_dir, arg("--delete-conv") or ""), ensure_ascii=False))
        return
    out = {"ok": True}
    out.update(all_flags(load_meta(rec_dir), rec_dir))
    sys.stdout.write(json.dumps(out, ensure_ascii=False))


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