"""
howtoturnpro — WorldTour salary model v0.8 "pay bands" (16 Sep 2026).
Module name kept at _v07 so the import graph does not churn; the version of record is v0.8.

What changed from v0.7, all of it correctness rather than a better fit:
  a. experience is counted from the rider's own season record, not (age - 19);
  b. the Neo band requires at most two professional seasons at signing, so 80 riders left it;
  c. the price year can no longer use results that had not been raced when the deal was announced;
  d. the win/podium term enters raw - the points-per-top-three scaling it replaced was algebraically
     degenerate and only suppressed the win signal.
Measured against the 28 reported salaries, each of these changes nothing (24.8% median, 15 within 25%,
same as v0.7). That is expected: they move the lower two thirds of the peloton, where there are almost
no reported salaries to check against. They are in because they are right, not because they score.

Sergio's rule for riders without a reported salary:
  1. a pay BAND for the job (Benson May 2025 / Cyclingnews 2025-26 bands), scaled by the team's budget — a smaller team pays
     less for the same job;
  2. the position inside the band comes from results in the 1-2 seasons BEFORE the contract was signed, and a signature
     result (a WorldTour win, a Grand Tour top-10, a Worlds medal) pushes to the top of the band;
  3. neo-pros: results in the season before the first contract (U23 stage races, Worlds/Euro U23) — a tagged prospect
     sits in the upper half of the neo band.
Tiers (T1..T4, Neo) come from v0.6 (team hierarchy at signing). Reported salaries (anchors) stay fixed.
The team wage pool (budget x wage share) is only a CHECK: if a team's modelled total is more than 15% away from its pool,
the unanchored riders are scaled to the edge of that corridor and the page says so.
Market value 2026 = the same bands on today's tier and results, no anchors.
"""
import os, sys, json
import pandas as pd, numpy as np
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, f'{ROOT}/model')
import salary_model_v06 as V6

# bands in EUR for a ~EUR 45M team (base factor 1.0): (low, high)
BANDS = {
    'T1':   (2_000_000, 5_000_000),   # proven star (>= 1200 VP at signing)
    'T1u':  (1_200_000, 2_500_000),   # the leader of a smaller team, or a leader bought on promise
    'T2':   (  800_000, 2_500_000),
    'T3':   (  450_000, 1_200_000),
    'T4':   (  200_000,   500_000),
    'T4b':  (  130_000,   320_000),   # second contract: 2-4 pro seasons at signing, age <= 25
    'Neo':  (   80_000,   300_000),
}
# how much of the team factor applies: a star costs what the market says x the team's means, a domestique's job pays
# about the same everywhere (Benson's bands are league-wide)
FACTOR_POWER = {'T1': 1.0, 'T1u': 1.0, 'T2': 1.0, 'T3': 0.6, 'T4': 0.35, 'T4b': 0.35, 'Neo': 0.35}
ROLE_MULT = {'GC leader': 1.10, 'Sprinter': 1.05, 'Classics leader': 1.05}

def team_factor(budget_M):
    """A EUR 20M team pays ~0.65x, a EUR 28M team ~0.78x, EUR 45M = 1.0, the EUR 55-60M teams ~1.15x for the same job."""
    return float(np.clip((budget_M / 45.0) ** 0.6, 0.65, 1.15))

