#!/usr/bin/env python3
"""bestusavps.com ranking formula (published at /how-we-rank/#formula).

score = 0.75 * Objective + 0.25 * Reputation'
  Objective  = sum(weight_i * input_score_i) with per-page weights (WEIGHTS below), each input 0-10.
  Reputation = 0.25 platform + 0.25 support + 0.20 account risk + 0.15 billing/renewal + 0.15 incidents
  Reputation' (evidence-confidence rule, all providers) = (n*R + K*5) / (n + K), K = 200,
               n = number of platform reviews behind the rating (Trustpilot + G2 where counted).
Eligibility gates (GATES below) run before scoring; ineligible providers are listed with the reason and not ranked.
Plan-level: each entry is one plan (PAGE_PLAN below); CPU type is the scored plan's own (inputs plans.*.cpu).
Order: ranked strictly by total score (unrounded; Reputation' only breaks an exactly equal total).
       Every provider gets its own rank 1, 2, 3, ... No provider-specific adjustments.
Usage: python score.py [inputs.json] [--md out.md] [--json out.json]
"""
import json, math, sys

K_CONF = 200
WEIGHTS = {
 "homepage":      {"price_2gb":.30, "renewal_billing":.15, "us_locations":.20, "features":.15, "ease":.15, "ddos":.05},
 "cheap-under-5": {"price_2gb":.40, "renewal_billing":.25, "us_locations":.15, "traffic":.10, "test_endpoint":.10},
 "wordpress":     {"price_4gb":.30, "features":.20, "us_locations":.15, "renewal_billing":.15, "traffic":.10, "test_endpoint":.10},
 "minecraft":     {"price_4gb":.25, "price_8gb":.15, "dedicated_cpu":.20, "ddos":.20, "us_locations":.10, "traffic":.05, "renewal_billing":.05},
 "forex-trading": {"ny_nj":.35, "price_windows":.30, "dedicated_cpu":.20, "renewal_billing":.15},
 "docker":        {"price_4gb":.25, "features":.30, "dedicated_cpu":.15, "renewal_billing":.15, "test_endpoint":.10, "us_locations":.05},
 "windows":       {"price_windows":.40, "dedicated_cpu":.20, "us_locations":.15, "renewal_billing":.15, "traffic":.10},
 "game-server":   {"price_8gb":.20, "dedicated_cpu":.30, "ddos":.25, "us_locations":.15, "traffic":.05, "renewal_billing":.05},
 "vpn":           {"traffic":.30, "price_2gb":.25, "us_locations":.25, "renewal_billing":.10, "test_endpoint":.10},
 "development":   {"price_4gb":.25, "features":.30, "renewal_billing":.15, "test_endpoint":.10, "dedicated_cpu":.10, "us_locations":.10},
 "seo-tools":     {"us_locations":.30, "price_4gb":.25, "traffic":.15, "dedicated_cpu":.15, "renewal_billing":.15},
}
RENEWAL = {"fixed":10, "locked_promo":8, "intro_published":5, "intro_unpublished":2}
BILLING = {"hourly":10, "monthly":7, "annual":4, "term":4}
NYNJ = {"metro":10, "near":5, "no":0}

def tri(v):            # true / false / "unknown" -> 10 / 0 / 5
    return 5.0 if v == "unknown" else (10.0 if v else 0.0)

def price_score(p, pmin):
    if p is None: return 0.0          # product not sold
    if p == "unknown": return 5.0     # no evidence -> neutral
    return 10.0 * math.sqrt(pmin / p)

PREPAY_MULT = None    # v2: no prepay multiplier; prepaid monthly equivalent scored as-is, drawback via billing flexibility
PREPAY_WEIGHT = None  # None = use listed price as-is (main run)

