defmodule TradeMachine.GMGradeV2.Pillars.ContractHealth.Shared do @moduledoc """ Shared contract-season normalization and salary weighting for Contract Health. Contract Health consumes a frozen final-roster contract snapshot rather than querying contracts or consulting the current league season. The snapshot may carry persisted contract rows under `:seasons` or the cap engine's scenario-adjusted rows under `:entries`; both are normalized to: %{ season: "2026-27", salary: 12_000_000, guaranteed: 6_000_000, guaranteed_pct: 0.5, option_type: "team" } Callers must pass only the snapshot's remaining-season window. Projection entries are additionally restricted to active contract statuses, which keeps cap holds, declined options, and empty projection cells out of this pillar. `discounted_remaining_salary/1` uses `TradeMachine.GMGradeV2.ModelVersion.contract_season_weights/0` in season order. The final weight repeats if a future CBA permits a contract longer than the versioned five-season window: AVC defines that band as "four or more seasons ahead", not as an unweighted sixth season. Guarantee percentage is `guaranteed / salary` from the frozen `ContractSeason` integer fields. Missing guarantee data stays `nil`; it is not silently converted to either fully guaranteed or non-guaranteed. The one projection exception is a scenario signing: the cap engine creates ordinary re-signing and external-signing years as fully guaranteed but omits that value from its payroll projection, so normalization restores salary as guaranteed dollars. Option type remains intact so player/team option rules still take precedence in each metric. Unsigned drafted-rookie rights are explicitly not scenario signings and are excluded from Contract Health. Team options and non-guaranteed seasons remain in the normalized list so each metric can apply its own AVC treatment. """ alias TradeMachine.GMGradeV2.ExpectedSalary alias TradeMachine.GMGradeV2.ModelVersion alias TradeMachine.NBA.Contract alias TradeMachine.NBA.ContractSeason alias TradeMachine.NBA.Player @active_projection_statuses [:contracted, :re_signed, :overridden] @active_projection_status_strings Enum.map(@active_projection_statuses, &Atom.to_string/1) @type normalized_season :: %{ season: String.t(), salary: non_neg_integer(), guaranteed: non_neg_integer() | nil, guaranteed_pct: float() | nil, option_type: String.t() | nil } @doc """ Returns the final roster's active contract rows. The payroll projection is authoritative for standard contracts and scenario changes. Two-way players are absent from that engine seam by design, so they are composed from the frozen `player_rows` seam. An explicit `:contracts` collection remains a complete override for pure callers and fixtures. """ @spec active_contracts(map()) :: [map()] def active_contracts(scenario_state) when is_map(scenario_state) do contracts = case fetch_any(scenario_state, :contracts) do rows when is_list(rows) -> rows _other -> projection_contracts(scenario_state) ++ two_way_contracts(scenario_state) end Enum.filter(contracts, fn contract -> fetch_any(contract, :is_off_roster) != true and fetch_any(contract, :is_draft_pick) != true and remaining_seasons(contract) != [] end) end def active_contracts(_scenario_state), do: [] @doc """ Identifies a final-roster scenario signing represented only by payroll output. Re-signings are marked directly. External signings are the non-draft synthetic rows created from `payroll_added_players`; drafted-rookie rows use the same engine container but remain unsigned rights and are not contracts. """ @spec scenario_signing?(map()) :: boolean() def scenario_signing?(contract) when is_map(contract) do fetch_any(contract, :is_re_signing) == true or (fetch_any(contract, :is_synthetic) == true and fetch_any(contract, :is_draft_pick) != true) end def scenario_signing?(_contract), do: false @doc """ Resolves the player record behind a final payroll contract snapshot. Scenario signings may have no matching `player_rows` entry, so they receive a minimal synthetic player that `ControlProjection` can classify once the projection entries are converted into a contract. """ @spec source_player(map(), map()) :: map() | nil def source_player(scenario_state, contract) do player_id = fetch_any(contract, :player_id) scenario_state |> fetch_any(:player_rows) |> List.wrap() |> Enum.find_value(fn row -> player = fetch_any(row, :player) if fetch_any(player, :id) == player_id, do: player end) |> case do nil -> fetch_any(contract, :player) || synthetic_source_player(contract) player -> player end end @doc """ Converts a scenario-adjusted payroll contract into `ControlProjection` input. External signings and re-signings exist authoritatively in payroll projection entries, not necessarily in the source player's persisted contract list. Rebuilding a synthetic contract here keeps both Contract Optionality and Core Continuity on the same shared translation. """ @spec control_projection_input(map(), map(), map()) :: {map(), map()} def control_projection_input(source_player, contract, modifications) do seasons = remaining_seasons(contract) if scenario_signing?(contract) do synthetic_contract = %Contract{ signing_type: if(fetch_any(contract, :is_re_signing) == true, do: "re_sign", else: "free_agent"), seasons: Enum.map(seasons, fn season -> %ContractSeason{ season: season.season, salary: season.salary, cap_hit: season.salary, guaranteed: season.guaranteed, option_type: season.option_type } end) } player = Map.put(source_player, :contracts, [synthetic_contract]) {player, drop_player_option_decisions(modifications, fetch_any(contract, :player_id))} else {source_player, modifications} end end @doc """ Enumerates a frozen contract's remaining salary seasons in chronological order. Persisted contract snapshots expose `seasons`; scenario-adjusted payroll snapshots expose `entries`. Both atom- and string-keyed maps are accepted because frozen JSON documents return string keys while in-memory engine snapshots use atoms. """ @spec remaining_seasons(map() | [map()]) :: [normalized_season()] def remaining_seasons(contract_snapshot) def remaining_seasons(rows) when is_list(rows) do normalize_rows(rows, :contract) end def remaining_seasons(contract_snapshot) when is_map(contract_snapshot) do cond do rows = fetch_any(contract_snapshot, :entries) -> normalize_rows(rows, :projection, scenario_signing?(contract_snapshot)) rows = fetch_any(contract_snapshot, :seasons) -> normalize_rows(rows, :contract, false) true -> [] end end def remaining_seasons(_contract_snapshot), do: [] @doc """ Returns the discounted dollars remaining on a normalized contract. Seasons are ordered before weighting so serialized map/list ordering cannot change a score. Invalid rows should already have been removed by `remaining_seasons/1`; this function nevertheless treats a missing or non-numeric salary as zero so a malformed optional row cannot crash an otherwise explainable grade. """ @spec discounted_remaining_salary([map()], map()) :: number() def discounted_remaining_salary(seasons, override \\ %{}) def discounted_remaining_salary(seasons, override) when is_list(seasons) do weights = ModelVersion.contract_season_weights(override) final_weight = List.last(weights) || 0 seasons |> Enum.sort_by(&fetch_any(&1, :season)) |> Enum.with_index() |> Enum.reduce(0.0, fn {season, index}, total -> salary = fetch_any(season, :salary) weight = Enum.at(weights, index, final_weight) total + if(is_number(salary), do: salary * weight, else: 0) end) end def discounted_remaining_salary(_seasons, _override), do: 0.0 @doc """ Returns a player's share of the team's discounted remaining salary. A team total of zero has no financial weight to allocate and returns `0.0`. The metrics that need a neutral empty-roster fallback record it at their own result boundary. """ @spec contract_salary_weight(number(), number()) :: float() def contract_salary_weight(player_discounted_salary, team_discounted_salary) when is_number(player_discounted_salary) and is_number(team_discounted_salary) and team_discounted_salary > 0 do player_discounted_salary / team_discounted_salary end def contract_salary_weight(_player_discounted_salary, _team_discounted_salary), do: 0.0 @doc """ Delegates AVC's confidence blend to the shared Expected Salary service. """ @spec confidence_adjusted_expected(number() | nil, number(), number()) :: integer() | nil def confidence_adjusted_expected(actual_salary, expected_salary, confidence) do ExpectedSalary.confidence_adjusted(actual_salary, expected_salary, confidence) end defp normalize_rows(rows, source), do: normalize_rows(rows, source, false) defp normalize_rows(rows, source, scenario_signing?) when is_list(rows) do rows |> Enum.filter(&included_row?(&1, source)) |> Enum.map(&normalize_row(&1, source, scenario_signing?)) |> Enum.sort_by(& &1.season) end defp normalize_rows(_rows, _source, _scenario_signing?), do: [] defp included_row?(row, :contract) when is_map(row) do valid_season?(row) and positive_salary?(row) end defp included_row?(row, :projection) when is_map(row) do status = fetch_any(row, :status) valid_season?(row) and positive_salary?(row) and (status in @active_projection_statuses or status in @active_projection_status_strings) end defp included_row?(_row, _source), do: false defp valid_season?(row), do: is_binary(fetch_any(row, :season)) defp positive_salary?(row) do salary = fetch_any(row, :salary) is_integer(salary) and salary > 0 end defp normalize_row(row, source, scenario_signing?) do salary = fetch_any(row, :salary) exercised? = fetch_any(row, :option_decision) == "exercise" re_signed? = source == :projection and fetch_any(row, :status) in [:re_signed, "re_signed"] guaranteed = if exercised? or re_signed? or scenario_signing?, do: salary, else: fetch_any(row, :guaranteed) %{ season: fetch_any(row, :season), salary: salary, guaranteed: guaranteed, guaranteed_pct: guaranteed_pct(guaranteed, salary), option_type: if(exercised?, do: nil, else: fetch_any(row, :option_type)) } end defp projection_contracts(scenario_state) do scenario_state |> fetch_any(:payroll_projection) |> fetch_any(:players) |> List.wrap() end defp two_way_contracts(scenario_state) do current_season = fetch_any(scenario_state, :season) scenario_state |> fetch_any(:player_rows) |> List.wrap() |> Enum.filter(&(fetch_any(&1, :is_two_way) == true and not is_nil(fetch_any(&1, :season)))) |> Enum.map(fn row -> player = fetch_any(row, :player) contract = fetch_any(row, :contract) entries = contract |> fetch_any(:seasons) |> List.wrap() |> Enum.filter(&(is_nil(current_season) or fetch_any(&1, :season) >= current_season)) |> Enum.map(fn season -> %{ season: fetch_any(season, :season), salary: fetch_any(season, :salary), guaranteed: fetch_any(season, :guaranteed), option_type: fetch_any(season, :option_type), option_decision: nil, status: :contracted } end) %{ player_id: fetch_any(player, :id), name: player_name(player), is_two_way: true, is_off_roster: false, contract: contract, player: player, entries: entries } end) end defp player_name(player) do first = fetch_any(player, :first_name) last = fetch_any(player, :last_name) [first, last] |> Enum.filter(&(is_binary(&1) and &1 != "")) |> Enum.join(" ") end defp synthetic_source_player(contract) do if scenario_signing?(contract) do %Player{ id: fetch_any(contract, :player_id), first_name: fetch_any(contract, :name) || "Scenario", last_name: "", position: fetch_any(contract, :position), years_of_service: 0, contracts: [] } end end defp drop_player_option_decisions(modifications, player_id) do decisions = Map.get(modifications, "option_decisions") || %{} cleaned = decisions |> Map.delete(player_id) |> Map.delete(to_string(player_id)) Map.put(modifications, "option_decisions", cleaned) end defp guaranteed_pct(guaranteed, salary) when is_integer(guaranteed) and is_integer(salary) and salary > 0 do guaranteed / salary end defp guaranteed_pct(_guaranteed, _salary), do: nil defp fetch_any(map, key) when is_map(map) do case Map.fetch(map, key) do {:ok, value} -> value :error -> Map.get(map, Atom.to_string(key)) end end defp fetch_any(_other, _key), do: nil end