def prepare():
    g = V6.base_frame(); df, BUDGET, wage_share = g['df'], g['BUDGET'], g['wage_share']
    df['slug'] = df['Rider'].map(V6.PCS2SLUG)
    p = df[df['in_pool']].copy()
    p = p.join(V6.SIGN[['price_year', 'age_at_signing', 'vp_at_signing', 'vp_now', 'contract_len', 'price_year_src', 'season_frac']], on='slug')
    p = p.join(V6.ROLES[['role_primary', 'role_secondary']].rename(columns={'role_primary': 'role', 'role_secondary': 'sec'}), on='slug')
    p['role'] = p['role'].fillna('Domestique'); p['price_year'] = p['price_year'].fillna(2025).astype(int)
    p['vp_at_signing'] = p['vp_at_signing'].fillna(p['VP']); p['vp_now'] = p['vp_now'].fillna(p['VP'])
    p['age_sign'] = p['age_at_signing'].fillna(p['age']); p['contract_len'] = p['contract_len'].fillna(3)
    # how much of the signing season had actually been raced when the deal was announced. 1.0 means the
    # season was over (or we have no announcement date); anything below it flags an estimate built on a
    # part-season, which is the weakest assumption in the model and has to be auditable from the output.
    p['season_frac'] = p['season_frac'].fillna(1.0) if 'season_frac' in p.columns else 1.0
    # v0.8: how many professional seasons he had behind him WHEN THE DEAL WAS PRICED. Separate from
    # age and from WorldTour seasons: a rider can be new to the WorldTour and far from a beginner.
    p['pro_sign'] = [max(0, int(V6.MODEL.experience(n, int(y))[0] or 0)) for n, y in zip(p['Rider'], p['price_year'])]
    res = p.apply(lambda r: V6.results_upto(r['slug'], r['price_year']), axis=1, result_type='expand'); res.columns = ['win_sign', 'pod_sign', 'wt_sign', '_vp']
    p = pd.concat([p, res], axis=1)
    now = p.apply(lambda r: V6.results_upto(r['slug'], 2026), axis=1, result_type='expand'); now.columns = ['win_now', 'pod_now', 'wt_now', '_vpn']
    p = pd.concat([p, now], axis=1)
    return p, BUDGET, wage_share

def band_key(tier, proven, wt_sign, age_sign):
    if tier == 'T1' and not proven: return 'T1u'
    if tier == 'T4' and 2 <= wt_sign <= 4 and age_sign <= 25: return 'T4b'
    return tier

def price(p, tiers, proven, prospect, vp_col, win_col, pod_col, age_col, wt_col, BUDGET, contract=True):
    """Band position for every rider. q in [0,1] = 0.6 x results percentile inside the tier + 0.4 x wins/podiums percentile,
    +0.2 for a signature result, prospects >= 0.6. Salary = low + q x (high - low), x team factor, x role, x age."""
    key = pd.Series([band_key(tiers[i], bool(proven[i]), p.at[i, wt_col], p.at[i, age_col]) for i in p.index], index=p.index)
    q = pd.Series(0.5, index=p.index)
    for k, g in p.assign(_k=key).groupby('_k'):
        if len(g) < 2: continue
        r1 = g[vp_col].rank(pct=True)
        # v0.8: the win/podium count enters raw. An earlier version scaled it by points-per-top-three
        # to tell a 1.UWT win from a 2.1 win, but that scaling is algebraically degenerate: with
        # n = 3*wins + podiums, n * (vp/n) / median(vp/n) = vp / median(vp/n), so the second term
        # collapsed back onto the first and merely suppressed the win signal. Measured Spearman
        # between the two ranks was 0.945 / 0.929 / 0.909 in T1 / T1u / T2. Telling race levels apart
        # needs per-race class data, not a rearrangement of the same points total.
        r2 = (g[win_col] * 3 + g[pod_col]).rank(pct=True)
        q[g.index] = 0.6 * r1 + 0.4 * r2
    sig = (p[win_col] >= 3) | (p[pod_col] >= 6)          # a run of wins / podiums in the pricing window
    q = (q + 0.2 * sig).clip(0, 1)
    q[prospect] = q[prospect].clip(lower=0.6)
    lo = key.map(lambda k: BANDS[k][0]).astype(float); hi = key.map(lambda k: BANDS[k][1]).astype(float)
    sal = lo + q * (hi - lo)
    mult = pd.Series([team_factor(BUDGET[p.at[i, 'Team']]) ** FACTOR_POWER[key[i]] for i in p.index], index=p.index)
    mult *= p['role'].map(lambda r: ROLE_MULT.get(r, 1.0))
    a = p[age_col]
    mult[a >= 35] *= 0.85
    if contract:
        mult[(a <= 24) & (p['contract_len'] >= 4) & (tiers != 'T1')] *= 0.9    # long first deals are cheap per year
    sal = (sal * mult).clip(lower=p['floor'])
    bounds = pd.DataFrame({'band_lo': (lo * mult).round(-3), 'band_hi': (hi * mult).round(-3)}, index=p.index)
    return sal, key, q, bounds

