"""Project the rest of the FBS season and the 12-team College Football Playoff field.

Used by the College Football Playoff projection (/rankings/college-football/playoff).
Public data only:
  - ESPN's college scoreboard feed for every FBS game (results, schedule, sites, conference games)
  - ESPN's conference standings (membership and divisions)
  - ESPN's college Football Power Index (team ratings)
  - ESPN's rankings feed (AP poll and, once released, the CFP selection committee's Top 25)

Usage:
  python3 -I project.py <season> <outdir> [--sims 10000] [--adjust "OSU:-4,IND:-1"] [--seed 7]

--adjust  rating changes in points, by ESPN abbreviation, for injuries FPI hasn't caught up with
          (a starting QB out, say). Size them to the gap between FPI's implied margin and the
          market line for that team's next game, and say so on the page.

Writes into <outdir>:
  games.json      every FBS game this season, completed or not
  sim.json        per-team results of the simulation, the projected field and bracket
and prints a summary.

Method (keep in step with the Method text on the page):
  game      = home rating - away rating + 2.5 (0 at neutral sites), outcome SD 14
  season    = each simulation redraws every rating with SD 3 (rating uncertainty)
  FCS teams = rating -20
  standings = conference games only; title game = top two by conference win percentage
              (Sun Belt: the two division winners); ties broken by head-to-head among the
              tied teams, then by rating (a stand-in for the leagues' longer tiebreakers)
  title games = neutral site for the Power 4 and the MAC, at the top seed for the others
  committee stand-in (not a model of the committee): resume = wins above a benchmark team
              (the 12th-best FPI rating) against the same schedule and sites, + 0.04 x rating,
              + 0.5 for a Power 4 title; a title-game loss counts half
  field     = the four Power 4 champions, the best-ranked Group of Six team, Notre Dame if it
              ranks in the top 12, then the best remaining teams; seeds by rank, with automatic
              qualifiers outside the top 12 placed at the bottom; seeds 1-4 get byes
  projected = for each Power 4 league the most likely champion; the most likely Group of Six
              team; Notre Dame if it makes the field in more than half the simulations; the
              rest by how often they make the field. Seeded by average simulated rank.
"""
import argparse, json, math, os, sys, urllib.request
from collections import Counter, defaultdict
import numpy as np

HFA, SIGMA, SEASON_SD, FCS_RATING = 2.5, 14.0, 3.0, -20.0
RESUME_RATING_W, P4_TITLE_BONUS, TITLE_LOSS_REFUND = 0.04, 0.5, 0.5
UA = {"User-Agent": "RaidersDaily-cfp/1.0"}
API = "https://site.api.espn.com/apis/site/v2/sports/football/college-football"
P4 = ["ACC", "Big Ten", "Big 12", "SEC"]
G6 = ["American", "CUSA", "MAC", "Mountain West", "Pac-12", "Sun Belt"]
CONF_NAMES = {
    "Atlantic Coast Conference": "ACC", "Big Ten Conference": "Big Ten", "Big 12 Conference": "Big 12",
    "Southeastern Conference": "SEC", "American Conference": "American", "American Athletic Conference": "American",
    "Conference USA": "CUSA", "Mid-American Conference": "MAC", "Mountain West Conference": "Mountain West",
    "Pac-12 Conference": "Pac-12", "Sun Belt Conference": "Sun Belt", "FBS Independents": "Independent",
}
TITLE_GAMES = {  # words in ESPN's note for each title game -> conference
    "ACC Championship": "ACC", "Big Ten Championship": "Big Ten", "Big 12 Championship": "Big 12",
    "SEC Championship": "SEC", "American Championship": "American", "Conference USA Championship": "CUSA",
    "MAC Championship": "MAC", "Mountain West Championship": "Mountain West", "Pac-12 Championship": "Pac-12",
    "Sun Belt Championship": "Sun Belt",
}
NEUTRAL_TITLE = {"ACC", "Big Ten", "Big 12", "SEC", "MAC"}


def get_json(url):
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=40) as r:
        return json.load(r)


