package api

import "math"

// Uncertainty on the deltas the recommendation-metrics card shows (#2495).
//
// Why this exists: the card had exactly one volume threshold,
// recMetricsLowVolume = 20, and it was doing two jobs. Twenty plays is enough to
// be worth DISPLAYING — below that a skip rate is anecdote — but it is nowhere
// near enough to ACT on. Detecting the ~13pp differences that actually matter
// needs roughly 133 plays per arm for 80% power at α=0.05.
//
// So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as
// full-confidence rows with a bold delta beside them, and that comparison sits at
// p ≈ 0.06. The card said "signal"; the arithmetic said "maybe". It led directly
// to a recommendation the data didn't support, and any reader with the same
// numbers would have made the same call.
//
// The fix is to publish the margin of error next to the delta and flag when the
// delta is smaller than it — i.e. not distinguishable from zero. Computed here,
// server-side, so both clients agree rather than each re-deriving it.
//
// recMetricsLowVolume stays exactly as it was. This is a second, independent
// signal, not a replacement: "too thin to show" and "too thin to act on" are
// different questions and deserve different answers.

// deltaZ is the two-sided 95% normal critical value. Normal rather than
// Student's t: at the sample sizes where a delta is worth acting on (n in the
// hundreds) the difference is immaterial, and a household dashboard does not
// need a t-table.
const deltaZ = 1.96

// metricDelta is a difference from the baseline, with its uncertainty.
//
// Both figures are in PERCENTAGE POINTS, matching how the card reads them out —
// a skip rate of 0.153 against a baseline of 0.270 is "-11.7", not "-0.117".
type metricDelta struct {
	// DeltaPP is surface minus baseline. Negative skip is better; negative
	// completion is worse. The client owns that colouring.
	DeltaPP float64 `json:"delta_pp"`
	// MarginPP is the 95% margin of error on DeltaPP. Read the delta as
	// DeltaPP ± MarginPP.
	MarginPP float64 `json:"margin_pp"`
	// Distinguishable reports |DeltaPP| >= MarginPP: the interval excludes
	// zero, so the difference is worth reading as a difference. When false the
	// number may be pure noise no matter how large it looks.
	Distinguishable bool `json:"distinguishable"`
}

// proportionDelta compares two rates (skips/plays) as a two-proportion
// difference. Returns nil when either sample is empty, or when either rate is
// degenerate (0 or 1) — a rate with no observed variation has an SE of 0 on its
// side, which would report a spuriously narrow margin rather than an honest one.
func proportionDelta(rate1 float64, n1 int64, rate2 float64, n2 int64) *metricDelta {
	if n1 <= 0 || n2 <= 0 {
		return nil
	}
	v1 := rate1 * (1 - rate1) / float64(n1)
	v2 := rate2 * (1 - rate2) / float64(n2)
	se := math.Sqrt(v1 + v2)
	if se <= 0 {
		// Both rates are 0 or both are 1. The delta is exactly zero and the
		// margin is meaningless; reporting nothing is more honest than
		// reporting certainty.
		return nil
	}
	return newDelta((rate1-rate2)*100, deltaZ*se*100)
}

// meanDelta compares two means (average completion ratio) using Welch's
// standard error, which does not assume equal variances between the two groups.
//
// Note the margins here are wider than intuition suggests, and that is correct:
// completion is strongly bimodal — a play is either abandoned early (≈0.05) or
// finished (≈1.0), with little in between — so its standard deviation is large
// (~0.4) even though the mean looks stable.
func meanDelta(mean1 float64, variance1 float64, n1 int64, mean2 float64, variance2 float64, n2 int64) *metricDelta {
	// Two observations minimum per side: a sample variance needs n-1 > 0.
	if n1 < 2 || n2 < 2 {
		return nil
	}
	se := math.Sqrt(variance1/float64(n1) + variance2/float64(n2))
	if se <= 0 || math.IsNaN(se) || math.IsInf(se, 0) {
		return nil
	}
	return newDelta((mean1-mean2)*100, deltaZ*se*100)
}

func newDelta(deltaPP, marginPP float64) *metricDelta {
	return &metricDelta{
		DeltaPP:  deltaPP,
		MarginPP: marginPP,
		// >= rather than >: a delta exactly equal to its margin sits on the
		// boundary, and calling the boundary "distinguishable" is the
		// conventional reading of a 95% interval that just excludes zero.
		Distinguishable: math.Abs(deltaPP) >= marginPP,
	}
}

// sampleVariance recovers the sample variance from the aggregates the SQL
// returns. sum is mean×n rather than a selected column, which keeps the query to
// one extra expression.
//
// The subtraction can go very slightly negative through floating-point
// cancellation when every observation is identical, so the result is clamped —
// a negative variance would produce NaN downstream.
func sampleVariance(sum, sqSum float64, n int64) float64 {
	if n < 2 {
		return 0
	}
	nf := float64(n)
	v := (sqSum - (sum * sum / nf)) / (nf - 1)
	if v < 0 {
		return 0
	}
	return v
}
