"""
howtoturnpro.com — UCI ProTeam (second division) rider salary estimation, model v0.1 (12 Sep 2026)

Same machinery as the WorldTour model v0.4 (salary_model.py), applied to the 16 ProTeams with:
- ProTeam budgets (journalistic estimates; far less reported than WorldTour budgets, see BUDGET_SRC)
- the same budget-dependent rider-wage share, clipped at 36% for budgets under EUR 14M
- floors built on the UCI ProTeam minimums (employed EUR 35,392; self-employed EUR 58,043), scaled by budget
- the 50/50 team / tier-wide blend is run within the ProTeam tier, not against WorldTeams
- two reported salaries used as anchors (Pidcock, Alaphilippe)
- for the smallest teams the floors alone can exceed budget x share; the pool is then raised to the floors + 5%
- Team Novo Nordisk (mission-funded, budget not spent on results) gets a pool of floors + 25%
Riders under 18 never get a salary figure.
"""
import pandas as pd, numpy as np, json

ROSTER = '/home/claude/pelotonmarkt/data/proteam_roster_2026.tsv'
OUT = '/home/claude/pelotonmarkt/data/prt_salary_estimates_v01.csv'

# --- team budgets 2026 (EUR M) ---------------------------------------------------------------
BUDGET = {
 'Tudor Pro Cycling Team': 25, 'Cofidis': 22, 'Pinarello Q36.5 Pro Cycling Team': 19, 'TotalEnergies': 14,
 'Unibet Rose Rockets': 8, 'Team Novo Nordisk': 7, 'Team Flanders - Baloise': 4, 'Caja Rural - Seguros RGA': 5,
 'Modern Adventure Pro Cycling': 5, 'Team Polti VisitMalta': 5, 'Bardiani CSF 7 Saber': 4.5, 'Euskaltel - Euskadi': 4.5,
 'Equipo Kern Pharma': 4, 'MBH Bank CSB Telecom Fort': 4, 'Burgos Burpellet BH': 3.5, 'Solution Tech NIPPO Rali': 2.5,
}
BUDGET_RANGE = {
 'Tudor Pro Cycling Team': (22, 30), 'Cofidis': (19, 25), 'Pinarello Q36.5 Pro Cycling Team': (15, 22), 'TotalEnergies': (12, 16),
 'Unibet Rose Rockets': (6, 10), 'Team Novo Nordisk': (5, 9), 'Team Flanders - Baloise': (3.5, 5), 'Caja Rural - Seguros RGA': (4, 6),
 'Modern Adventure Pro Cycling': (4, 7), 'Team Polti VisitMalta': (4, 6), 'Bardiani CSF 7 Saber': (3.5, 5.5), 'Euskaltel - Euskadi': (4, 5.5),
 'Equipo Kern Pharma': (3.5, 5), 'MBH Bank CSB Telecom Fort': (3, 5), 'Burgos Burpellet BH': (3, 4.5), 'Solution Tech NIPPO Rali': (2, 3.5),
}
def wage_share(budget_M):
    return max(0.36, min(0.55, 0.37 + (budget_M - 14) / (55 - 14) * (0.50 - 0.37)))
ALPHA = 1.15
UCI_MIN_EMPLOYED = 35_392      # ProTeam employed minimum 2026 (UCI/CPA joint agreement)
UCI_MIN_SELF = 58_043          # ProTeam self-employed minimum 2026
NEO_MIN = 30_000               # our floor for first contracts (UCI neo-pro ProTeam minimum is lower still)

# Mission-funded teams whose budget is not spent on buying results: the wage pool is floors x 1.25 instead of budget x share.
NON_COMPETITIVE = {'Team Novo Nordisk'}
ANCHORS = {'PIDCOCK Tom': 4_000_000, 'ALAPHILIPPE Julian': 2_000_000}
ANCHOR_SRC = 'Gazzetta dello Sport Jan 2026 / press reports on the Q36.5 (2024) and Tudor (2025) moves'

