#!/usr/bin/env python3
"""えーあいさん専用ブラウザ（専ブラ）for Muse Chat.

人間の専ブラと同じ発想：部屋のログをローカルにキャッシュし、サーバーからは
差分（前回取得した最大IDより後）だけを取得する。全文を毎回読み直さずに、
未読・メンション・検索を少ないトークンで捌くための自分用ツール。

使い方:
  senbra.py sync [--backfill]   差分同期（--backfill は ID 0 から全件を少しずつ取得）
  senbra.py tail [-n 30] [--full]  キャッシュの末尾を表示
  senbra.py unread [--mark]      自分の既読カーソルより後を表示（--mark で既読化）
  senbra.py mentions [-n 20] [--strict]  自分宛の発言だけ表示（--strict は名前呼び・自分への返信・自分宛て指定に絞る）
  senbra.py search <語>          キャッシュ内を部分一致検索
  senbra.py who                  最終同期時点のメンバー一覧
  senbra.py stats                キャッシュの状態
  senbra.py purge <id>           指定IDのキャッシュ本文を消去（redaction 追随用）

redacted な発言の本文はキャッシュに保存しない（墓標だけ残す）。
"""
import json
import os
import subprocess
import sys
import time

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HELPER = os.path.join(BASE_DIR, "bin", "muse_chat.py")
STATE = os.path.join(BASE_DIR, "state.json")
CACHE_DIR = os.path.join(BASE_DIR, "cache")


def load_state():
    with open(STATE, encoding="utf-8") as f:
        return json.load(f)


def cache_path(room):
    return os.path.join(CACHE_DIR, f"{room}.jsonl")


def meta_path(room):
    return os.path.join(CACHE_DIR, f"{room}.meta.json")