def effective_price(x, m):
    """Prepay rule: if the cheapest listed price requires prepaying more than one month
    (billing annual/term) and the official month-to-month price is recorded in
    x["monthly_prices"], score  w * prepaid-monthly-equivalent + (1 - w) * month-to-month."""
    p = x[m]
    mp = (x.get("monthly_prices") or {}).get(m)
    # Windows prices are month-to-month for every provider in the inputs, so the prepay rule skips them
    if PREPAY_MULT is not None and m != "price_windows" and x.get("billing") in ("annual", "term") and isinstance(p, (int, float)):
        return p * PREPAY_MULT
    if PREPAY_WEIGHT is None or x.get("billing") not in ("annual", "term") \
            or not isinstance(p, (int, float)) or not isinstance(mp, (int, float)):
        return p
    return PREPAY_WEIGHT * p + (1 - PREPAY_WEIGHT) * mp

WINDOWS_PAGES = ("windows", "forex-trading")  # these pages score the Windows product, so its own billing term applies

PAGE_PLAN = {"homepage": "2gb", "cheap-under-5": "2gb", "vpn": "2gb", "wordpress": "4gb", "docker": "4gb",
             "development": "4gb", "seo-tools": "4gb", "minecraft": "4gb", "game-server": "8gb",
             "windows": "windows", "forex-trading": "windows"}
CPU = {"dedicated": 10.0, "shared": 0.0, "unknown": 5.0}

def plan_of(x, page):
    return (x.get("plans") or {}).get(PAGE_PLAN[page])

def gate(x, page):
    """Return None if eligible, else the public reason. Same rules for every provider."""
    pl = plan_of(x, page)
    if page in ("windows", "forex-trading"):
        if not pl or x.get("price_windows") is None:
            return "no purchasable plan with a licensed Windows Server in our data"
    if not pl:
        return "no plan of the required size in our data"
    if not pl.get("us_purchasable"):
        return "scored plan not orderable in a US location"
    if page == "cheap-under-5":
        p = pl["price_mo"]
        if not isinstance(p, (int, float)) or p > 5.0:
            return f"its 2 GB plan costs ${p:g}/mo, over the $5 limit" if isinstance(p, (int, float)) else "2 GB price not published"
    if page == "forex-trading" and x["ny_nj"] == "no":
        return "no US location in the New York / New Jersey area"
    return None

def input_scores(P, cand, page=None):
    out = {}
    P = {c: dict(P[c], **{m: effective_price(P[c], m) for m in ("price_2gb", "price_4gb", "price_8gb", "price_windows")}) for c in cand}
    for m in ("price_2gb", "price_4gb", "price_8gb", "price_windows"):
        known = [P[c][m] for c in cand if isinstance(P[c][m], (int, float))]
        pmin = min(known)
        for c in cand: out.setdefault(c, {})[m] = price_score(P[c][m], pmin)
    for c in cand:
        x = P[c]; s = out[c]
        s["us_locations"] = 10.0 * min(x["us_locations"], 9) / 9
        s["ny_nj"] = NYNJ[x["ny_nj"]]
        t = x["traffic_tb_2gb"]; s["traffic"] = 10.0 if t == "unmetered" else 10.0 * min(t, 5) / 5
        s["renewal"] = RENEWAL[x["renewal"]]
        bill = x.get("billing_windows", x["billing"]) if page in WINDOWS_PAGES else x["billing"]
        s["renewal_billing"] = (RENEWAL[x["renewal"]] + BILLING[bill]) / 2
        s["features"] = sum(tri(v) / 10 for v in x["features"].values()) / len(x["features"]) * 10
        s["ease"] = 0.4 * tri(x["panel_at_checkout"]) + 0.4 * tri(x["human_os_support"]) + 0.2 * tri(x["features"]["one_click"])
        s["ddos"] = tri(x["ddos"]); pl = plan_of(x, page or "homepage"); s["dedicated_cpu"] = CPU[pl["cpu"]] if pl else 0.0; s["test_endpoint"] = tri(x["public_test_endpoint"])
    return out