NAT = {'Italy':'IT','Spain':'ES','France':'FR','Belgium':'BE','Netherlands':'NL','Germany':'DE','Great Britain':'GB','Switzerland':'CH',
 'Austria':'AT','Luxembourg':'LU','Poland':'PL','Czech Republic':'CZ','Slovakia':'SK','Slovenia':'SI','Hungary':'HU','Serbia':'RS','Ukraine':'UA',
 'Lithuania':'LT','Latvia':'LV','Sweden':'SE','Denmark':'DK','Norway':'NO','Ireland':'IE','Portugal':'PT','Greece':'GR','Malta':'MT',
 'United States':'US','Canada':'CA','Australia':'AU','New Zealand':'NZ','South Africa':'ZA','Eritrea':'ER','Mauritius':'MU','Japan':'JP',
 'Taiwan':'TW','Uzbekistan':'UZ','Mongolia':'MN','Colombia':'CO','Mexico':'MX','Chile':'CL','Uruguay':'UY','Argentina':'AR','Venezuela':'VE',
 'Guatemala':'GT','Panama':'PA'}

df = pd.read_csv(ROSTER, sep='\t')
missing = sorted(set(df['nat']) - set(NAT)); assert not missing, missing
df['Team'] = df['teamtitle']; df['Rider'] = df['name']; df['Nat'] = df['nat'].map(NAT)
df['Age'] = df['age']; df['PCS rank 2026'] = np.nan
df['PCS points 2026'] = pd.to_numeric(df['pts26'], errors='coerce').fillna(0).astype(int)
df['Career PCS points'] = pd.to_numeric(df['career'], errors='coerce').fillna(0).astype(int)
df['Specialty'] = df['spec'].fillna('')
df['Contract until (PCS)'] = pd.to_numeric(df['until'], errors='coerce')
df['Status'] = np.where(df['Contract until (PCS)'] <= 2026, 'No contract for 2027', 'Under contract')
df['Next team (2027)'] = np.nan; df['Next contract until'] = np.nan; df['Note / verification'] = np.nan; df['Source'] = np.nan
df['first_season'] = pd.to_numeric(df['first'], errors='coerce')
df['slug'] = df['slug']

df['pts'] = df['PCS points 2026']; df['career'] = df['Career PCS points']; df['age'] = df['Age']
df['seasons'] = (df['age'] - 19).clip(lower=1)
df['career_py'] = df['career'] / df['seasons']
df['VP'] = 0.6 * df['pts'] + 0.4 * df['career_py']
def age_factor(a):
    if a < 23: return 0.75
    if a <= 25: return 0.9
    if a <= 32: return 1.0
    if a <= 35: return 0.9
    return 0.8
df['age_f'] = df['age'].apply(age_factor)
df['in_pool'] = df['age'] >= 18          # no money figures for minors, ever
df['w'] = np.where(df['in_pool'], (df['VP'].clip(lower=5)) ** ALPHA * df['age_f'], 0)
df['anchor'] = df['Rider'].map(ANCHORS)

AVG_BUDGET = sum(BUDGET.values()) / len(BUDGET)
def floor_for(row):
    # ProTeam floors: what a second-division roster place costs. Small Spanish/Italian teams pay at or near the
    # UCI minimum; Tudor/Q36.5/Cofidis pay WorldTour-like money for the same job. Scaled by (budget / avg)^0.5, clipped 0.6-1.6.
    if not row['in_pool']: return 0
    bf = min(1.6, max(0.6, (BUDGET[row['Team']] / AVG_BUDGET) ** 0.5))
    seasons = row['age'] - 19
    if row['age'] < 23:                          base = 30_000
    elif seasons >= 5 and row['career'] >= 1500: base = 70_000
    elif seasons >= 5 or row['career'] >= 800:   base = 50_000
    else:                                        base = 36_000
    f = round(base * bf, -3)
    return max(f, NEO_MIN if row['age'] < 23 else UCI_MIN_EMPLOYED)
df['floor'] = df.apply(floor_for, axis=1)
df['salary_est'] = 0.0

POOL = {}
for team, g in df.groupby('Team'):
    pool = BUDGET[team] * 1e6 * wage_share(BUDGET[team])
    fl = g.loc[g['in_pool'] & g['anchor'].isna(), 'floor'].sum() + g.loc[g['anchor'].notna(), 'anchor'].sum()
    pool = max(pool, 1.05 * fl)
    if team in NON_COMPETITIVE: pool = 1.25 * fl
    POOL[team] = pool
    idx = g.index
    anchored = g['anchor'].notna() & g['in_pool']
    pool_left = pool - g.loc[anchored, 'anchor'].sum()
    free = g['in_pool'] & ~anchored
    sal = pd.Series(0.0, index=idx); sal[anchored] = g.loc[anchored, 'anchor']
    remaining = free.copy(); budget_left = pool_left
    for _ in range(12):
        wsum = g.loc[remaining, 'w'].sum()
        if wsum <= 0: break
        alloc = g.loc[remaining, 'w'] / wsum * budget_left
        below = alloc < g.loc[remaining, 'floor']
        if not below.any():
            sal[remaining] = alloc; break
        fl_idx = alloc[below].index
        sal[fl_idx] = g.loc[fl_idx, 'floor']
        budget_left -= g.loc[fl_idx, 'floor'].sum()
        remaining = remaining & ~g.index.isin(fl_idx)
    df.loc[idx, 'salary_est'] = sal.round(-3)
