#!/usr/bin/env python3
"""
Room Reader - find the comments on a small YouTube channel that still need an answer.

Single file. Local-first. No API keys, no accounts, nothing sent to a hosted AI worker.
The only network calls read public data about a channel you own or are reviewing.

The read runs offline with an open-weight model:
  Xenova/nli-deberta-v3-xsmall  (DeBERTa-v3-xsmall, MNLI-fine-tuned, exported to ONNX)
A cross-encoder NLI model gives zero-shot classification for free: for each comment we
measure how strongly it entails the sentence "The viewer is asking a question." with no
training data and no labels of our own. A second, regex pass flags complaints the
question model misses (damaged, refund, never arrived).

Then it opens each thread the model flagged: if the creator already replied, the
comment drops out. What's left is the pile a channel owner never answers.

Usage:
    python3 roomreader.py @handle
    python3 roomreader.py @handle --videos 5 --check-replies --json out.json
    python3 roomreader.py UCxxxxxxxxxxxxxxxxxxxxxx

Download the model once (~87 MB model + ~9 MB tokenizer):
    mkdir -p model
    curl -L -o model/model_quantized.onnx \
      https://huggingface.co/Xenova/nli-deberta-v3-xsmall/resolve/main/onnx/model_quantized.onnx
    curl -L -o model/tokenizer.json \
      https://huggingface.co/Xenova/nli-deberta-v3-xsmall/resolve/main/tokenizer.json
"""
import argparse
import json
import os
import re
import time
import urllib.parse
import urllib.request