def reputation(x):
    rat = [(r - 1) / 4 * 10 for r in (x["trustpilot"], x["g2"]) if isinstance(r, (int, float))]
    platform = sum(rat) / len(rat) if rat else 5.0
    r = x["rep"]
    R = .25 * platform + .25 * r["support"] + .20 * r["account_risk"] + .15 * r["billing"] + .15 * r["incidents"]
    # n counts reviews only for ratings actually used; an unrecorded count means no adjustment (flagged)
    tp_n = x["trustpilot_n"] if isinstance(x["trustpilot_n"], (int, float)) else None
    g2_n = x["g2_n"] if (isinstance(x["g2"], (int, float)) and isinstance(x["g2_n"], (int, float))) else 0
    n = None if tp_n is None else tp_n + g2_n
    Rc = R if n is None else (n * R + K_CONF * 5) / (n + K_CONF)
    return platform, R, Rc, n

def rank(P, page, cand):
    excluded = [dict(id=c, name=P[c]["name"], reason=gate(P[c], page)) for c in cand if gate(P[c], page)]
    cand = [c for c in cand if not gate(P[c], page)]
    W = WEIGHTS[page]; S = input_scores(P, cand, page); rows = []
    for c in cand:
        O = sum(w * S[c][k] for k, w in W.items())
        platform, R, Rc, n = reputation(P[c])
        rows.append(dict(id=c, name=P[c]["name"], objective=O, platform=platform, rep_raw=R, rep=Rc, n=n,
                         total=.75 * O + .25 * Rc, inputs={k: round(S[c][k], 2) for k in W},
                         plan=dict(plan_of(P[c], page), key=PAGE_PLAN[page])))
    # Ranked strictly by unrounded total score; no shared ranks.
    rows.sort(key=lambda r: (-r["total"], -r["rep"]))
    for i, r in enumerate(rows, 1): r["rank"] = i
    EXCLUDED[page if cand != HOMEPAGE_SET else page + "_5"] = excluded
    return rows

EXCLUDED = {}


HOMEPAGE_SET = ["interserver", "digitalocean", "kamatera", "vultr", "upcloud"]

def main():
    global PREPAY_WEIGHT, PREPAY_MULT
    a = sys.argv[1:]
    if "--prepay-mult" in a: PREPAY_MULT = float(a[a.index("--prepay-mult") + 1])
    if "--no-prepay-rule" in a: PREPAY_MULT = None
    if "--prepay-weight" in a: PREPAY_WEIGHT = float(a[a.index("--prepay-weight") + 1])
    path = a[0] if a and not a[0].startswith("--") else "inputs.json"
    P = json.load(open(path, encoding="utf-8"))["providers"]
    allc = list(P)
    res = {"homepage_5": rank(P, "homepage", HOMEPAGE_SET), "homepage_all10": rank(P, "homepage", allc)}
    for page in WEIGHTS:
        if page != "homepage": res[page] = rank(P, page, allc)
    import awards; res["awards"] = awards.compute(P)
    res["excluded"] = EXCLUDED; res["gates"] = json.load(open(path, encoding="utf-8"))["meta"]["gates"]
    lines = []
    for k, rows in res.items():
        if not isinstance(rows, list) or k == "awards": continue
        lines.append(f"\n## {k}\n| # | provider | objective | reputation' (raw, n) | score |\n|---|---|---|---|---|")
        for r in rows:
            lines.append(f"| {r['rank']} | {r['name']} | {r['objective']:.2f} | {r['rep']:.2f} ({r['rep_raw']:.2f}, n={r['n']}) | {r['total']:.2f} |")
    print("\n".join(lines))
    if "--md" in a: open(a[a.index("--md") + 1], "w", encoding="utf-8").write("\n".join(lines))
    if "--json" in a: json.dump(res, open(a[a.index("--json") + 1], "w", encoding="utf-8"), indent=1)

if __name__ == "__main__":
    main()