df['salary_team_alloc'] = df['salary_est']

# tier-wide blend (within ProTeams)
free_all = df['in_pool'] & df['anchor'].isna()
total_pool = sum(POOL.values()) - df.loc[df['anchor'].notna() & df['in_pool'], 'anchor'].sum()
df['w_global'] = np.where(free_all, df['w'] * (df['Team'].map(BUDGET) / AVG_BUDGET) ** 0.5, 0)
df['salary_global_alloc'] = np.where(free_all, df['w_global'] / df['w_global'].sum() * total_pool, df['salary_est'])
df['salary_est'] = np.where(free_all, 0.5 * df['salary_team_alloc'] + 0.5 * df['salary_global_alloc'], df['salary_est'])
df['salary_est'] = np.maximum(df['salary_est'], np.where(df['in_pool'], df['floor'], 0))
young_cap = free_all & (df['age'] < 23)
df.loc[young_cap, 'salary_est'] = df.loc[young_cap, 'salary_est'].clip(upper=1_000_000)

# team books balance
for team, g in df.groupby('Team'):
    pool = POOL[team]
    anch = g['anchor'].notna() & g['in_pool']; free = g['in_pool'] & ~anch
    target = pool - g.loc[anch, 'anchor'].sum()
    fixed = pd.Series(False, index=g.index)
    for _ in range(8):
        movable = free & ~fixed
        base = df.loc[(fixed & free)[fixed & free].index, 'salary_est'].sum() if fixed.any() else 0.0
        cur = df.loc[movable[movable].index, 'salary_est'].sum()
        if cur <= 0: break
        scale = (target - base) / cur
        newv = df.loc[movable[movable].index, 'salary_est'] * scale
        below = newv < df.loc[newv.index, 'floor']
        df.loc[newv.index, 'salary_est'] = np.where(below, df.loc[newv.index, 'floor'], newv)
        if not below.any(): break
        fixed.loc[newv.index[below]] = True
    yc = free & (g['age'] < 23)
    df.loc[yc[yc].index, 'salary_est'] = df.loc[yc[yc].index, 'salary_est'].clip(upper=1_000_000)
df['salary_est'] = df['salary_est'].round(-3)
df['salary_est_k'] = (df['salary_est'] / 1000).round(0)
df['is_anchor'] = df['anchor'].notna()
df['budget_est_M'] = df['Team'].map(BUDGET)
df['pool_M'] = (df['Team'].map(POOL) / 1e6).round(2)
df['pts_per_100k'] = np.where(df['salary_est'] > 0, df['pts'] / (df['salary_est'] / 1e5), np.nan).round(1)

p = df[df['in_pool']]
stats = {'riders_in_pool': int(len(p)), 'total_wage_M': round(p['salary_est'].sum()/1e6, 1), 'mean_k': round(p['salary_est'].mean()/1e3),
         'median_k': round(p['salary_est'].median()/1e3), 'over_500k': int((p['salary_est'] >= 5e5).sum()), 'over_1M': int((p['salary_est'] >= 1e6).sum()),
         'at_floor': int((p['salary_est'] <= p['floor']).sum()), 'under_50k': int((p['salary_est'] < 5e4).sum())}
if __name__ == '__main__':
    print(json.dumps(stats, indent=1))
    print(p.sort_values('salary_est', ascending=False)[['Rider','Team','pts','VP','salary_est_k','is_anchor']].head(30).to_string())
    print('\nTeam totals (M) vs pool:')
    tt = (p.groupby('Team')['salary_est'].sum()/1e6).round(2)
    for t in tt.sort_values(ascending=False).index: print(f'{t:36s} {tt[t]:6.2f}  pool {POOL[t]/1e6:5.2f}  budget {BUDGET[t]:5.1f}  median {p[p.Team==t].salary_est.median()/1e3:5.0f}k')
    df.to_csv(OUT, index=False); print('saved', OUT)
