#!/usr/bin/env python3
"""Uptober v1.3 score: the US dollars an entry wallet spent on its qualifying buys (rule 9).

Standard library only. It reads a snapshot (uptober.py snapshot) or check --json output and adds a
"score" block to every result:

  score.py --snapshot DIR_OR_snapshot.json [--out scored.json] [--prices sol_usd.json]

How a buy is valued (rule 9):
  - USDC or USDT paid: face value.
  - SOL paid (as uptober.py measures it: net of the network fee and new token-account rent, wSOL counted
    as SOL): times CoinGecko's SOL/USD price for the buy's UTC date (/coins/solana/history, which is the
    price at 00:00 UTC that day). Prices are cached in sol_usd.json so every rerun gives the same number.
  - Paid with any other token (v1.3): the SOL/USDC/USDT the pool that sold our token received in that
    transaction, SOL valued the same way.
  - A transaction that buys both tokens is counted once, split evenly between them.
Per token, the dollars spent are multiplied by the share of the bought tokens still kept:
  kept = (net bought + balance held before the start, through the hold end when the run has one)
         / tokens bought, between 0 and 1.
  (v1.8: whatever leaves the wallet comes out of tokens held before the start first, so selling an
  old bag never lowers the score; transfers in are not buys and never raise it.)
The score is the sum over both tokens, in cents. Top Buyer of the Week uses the same formula on that
week's buys only. Ties: the wallet whose last counted buy came first ranks higher.
"""
import argparse
import datetime as dt
import json
import os
import sys
import time
import urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
PRICES = os.path.join(HERE, "sol_usd.json")
CG = "https://api.coingecko.com/api/v3/coins/solana/history?date=%s&localization=false"
UA = "uptober-score/1.2 (+https://pepebrock.com/uptober/)"
WEEKS = [("1", "2026-10-07T00:00:00Z", "2026-10-13T23:59:59Z"),
         ("2", "2026-10-14T00:00:00Z", "2026-10-20T23:59:59Z"),
         ("3", "2026-10-21T00:00:00Z", "2026-10-27T23:59:59Z"),
         ("4", "2026-10-28T00:00:00Z", "2026-10-31T23:59:59Z")]       # v1.9


def ts(s):
    return int(dt.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc).timestamp())


class SolPrice:
    """SOL/USD by UTC date, from a cache file, fetching missing dates from CoinGecko."""

    def __init__(self, path=PRICES, fetch=True):
        self.path, self.fetch = path, fetch
        self.table = {}
        if path and os.path.exists(path):
            with open(path) as f:
                self.table = json.load(f).get("usd", {})
        self.dirty = False

    def __call__(self, day):
        if day not in self.table:
            if not self.fetch:
                raise KeyError("no SOL/USD price for %s in %s" % (day, self.path))
            self.table[day] = self._get(day)
            self.dirty = True
        return float(self.table[day])

    def _get(self, day):
        y, m, d = day.split("-")
        last = None
        for attempt in range(5):
            try:
                req = urllib.request.Request(CG % ("%s-%s-%s" % (d, m, y)), headers={"User-Agent": UA})
                with urllib.request.urlopen(req, timeout=30) as r:
                    p = json.loads(r.read())["market_data"]["current_price"]["usd"]
                return round(float(p), 6)
            except Exception as e:  # noqa: BLE001 - rate limits: back off and retry
                last = e
                time.sleep(6 * (attempt + 1))
        raise RuntimeError("CoinGecko SOL/USD for %s unavailable: %s" % (day, last))

    def save(self):
        if self.dirty and self.path:
            tmp = self.path + ".tmp"
            with open(tmp, "w") as f:
                json.dump({"source": "CoinGecko /coins/solana/history (00:00 UTC price of each date)",
                           "usd": dict(sorted(self.table.items()))}, f, indent=1)
                f.write("\n")
            os.replace(tmp, self.path)
            self.dirty = False


def buy_usd(b, price):
    """Dollar value of one qualifying buy. Paid in SOL/USDC/USDT: what left the wallet. Paid with another
    token (v1.3): what the pool that sold our token received in that transaction."""
    if str(b.get("paid_with") or "").startswith("token:"):
        sol = max(float(b.get("pool_sol") or 0), 0.0)
        stables = sum(max(float(v), 0.0) for v in (b.get("pool_stables") or {}).values())
    else:
        sol = max(float(b.get("sol") or 0), 0.0)
        stables = sum(max(float(v), 0.0) for v in (b.get("stables") or {}).values())
    return (sol * price(b["time"][:10]) if sol else 0.0) + stables


def wallet_score(result, price, start=None, end=None):
    """-> {"usd": float (cents), "by_token": {...}, "last_buy": iso|None} for one check result."""
    toks = result.get("tokens") or {}
    share = {}
    for t in toks.values():
        for b in t.get("qualifying_buys") or []:
            share[b["sig"]] = share.get(b["sig"], 0) + 1
    lo = ts(start) if start else None
    hi = ts(end) if end else None
    total, by, last = 0.0, {}, None
    for sym, t in sorted(toks.items()):
        bought = float(t.get("qualifying_bought") or 0)
        net = t.get("net_bought_through_hold_end", t.get("net_bought")) or 0
        old = float(t.get("balance_at_start") or 0)        # sold first (v1.8)
        kept = max(0.0, min(1.0, (float(net) + old) / bought)) if bought > 0 else 0.0
        spent = 0.0
        for b in t.get("qualifying_buys") or []:
            bt = ts(b["time"])
            if (lo is not None and bt < lo) or (hi is not None and bt > hi):
                continue
            spent += buy_usd(b, price) / share[b["sig"]]
            last = b["time"] if last is None or b["time"] > last else last
        by[sym] = {"spent_usd": round(spent, 2), "kept": round(kept, 6), "score_usd": round(spent * kept, 2)}
        total += spent * kept
    return {"usd": round(total, 2), "by_token": by, "last_buy": last}


def score_results(results, price):
    for r in results:
        if r.get("error") or not r.get("tokens"):
            continue
        s = wallet_score(r, price)
        s["weeks"] = {w: wallet_score(r, price, a, b)["usd"] for w, a, b in WEEKS}
        r["score"] = s
    return results


def rank_key(score):
    """Sort key: higher score first, then the earlier last counted buy."""
    return (-score["usd"], score.get("last_buy") or "9999")


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--snapshot", required=True, help="snapshot dir, snapshot.json or check --json output")
    ap.add_argument("--out", help="write here (default: overwrite the input)")
    ap.add_argument("--prices", default=PRICES)
    ap.add_argument("--offline", action="store_true", help="never fetch prices; fail if one is missing")
    args = ap.parse_args(argv)
    path = args.snapshot
    if os.path.isdir(path):
        path = os.path.join(path, "snapshot.json")
    with open(path) as f:
        doc = json.load(f)
    results = doc["results"] if isinstance(doc, dict) and "results" in doc else (doc if isinstance(doc, list) else [doc])
    price = SolPrice(args.prices, fetch=not args.offline)
    try:
        score_results(results, price)
    finally:
        price.save()
    out = args.out or path
    tmp = out + ".tmp"
    with open(tmp, "w") as f:
        json.dump(doc, f, indent=1)
        f.write("\n")
    os.replace(tmp, out)
    ranked = sorted((r for r in results if r.get("score") and r.get("eligible")), key=lambda r: rank_key(r["score"]))
    print(json.dumps({"out": out, "scored": sum(1 for r in results if r.get("score")),
                      "eligible_ranked": len(ranked),
                      "top": [r["score"]["usd"] for r in ranked[:5]]}))
    return 0


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