def team_check(p, sal, anchor, BUDGET, wage_share, corridor=0.15):
    """Anchors fixed. If (anchors + modelled) is outside pool x (1 +/- corridor), scale the modelled riders to the edge."""
    out = sal.copy(); note = {}
    for team, g in p.groupby('Team'):
        pool = BUDGET[team] * 1e6 * wage_share(BUDGET[team])
        anch = anchor[g.index].notna()
        out[g.index[anch]] = anchor[g.index[anch]]
        free = g.index[~anch]
        tot = out[g.index].sum()
        ratio = tot / pool
        k = 1.0
        if ratio > 1 + corridor: k = (pool * (1 + corridor) - out[g.index[anch]].sum()) / out[free].sum()
        elif ratio < 1 - corridor: k = (pool * (1 - corridor) - out[g.index[anch]].sum()) / out[free].sum()
        if k != 1.0 and k > 0:
            out[free] = (out[free] * k).clip(lower=p.loc[free, 'floor'])
        note[team] = {'pool_M': round(pool / 1e6, 1), 'model_M': round(tot / 1e6, 1), 'ratio': round(ratio, 2), 'scale': round(k, 2)}
    return out, note

def run(exclude=None, verbose=False, corridor=0.15):
    p, BUDGET, wage_share = prepare()
    anchor = p['anchor'].copy()
    if exclude is not None: anchor[p['Rider'] == exclude] = np.nan
    tiers, proven, prospect = V6.assign_tiers(p, 'price_year', 'vp_at_signing')
    sal, key, q, bounds = price(p, tiers, proven, prospect, 'vp_at_signing', 'win_sign', 'pod_sign', 'age_sign', 'wt_sign', BUDGET, contract=True)
    p['band_lo'] = bounds['band_lo']; p['band_hi'] = bounds['band_hi']
    p['tier'] = tiers; p['tier_proven'] = proven; p['prospect'] = prospect; p['band'] = key; p['band_pos'] = q.round(2); p['band_salary'] = sal.round(-3)
    final, note = team_check(p, sal, anchor, BUDGET, wage_share, corridor)
    p['salary_est'] = final.round(-3)
    p['team_scale'] = p['Team'].map(lambda t: note[t]['scale'])
    # market value: today's tier and results, no anchors, no contract discounts
    p2 = p.copy(); p2['win_sign'] = p['win_now']; p2['pod_sign'] = p['pod_now']; p2['wt_sign'] = p['wt_now']; p2['age_sign'] = p['age']; p2['pro_sign'] = p['pro_seasons']
    t2, pr2, pp2 = V6.assign_tiers(p2, 'x', 'vp_now')
    mv, _, _, _ = price(p2, t2, pr2, pp2, 'vp_now', 'win_now', 'pod_now', 'age', 'wt_now', BUDGET, contract=False)
    p['tier_now'] = t2; p['market_value'] = mv.round(-3)
    p['is_anchor'] = anchor.notna()
    if verbose:
        print('riders', len(p), 'wage M', round(p['salary_est'].sum() / 1e6, 1), 'median k', round(p['salary_est'].median() / 1e3), 'mean k', round(p['salary_est'].mean() / 1e3),
              'over 1M', int((p['salary_est'] >= 1e6).sum()))
        print(pd.DataFrame(note).T.to_string())
    return p, note

