#!/usr/bin/env python3
"""Build the public Uptober status board (status.json) from entries.csv and a snapshot.

Standard library only. Read-only: it reads two local files and writes one. It never contacts anyone.

  status_build.py --entries entries.csv --snapshot DIR_OR_snapshot.json --out status.json

entries.csv columns: go_id,x_handle,post_url,wallet,submitted_utc
  (optional extra column review_code: a code set by hand after review, e.g. X_TOO_NEW, NOT_A_REPLY,
   CONTENT_RULE, DUPLICATE_ACCOUNT, LOCKSTEP, KEEPER_REVIEW or OK; it overrides the computed code)
  An empty file, or a header with no rows, is fine.

status.json:
  {updated_utc, qualified_wallets, eligible_list_sha256, milestones: {"25", "50", "100": reached},
   entries: [{handle, status_code}], weekly_picks: []}

It NEVER writes wallets, post URLs or GO ids: entries carry the X handle and a status code only.
qualified_wallets counts the snapshot's eligible wallets with each funding group counted once
(wallets sharing a non-exchange funder within 2 hops are one person), whether or not they entered.
Milestones are permanent once reached: a reached flag in the previous status.json stays reached.
weekly_picks is carried over from the previous status.json (the operator fills it by hand).
"""
import argparse
import csv
import datetime as dt
import io
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import uptober  # noqa: E402  (same folder; used for funding groups, list hash and times)

MILESTONES = (25, 50, 100)
ENTRY_COLUMNS = ["go_id", "x_handle", "post_url", "wallet", "submitted_utc"]
CODES = ("OK", "BELOW_LINE_CARNAGE", "BELOW_LINE_PEPENOM", "NET_BELOW_LINE", "DROPPED_BELOW_LINE", "NOT_HOLDING",
         "EXCLUDED_LIST", "FUNDED_BY_LISTED", "RECEIVED_FROM_LISTED", "LISTED_ROUTER", "AUTOMATED",
         "FUNDING_GROUP", "LOCKSTEP", "DUPLICATE_WALLET", "DUPLICATE_ACCOUNT", "X_TOO_NEW", "NOT_A_REPLY",
         "CONTENT_RULE", "LATE", "KEEPER_REVIEW", "PENDING")
# PENDING is not in the spec's list: the wallet has not been checked yet (not in the snapshot, RPC error,
# incomplete data or not a valid address). It clears on the next run.


def now_iso():
    return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def norm_handle(h):
    h = (h or "").strip()
    if "/" in h:                       # pasted profile URL
        h = h.rstrip("/").rsplit("/", 1)[-1]
    return h.lstrip("@").strip()


def read_entries(path):
    """-> list of dicts with ENTRY_COLUMNS (+ review_code). Missing or empty file -> []."""
    if not path or not os.path.exists(path):
        return []
    with open(path, encoding="utf-8-sig", newline="") as f:
        text = f.read()
    if not text.strip():
        return []
    rows = list(csv.DictReader(io.StringIO(text)))
    head = [h.strip().lower() for h in next(csv.reader(io.StringIO(text)))]
    miss = [c for c in ENTRY_COLUMNS if c not in head]
    if miss:
        raise ValueError("entries.csv is missing column(s): %s" % ", ".join(miss))
    out = []
    for r in rows:
        r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
        if not any(r.get(c) for c in ENTRY_COLUMNS):
            continue
        out.append(r)
    return out


def load_snapshot(path):
    if path and os.path.isdir(path):
        path = os.path.join(path, "snapshot.json")
    if not path or not os.path.exists(path):
        return None
    with open(path) as f:
        return json.load(f)


def chain_code(r, symbols):
    """Status code for one wallet's check result (snapshot 'results' item)."""
    if r is None or r.get("error"):
        return "PENDING"
    reasons = " | ".join(r.get("exclusion_reasons") or [])
    if r.get("excluded"):
        if "listed:" in reasons or "off-curve" in reasons:
            return "EXCLUDED_LIST"
        if "funded (hop" in reasons:
            return "FUNDED_BY_LISTED"
        if "by transfer from listed" in reasons:
            return "RECEIVED_FROM_LISTED"
        if "flagged program" in reasons:
            return "LISTED_ROUTER"
        if "bot rule" in reasons:
            return "AUTOMATED"
        return "EXCLUDED_LIST"
    toks = r.get("tokens") or {}
    if any(t.get("history_truncated") or t.get("missing_txs") for t in toks.values()):
        return "PENDING"
    if r.get("eligible"):
        return "OK"
    keeper = any((t.get("program_in") or 0) > 0 for t in toks.values())
    code = None
    for sym in symbols:
        p = (toks.get(sym) or {}).get("passes") or {}
        if not p.get("bought_enough"):
            code = "BELOW_LINE_" + sym
            break
    if code is None:
        for key, c in (("net_bought_enough", "NET_BELOW_LINE"), ("held_continuously", "DROPPED_BELOW_LINE"),
                       ("holding_at_end", "NOT_HOLDING"), ("net_bought_enough_through_hold_end", "NET_BELOW_LINE"),
                       ("holding_at_hold_end", "NOT_HOLDING")):
            if any(key in (t.get("passes") or {}) and not t["passes"][key] for t in toks.values()):
                code = c
                break
    if keeper:                         # a keeper fill might count after review
        return "KEEPER_REVIEW"
    return code or "PENDING"