import numpy as np

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36")
YT_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"  # public web client key
INVIDIOUS = os.environ.get("INVIDIOUS", "https://inv.nadeko.net")
MODEL_DIR = os.environ.get("MODEL_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "model"))
MODEL_REPO = "Xenova/nli-deberta-v3-xsmall"

QUESTION_HYPOTHESIS = "The viewer is asking a question."
ENTAILMENT = 1  # id2label for these exports: 0=contradiction 1=entailment 2=neutral
MAX_LEN = 256
QUESTION_THRESHOLD = 0.5

SPAM_RE = re.compile(r"(?i)(https?://|www\.|t\.me/|follow me|sub4sub|sub 4 sub|"
                     r"check out my (channel|video|page)|free followers|link in bio|"
                     r"dm me|whatsapp|\+?\d{9,})")
CONCERN_RE = re.compile(r"(?i)(damag|broken|refund|never (arrived|received|got|came)|"
                        r"didn'?t (arrive|receive|get|come)|wrong (item|order|size)|"
                        r"scam|charged (me )?twice|customer service|no reply|unanswered)")
# Surface cue for a question: a '?' or an interrogative word. Cuts the model's
# false positives on long praise comments that happen to score high.
INTERROG_RE = re.compile(r"(?i)\b(what|where|when|why|how|who|which|whom|can|could|"
                         r"do|does|did|is|are|will|would|should|any|anyone|anybody|"
                         r"help|link|recipe|recommend|available|ship|shipment)\b")


def fetch(url, data=None, timeout=30):
    req = urllib.request.Request(url, data=data)
    req.add_header("User-Agent", UA)
    req.add_header("Accept-Language", "en-US,en;q=0.9")
    if data is not None:
        req.add_header("Content-Type", "application/json")
    return urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", "replace")


def walk(obj, key):
    if isinstance(obj, dict):
        for k, v in obj.items():
            if k == key:
                yield v
            yield from walk(v, key)
    elif isinstance(obj, list):
        for item in obj:
            yield from walk(item, key)


# ---------------------------------------------------------------- channel data

def resolve_channel(handle):
    if re.fullmatch(r"UC[\w-]{20,}", handle):
        return handle, handle
    h = handle.strip().split("youtube.com/")[-1].split("?")[0].strip("/")
    if not h.startswith("@"):
        h = "@" + h
    html = fetch(f"https://www.youtube.com/{h}")
    m = re.search(r'"externalId":"(UC[\w-]+)"', html)
    t = re.search(r'<meta property="og:title" content="([^"]+)"', html)
    if not m:
        raise SystemExit(f"could not resolve channel id for {handle}")
    return m.group(1), (t.group(1) if t else h)


def recent_videos(cid, n):
    body = {"context": {"client": {"clientName": "WEB", "clientVersion": "2.20260807.00.00",
                                   "hl": "en", "gl": "US"}},
            "browseId": cid, "params": "EgZ2aWRlb3PyBgQKAjoA"}
    d = json.loads(fetch("https://www.youtube.com/youtubei/v1/browse?key=" + YT_KEY,
                         json.dumps(body).encode()))
    out = []
    for lv in walk(d, "lockupViewModel"):
        vid = lv.get("contentId")
        if not vid:
            continue
        title = None
        for tt in walk(lv, "lockupMetadataViewModel"):
            title = tt.get("title", {}).get("content")
            break
        out.append({"videoId": vid, "title": title})
        if len(out) >= n:
            break
    return out


def player(vid):
    body = {"context": {"client": {"clientName": "WEB", "clientVersion": "2.20260807.00.00",
                                   "hl": "en", "gl": "US"}}, "videoId": vid}
    j = json.loads(fetch("https://www.youtube.com/youtubei/v1/player?key=" + YT_KEY,
                         json.dumps(body).encode()))
    vd = j.get("videoDetails", {}) or {}
    return vd.get("title", ""), int(vd.get("viewCount") or 0)


def comments_page(vid, continuation=None, tries=3):
    url = f"{INVIDIOUS}/api/v1/comments/{vid}?hl=en"
    if continuation:
        url += "&continuation=" + urllib.parse.quote(continuation)
    for a in range(tries):
        try:
            return json.loads(fetch(url))
        except Exception as e:
            if a == tries - 1:
                return {"comments": [], "error": str(e)[:80]}
            time.sleep(1.5)


def fetch_comments(vid, cap=60):
    first = comments_page(vid)
    out = list(first.get("comments", []) or [])
    total = first.get("commentCount")
    cont = first.get("continuation")
    while cont and len(out) < cap:
        page = comments_page(vid, cont)
        batch = page.get("comments", []) or []
        if not batch:
            break
        out.extend(batch)
        cont = page.get("continuation")
        time.sleep(0.4)
    return out[:cap], total, first.get("error")


def fetch_reply_authors(vid, continuation, cap=20):
    try:
        j = json.loads(fetch(f"{INVIDIOUS}/api/v1/comments/{vid}?hl=en&continuation="
                             + urllib.parse.quote(continuation)))
    except Exception:
        return []
    return [(c.get("author"), bool(c.get("authorIsChannelOwner")))
            for c in (j.get("comments") or [])[:cap]]


# ---------------------------------------------------------------- local model

class Reader:
    """Zero-shot 'does this comment ask something?' via an open-weight NLI model. CPU only."""

    def __init__(self, model_dir=MODEL_DIR):
        import onnxruntime as ort
        from tokenizers import Tokenizer
        mp = os.path.join(model_dir, "model_quantized.onnx")
        tp = os.path.join(model_dir, "tokenizer.json")
        if not os.path.exists(mp) or not os.path.exists(tp):
            base = f"https://huggingface.co/{MODEL_REPO}/resolve/main"
            raise SystemExit(
                "model files missing. Download once:\n"
                f"  mkdir -p {model_dir}\n"
                f"  curl -L -o {mp} {base}/onnx/model_quantized.onnx\n"
                f"  curl -L -o {tp} {base}/tokenizer.json")
        self.tok = Tokenizer.from_file(tp)
        self.tok.enable_truncation(max_length=MAX_LEN)
        self.sess = ort.InferenceSession(mp, providers=["CPUExecutionProvider"])
        self.names = [i.name for i in self.sess.get_inputs()]

    def question_prob(self, comment):
        enc = self.tok.encode(comment or " ", QUESTION_HYPOTHESIS)
        n = min(len(enc.ids), MAX_LEN)
        ids = np.array([(enc.ids + [0] * (MAX_LEN - n))[:MAX_LEN]], dtype=np.int64)
        mask = np.array([([1] * n + [0] * (MAX_LEN - n))[:MAX_LEN]], dtype=np.int64)
        feed = {"input_ids": ids, "attention_mask": mask}
        if "token_type_ids" in self.names:
            feed["token_type_ids"] = np.zeros_like(ids)
        logits = self.sess.run(None, feed)[0][0]
        ex = np.exp(logits - logits.max())
        return float((ex / ex.sum())[ENTAILMENT])


def label_comment(reader, text):
    if not text:
        return "empty", 0.0
    if SPAM_RE.search(text):
        return "spam", 0.99
    q = reader.question_prob(text)
    cue = ("?" in text) or bool(INTERROG_RE.search(text))
    if q >= QUESTION_THRESHOLD and cue:
        return "question", q
    if CONCERN_RE.search(text):
        return "concern", q
    return "other", q


# ---------------------------------------------------------------- report

def analyze(handle, nvids, cap, check_replies, reader):
    cid, cname = resolve_channel(handle)
    vids = recent_videos(cid, nvids)
    report = {"channel": cname, "channelId": cid, "videos": [], "queue": [],
              "model": MODEL_REPO, "hypothesis": QUESTION_HYPOTHESIS,
              "generated": int(time.time())}
    for v in vids:
        vid = v["videoId"]
        title, views = player(vid)
        raw, total, err = fetch_comments(vid, cap)
        cleaned = []
        for c in raw:
            if c.get("authorIsChannelOwner"):
                continue
            cleaned.append({
                "author": c.get("author"),
                "content": (c.get("content") or "").strip(),
                "likes": c.get("likeCount") or 0,
                "hearted": bool(c.get("creatorHeart")),
                "replyCount": (c.get("replies") or {}).get("replyCount", 0),
                "published": c.get("publishedText"),
                "commentId": c.get("commentId"),
                "_cont": (c.get("replies") or {}).get("continuation"),
            })
        for c in cleaned:
            c["intent"], c["score"] = label_comment(reader, c["content"])
            c["answered"] = None
            if check_replies and c["intent"] in ("question", "concern") \
                    and c["replyCount"] > 0 and c["_cont"]:
                c["answered"] = any(own for _, own in fetch_reply_authors(vid, c["_cont"]))
        needed = [c for c in cleaned if c["intent"] in ("question", "concern")]
        vid_rec = {"videoId": vid, "title": title or v.get("title"), "views": views,
                   "commentCount": total, "scanned": len(cleaned), "replyNeeded": len(needed)}
        for c in needed:
            if c["answered"] is True:
                continue
            report["queue"].append({
                "videoId": vid, "videoTitle": title or v.get("title"),
                "author": c["author"], "content": c["content"][:300],
                "kind": c["intent"], "score": round(c["score"], 3), "likes": c["likes"],
                "hearted": c["hearted"], "answered": c["answered"],
                "published": c["published"], "commentId": c["commentId"]})
        report["videos"].append(vid_rec)
        time.sleep(1.0)
    report["queue"].sort(key=lambda x: -x["score"])
    report["summary"] = {
        "videos": len(report["videos"]),
        "commentsSeen": sum(v["commentCount"] or 0 for v in report["videos"]),
        "scanned": sum(v["scanned"] for v in report["videos"]),
        "queue": len(report["queue"]),
    }
    return report


def print_report(r):
    s = r["summary"]
    print(f"\nROOM READER - {r['channel']} ({r['channelId']})")
    print(f"{s['videos']} videos | {s['commentsSeen']} comments on them | scanned {s['scanned']} | "
          f"{s['queue']} still want a reply\n")
    for v in r["videos"]:
        print(f"  {v['videoId']}  views={v['views']}  comments={v['commentCount']}  "
              f"reply-needed={v['replyNeeded']}  {(v['title'] or '')[:48]}")
    if not r["queue"]:
        print("\n  REPLY QUEUE: empty. Nobody is waiting on you here.")
        print()
        return
    print(f"\n  REPLY QUEUE  (open-weight model, offline; hypothesis: \"{r['hypothesis']}\")")
    for i, c in enumerate(r["queue"][:15], 1):
        ans = "?" if c["answered"] is None else ("owner-answered" if c["answered"] else "unanswered")
        heart = " hearted" if c["hearted"] else ""
        print(f"  {i:>2}. [{c['kind']} {c['score']:.2f}{heart} {ans}] {c['author']} ({c['likes']}l): "
              f"{c['content'][:108].replace(chr(10), ' ')}")
    print()


def main():
    ap = argparse.ArgumentParser(description="Find the comments a small channel still needs to answer.")
    ap.add_argument("channel", help="@handle, channel URL, or UC... id")
    ap.add_argument("--videos", type=int, default=5)
    ap.add_argument("--comments", type=int, default=60, help="max top-level comments scanned per video")
    ap.add_argument("--check-replies", action="store_true",
                    help="open reply threads to confirm whether the owner already answered")
    ap.add_argument("--json", metavar="PATH", help="also write the full report as JSON")
    args = ap.parse_args()

    reader = Reader()
    report = analyze(args.channel, args.videos, args.comments, args.check_replies, reader)
    print_report(report)
    if args.json:
        with open(args.json, "w") as f:
            json.dump(report, f, indent=1)
        print(f"wrote {args.json}")


if __name__ == "__main__":
    main()
