#!/usr/bin/env python3
"""Collect Uptober entries (v1.2) from public replies to @PEPEVERSEHQ_Sol's Uptober posts.

Standard library only. Read-only on X: it reads public replies through the FxTwitter API and never
posts, likes or messages anyone.

  replies.py --out entries.csv [--post ID ...] [--manual entries_manual.csv]

An entry is a reply that contains a Solana wallet address (the last address in the reply is the entry
wallet). Replies without an address are comments, not entries, and are skipped. Every run merges into
--out, so a reply seen once stays an entry even after it drops off the first page of replies.
Rows from --manual (same columns) are merged too: use it for an entry someone emailed in with its link.
review_code X_TOO_NEW is set when the X account joined after June 2026 (rule 4).
"""
import argparse
import csv
import datetime as dt
import json
import os
import re
import sys
import time
import urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import uptober  # noqa: E402

HQ = "PEPEVERSEHQ_Sol"
POSTS = ["2107678268228788719",   # Oct 7 launch post
         "2108025269730558298",   # Oct 8 promo post
         "2108393222833606999"]   # Oct 9 v1.2 post (pin this one)
FX = "https://api.fxtwitter.com/2/conversation/%s"
UA = "uptober-replies/1.2 (+https://pepebrock.com/uptober/)"
COLUMNS = ["go_id", "x_handle", "post_url", "wallet", "submitted_utc", "review_code"]
JOINED_BY = dt.datetime(2026, 7, 1, tzinfo=dt.timezone.utc)
ADDR = re.compile(r"(?<![1-9A-HJ-NP-Za-km-z])[1-9A-HJ-NP-Za-km-z]{32,44}(?![1-9A-HJ-NP-Za-km-z])")


def x_time(s):
    """FxTwitter 'Wed Oct 07 04:30:30 +0000 2026' -> aware datetime."""
    return dt.datetime.strptime(s, "%a %b %d %H:%M:%S %z %Y")


def fetch(post_id):
    last = None
    for attempt in range(4):
        try:
            req = urllib.request.Request(FX % post_id, headers={"User-Agent": UA})
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.loads(r.read())
        except Exception as e:  # noqa: BLE001
            last = e
            time.sleep(4 * (attempt + 1))
    raise RuntimeError("replies for %s unavailable: %s" % (post_id, last))


def wallet_in(text):
    """Last valid on-curve Solana address in the text, or None."""
    found = [a for a in ADDR.findall(text or "") if uptober.is_address(a) and uptober.is_on_curve(a)]
    return found[-1] if found else None


def entries_from(doc, post_id):
    out = []
    for r in doc.get("replies") or []:
        a = r.get("author") or {}
        h = a.get("screen_name") or ""
        if not h or h.lower() == HQ.lower():
            continue
        to = (r.get("replying_to_status") or r.get("replying_to") or {})
        if isinstance(to, dict) and to.get("status") and str(to.get("status")) != str(post_id):
            continue                       # a reply to another reply, not to the post
        w = wallet_in(r.get("text") or "")
        if not w:
            continue
        code = ""
        try:
            if x_time(a["joined"]) >= JOINED_BY:
                code = "X_TOO_NEW"
        except (KeyError, ValueError):
            pass
        out.append({"go_id": "", "x_handle": h, "post_url": "https://x.com/%s/status/%s" % (h, r["id"]),
                    "wallet": w, "submitted_utc": x_time(r["created_at"]).strftime("%Y-%m-%dT%H:%M:%SZ"),
                    "review_code": code})
    return out


def read_csv(path):
    if not path or not os.path.exists(path):
        return []
    with open(path, encoding="utf-8-sig", newline="") as f:
        return [{c: (row.get(c) or "").strip() for c in COLUMNS} for row in csv.DictReader(f)]


def merge(*lists):
    """Rows keyed by post_url (else handle+time); a later list overrides an earlier one."""
    rows = {}
    for lst in lists:
        for r in lst:
            k = r.get("post_url") or ("%s|%s" % (r.get("x_handle", "").lower(), r.get("submitted_utc")))
            if k in rows and not r.get("review_code") and rows[k].get("review_code"):
                r = dict(r, review_code=rows[k]["review_code"])   # keep a code set by hand
            rows[k] = r
    return sorted(rows.values(), key=lambda r: (r["submitted_utc"], r["x_handle"].lower()))


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--out", required=True)
    ap.add_argument("--post", action="append", help="Uptober post id (default: the known HQ posts)")
    ap.add_argument("--manual", help="extra rows, same columns")
    ap.add_argument("--wallets-out", help="also write the entry wallets, one per line (for uptober.py snapshot)")
    args = ap.parse_args(argv)
    posts = args.post or POSTS
    fresh, errors = [], []
    for p in posts:
        try:
            fresh += entries_from(fetch(p), p)
        except RuntimeError as e:
            errors.append(str(e))
    rows = merge(read_csv(args.out), fresh, read_csv(args.manual))
    tmp = args.out + ".tmp"
    with open(tmp, "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=COLUMNS)
        w.writeheader()
        w.writerows(rows)
    os.replace(tmp, args.out)
    if args.wallets_out:
        with open(args.wallets_out, "w") as f:
            f.write("\n".join(sorted({r["wallet"] for r in rows if r["wallet"]})) + "\n")
    print(json.dumps({"out": args.out, "posts": posts, "seen_now": len(fresh), "entries": len(rows),
                      "errors": errors}))
    return 1 if errors and not fresh else 0


if __name__ == "__main__":
    sys.exit(main())
