/**
 * Taking a reward apart, and putting it back together with different weights.
 *
 * The reward editor is the point of the whole site: change what "good" means
 * and watch the ranking move. This module is the arithmetic behind that, and it
 * has one job beyond adding numbers up — never to manufacture one. An unscored
 * component stays `null` all the way to the bar chart.
 */

import { isNotScored, rewardTotal } from './episode';
import type { RewardComponent, RewardValues } from './types';

/** Weights closer than this are the same weight. Guards float drift in sliders. */
export const WEIGHT_EPSILON = 1e-9;

/** A user's edits to the shipped weights, keyed by component. */
export type WeightOverrides = Readonly<Record<string, number>>;

export interface RewardRow {
  key: string;
  /** Plain English, straight off the component. */
  label: string;
  description: string;
  /** The raw score the environment emitted. `null` means not scored. */
  score: number | null;
  /** The weight in force — shipped, or edited, depending what you passed in. */
  weight: number;
  /** `score x weight`, the component's actual contribution. `null` when unscored. */
  value: number | null;
  role: RewardComponent['role'];
}

export interface RewardBreakdown {
  rows: RewardRow[];
  /** Sum of the scored contributions, or `null` when nothing was scored. */
  total: number | null;
  /** How many components carry a real number. */
  scored: number;
  /** Components the environment did not grade. Rendered as "not scored". */
  unscored: string[];
}

/** Per-component rows plus the total, ready to render. */
export function decompose(
  values: RewardValues,
  components: readonly RewardComponent[],
): RewardBreakdown {
  const rows: RewardRow[] = components.map((component) => {
    const raw = values[component.key];
    const scored = !isNotScored(raw);
    return {
      key: component.key,
      label: component.label,
      description: component.description,
      score: scored ? raw : null,
      weight: component.weight,
      value: scored ? raw * component.weight : null,
      role: component.role,
    };
  });

  return {
    rows,
    total: rewardTotal(values, components),
    scored: rows.filter((row) => row.score !== null).length,
    unscored: rows.filter((row) => row.score === null).map((row) => row.key),
  };
}

/**
 * Apply the visitor's weight edits and renormalise so the weights sum to 1.0.
 *
 * Renormalising is what makes the editor honest. Without it, dragging one
 * slider up raises the total for every arm at once and the ranking looks like
 * it moved when only the scale did. With it, the visitor is trading weight
 * between components — which is the actual decision a reward designer makes.
 *
 * Negative weights are clamped to zero: a negative weight survives
 * normalisation as a sign flip somewhere else in the vector, and the resulting
 * chart is arithmetically correct and completely unreadable. If you want a
 * component to subtract, that belongs in the environment's grader, not here.
 *
 * If every weight is edited to zero the result is all zeros — there is no
 * honest way to normalise a zero vector, and inventing an equal split would be
 * putting words in the visitor's mouth. Call `weightsAreDegenerate()` on the
 * result and render "no weight assigned" rather than a 0.00 total.
 */
export function reweight(
  components: readonly RewardComponent[],
  overrides: WeightOverrides,
): RewardComponent[] {
  const clamped = components.map((component) => {
    const override = overrides[component.key];
    const weight = override === undefined || !Number.isFinite(override) ? component.weight : override;
    return { component, weight: Math.max(0, weight) };
  });

  const sum = clamped.reduce((acc, entry) => acc + entry.weight, 0);
  if (sum <= WEIGHT_EPSILON) {
    return clamped.map(({ component }) => ({ ...component, weight: 0 }));
  }

  return clamped.map(({ component, weight }) => ({ ...component, weight: weight / sum }));
}

/** True when `reweight` could not normalise, i.e. everything was zeroed. */
export function weightsAreDegenerate(components: readonly RewardComponent[]): boolean {
  return components.reduce((acc, c) => acc + c.weight, 0) <= WEIGHT_EPSILON;
}

/**
 * Has the visitor actually changed anything?
 *
 * Pass `components` whenever you have them. Without them this can only ask
 * "are there any override keys", which reports an edit for a slider that was
 * dragged and put back — and then the page shows a "modified reward" badge over
 * the shipped numbers, which is a lie in the other direction.
 */
export function isEdited(
  overrides: WeightOverrides,
  components?: readonly RewardComponent[],
): boolean {
  const keys = Object.keys(overrides);
  if (keys.length === 0) return false;
  if (!components) return true;

  return components.some((component) => {
    const override = overrides[component.key];
    if (override === undefined || !Number.isFinite(override)) return false;
    return Math.abs(override - component.weight) > WEIGHT_EPSILON;
  });
}

/** Drop overrides that match the shipped weight, so a reset yields a clean URL. */
export function pruneOverrides(
  overrides: WeightOverrides,
  components: readonly RewardComponent[],
): WeightOverrides {
  const out: Record<string, number> = {};
  for (const component of components) {
    const override = overrides[component.key];
    if (override === undefined || !Number.isFinite(override)) continue;
    if (Math.abs(override - component.weight) > WEIGHT_EPSILON) out[component.key] = override;
  }
  return out;
}