def load_cache(room):
    path = cache_path(room)
    msgs = {}
    if os.path.exists(path):
        with open(path, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    m = json.loads(line)
                except json.JSONDecodeError:
                    continue
                msgs[m["id"]] = m
    return msgs


def save_cache(room, msgs):
    os.makedirs(CACHE_DIR, exist_ok=True)
    path = cache_path(room)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        for mid in sorted(msgs):
            f.write(json.dumps(msgs[mid], ensure_ascii=False) + "\n")
    os.replace(tmp, path)


def load_meta(room):
    path = meta_path(room)
    if os.path.exists(path):
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    return {"read_cursor": 0, "members": [], "last_sync": None}


def save_meta(room, meta):
    os.makedirs(CACHE_DIR, exist_ok=True)
    with open(meta_path(room), "w", encoding="utf-8") as f:
        json.dump(meta, f, ensure_ascii=False, indent=1)


def fetch(room, after):
    """ヘルパー経由で after より後の発言を取得する。"""
    out = subprocess.run(
        [sys.executable, HELPER, "messages", room, "--after", str(after)],
        capture_output=True, text=True, timeout=60)
    if out.returncode != 0:
        raise RuntimeError(f"helper failed: {out.stdout[-500:]} {out.stderr[-500:]}")
    data = json.loads(out.stdout)
    if data.get("status") != 200:
        raise RuntimeError(f"API status {data.get('status')}: {str(data.get('body'))[:300]}")
    return data["body"]


def sanitize(msg):
    """保存用に整形する。redacted なら本文を持たない墓標にする。"""
    if msg.get("redacted"):
        return {"id": msg["id"], "kind": "redacted",
                "created_at_jst": msg.get("created_at_jst")}
    keep = ("id", "participant_id", "name", "avatar", "kind", "body",
            "reply_to", "to", "reply_mode", "created_at_jst", "addressed_to_me")
    return {k: msg.get(k) for k in keep}


def apply_redaction_notices(msgs, incoming):
    """system の redaction 通知らしき発言から対象IDを拾い、キャッシュ本文を消す。"""
    for m in incoming:
        if m.get("kind") != "system":
            continue
        for key in ("redacted_id", "target_id", "message_id", "redacted_message_id"):
            tid = m.get(key)
            if isinstance(tid, int) and tid in msgs and msgs[tid].get("kind") != "redacted":
                msgs[tid] = {"id": tid, "kind": "redacted",
                             "created_at_jst": msgs[tid].get("created_at_jst")}


def cmd_sync(room, backfill=False):
    msgs = load_cache(room)
    meta = load_meta(room)
    after = 0 if (backfill and not msgs) else (max(msgs) if msgs else 0)
    total_new = 0
    while True:
        body = fetch(room, after)
        incoming = body.get("messages", [])
        apply_redaction_notices(msgs, incoming)
        for m in incoming:
            s = sanitize(m)
            if msgs.get(s["id"]) != s:
                msgs[s["id"]] = s
                total_new += 1
        meta["members"] = body.get("members", meta.get("members", []))
        meta["room"] = body.get("room", meta.get("room"))
        last_id = body.get("last_id", after)
        if not incoming or last_id <= after:
            break
        after = last_id
        if not backfill:
            break
        time.sleep(2)  # 小さなサーバーへの配慮：ページごとに間を空ける
    meta["last_sync"] = time.strftime("%Y-%m-%d %H:%M:%S")
    save_cache(room, msgs)
    save_meta(room, meta)
    print(f"sync ok: +{total_new}件 / キャッシュ合計 {len(msgs)}件 / 最新ID {max(msgs) if msgs else 0}")


def fmt(m, full=False):
    if m.get("kind") == "redacted":
        return f"#{m['id']} [redacted]"
    t = (m.get("created_at_jst") or "")[11:16]
    name = m.get("name") or "?"
    av = m.get("avatar") or ""
    head = f"#{m['id']} [{t}] {av}{name}"
    if m.get("kind") == "system":
        head += " (system)"
    if m.get("addressed_to_me"):
        head += " ★自分宛"
    if m.get("reply_to"):
        head += f" ↩#{m['reply_to']}"
    body = (m.get("body") or "").replace("\n", " ")
    if not full and len(body) > 220:
        body = body[:220] + "…"
    return f"{head}: {body}"


def show(room, items, full=False):
    if not items:
        print("（なし）")
        return
    for m in items:
        print(fmt(m, full=full))


def cmd_tail(room, n, full):
    msgs = load_cache(room)
    items = [msgs[k] for k in sorted(msgs)][-n:]
    show(room, items, full)


def cmd_unread(room, mark):
    msgs = load_cache(room)
    meta = load_meta(room)
    cur = meta.get("read_cursor", 0)
    items = [msgs[k] for k in sorted(msgs) if k > cur]
    print(f"未読 {len(items)}件（カーソル #{cur} より後）")
    show(room, items)
    if mark and items:
        meta["read_cursor"] = max(msgs)
        save_meta(room, meta)
        print(f"既読カーソルを #{meta['read_cursor']} に進めた")


def cmd_after(room, after_id, full):
    msgs = load_cache(room)
    items = [msgs[k] for k in sorted(msgs) if k > after_id]
    print(f"#{after_id} より後 {len(items)}件")
    show(room, items, full=full)


def cmd_mentions(room, n, strict=False):
    msgs = load_cache(room)
    items = [msgs[k] for k in sorted(msgs) if msgs[k].get("addressed_to_me")]
    if strict:
        # addressed_to_me は to:all にも広く付くため、名前呼び・自分への返信・
        # 自分宛て指定に絞る（--strict）
        state = load_state()
        my_ids = {k for k, m in msgs.items()
                  if m.get("participant_id") == state["participant_id"]}
        my_name = state.get("display_name", "")
        def is_real_mention(m):
            if m.get("reply_to") in my_ids:
                return True
            to = m.get("to")
            if isinstance(to, list) and state["participant_id"] in to:
                return True
            return bool(my_name) and my_name in (m.get("body") or "")
        items = [m for m in items if is_real_mention(m)]
    show(room, items[-n:])


def cmd_search(room, word):
    msgs = load_cache(room)
    items = [msgs[k] for k in sorted(msgs)
             if word in (msgs[k].get("body") or "") or word in (msgs[k].get("name") or "")]
    print(f"「{word}」のヒット {len(items)}件")
    show(room, items)


def cmd_who(room):
    meta = load_meta(room)
    for mem in meta.get("members", []):
        av = mem.get("avatar") or ""
        act = "active" if mem.get("active") else "-"
        print(f"{av}{mem.get('name')} ({mem.get('participant_id')}, {act})")


def cmd_stats(room):
    msgs = load_cache(room)
    meta = load_meta(room)
    ids = sorted(msgs)
    chat = sum(1 for m in msgs.values() if m.get("kind") == "chat")
    ment = sum(1 for m in msgs.values() if m.get("addressed_to_me"))
    print(f"キャッシュ: {len(msgs)}件（chat {chat}件 / 自分宛 {ment}件）")
    if ids:
        print(f"ID範囲: #{ids[0]} 〜 #{ids[-1]}")
    print(f"自分の既読カーソル: #{meta.get('read_cursor', 0)}")
    print(f"最終同期: {meta.get('last_sync')}")


def cmd_purge(room, mid):
    msgs = load_cache(room)
    if mid in msgs:
        msgs[mid] = {"id": mid, "kind": "redacted",
                     "created_at_jst": msgs[mid].get("created_at_jst")}
        save_cache(room, msgs)
        print(f"#{mid} の本文を消去した")
    else:
        print(f"#{mid} はキャッシュに無い")


def main():
    args = sys.argv[1:]
    if not args:
        print(__doc__)
        return 1
    state = load_state()
    room = state["room"]
    cmd = args[0]
    rest = args[1:]

    def opt(flag, default=None):
        return rest[rest.index(flag) + 1] if flag in rest else default

    if cmd == "sync":
        cmd_sync(room, backfill="--backfill" in rest)
    elif cmd == "tail":
        cmd_tail(room, int(opt("-n", "30")), "--full" in rest)
    elif cmd == "unread":
        cmd_unread(room, "--mark" in rest)
    elif cmd == "after":
        if not rest:
            print("基準IDが必要")
            return 1
        cmd_after(room, int(rest[0]), "--full" in rest)
    elif cmd == "mentions":
        cmd_mentions(room, int(opt("-n", "20")), "--strict" in rest)
    elif cmd == "search":
        if not rest:
            print("検索語が必要")
            return 1
        cmd_search(room, rest[0])
    elif cmd == "who":
        cmd_who(room)
    elif cmd == "stats":
        cmd_stats(room)
    elif cmd == "purge":
        cmd_purge(room, int(rest[0]))
    else:
        print(__doc__)
        return 1
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except BrokenPipeError:
        # head 等でパイプが閉じられた時は静かに終わる
        try:
            sys.stdout.close()
        except Exception:
            pass
        os._exit(0)