def phi(x):
    return 0.5 * (1 + math.erf(x / math.sqrt(2)))


def conf_key(name):
    if name in CONF_NAMES:
        return CONF_NAMES[name], None
    if name.startswith("Sun Belt"):
        return "Sun Belt", name.split("-")[-1].strip()
    return name, None


def load_teams(season):
    d = get_json(f"https://site.api.espn.com/apis/v2/sports/football/college-football/standings?group=80&season={season}")
    teams = {}

    def walk(node):
        if "standings" in node:
            conf, div = conf_key(node.get("name", ""))
            for e in node["standings"]["entries"]:
                t = e["team"]
                st = {s.get("type", s.get("name")): s.get("displayValue") for s in e["stats"]}
                teams[t["id"]] = {
                    "id": t["id"], "abbr": t.get("abbreviation"), "name": t.get("location") or t.get("shortDisplayName"),
                    "short": t.get("shortDisplayName"), "full": t.get("displayName"), "conf": conf, "div": div,
                    "espn_overall": st.get("total"), "espn_conf": st.get("vsconf"),
                }
        for c in node.get("children", []):
            walk(c)
    walk(d)
    return teams


def load_games(season, teams):
    games, seen = [], set()
    for week in range(1, 16):
        d = get_json(f"{API}/scoreboard?groups=80&seasontype=2&week={week}&dates={season}&limit=500")
        for ev in d.get("events", []):
            if ev["id"] in seen:
                continue
            seen.add(ev["id"])
            c = ev["competitions"][0]
            note = " ".join(n.get("headline", "") for n in c.get("notes", []))
            title = next((v for k, v in TITLE_GAMES.items() if k in note), None)
            home = next(x for x in c["competitors"] if x["homeAway"] == "home")
            away = next(x for x in c["competitors"] if x["homeAway"] == "away")
            hid, aid = home["team"].get("id"), away["team"].get("id")
            tbd = bool(title) and not (hid in teams and aid in teams)
            done = c["status"]["type"].get("completed", False)
            g = {
                "id": ev["id"], "week": week, "date": ev["date"], "title": title,
                "home": hid if not tbd else None, "away": aid if not tbd else None,
                "home_name": home["team"].get("shortDisplayName"), "away_name": away["team"].get("shortDisplayName"),
                "neutral": bool(c.get("neutralSite")), "conf_game": bool(c.get("conferenceCompetition")),
                "done": bool(done),
            }
            if done:
                g["home_score"] = int(float(home.get("score") or 0))
                g["away_score"] = int(float(away.get("score") or 0))
                g["home_won"] = bool(home.get("winner")) if "winner" in home else g["home_score"] > g["away_score"]
            if title is None and (hid not in teams and aid not in teams):
                continue  # not an FBS game
            games.append(g)
    return games


def load_fpi(season):
    d = get_json(f"https://site.web.api.espn.com/apis/fitt/v3/sports/football/college-football/powerindex?season={season}&limit=300")
    names = {c["name"]: c["names"] for c in d["categories"]}
    out = {}
    for t in d["teams"]:
        rec = {}
        for c in t["categories"]:
            for k, v in zip(names[c["name"]], c.get("values") or c.get("totals") or []):
                rec[c["name"] + "." + k] = v
        out[t["team"]["id"]] = rec
    return out, d.get("lastUpdated")