def build_status(entries, snap, previous=None, close=None, updated=None):
    previous = previous or {}
    results = {r.get("wallet"): r for r in (snap or {}).get("results", [])}
    symbols = [t["symbol"] for t in ((snap or {}).get("config") or {}).get("tokens", [])] or ["CARNAGE", "PEPENOM"]
    eligible = sorted(w for w, r in results.items() if r.get("eligible"))
    groups = uptober.funding_groups(list(results.values()), eligible)
    qualified = len(groups)

    # one entry per X account: the latest submission before the close counts
    close_ts = uptober.parse_time(close) if close else None
    latest = {}
    for e in entries:
        h = norm_handle(e.get("x_handle"))
        if not h:
            continue
        try:
            ts = uptober.parse_time(e.get("submitted_utc")) if e.get("submitted_utc") else None
        except ValueError:
            ts = None
        e = dict(e, _handle=h, _ts=ts)
        late = close_ts is not None and ts is not None and ts > close_ts
        e["_late"] = late
        k = h.lower()
        cur = latest.get(k)
        # an on-time submission always beats a late one; otherwise the newer one wins
        rank = (not late, ts or 0)
        if cur is None or rank >= (not cur["_late"], cur["_ts"] or 0):
            latest[k] = e
    chosen = list(latest.values())

    # the same wallet behind different X accounts
    # (late entries are void, so they never make another entry a duplicate)
    on_time = [e for e in chosen if not e["_late"]]
    by_wallet = {}
    for e in on_time:
        if e.get("wallet"):
            by_wallet.setdefault(e["wallet"], []).append(e)
    # a funding group that submits more than one entry: all of its entries are out
    entry_wallets = sorted({e["wallet"] for e in on_time if e.get("wallet") in results})
    group_of = {}
    for g in uptober.funding_groups(list(results.values()), entry_wallets):
        for w in g:
            group_of[w] = tuple(g)
    group_entries = {}
    for e in on_time:
        g = group_of.get(e.get("wallet"))
        if g:
            group_entries.setdefault(g, set()).add(e["wallet"])

    out_entries = []
    for e in sorted(chosen, key=lambda x: (x["_handle"].lower(), x["_handle"])):
        w = e.get("wallet", "")
        manual = (e.get("review_code") or "").strip().upper()
        if manual and manual not in CODES:
            raise ValueError("unknown review_code %r for @%s" % (manual, e["_handle"]))
        if manual:
            code = manual
        elif e["_late"]:
            code = "LATE"
        elif not uptober.is_address(w):
            code = "PENDING"
        elif len(by_wallet.get(w, [])) > 1:
            code = "DUPLICATE_WALLET"
        elif w in group_of and len(group_entries.get(group_of[w], ())) > 1:
            code = "FUNDING_GROUP"
        else:
            code = chain_code(results.get(w), symbols)
        out_entries.append({"handle": e["_handle"], "status_code": code})

    prev_m = previous.get("milestones") or {}
    milestones = {str(m): bool(prev_m.get(str(m))) or qualified >= m for m in MILESTONES}
    return {
        "updated_utc": updated or now_iso(),
        "qualified_wallets": qualified,
        "eligible_list_sha256": (snap or {}).get("eligible_sha256") or uptober.list_sha256(eligible),
        "milestones": milestones,
        "entries": out_entries,
        "weekly_picks": list(previous.get("weekly_picks") or []),
    }


def assert_no_wallets(status, wallets):
    text = json.dumps(status)
    leaked = [w for w in wallets if w and w in text]
    if leaked:
        raise AssertionError("status.json would contain wallet addresses: %d" % len(leaked))


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--entries", required=True, help="entries.csv (may be empty)")
    ap.add_argument("--snapshot", required=True, help="snapshot output dir or its snapshot.json")
    ap.add_argument("--out", required=True, help="status.json to write")
    ap.add_argument("--previous", help="previous status.json (default: --out if it exists)")
    ap.add_argument("--config", default=os.path.join(HERE, "config.json"), help="for the entry close time")
    args = ap.parse_args(argv)

    snap = load_snapshot(args.snapshot)
    if snap is None:
        print("snapshot not found: %s" % args.snapshot, file=sys.stderr)
        return 2
    entries = read_entries(args.entries)
    prev_path = args.previous or (args.out if os.path.exists(args.out) else None)
    previous = None
    if prev_path and os.path.exists(prev_path):
        with open(prev_path) as f:
            previous = json.load(f)
    close = None
    if args.config and os.path.exists(args.config):
        with open(args.config) as f:
            close = json.load(f).get("end")
    status = build_status(entries, snap, previous, close)
    wallets = {e.get("wallet") for e in entries} | {r.get("wallet") for r in snap.get("results", [])}
    assert_no_wallets(status, wallets)
    os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
    tmp = args.out + ".tmp"
    with open(tmp, "w") as f:
        json.dump(status, f, indent=1)
        f.write("\n")
    os.replace(tmp, args.out)
    print(json.dumps({"out": args.out, "qualified_wallets": status["qualified_wallets"],
                      "entries": len(status["entries"]), "milestones": status["milestones"]}))
    return 0


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