def loo(quiet=True):
    base, note = run()
    rows = []
    # v0.9: press estimates anchor the model but stay out of this test. Leave-one-out measures how
    # far the model is from a REPORTED salary; a journalist's estimate is another model, so scoring
    # against it would measure how well two models agree, not how accurate ours is.
    _press = set()
    _pap = f'{ROOT}/data/press_anchors.csv'
    if os.path.exists(_pap):
        _press = set(pd.read_csv(_pap, sep=';')['pcs_name'])
    for _, a in base[base['is_anchor'] & ~base['Rider'].isin(_press)].iterrows():
        d, _ = run(exclude=a['Rider'])
        r = d[d['Rider'] == a['Rider']].iloc[0]
        rows.append({'rider': a['Rider'], 'team': a['Team'], 'tier': r['tier'], 'band': r['band'], 'pos': r['band_pos'], 'price_year': int(r['price_year']),
                     'reported': int(a['anchor']), 'model_loo': int(r['salary_est']), 'diff_pct': round((r['salary_est'] - a['anchor']) / a['anchor'] * 100, 1)})
    v = pd.DataFrame(rows).sort_values('reported', ascending=False); ab = v['diff_pct'].abs()
    stats = {'n': len(v), 'median_abs': round(ab.median(), 1), 'mean_abs': round(ab.mean(), 1), 'median_diff': round(v['diff_pct'].median(), 1),
             'within25': int((ab <= 25).sum()), 'within40': int((ab <= 40).sum()), 'over50': int((ab > 50).sum()), 'under': int((v['diff_pct'] < 0).sum()),
             'median_k': round(base['salary_est'].median() / 1e3), 'mean_k': round(base['salary_est'].mean() / 1e3), 'total_M': round(base['salary_est'].sum() / 1e6, 1)}
    if not quiet: print(v.to_string(index=False)); print(stats)
    return v, stats, base, note

if __name__ == '__main__':
    v, st, base, note = loo(quiet=False)
    print(pd.DataFrame(note).T.to_string())
    v.to_csv(f'{ROOT}/data/validation_loo_v07.csv', index=False)
    # keep every column the site generator expects from v0.4, plus the v0.7 ones
    v04 = ['Team', 'Rider', 'Nat', 'Age', 'PCS rank 2026', 'PCS points 2026', 'Career PCS points', 'Specialty',
           'Contract until (PCS)', 'Status', 'Next team (2027)', 'Next contract until', 'Note / verification', 'Source',
           'pts', 'career', 'age', 'seasons', 'career_py', 'VP', 'age_f', 'in_pool', 'anchor', 'floor', 'salary_est', 'is_anchor']
    extra = ['role', 'tier', 'tier_proven', 'prospect', 'band', 'band_pos', 'price_year', 'price_year_src', 'season_frac', 'age_sign',
             'vp_at_signing', 'vp_now', 'contract_len', 'win_sign', 'pod_sign', 'band_lo', 'band_hi', 'band_salary', 'team_scale', 'tier_now', 'market_value']
    # keep every rider the site had under v0.4, including the ones the model does not price
    # (stagiaires, riders who retired mid-season). They still get a page saying "no salary estimate";
    # dropping them silently deleted 10 URLs that were already in the sitemap.
    full = V6.base_frame()['df'].copy()
    full['slug'] = full['Rider'].map(V6.PCS2SLUG)
    priced = base.drop(columns=[c for c in ('Team',) if c in base.columns])
    out = full.merge(priced, on='Rider', how='left', suffixes=('', '_v07'))
    for c in list(out.columns):
        if c.endswith('_v07') and c[:-4] in out.columns:
            out[c[:-4]] = out[c[:-4]].where(out[c].isna(), out[c])
            out = out.drop(columns=[c])
    out['is_anchor'] = out['is_anchor'].fillna(False).astype(bool)
    out['salary_est_k'] = (out['salary_est'] / 1000).round()
    out['pts_per_100k'] = (out['pts'] / (out['salary_est'] / 100_000)).round(1)
    out['budget_est_M'] = out['Team'].map(lambda t: V6.base_frame()['BUDGET'][t]) if False else np.nan
    cols = [c for c in v04 + extra + ['salary_est_k', 'pts_per_100k'] if c in out.columns]
    out.sort_values(['Team', 'salary_est'], ascending=[True, False])[cols].to_csv(f'{ROOT}/data/wt_salary_estimates_v07.csv', index=False)
    print('wrote', len(out), 'riders,', len(cols), 'columns')