def load_polls():
    d = get_json(f"{API}/rankings")
    polls = {}
    for p in d.get("rankings", []):
        nm = p.get("name", "")
        key = "cfp" if "Playoff" in nm else "ap" if nm.startswith("AP") else None
        if not key:
            continue
        polls[key] = {
            "name": nm, "date": p.get("date"), "headline": p.get("headline"),
            "ranks": [{"rank": r["current"], "id": r["team"]["id"], "team": r["team"].get("location"),
                       "record": r.get("recordSummary"), "prev": r.get("previous")} for r in p.get("ranks", [])],
        }
    return polls


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("season", type=int)
    ap.add_argument("outdir")
    ap.add_argument("--sims", type=int, default=10000)
    ap.add_argument("--adjust", default="")
    ap.add_argument("--seed", type=int, default=7)
    a = ap.parse_args()
    os.makedirs(a.outdir, exist_ok=True)
    rng = np.random.default_rng(a.seed)

    teams = load_teams(a.season)
    games = load_games(a.season, teams)
    fpi, fpi_updated = load_fpi(a.season)
    polls = load_polls()
    with open(os.path.join(a.outdir, "games.json"), "w") as f:
        json.dump(games, f, indent=1)

    ids = sorted(teams)
    IDX = {t: i for i, t in enumerate(ids)}
    N = len(ids)
    by_abbr = {teams[t]["abbr"]: t for t in ids}
    base = np.array([float(fpi.get(t, {}).get("fpi.fpi", 0.0) or 0.0) for t in ids])
    adjustments = {}
    for part in filter(None, (p.strip() for p in a.adjust.split(","))):
        ab, v = part.split(":")
        if ab not in by_abbr:
            sys.exit(f"--adjust: unknown abbreviation {ab}")
        base[IDX[by_abbr[ab]]] += float(v)
        adjustments[ab] = float(v)
    bench = float(np.sort(base)[::-1][11])  # the 12th-best rating

    def rating_of(tid):
        return base[IDX[tid]] if tid in IDX else FCS_RATING

    # Completed results and benchmark expectations (regular season and finished title games).
    W = np.zeros(N); L = np.zeros(N); CW = np.zeros(N); CL = np.zeros(N); PB = np.zeros(N)
    rem, title_games = [], {}
    h2h = defaultdict(lambda: [0, 0])  # (a,b) -> [a wins, games] in conference games
    for g in games:
        if g["title"]:
            title_games[g["title"]] = g
            continue
        h, aw = g["home"], g["away"]
        for t, o, sign in ((h, aw, 1), (aw, h, -1)):
            if t in IDX:
                venue = 0 if g["neutral"] else HFA * sign
                PB[IDX[t]] += phi((bench - rating_of(o) + venue) / SIGMA)
        if g["done"]:
            hw = g["home_won"]
            for t, won in ((h, hw), (aw, not hw)):
                if t in IDX:
                    (W if won else L)[IDX[t]] += 1
                    if g["conf_game"]:
                        (CW if won else CL)[IDX[t]] += 1
            if g["conf_game"] and h in IDX and aw in IDX:
                h2h[(h, aw)][0] += hw; h2h[(h, aw)][1] += 1
                h2h[(aw, h)][0] += (not hw); h2h[(aw, h)][1] += 1
        else:
            rem.append(g)

    S = a.sims
    R = base[None, :] + rng.normal(0, SEASON_SD, size=(S, N))
    G = len(rem)
    hi = np.array([IDX.get(g["home"], -1) for g in rem]); ai = np.array([IDX.get(g["away"], -1) for g in rem])
    hr = np.where(hi >= 0, R[:, np.clip(hi, 0, None)], FCS_RATING)
    ar = np.where(ai >= 0, R[:, np.clip(ai, 0, None)], FCS_RATING)
    hfa = np.array([0 if g["neutral"] else HFA for g in rem])
    home_win = (hr - ar + hfa[None, :] + rng.normal(0, SIGMA, size=(S, G))) > 0 if G else np.zeros((S, 0), bool)

    Wsim = np.repeat(W[None, :], S, 0); Lsim = np.repeat(L[None, :], S, 0)
    CWs = np.repeat(CW[None, :], S, 0); CLs = np.repeat(CL[None, :], S, 0)
    for j, g in enumerate(rem):
        hwin = home_win[:, j]
        for idx, won in ((hi[j], hwin), (ai[j], ~hwin)):
            if idx < 0:
                continue
            Wsim[:, idx] += won; Lsim[:, idx] += ~won
            if g["conf_game"]:
                CWs[:, idx] += won; CLs[:, idx] += ~won
    conf_rem = [(j, g) for j, g in enumerate(rem) if g["conf_game"] and g["home"] in IDX and g["away"] in IDX]

    members = defaultdict(list); divs = defaultdict(list)
    for t in ids:
        members[teams[t]["conf"]].append(t)
        if teams[t]["div"]:
            divs[(teams[t]["conf"], teams[t]["div"])].append(t)
    is_g6 = np.array([teams[t]["conf"] in G6 for t in ids])
    nd = by_abbr.get("ND")

    out_conf = defaultdict(Counter); out_tg = defaultdict(Counter); in_field = np.zeros(N); bye = np.zeros(N)
    seed_hist = defaultdict(Counter); rank_sum = np.zeros(N); records = defaultdict(Counter)
    g6_bid = np.zeros(N); champ_pairs = defaultdict(Counter)
    auto_bid = np.zeros(N)

    def order(group, s):
        wp = {t: (CWs[s, IDX[t]] / max(1, CWs[s, IDX[t]] + CLs[s, IDX[t]])) for t in group}
        out = []
        for pct in sorted(set(wp.values()), reverse=True):
            tied = [t for t in group if wp[t] == pct]
            if len(tied) > 1:
                def hh(t):
                    w = gm = 0
                    for o in tied:
                        if o == t:
                            continue
                        w0, g0 = h2h.get((t, o), (0, 0)); w += w0; gm += g0
                        for j, g in conf_rem:
                            if {g["home"], g["away"]} == {t, o}:
                                won = home_win[s, j] if g["home"] == t else not home_win[s, j]
                                w += won; gm += 1
                    return w / gm if gm else 0.5
                tied.sort(key=lambda t: (hh(t), R[s, IDX[t]] + rng.normal(0, 1)), reverse=True)
            out += tied
        return out

    for s in range(S):
        champs, tg_loser = {}, {}
        score_extra = np.zeros(N)
        Wt = Wsim[s].copy(); Lt = Lsim[s].copy(); PBt = PB.copy()
        for conf in P4 + G6:
            tg = title_games.get(conf)
            if tg and tg["home"] in IDX and tg["away"] in IDX:
                pair = [tg["home"], tg["away"]]
            elif conf == "Sun Belt" and divs:
                pair = [order(divs[(conf, d)], s)[0] for d in ("East", "West") if (conf, d) in divs]
            else:
                pair = order(members[conf], s)[:2]
            if len(pair) < 2:
                continue
            top, other = pair
            for t in pair:
                out_tg[t][conf] += 1
            if tg and tg.get("done"):
                won_top = tg["home_won"] if tg["home"] == top else not tg["home_won"]
            else:
                adv = 0 if conf in NEUTRAL_TITLE else HFA
                won_top = R[s, IDX[top]] - R[s, IDX[other]] + adv + rng.normal(0, SIGMA) > 0
            champ, loser = (top, other) if won_top else (other, top)
            champs[conf] = champ; tg_loser[conf] = loser
            Wt[IDX[champ]] += 1; Lt[IDX[loser]] += 1
            for t, o in ((top, other), (other, top)):
                adv = 0 if conf in NEUTRAL_TITLE else (HFA if t == top else -HFA)
                PBt[IDX[t]] += phi((bench - base[IDX[o]] + adv) / SIGMA)
            score_extra[IDX[loser]] += TITLE_LOSS_REFUND
            if conf in P4:
                score_extra[IDX[champ]] += P4_TITLE_BONUS
            out_conf[champ][conf] += 1
            champ_pairs[conf][(champ, loser)] += 1
        score = (Wt - PBt) + RESUME_RATING_W * R[s] + score_extra
        rank_order = np.argsort(-score)
        rank = np.empty(N, int); rank[rank_order] = np.arange(1, N + 1)
        rank_sum += rank
        autos = {IDX[champs[c]] for c in P4 if c in champs}
        g6_top = min((i for i in range(N) if is_g6[i]), key=lambda i: rank[i])
        g6_bid[g6_top] += 1
        autos.add(g6_top)
        if nd in IDX and rank[IDX[nd]] <= 12:
            autos.add(IDX[nd])
        field = set(autos)
        for i in rank_order:
            if len(field) >= 12:
                break
            field.add(int(i))
        inside = sorted([i for i in field if rank[i] <= 12 or i not in autos], key=lambda i: rank[i])
        outside = sorted([i for i in field if i not in inside], key=lambda i: rank[i])
        seeds = inside + outside  # always 12: the automatic bids plus the best others
        for n_, i in enumerate(seeds, 1):
            in_field[i] += 1
            seed_hist[i][n_] += 1
            if n_ <= 4:
                bye[i] += 1
            if i in autos:
                auto_bid[i] += 1
        for i in range(N):
            records[i][f"{int(Wt[i])}-{int(Lt[i])}"] += 1

    # ---------- summarize ----------
    def p(x):
        return round(float(x) / S, 4)

    rows = []
    for t in ids:
        i = IDX[t]
        f = fpi.get(t, {})
        rows.append({
            "id": t, "abbr": teams[t]["abbr"], "team": teams[t]["name"], "conf": teams[t]["conf"],
            "record": f"{int(W[i])}-{int(L[i])}", "conf_record": f"{int(CW[i])}-{int(CL[i])}",
            "fpi": round(float(base[i]), 1), "adjust": adjustments.get(teams[t]["abbr"]),
            "exp_wins": round(float(Wsim[:, i].mean()), 2),
            "modal_record": records[i].most_common(1)[0][0],
            "p_title_game": p(sum(out_tg[t].values())), "p_conf": p(sum(out_conf[t].values())),
            "p_field": p(in_field[i]), "p_bye": p(bye[i]), "p_auto": p(auto_bid[i]),
            "p_g6_bid": p(g6_bid[i]) if is_g6[i] else None,
            "mean_rank": round(float(rank_sum[i]) / S, 2),
            "seed_dist": {str(k): p(v) for k, v in sorted(seed_hist[i].items())},
            "espn_playoff_pct": f.get("fpi.probmakeplayoffs"), "espn_conf_pct": f.get("fpi.probwinconf"),
            "sor_rank": f.get("resume.accomplishmentrank"), "sos_rank": f.get("resume.avgsosrank"),
            "sos_remaining_rank": f.get("resume.sosremainingrank"),
        })
    rows.sort(key=lambda r: -r["p_field"])
    row_of = {r["id"]: r for r in rows}

    # Projected field.
    proj_auto = {}
    for conf in P4:
        cand = [r for r in rows if r["conf"] == conf]
        best = max(cand, key=lambda r: r["p_conf"])
        proj_auto[best["id"]] = f"{conf} champion"
    g6best = max((r for r in rows if r["conf"] in G6), key=lambda r: r["p_g6_bid"] or 0)
    proj_auto[g6best["id"]] = "Highest-ranked Group of Six team"
    if nd and row_of[nd]["p_field"] > 0.5:
        proj_auto[nd] = "Notre Dame, top 12"
    field = list(proj_auto)
    for r in rows:
        if len(field) >= 12:
            break
        if r["id"] not in field:
            field.append(r["id"])
    by_rank = sorted(field, key=lambda t: row_of[t]["mean_rank"])
    top12 = [r["id"] for r in sorted(rows, key=lambda r: r["mean_rank"])][:12]
    inside = [t for t in by_rank if t in top12 or t not in proj_auto]
    outside = [t for t in by_rank if t not in inside]
    seeds = inside + outside
    proj = [{"seed": n, "id": t, "abbr": row_of[t]["abbr"], "team": row_of[t]["team"], "conf": row_of[t]["conf"],
             "bid": proj_auto.get(t, "At-large"), "p_field": row_of[t]["p_field"], "mean_rank": row_of[t]["mean_rank"]}
            for n, t in enumerate(seeds, 1)]
    bubble = [r for r in rows if r["id"] not in field][:8]

    # Remaining games for every team with at least a 1 percent chance.
    sd_game = math.sqrt(SIGMA ** 2 + 2 * SEASON_SD ** 2)
    remaining = {}
    for r in rows:
        if r["p_field"] < 0.01 and r["p_conf"] < 0.05:
            continue
        t = r["id"]; lst = []
        for g in rem:
            if t not in (g["home"], g["away"]):
                continue
            home = g["home"] == t; o = g["away"] if home else g["home"]
            venue = 0 if g["neutral"] else (HFA if home else -HFA)
            m = rating_of(t) - rating_of(o) + venue
            lst.append({"date": g["date"], "where": "vs" if home and not g["neutral"] else "at" if not home and not g["neutral"] else "neutral",
                        "opp": teams[o]["name"] if o in teams else (g["away_name"] if home else g["home_name"]),
                        "opp_abbr": teams[o]["abbr"] if o in teams else None, "margin": round(m, 1),
                        "p_win": round(phi(m / sd_game), 3), "conf_game": g["conf_game"]})
        remaining[r["abbr"]] = lst

    title_pairs = {}
    for conf, cnt in champ_pairs.items():
        pairs = Counter()
        for (c, l), n in cnt.items():
            pairs[tuple(sorted((c, l)))] += n
        top3 = pairs.most_common(3)
        title_pairs[conf] = [{"teams": [teams[x]["name"] for x in pr], "p": p(n)} for pr, n in top3]
    tg_info = {c: {"date": g["date"], "home": teams[g["home"]]["name"] if g["home"] in teams else None,
                   "away": teams[g["away"]]["name"] if g["away"] in teams else None, "done": g["done"]}
               for c, g in title_games.items()}

    sim = {
        "season": a.season, "sims": S, "fpi_updated": fpi_updated, "benchmark_rating": round(bench, 1),
        "adjustments": adjustments,
        "model": {"hfa": HFA, "sigma": SIGMA, "season_sd": SEASON_SD, "fcs_rating": FCS_RATING,
                  "resume_rating_w": RESUME_RATING_W, "p4_title_bonus": P4_TITLE_BONUS, "title_loss_refund": TITLE_LOSS_REFUND},
        "remaining_games": len(rem), "polls": polls, "teams": rows, "projected_field": proj, "bubble": bubble,
        "remaining": remaining, "title_pairs": title_pairs, "title_games": tg_info,
    }
    with open(os.path.join(a.outdir, "sim.json"), "w") as f:
        json.dump(sim, f, indent=1)

    # ---------- print ----------
    print(f"{a.season}: {len(games)} FBS games, {len(rem)} regular-season games left; {S} simulations; benchmark rating {bench:.1f}")
    if adjustments:
        print("adjustments:", adjustments)
    for k in ("cfp", "ap"):
        if k in polls:
            print(f"{polls[k]['name']} ({polls[k]['date'][:10]}): " + ", ".join(f"{x['rank']} {x['team']}" for x in polls[k]["ranks"][:12]))
    print("\nConference races (chance to win the league / reach the title game):")
    for conf in P4 + G6:
        cand = sorted([r for r in rows if r["conf"] == conf], key=lambda r: -r["p_conf"])[:5]
        print(f"  {conf:14} " + "; ".join(f"{r['team']} {r['p_conf']*100:.0f}/{r['p_title_game']*100:.0f}" for r in cand if r["p_conf"] > 0.005))
    print("\nTeam            rec   conf  FPI    expW  modal  P(field) P(bye) P(conf) meanRk  ESPN%")
    for r in rows[:30]:
        print(f"  {r['team'][:14]:14} {r['record']:5} {r['conf_record']:5} {r['fpi']:5.1f} {r['exp_wins']:6.2f} {r['modal_record']:6} "
              f"{r['p_field']*100:7.1f} {r['p_bye']*100:6.1f} {r['p_conf']*100:7.1f} {r['mean_rank']:6.1f}  {r['espn_playoff_pct']}")
    print("\nProjected field:")
    for x in proj:
        print(f"  {x['seed']:2}. {x['team']:16} {x['conf']:14} {x['bid']:34} in {x['p_field']*100:5.1f}%  avg rank {x['mean_rank']}")
    print("First out: " + ", ".join(f"{r['team']} ({r['p_field']*100:.0f}%)" for r in bubble[:4]))


if __name__ == "__main__":
    main()
