OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
 
master @ 879 LINES
 
[ HISTORY ]  [ UP ]
 

package generator

import (
	"database/sql"
	"fmt"
	"path/filepath"
	"time"

	"ookstats/internal/wow"
	"ookstats/internal/writer"
)

// StatsJSON is the top-level shape for web/public/api/stats.json.
// Three scopes are emitted: cross-season "all_time" plus one entry per season num
ber.
type StatsJSON struct {
	GeneratedAt int64 `json:"generated_at"`
	// Scopes is keyed by region (global/us/eu/kr/tw), then by season key
	// (all_time/season_1/season_2). Region "global" includes all regions.
	Scopes map[string]map[string]StatsScope `json:"scopes"`
}

// StatsScope holds aggregated stats for a single scope (all-time or one season).
type StatsScope struct {
	TotalRuns         int64                      `json:"total_runs"`
	TotalPlayers      int64                      `json:"total_players"`
	NineOfNinePlayers int64                      `json:"nine_of_nine_players"`
	CompletionTiers   CompletionTiers            `json:"completion_tiers"`
	SpecCounts        map[string]SpecCountBucket `json:"spec_counts"`
	WeeklyActivity    []WeeklyActivityEntry      `json:"weekly_activity"`
}

// TotalRuns is the distinct-run denominator so the chart can switch between
// "total slot count" and "% of runs containing the spec" without recomputing
type SpecCountBucket struct {
	TotalRuns int64                     `json:"total_runs"`
	Entries   []SpecCountEntry          `json:"entries"`
	ByDungeon map[int]DungeonSpecBucket `json:"by_dungeon"`
}

type DungeonSpecBucket struct {
	TotalRuns int64            `json:"total_runs"`
	Entries   []SpecCountEntry `json:"entries"`
}

type CompletionTiers struct {
	NineOfNineGold     CompletionTier `json:"9_of_9_gold"`
	NineOfNinePlatinum CompletionTier `json:"9_of_9_platinum"`
	NineOfNineTitle    CompletionTier `json:"9_of_9_title"`
}

// percentile denominators:
// of_all_players       = scoped pool
// of_completed_players = finishers (players_with_9_dungeon_bests)
// of_all_time_players  = stable cross-scope denominator
type CompletionTier struct {
	Count                        int64   `json:"count"`
	PercentileOfAllPlayers       float64 `json:"percentile_of_all_players"`
	PercentileOfCompletedPlayers float64 `json:"percentile_of_completed_players"`
	PercentileOfAllTimePlayers   float64 `json:"percentile_of_all_time_players"`
}

// `count` is total run_member spec slots (a run with two of the same spec
// contributes 2); `runs_with_spec` is distinct runs containing the spec
// (same run with two of the spec contributes 1, capped at total_runs)
type SpecCountEntry struct {
	SpecID       int    `json:"spec_id"`
	ClassName    string `json:"class_name"`
	SpecName     string `json:"spec_name"`
	Count        int64  `json:"count"`
	RunsWithSpec int64  `json:"runs_with_spec"`
}

type WeeklyActivityEntry struct {
	WeekStart string `json:"week_start"` // YYYY-MM-DD
	RunCount  int64  `json:"run_count"`
}

// gold/platinum/title timer thresholds in ms; gold is the achievement par
// time, platinum and title are tighter community-defined cutoffs
type dungeonThresholds struct {
	GoldMs     int64
	PlatinumMs int64
	TitleMs    int64
}

var dungeonTimerThresholds = map[int]dungeonThresholds{
	2:  {GoldMs: 900000, PlatinumMs: 615000, TitleMs: 510000},  // Temple of the Jade
 Serpent (15:00 / 10:15 / 8:30)
	56: {GoldMs: 720000, PlatinumMs: 495000, TitleMs: 390000},  // Stormstout Brewery
        (12:00 / 8:15 / 6:30)
	57: {GoldMs: 780000, PlatinumMs: 480000, TitleMs: 330000},  // Gate of the Settin
g Sun   (13:00 / 8:00 / 5:30)
	58: {GoldMs: 1260000, PlatinumMs: 840000, TitleMs: 630000}, // Shado-Pan Monaster
y       (21:00 / 14:00 / 10:30)
	59: {GoldMs: 1050000, PlatinumMs: 735000, TitleMs: 615000}, // Siege of Niuzao Te
mple    (17:30 / 12:15 / 10:15)
	60: {GoldMs: 720000, PlatinumMs: 495000, TitleMs: 405000},  // Mogu'shan Palace  
        (12:00 / 8:15 / 6:45)
	76: {GoldMs: 1140000, PlatinumMs: 615000, TitleMs: 435000}, // Scholomance       
        (19:00 / 10:15 / 7:15)
	77: {GoldMs: 780000, PlatinumMs: 480000, TitleMs: 255000},  // Scarlet Halls     
        (13:00 / 8:00 / 4:15)
	78: {GoldMs: 780000, PlatinumMs: 540000, TitleMs: 330000},  // Scarlet Monastery 
        (13:00 / 9:00 / 5:30)
}

// must clear all of them to qualify for 9/9
var totalDungeons = len(dungeonTimerThresholds)

// in-game cutoffs are lenient: a 5:30.800 run beats a "5:30" title cutoff
// because the timer reads "5:30" until it ticks to 5:31, so pass condition
// is `duration < threshold + leniencyMs`
const leniencyMs int64 = 1000

func beatsTier(duration, thresholdMs int64) bool {
	return duration < thresholdMs+leniencyMs
}

// order matters for JSON readability
type statsScopeDef struct {
	Key       string
	SeasonNum int // 0 = all-time
}

// statsRegions are the region scopes emitted; "global" means no region filter.
var statsRegions = []string{"global", "us", "eu", "kr", "tw"}

// GenerateStats writes outDir/api/stats.json with aggregated stats per (region, s
eason) scope.
func GenerateStats(db *sql.DB, outDir string) error {
	statsScopes, err := loadStatsScopes(db)
	if err != nil {
		return fmt.Errorf("load stats scopes: %w", err)
	}

	out := StatsJSON{
		GeneratedAt: time.Now().UnixMilli(),
		Scopes:      make(map[string]map[string]StatsScope, len(statsRegions)),
	}

	for _, region := range statsRegions {
		regionScopes := make(map[string]StatsScope, len(statsScopes))
		// Compute all-time first per region so its total_players is the stable
		// denominator for per-season scopes within that region.
		var allTimeTotalPlayers int64
		for _, sc := range statsScopes {
			ss, err := buildStatsScope(db, sc.SeasonNum, region, allTimeTotalPlayers)
			if err != nil {
				return fmt.Errorf("scope %s/%s: %w", region, sc.Key, err)
			}
			if sc.SeasonNum == 0 {
				allTimeTotalPlayers = ss.TotalPlayers
			}
			regionScopes[sc.Key] = ss
		}
		out.Scopes[region] = regionScopes
	}

	outPath := filepath.Join(outDir, "api", "stats.json")
	if err := writer.WriteJSONFileCompact(outPath, out); err != nil {
		return fmt.Errorf("write stats.json: %w", err)
	}
	return nil
}

// loadStatsScopes returns all-time plus one scope per season in the DB
func loadStatsScopes(db *sql.DB) ([]statsScopeDef, error) {
	rows, err := db.Query(`SELECT DISTINCT season_number FROM seasons ORDER BY season
_number ASC`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	scopes := []statsScopeDef{{Key: "all_time", SeasonNum: 0}}
	for rows.Next() {
		var n int
		if err := rows.Scan(&n); err != nil {
			return nil, err
		}
		scopes = append(scopes, statsScopeDef{Key: fmt.Sprintf("season_%d", n), SeasonNu
m: n})
	}
	return scopes, rows.Err()
}

// buildStatsScope assembles a single StatsScope.
// seasonNum == 0 means cross-season (all-time). region == "global" means no regio
n filter.
// For all-time, pass allTimeTotalPlayers=0; for per-season builds, pass the all-t
ime scope's
// TotalPlayers (within the same region) so the `percentile_of_all_time_players` f
ield
// uses a stable denominator.
func buildStatsScope(db *sql.DB, seasonNum int, region string, allTimeTotalPlayers
 int64) (StatsScope, error) {
	var s StatsScope
	var err error

	if s.TotalRuns, err = countTotalRuns(db, seasonNum, region); err != nil {
		return s, fmt.Errorf("total runs: %w", err)
	}

	if s.TotalPlayers, err = countTotalPlayers(db, seasonNum, region); err != nil {
		return s, fmt.Errorf("total players: %w", err)
	}

	// On all-time, the stable denominator is just this scope's own TotalPlayers.
	allTimeDen := allTimeTotalPlayers
	if seasonNum == 0 {
		allTimeDen = s.TotalPlayers
	}

	tiers, completedCount, err := computeCompletionTiers(db, seasonNum, region)
	if err != nil {
		return s, fmt.Errorf("completion tiers: %w", err)
	}
	s.CompletionTiers = tiers
	s.NineOfNinePlayers = completedCount
	// fill percentile fields now that we know all three denominators
	s.CompletionTiers.NineOfNineGold = withPercentiles(s.CompletionTiers.NineOfNineGo
ld, s.TotalPlayers, completedCount, allTimeDen)
	s.CompletionTiers.NineOfNinePlatinum = withPercentiles(s.CompletionTiers.NineOfNi
nePlatinum, s.TotalPlayers, completedCount, allTimeDen)
	s.CompletionTiers.NineOfNineTitle = withPercentiles(s.CompletionTiers.NineOfNineT
itle, s.TotalPlayers, completedCount, allTimeDen)

	if s.SpecCounts, err = computeSpecCounts(db, seasonNum, region); err != nil {
		return s, fmt.Errorf("spec counts: %w", err)
	}

	if s.WeeklyActivity, err = queryWeeklyActivity(db, seasonNum, region); err != nil
 {
		return s, fmt.Errorf("weekly activity: %w", err)
	}

	return s, nil
}

// seasonClauseAndArg returns the SQL fragment and arg slice for filtering by chal
lenge_runs.season_id.
// Empty string for all-time (seasonNum == 0).
func seasonClauseAndArg(seasonNum int, alias string) (string, []any) {
	if seasonNum == 0 {
		return "", nil
	}
	if alias == "" {
		alias = "challenge_runs"
	}
	return fmt.Sprintf(" AND %s.season_id = ?", alias), []any{seasonNum}
}

// regionClauseAndArg filters by realm region. region == "global" returns empty (n
o filter).
// `realmAlias` is the SQL alias of the realms table reference in the FROM/JOIN cl
ause.
func regionClauseAndArg(region, realmAlias string) (string, []any) {
	if region == "" || region == "global" {
		return "", nil
	}
	if realmAlias == "" {
		realmAlias = "r"
	}
	return fmt.Sprintf(" AND %s.region = ?", realmAlias), []any{region}
}

func countTotalRuns(db *sql.DB, seasonNum int, region string) (int64, error) {
	// Region filter requires joining realms; only join when needed to keep the globa
l
	// case as a fast plain COUNT(*) on challenge_runs.
	if region == "" || region == "global" {
		q := "SELECT COUNT(*) FROM challenge_runs cr WHERE 1=1"
		clause, args := seasonClauseAndArg(seasonNum, "cr")
		var n int64
		if err := db.QueryRow(q+clause, args...).Scan(&n); err != nil {
			return 0, err
		}
		return n, nil
	}
	q := "SELECT COUNT(*) FROM challenge_runs cr JOIN realms r ON r.id = cr.realm_id 
WHERE 1=1"
	sClause, sArgs := seasonClauseAndArg(seasonNum, "cr")
	rClause, rArgs := regionClauseAndArg(region, "r")
	args := append(sArgs, rArgs...)
	var n int64
	if err := db.QueryRow(q+sClause+rClause, args...).Scan(&n); err != nil {
		return 0, err
	}
	return n, nil
}

// countTotalPlayers counts distinct players who appear in run_members within scop
e.
// "Within scope" = any player who participated in a run during the given season+r
egion.
func countTotalPlayers(db *sql.DB, seasonNum int, region string) (int64, error) {
	// Always join through challenge_runs so we can filter by season and/or region.
	q := `SELECT COUNT(DISTINCT rm.player_id)
	      FROM run_members rm
	      JOIN challenge_runs cr ON rm.run_id = cr.id`
	if region != "" && region != "global" {
		q += " JOIN realms r ON r.id = cr.realm_id"
	}
	q += " WHERE 1=1"
	sClause, sArgs := seasonClauseAndArg(seasonNum, "cr")
	rClause, rArgs := regionClauseAndArg(region, "r")
	args := append(sArgs, rArgs...)
	var n int64
	if err := db.QueryRow(q+sClause+rClause, args...).Scan(&n); err != nil {
		return 0, err
	}
	return n, nil
}

// tiers are achievements earned within a single season; for all-time scope
// we take the union of per-season qualifiers (hit 9/9 of a tier in any one
// season counts), not combine best times across seasons. returns tier
// counts plus the "completed" denominator (any-season 9-dungeon finishers).
func computeCompletionTiers(db *sql.DB, seasonNum int, region string) (CompletionT
iers, int64, error) {
	q := `
		SELECT rm.player_id, cr.season_id, cr.dungeon_id, MIN(cr.duration) AS best_durat
ion
		FROM run_members rm
		JOIN challenge_runs cr ON rm.run_id = cr.id
		%s
		WHERE 1=1 %s %s
		GROUP BY rm.player_id, cr.season_id, cr.dungeon_id
	`
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.realm_id"
	}
	sClause, sArgs := seasonClauseAndArg(seasonNum, "cr")
	rClause, rArgs := regionClauseAndArg(region, "r")
	args := append(sArgs, rArgs...)
	rows, err := db.Query(fmt.Sprintf(q, regionJoin, sClause, rClause), args...)
	if err != nil {
		return CompletionTiers{}, 0, err
	}
	defer rows.Close()

	// (player_id, season_id) -> per-tier dungeon-cleared count
	type key struct {
		playerID int64
		seasonID int
	}
	type counts struct{ gold, plat, title, anyDungeon int }
	per := make(map[key]*counts)
	for rows.Next() {
		var playerID int64
		var seasonID, dungeonID int
		var bestDuration int64
		if err := rows.Scan(&playerID, &seasonID, &dungeonID, &bestDuration); err != nil
 {
			return CompletionTiers{}, 0, err
		}
		threshold, ok := dungeonTimerThresholds[dungeonID]
		if !ok {
			continue // unknown dungeon - skip silently
		}
		k := key{playerID, seasonID}
		c := per[k]
		if c == nil {
			c = &counts{}
			per[k] = c
		}
		c.anyDungeon++
		if beatsTier(bestDuration, threshold.GoldMs) {
			c.gold++
		}
		if beatsTier(bestDuration, threshold.PlatinumMs) {
			c.plat++
		}
		if beatsTier(bestDuration, threshold.TitleMs) {
			c.title++
		}
	}
	if err := rows.Err(); err != nil {
		return CompletionTiers{}, 0, err
	}

	// Union per player: did they qualify in *any* season?
	type qualifiers struct{ gold, plat, title, completed bool }
	playerQual := make(map[int64]*qualifiers)
	for k, c := range per {
		q := playerQual[k.playerID]
		if q == nil {
			q = &qualifiers{}
			playerQual[k.playerID] = q
		}
		if c.anyDungeon == totalDungeons {
			q.completed = true
		}
		if c.gold == totalDungeons {
			q.gold = true
		}
		if c.plat == totalDungeons {
			q.plat = true
		}
		if c.title == totalDungeons {
			q.title = true
		}
	}

	var tiers CompletionTiers
	var completedCount int64
	for _, q := range playerQual {
		if q.completed {
			completedCount++
		}
		if q.gold {
			tiers.NineOfNineGold.Count++
		}
		if q.plat {
			tiers.NineOfNinePlatinum.Count++
		}
		if q.title {
			tiers.NineOfNineTitle.Count++
		}
	}
	return tiers, completedCount, nil
}

func withPercentiles(t CompletionTier, totalPlayers, completedPlayers, allTimePlay
ers int64) CompletionTier {
	if totalPlayers > 0 {
		t.PercentileOfAllPlayers = round2(float64(t.Count) * 100.0 / float64(totalPlayer
s))
	}
	if completedPlayers > 0 {
		t.PercentileOfCompletedPlayers = round2(float64(t.Count) * 100.0 / float64(compl
etedPlayers))
	}
	if allTimePlayers > 0 {
		t.PercentileOfAllTimePlayers = round2(float64(t.Count) * 100.0 / float64(allTime
Players))
	}
	return t
}

// computeSpecCounts returns the spec-distribution charts:
//
//	all_runs:      every run_member row in scope
//	gold_runs:     run_members where the run beat the gold threshold for its dungeo
n
//	platinum_runs: same for platinum
//	title_runs:    same for title
//	top_50_runs:   run_members where the run was top-50 in scope (globally team-fil
tered for
//	               region=="global", regionally team-filtered for a specific region
)
//
// Each bucket carries both `count` (slot occurrences, includes spec stacking)
// and `runs_with_spec` (distinct run count, capped at the bucket's total_runs).
func computeSpecCounts(db *sql.DB, seasonNum int, region string) (map[string]SpecC
ountBucket, error) {
	out := map[string]SpecCountBucket{}

	allRuns, err := querySpecCountsForRuns(db, seasonNum, region)
	if err != nil {
		return nil, fmt.Errorf("all_runs: %w", err)
	}
	out["all_runs"] = allRuns

	for _, tier := range []struct {
		key        string
		thresholdF func(dungeonThresholds) int64
	}{
		{"gold_runs", func(t dungeonThresholds) int64 { return t.GoldMs }},
		{"platinum_runs", func(t dungeonThresholds) int64 { return t.PlatinumMs }},
		{"title_runs", func(t dungeonThresholds) int64 { return t.TitleMs }},
	} {
		tierCounts, err := querySpecCountsForTier(db, seasonNum, region, tier.thresholdF
)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", tier.key, err)
		}
		out[tier.key] = tierCounts
	}

	top50, err := querySpecCountsForTop50(db, seasonNum, region)
	if err != nil {
		return nil, fmt.Errorf("top_50_runs: %w", err)
	}
	out["top_50_runs"] = top50

	return out, nil
}

// querySpecCountsForRuns counts run_member spec_ids across all runs in scope.
// Returns slot count + distinct-run count per spec, plus the bucket's total
// distinct run count, plus the same broken out per dungeon.
func querySpecCountsForRuns(db *sql.DB, seasonNum int, region string) (SpecCountBu
cket, error) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.realm_id"
	}
	sClause, sArgs := seasonClauseAndArg(seasonNum, "cr")
	rClause, rArgs := regionClauseAndArg(region, "r")
	args := append(sArgs, rArgs...)

	// each challenge_run has a single dungeon_id, so one (dungeon, spec)
	// grouped query gives us both the per-dungeon and overall totals
	q := `
		SELECT cr.dungeon_id, rm.spec_id,
		       COUNT(*) AS slot_count,
		       COUNT(DISTINCT rm.run_id) AS run_count
		FROM run_members rm
		JOIN challenge_runs cr ON rm.run_id = cr.id
		%s
		WHERE rm.spec_id IS NOT NULL %s %s
		GROUP BY cr.dungeon_id, rm.spec_id
	`
	rows, err := db.Query(fmt.Sprintf(q, regionJoin, sClause, rClause), args...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer rows.Close()

	// dungeonId -> specId -> {count, distinctRuns}
	perDungeon := make(map[int]map[int]*specRunCount)
	overall := make(map[int]*specRunCount)
	for rows.Next() {
		var dungeonID, specID int
		var slot, runs int64
		if err := rows.Scan(&dungeonID, &specID, &slot, &runs); err != nil {
			return SpecCountBucket{}, err
		}
		dm, ok := perDungeon[dungeonID]
		if !ok {
			dm = make(map[int]*specRunCount)
			perDungeon[dungeonID] = dm
		}
		dm[specID] = &specRunCount{count: slot, distinctRuns: runs}
		o := overall[specID]
		if o == nil {
			o = &specRunCount{}
			overall[specID] = o
		}
		o.count += slot
		// Each run lives in one dungeon, so distinct runs sum cleanly across dungeons.
		o.distinctRuns += runs
	}
	if err := rows.Err(); err != nil {
		return SpecCountBucket{}, err
	}

	// per-dungeon and overall in one query
	perDungeonTotals, overallTotal, err := queryRunCountsForRuns(db, seasonNum, regio
n)
	if err != nil {
		return SpecCountBucket{}, err
	}

	bucket := SpecCountBucket{
		TotalRuns: overallTotal,
		Entries:   entriesFromCounts(overall),
		ByDungeon: make(map[int]DungeonSpecBucket, len(perDungeonTotals)),
	}
	for dungeonID, total := range perDungeonTotals {
		bucket.ByDungeon[dungeonID] = DungeonSpecBucket{
			TotalRuns: total,
			Entries:   entriesFromCounts(perDungeon[dungeonID]),
		}
	}
	return bucket, nil
}

// queryRunCountsForRuns returns distinct-run counts per dungeon (and the overall
// sum) within scope, used as the denominator for the spec-distribution chart's
// "Runs" metric and for the dungeon-distribution chart.
func queryRunCountsForRuns(db *sql.DB, seasonNum int, region string) (map[int]int6
4, int64, error) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.realm_id"
	}
	sClause, sArgs := seasonClauseAndArg(seasonNum, "cr")
	rClause, rArgs := regionClauseAndArg(region, "r")
	args := append(sArgs, rArgs...)
	q := fmt.Sprintf(`
		SELECT cr.dungeon_id, COUNT(DISTINCT cr.id)
		FROM challenge_runs cr
		%s
		WHERE 1=1 %s %s
		GROUP BY cr.dungeon_id
	`, regionJoin, sClause, rClause)
	rows, err := db.Query(q, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	perDungeon := make(map[int]int64)
	var total int64
	for rows.Next() {
		var dungeonID int
		var count int64
		if err := rows.Scan(&dungeonID, &count); err != nil {
			return nil, 0, err
		}
		perDungeon[dungeonID] = count
		total += count
	}
	return perDungeon, total, rows.Err()
}

// specRunCount aggregates slot count + distinct-run count for a single spec.
type specRunCount struct {
	count, distinctRuns int64
}

// entriesFromCounts converts a (specId -> {count, distinctRuns}) map into the
// sorted SpecCountEntry slice used by the chart, attaching class/spec metadata.
// Sorted descending by count for stable order; the chart re-orders by canonical
// class+spec layout but tooltips/iteration work nicer on a deterministic input.
func entriesFromCounts(m map[int]*specRunCount) []SpecCountEntry {
	if m == nil {
		return []SpecCountEntry{}
	}
	out := make([]SpecCountEntry, 0, len(m))
	for specID, c := range m {
		entry := SpecCountEntry{SpecID: specID, Count: c.count, RunsWithSpec: c.distinct
Runs}
		if cls, spec, ok := wow.GetClassAndSpec(specID); ok {
			entry.ClassName = cls
			entry.SpecName = spec
		}
		out = append(out, entry)
	}
	for i := 1; i < len(out); i++ {
		for j := i; j > 0 && out[j].Count > out[j-1].Count; j-- {
			out[j], out[j-1] = out[j-1], out[j]
		}
	}
	return out
}

// querySpecCountsForTier counts run_member spec_ids across runs that beat a per-d
ungeon threshold.
// We can't easily express the per-dungeon threshold in a single SQL query, so ins
tead we load
// (run_id, dungeon_id, duration) rows in scope, decide qualification in Go, then 
count specs by
// the qualifying run_id set. We track both slot occurrences and distinct-run-with
-spec counts,
// and a per-dungeon breakdown for the spec-distribution chart's dungeon filter.
func querySpecCountsForTier(db *sql.DB, seasonNum int, region string, thresholdF f
unc(dungeonThresholds) int64) (SpecCountBucket, error) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.realm_id"
	}
	sClause, sArgs := seasonClauseAndArg(seasonNum, "cr")
	rClause, rArgs := regionClauseAndArg(region, "r")
	args := append(sArgs, rArgs...)
	rows, err := db.Query(fmt.Sprintf(`
		SELECT cr.id, cr.dungeon_id, cr.duration
		FROM challenge_runs cr
		%s
		WHERE 1=1 %s %s
	`, regionJoin, sClause, rClause), args...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	// runId -> dungeonId for runs that beat the threshold. Per-dungeon counts
	// fall out of the same map.
	qualifyingDungeon := make(map[int64]int)
	dungeonRunCounts := make(map[int]int64)
	for rows.Next() {
		var id int64
		var dungeonID int
		var duration int64
		if err := rows.Scan(&id, &dungeonID, &duration); err != nil {
			rows.Close()
			return SpecCountBucket{}, err
		}
		threshold, ok := dungeonTimerThresholds[dungeonID]
		if !ok {
			continue
		}
		if beatsTier(duration, thresholdF(threshold)) {
			qualifyingDungeon[id] = dungeonID
			dungeonRunCounts[dungeonID]++
		}
	}
	rows.Close()
	if len(qualifyingDungeon) == 0 {
		return SpecCountBucket{TotalRuns: 0, Entries: []SpecCountEntry{}, ByDungeon: map
[int]DungeonSpecBucket{}}, nil
	}

	// For qualifying runs, count slot occurrences AND distinct runs per spec,
	// both overall and per-dungeon. Per-dungeon distinct-run counting reuses
	// the qualifying map (one run -> one dungeon, no double-count).
	overall := make(map[int]*specAgg)
	perDungeon := make(map[int]map[int]*specAgg)
	memberRows, err := db.Query(`
		SELECT run_id, spec_id
		FROM run_members
		WHERE spec_id IS NOT NULL
	`)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer memberRows.Close()
	for memberRows.Next() {
		var runID int64
		var specID int
		if err := memberRows.Scan(&runID, &specID); err != nil {
			return SpecCountBucket{}, err
		}
		dungeonID, ok := qualifyingDungeon[runID]
		if !ok {
			continue
		}
		o := overall[specID]
		if o == nil {
			o = &specAgg{runs: make(map[int64]struct{})}
			overall[specID] = o
		}
		o.count++
		o.runs[runID] = struct{}{}

		dm, ok := perDungeon[dungeonID]
		if !ok {
			dm = make(map[int]*specAgg)
			perDungeon[dungeonID] = dm
		}
		da := dm[specID]
		if da == nil {
			da = &specAgg{runs: make(map[int64]struct{})}
			dm[specID] = da
		}
		da.count++
		da.runs[runID] = struct{}{}
	}

	bucket := SpecCountBucket{
		TotalRuns: int64(len(qualifyingDungeon)),
		Entries:   specAggsToSlice(overall),
		ByDungeon: make(map[int]DungeonSpecBucket, len(perDungeon)),
	}
	for dungeonID, total := range dungeonRunCounts {
		bucket.ByDungeon[dungeonID] = DungeonSpecBucket{
			TotalRuns: total,
			Entries:   specAggsToSlice(perDungeon[dungeonID]),
		}
	}
	return bucket, nil
}

// querySpecCountsForTop50 counts spec_ids in top-50 runs.
// region "global" ? top 50 globally (filtered); region "us"/"eu"/etc ? top 50 wit
hin that region.
// Per-dungeon breakdown is also returned (each dungeon's top 50 contributes 50 ru
ns to its bucket).
func querySpecCountsForTop50(db *sql.DB, seasonNum int, region string) (SpecCountB
ucket, error) {
	rankingType := "global"
	rankingScope := "filtered"
	if region != "" && region != "global" {
		rankingType = "regional"
		rankingScope = region + "_filtered"
	}
	// Group by (dungeon, spec) so we get both per-dungeon and overall totals
	// from a single result set. cr.dungeon_id comes from joining challenge_runs.
	q := `
		SELECT cr.dungeon_id, rm.spec_id,
		       COUNT(*) AS slot_count,
		       COUNT(DISTINCT rm.run_id) AS run_count
		FROM run_members rm
		JOIN run_rankings rr ON rm.run_id = rr.run_id
		JOIN challenge_runs cr ON cr.id = rm.run_id
		WHERE rm.spec_id IS NOT NULL
		  AND rr.ranking_type = ?
		  AND rr.ranking_scope = ?
		  AND rr.ranking <= 50
	`
	args := []any{rankingType, rankingScope}
	if seasonNum != 0 {
		q += " AND rr.season_id = ?"
		args = append(args, seasonNum)
	}
	q += " GROUP BY cr.dungeon_id, rm.spec_id"

	rows, err := db.Query(q, args...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer rows.Close()

	perDungeon := make(map[int]map[int]*specRunCount)
	overall := make(map[int]*specRunCount)
	for rows.Next() {
		var dungeonID, specID int
		var slot, runs int64
		if err := rows.Scan(&dungeonID, &specID, &slot, &runs); err != nil {
			return SpecCountBucket{}, err
		}
		dm, ok := perDungeon[dungeonID]
		if !ok {
			dm = make(map[int]*specRunCount)
			perDungeon[dungeonID] = dm
		}
		dm[specID] = &specRunCount{count: slot, distinctRuns: runs}
		o := overall[specID]
		if o == nil {
			o = &specRunCount{}
			overall[specID] = o
		}
		o.count += slot
		o.distinctRuns += runs
	}
	if err := rows.Err(); err != nil {
		return SpecCountBucket{}, err
	}

	// Per-dungeon distinct top-50 run counts (and total).
	totalsQ := `
		SELECT cr.dungeon_id, COUNT(DISTINCT rr.run_id)
		FROM run_rankings rr
		JOIN challenge_runs cr ON cr.id = rr.run_id
		WHERE rr.ranking_type = ?
		  AND rr.ranking_scope = ?
		  AND rr.ranking <= 50
	`
	totalsArgs := []any{rankingType, rankingScope}
	if seasonNum != 0 {
		totalsQ += " AND rr.season_id = ?"
		totalsArgs = append(totalsArgs, seasonNum)
	}
	totalsQ += " GROUP BY cr.dungeon_id"
	totalRows, err := db.Query(totalsQ, totalsArgs...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer totalRows.Close()
	dungeonTotals := make(map[int]int64)
	var grandTotal int64
	for totalRows.Next() {
		var dungeonID int
		var count int64
		if err := totalRows.Scan(&dungeonID, &count); err != nil {
			return SpecCountBucket{}, err
		}
		dungeonTotals[dungeonID] = count
		grandTotal += count
	}

	bucket := SpecCountBucket{
		TotalRuns: grandTotal,
		Entries:   entriesFromCounts(overall),
		ByDungeon: make(map[int]DungeonSpecBucket, len(dungeonTotals)),
	}
	for dungeonID, total := range dungeonTotals {
		bucket.ByDungeon[dungeonID] = DungeonSpecBucket{
			TotalRuns: total,
			Entries:   entriesFromCounts(perDungeon[dungeonID]),
		}
	}
	return bucket, nil
}

// in-memory aggregation when SQL counting is awkward (see querySpecCountsForTier)
type specAgg struct {
	count int64
	runs  map[int64]struct{}
}

func specAggsToSlice(aggs map[int]*specAgg) []SpecCountEntry {
	out := make([]SpecCountEntry, 0, len(aggs))
	for specID, a := range aggs {
		entry := SpecCountEntry{SpecID: specID, Count: a.count, RunsWithSpec: int64(len(
a.runs))}
		if cls, spec, ok := wow.GetClassAndSpec(specID); ok {
			entry.ClassName = cls
			entry.SpecName = spec
		}
		out = append(out, entry)
	}
	// simple insertion sort by count desc (small N: ~33 specs)
	for i := 1; i < len(out); i++ {
		for j := i; j > 0 && out[j].Count > out[j-1].Count; j-- {
			out[j], out[j-1] = out[j-1], out[j]
		}
	}
	return out
}

// queryWeeklyActivity returns runs grouped by Monday-start week.
// Timestamps are stored in milliseconds; SQLite date functions need seconds.
func queryWeeklyActivity(db *sql.DB, seasonNum int, region string) ([]WeeklyActivi
tyEntry, error) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.realm_id"
	}
	sClause, sArgs := seasonClauseAndArg(seasonNum, "cr")
	rClause, rArgs := regionClauseAndArg(region, "r")
	args := append(sArgs, rArgs...)
	// 'weekday 1' advances to next Monday; '-7 days' rolls back to start of current 
week.
	q := fmt.Sprintf(`
		SELECT
			date(cr.completed_timestamp / 1000, 'unixepoch', 'weekday 1', '-7 days') AS wee
k_start,
			COUNT(*) AS run_count
		FROM challenge_runs cr
		%s
		WHERE cr.completed_timestamp IS NOT NULL %s %s
		GROUP BY week_start
		ORDER BY week_start
	`, regionJoin, sClause, rClause)
	rows, err := db.Query(q, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []WeeklyActivityEntry
	for rows.Next() {
		var e WeeklyActivityEntry
		if err := rows.Scan(&e.WeekStart, &e.RunCount); err != nil {
			return nil, err
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

func round2(f float64) float64 {
	return float64(int64(f*100+0.5)) / 100
}

package generator

import (
	"database/sql"
	"fmt"
	"path/filepath"
	"time"

	"ookstats/internal/wow"
	"ookstats/internal/writer"
)

// StatsJSON is the top-level shape for web/
public/api/stats.json.
// Three scopes are emitted: cross-season "a
ll_time" plus one entry per season number.
type StatsJSON struct {
	GeneratedAt int64 `json:"generated_at"`
	// Scopes is keyed by region (global/us/eu/
kr/tw), then by season key
	// (all_time/season_1/season_2). Region "gl
obal" includes all regions.
	Scopes map[string]map[string]StatsScope `js
on:"scopes"`
}

// StatsScope holds aggregated stats for a s
ingle scope (all-time or one season).
type StatsScope struct {
	TotalRuns         int64                    
  `json:"total_runs"`
	TotalPlayers      int64                    
  `json:"total_players"`
	NineOfNinePlayers int64                    
  `json:"nine_of_nine_players"`
	CompletionTiers   CompletionTiers          
  `json:"completion_tiers"`
	SpecCounts        map[string]SpecCountBucke
t `json:"spec_counts"`
	WeeklyActivity    []WeeklyActivityEntry    
  `json:"weekly_activity"`
}

// TotalRuns is the distinct-run denominator
 so the chart can switch between
// "total slot count" and "% of runs contain
ing the spec" without recomputing
type SpecCountBucket struct {
	TotalRuns int64                     `json:"
total_runs"`
	Entries   []SpecCountEntry          `json:"
entries"`
	ByDungeon map[int]DungeonSpecBucket `json:"
by_dungeon"`
}

type DungeonSpecBucket struct {
	TotalRuns int64            `json:"total_run
s"`
	Entries   []SpecCountEntry `json:"entries"`
}

type CompletionTiers struct {
	NineOfNineGold     CompletionTier `json:"9_
of_9_gold"`
	NineOfNinePlatinum CompletionTier `json:"9_
of_9_platinum"`
	NineOfNineTitle    CompletionTier `json:"9_
of_9_title"`
}

// percentile denominators:
// of_all_players       = scoped pool
// of_completed_players = finishers (players
_with_9_dungeon_bests)
// of_all_time_players  = stable cross-scope
 denominator
type CompletionTier struct {
	Count                        int64   `json:
"count"`
	PercentileOfAllPlayers       float64 `json:
"percentile_of_all_players"`
	PercentileOfCompletedPlayers float64 `json:
"percentile_of_completed_players"`
	PercentileOfAllTimePlayers   float64 `json:
"percentile_of_all_time_players"`
}

// `count` is total run_member spec slots (a
 run with two of the same spec
// contributes 2); `runs_with_spec` is disti
nct runs containing the spec
// (same run with two of the spec contribute
s 1, capped at total_runs)
type SpecCountEntry struct {
	SpecID       int    `json:"spec_id"`
	ClassName    string `json:"class_name"`
	SpecName     string `json:"spec_name"`
	Count        int64  `json:"count"`
	RunsWithSpec int64  `json:"runs_with_spec"`
}

type WeeklyActivityEntry struct {
	WeekStart string `json:"week_start"` // YYY
Y-MM-DD
	RunCount  int64  `json:"run_count"`
}

// gold/platinum/title timer thresholds in m
s; gold is the achievement par
// time, platinum and title are tighter comm
unity-defined cutoffs
type dungeonThresholds struct {
	GoldMs     int64
	PlatinumMs int64
	TitleMs    int64
}

var dungeonTimerThresholds = map[int]dungeon
Thresholds{
	2:  {GoldMs: 900000, PlatinumMs: 615000, Ti
tleMs: 510000},  // Temple of the Jade Serpe
nt (15:00 / 10:15 / 8:30)
	56: {GoldMs: 720000, PlatinumMs: 495000, Ti
tleMs: 390000},  // Stormstout Brewery      
  (12:00 / 8:15 / 6:30)
	57: {GoldMs: 780000, PlatinumMs: 480000, Ti
tleMs: 330000},  // Gate of the Setting Sun 
  (13:00 / 8:00 / 5:30)
	58: {GoldMs: 1260000, PlatinumMs: 840000, T
itleMs: 630000}, // Shado-Pan Monastery     
  (21:00 / 14:00 / 10:30)
	59: {GoldMs: 1050000, PlatinumMs: 735000, T
itleMs: 615000}, // Siege of Niuzao Temple  
  (17:30 / 12:15 / 10:15)
	60: {GoldMs: 720000, PlatinumMs: 495000, Ti
tleMs: 405000},  // Mogu'shan Palace        
  (12:00 / 8:15 / 6:45)
	76: {GoldMs: 1140000, PlatinumMs: 615000, T
itleMs: 435000}, // Scholomance             
  (19:00 / 10:15 / 7:15)
	77: {GoldMs: 780000, PlatinumMs: 480000, Ti
tleMs: 255000},  // Scarlet Halls           
  (13:00 / 8:00 / 4:15)
	78: {GoldMs: 780000, PlatinumMs: 540000, Ti
tleMs: 330000},  // Scarlet Monastery       
  (13:00 / 9:00 / 5:30)
}

// must clear all of them to qualify for 9/9
var totalDungeons = len(dungeonTimerThreshol
ds)

// in-game cutoffs are lenient: a 5:30.800 r
un beats a "5:30" title cutoff
// because the timer reads "5:30" until it t
icks to 5:31, so pass condition
// is `duration < threshold + leniencyMs`
const leniencyMs int64 = 1000

func beatsTier(duration, thresholdMs int64) 
bool {
	return duration < thresholdMs+leniencyMs
}

// order matters for JSON readability
type statsScopeDef struct {
	Key       string
	SeasonNum int // 0 = all-time
}

// statsRegions are the region scopes emitte
d; "global" means no region filter.
var statsRegions = []string{"global", "us", 
"eu", "kr", "tw"}

// GenerateStats writes outDir/api/stats.jso
n with aggregated stats per (region, season)
 scope.
func GenerateStats(db *sql.DB, outDir string
) error {
	statsScopes, err := loadStatsScopes(db)
	if err != nil {
		return fmt.Errorf("load stats scopes: %w",
 err)
	}

	out := StatsJSON{
		GeneratedAt: time.Now().UnixMilli(),
		Scopes:      make(map[string]map[string]St
atsScope, len(statsRegions)),
	}

	for _, region := range statsRegions {
		regionScopes := make(map[string]StatsScope
, len(statsScopes))
		// Compute all-time first per region so it
s total_players is the stable
		// denominator for per-season scopes withi
n that region.
		var allTimeTotalPlayers int64
		for _, sc := range statsScopes {
			ss, err := buildStatsScope(db, sc.SeasonN
um, region, allTimeTotalPlayers)
			if err != nil {
				return fmt.Errorf("scope %s/%s: %w", reg
ion, sc.Key, err)
			}
			if sc.SeasonNum == 0 {
				allTimeTotalPlayers = ss.TotalPlayers
			}
			regionScopes[sc.Key] = ss
		}
		out.Scopes[region] = regionScopes
	}

	outPath := filepath.Join(outDir, "api", "st
ats.json")
	if err := writer.WriteJSONFileCompact(outPa
th, out); err != nil {
		return fmt.Errorf("write stats.json: %w", 
err)
	}
	return nil
}

// loadStatsScopes returns all-time plus one
 scope per season in the DB
func loadStatsScopes(db *sql.DB) ([]statsSco
peDef, error) {
	rows, err := db.Query(`SELECT DISTINCT seas
on_number FROM seasons ORDER BY season_numbe
r ASC`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	scopes := []statsScopeDef{{Key: "all_time",
 SeasonNum: 0}}
	for rows.Next() {
		var n int
		if err := rows.Scan(&n); err != nil {
			return nil, err
		}
		scopes = append(scopes, statsScopeDef{Key:
 fmt.Sprintf("season_%d", n), SeasonNum: n})
	}
	return scopes, rows.Err()
}

// buildStatsScope assembles a single StatsS
cope.
// seasonNum == 0 means cross-season (all-ti
me). region == "global" means no region filt
er.
// For all-time, pass allTimeTotalPlayers=0;
 for per-season builds, pass the all-time sc
ope's
// TotalPlayers (within the same region) so 
the `percentile_of_all_time_players` field
// uses a stable denominator.
func buildStatsScope(db *sql.DB, seasonNum i
nt, region string, allTimeTotalPlayers int64
) (StatsScope, error) {
	var s StatsScope
	var err error

	if s.TotalRuns, err = countTotalRuns(db, se
asonNum, region); err != nil {
		return s, fmt.Errorf("total runs: %w", err
)
	}

	if s.TotalPlayers, err = countTotalPlayers(
db, seasonNum, region); err != nil {
		return s, fmt.Errorf("total players: %w", 
err)
	}

	// On all-time, the stable denominator is j
ust this scope's own TotalPlayers.
	allTimeDen := allTimeTotalPlayers
	if seasonNum == 0 {
		allTimeDen = s.TotalPlayers
	}

	tiers, completedCount, err := computeComple
tionTiers(db, seasonNum, region)
	if err != nil {
		return s, fmt.Errorf("completion tiers: %w
", err)
	}
	s.CompletionTiers = tiers
	s.NineOfNinePlayers = completedCount
	// fill percentile fields now that we know 
all three denominators
	s.CompletionTiers.NineOfNineGold = withPerc
entiles(s.CompletionTiers.NineOfNineGold, s.
TotalPlayers, completedCount, allTimeDen)
	s.CompletionTiers.NineOfNinePlatinum = with
Percentiles(s.CompletionTiers.NineOfNinePlat
inum, s.TotalPlayers, completedCount, allTim
eDen)
	s.CompletionTiers.NineOfNineTitle = withPer
centiles(s.CompletionTiers.NineOfNineTitle, 
s.TotalPlayers, completedCount, allTimeDen)

	if s.SpecCounts, err = computeSpecCounts(db
, seasonNum, region); err != nil {
		return s, fmt.Errorf("spec counts: %w", er
r)
	}

	if s.WeeklyActivity, err = queryWeeklyActiv
ity(db, seasonNum, region); err != nil {
		return s, fmt.Errorf("weekly activity: %w"
, err)
	}

	return s, nil
}

// seasonClauseAndArg returns the SQL fragme
nt and arg slice for filtering by challenge_
runs.season_id.
// Empty string for all-time (seasonNum == 0
).
func seasonClauseAndArg(seasonNum int, alias
 string) (string, []any) {
	if seasonNum == 0 {
		return "", nil
	}
	if alias == "" {
		alias = "challenge_runs"
	}
	return fmt.Sprintf(" AND %s.season_id = ?",
 alias), []any{seasonNum}
}

// regionClauseAndArg filters by realm regio
n. region == "global" returns empty (no filt
er).
// `realmAlias` is the SQL alias of the real
ms table reference in the FROM/JOIN clause.
func regionClauseAndArg(region, realmAlias s
tring) (string, []any) {
	if region == "" || region == "global" {
		return "", nil
	}
	if realmAlias == "" {
		realmAlias = "r"
	}
	return fmt.Sprintf(" AND %s.region = ?", re
almAlias), []any{region}
}

func countTotalRuns(db *sql.DB, seasonNum in
t, region string) (int64, error) {
	// Region filter requires joining realms; o
nly join when needed to keep the global
	// case as a fast plain COUNT(*) on challen
ge_runs.
	if region == "" || region == "global" {
		q := "SELECT COUNT(*) FROM challenge_runs 
cr WHERE 1=1"
		clause, args := seasonClauseAndArg(seasonN
um, "cr")
		var n int64
		if err := db.QueryRow(q+clause, args...).S
can(&n); err != nil {
			return 0, err
		}
		return n, nil
	}
	q := "SELECT COUNT(*) FROM challenge_runs c
r JOIN realms r ON r.id = cr.realm_id WHERE 
1=1"
	sClause, sArgs := seasonClauseAndArg(season
Num, "cr")
	rClause, rArgs := regionClauseAndArg(region
, "r")
	args := append(sArgs, rArgs...)
	var n int64
	if err := db.QueryRow(q+sClause+rClause, ar
gs...).Scan(&n); err != nil {
		return 0, err
	}
	return n, nil
}

// countTotalPlayers counts distinct players
 who appear in run_members within scope.
// "Within scope" = any player who participa
ted in a run during the given season+region.
func countTotalPlayers(db *sql.DB, seasonNum
 int, region string) (int64, error) {
	// Always join through challenge_runs so we
 can filter by season and/or region.
	q := `SELECT COUNT(DISTINCT rm.player_id)
	      FROM run_members rm
	      JOIN challenge_runs cr ON rm.run_id =
 cr.id`
	if region != "" && region != "global" {
		q += " JOIN realms r ON r.id = cr.realm_id
"
	}
	q += " WHERE 1=1"
	sClause, sArgs := seasonClauseAndArg(season
Num, "cr")
	rClause, rArgs := regionClauseAndArg(region
, "r")
	args := append(sArgs, rArgs...)
	var n int64
	if err := db.QueryRow(q+sClause+rClause, ar
gs...).Scan(&n); err != nil {
		return 0, err
	}
	return n, nil
}

// tiers are achievements earned within a si
ngle season; for all-time scope
// we take the union of per-season qualifier
s (hit 9/9 of a tier in any one
// season counts), not combine best times ac
ross seasons. returns tier
// counts plus the "completed" denominator (
any-season 9-dungeon finishers).
func computeCompletionTiers(db *sql.DB, seas
onNum int, region string) (CompletionTiers, 
int64, error) {
	q := `
		SELECT rm.player_id, cr.season_id, cr.dung
eon_id, MIN(cr.duration) AS best_duration
		FROM run_members rm
		JOIN challenge_runs cr ON rm.run_id = cr.i
d
		%s
		WHERE 1=1 %s %s
		GROUP BY rm.player_id, cr.season_id, cr.du
ngeon_id
	`
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.r
ealm_id"
	}
	sClause, sArgs := seasonClauseAndArg(season
Num, "cr")
	rClause, rArgs := regionClauseAndArg(region
, "r")
	args := append(sArgs, rArgs...)
	rows, err := db.Query(fmt.Sprintf(q, region
Join, sClause, rClause), args...)
	if err != nil {
		return CompletionTiers{}, 0, err
	}
	defer rows.Close()

	// (player_id, season_id) -> per-tier dunge
on-cleared count
	type key struct {
		playerID int64
		seasonID int
	}
	type counts struct{ gold, plat, title, anyD
ungeon int }
	per := make(map[key]*counts)
	for rows.Next() {
		var playerID int64
		var seasonID, dungeonID int
		var bestDuration int64
		if err := rows.Scan(&playerID, &seasonID, 
&dungeonID, &bestDuration); err != nil {
			return CompletionTiers{}, 0, err
		}
		threshold, ok := dungeonTimerThresholds[du
ngeonID]
		if !ok {
			continue // unknown dungeon - skip silent
ly
		}
		k := key{playerID, seasonID}
		c := per[k]
		if c == nil {
			c = &counts{}
			per[k] = c
		}
		c.anyDungeon++
		if beatsTier(bestDuration, threshold.GoldM
s) {
			c.gold++
		}
		if beatsTier(bestDuration, threshold.Plati
numMs) {
			c.plat++
		}
		if beatsTier(bestDuration, threshold.Title
Ms) {
			c.title++
		}
	}
	if err := rows.Err(); err != nil {
		return CompletionTiers{}, 0, err
	}

	// Union per player: did they qualify in *a
ny* season?
	type qualifiers struct{ gold, plat, title, 
completed bool }
	playerQual := make(map[int64]*qualifiers)
	for k, c := range per {
		q := playerQual[k.playerID]
		if q == nil {
			q = &qualifiers{}
			playerQual[k.playerID] = q
		}
		if c.anyDungeon == totalDungeons {
			q.completed = true
		}
		if c.gold == totalDungeons {
			q.gold = true
		}
		if c.plat == totalDungeons {
			q.plat = true
		}
		if c.title == totalDungeons {
			q.title = true
		}
	}

	var tiers CompletionTiers
	var completedCount int64
	for _, q := range playerQual {
		if q.completed {
			completedCount++
		}
		if q.gold {
			tiers.NineOfNineGold.Count++
		}
		if q.plat {
			tiers.NineOfNinePlatinum.Count++
		}
		if q.title {
			tiers.NineOfNineTitle.Count++
		}
	}
	return tiers, completedCount, nil
}

func withPercentiles(t CompletionTier, total
Players, completedPlayers, allTimePlayers in
t64) CompletionTier {
	if totalPlayers > 0 {
		t.PercentileOfAllPlayers = round2(float64(
t.Count) * 100.0 / float64(totalPlayers))
	}
	if completedPlayers > 0 {
		t.PercentileOfCompletedPlayers = round2(fl
oat64(t.Count) * 100.0 / float64(completedPl
ayers))
	}
	if allTimePlayers > 0 {
		t.PercentileOfAllTimePlayers = round2(floa
t64(t.Count) * 100.0 / float64(allTimePlayer
s))
	}
	return t
}

// computeSpecCounts returns the spec-distri
bution charts:
//
//	all_runs:      every run_member row in sc
ope
//	gold_runs:     run_members where the run 
beat the gold threshold for its dungeon
//	platinum_runs: same for platinum
//	title_runs:    same for title
//	top_50_runs:   run_members where the run 
was top-50 in scope (globally team-filtered 
for
//	               region=="global", regional
ly team-filtered for a specific region)
//
// Each bucket carries both `count` (slot oc
currences, includes spec stacking)
// and `runs_with_spec` (distinct run count,
 capped at the bucket's total_runs).
func computeSpecCounts(db *sql.DB, seasonNum
 int, region string) (map[string]SpecCountBu
cket, error) {
	out := map[string]SpecCountBucket{}

	allRuns, err := querySpecCountsForRuns(db, 
seasonNum, region)
	if err != nil {
		return nil, fmt.Errorf("all_runs: %w", err
)
	}
	out["all_runs"] = allRuns

	for _, tier := range []struct {
		key        string
		thresholdF func(dungeonThresholds) int64
	}{
		{"gold_runs", func(t dungeonThresholds) in
t64 { return t.GoldMs }},
		{"platinum_runs", func(t dungeonThresholds
) int64 { return t.PlatinumMs }},
		{"title_runs", func(t dungeonThresholds) i
nt64 { return t.TitleMs }},
	} {
		tierCounts, err := querySpecCountsForTier(
db, seasonNum, region, tier.thresholdF)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", tier.key
, err)
		}
		out[tier.key] = tierCounts
	}

	top50, err := querySpecCountsForTop50(db, s
easonNum, region)
	if err != nil {
		return nil, fmt.Errorf("top_50_runs: %w", 
err)
	}
	out["top_50_runs"] = top50

	return out, nil
}

// querySpecCountsForRuns counts run_member 
spec_ids across all runs in scope.
// Returns slot count + distinct-run count p
er spec, plus the bucket's total
// distinct run count, plus the same broken 
out per dungeon.
func querySpecCountsForRuns(db *sql.DB, seas
onNum int, region string) (SpecCountBucket, 
error) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.r
ealm_id"
	}
	sClause, sArgs := seasonClauseAndArg(season
Num, "cr")
	rClause, rArgs := regionClauseAndArg(region
, "r")
	args := append(sArgs, rArgs...)

	// each challenge_run has a single dungeon_
id, so one (dungeon, spec)
	// grouped query gives us both the per-dung
eon and overall totals
	q := `
		SELECT cr.dungeon_id, rm.spec_id,
		       COUNT(*) AS slot_count,
		       COUNT(DISTINCT rm.run_id) AS run_co
unt
		FROM run_members rm
		JOIN challenge_runs cr ON rm.run_id = cr.i
d
		%s
		WHERE rm.spec_id IS NOT NULL %s %s
		GROUP BY cr.dungeon_id, rm.spec_id
	`
	rows, err := db.Query(fmt.Sprintf(q, region
Join, sClause, rClause), args...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer rows.Close()

	// dungeonId -> specId -> {count, distinctR
uns}
	perDungeon := make(map[int]map[int]*specRun
Count)
	overall := make(map[int]*specRunCount)
	for rows.Next() {
		var dungeonID, specID int
		var slot, runs int64
		if err := rows.Scan(&dungeonID, &specID, &
slot, &runs); err != nil {
			return SpecCountBucket{}, err
		}
		dm, ok := perDungeon[dungeonID]
		if !ok {
			dm = make(map[int]*specRunCount)
			perDungeon[dungeonID] = dm
		}
		dm[specID] = &specRunCount{count: slot, di
stinctRuns: runs}
		o := overall[specID]
		if o == nil {
			o = &specRunCount{}
			overall[specID] = o
		}
		o.count += slot
		// Each run lives in one dungeon, so disti
nct runs sum cleanly across dungeons.
		o.distinctRuns += runs
	}
	if err := rows.Err(); err != nil {
		return SpecCountBucket{}, err
	}

	// per-dungeon and overall in one query
	perDungeonTotals, overallTotal, err := quer
yRunCountsForRuns(db, seasonNum, region)
	if err != nil {
		return SpecCountBucket{}, err
	}

	bucket := SpecCountBucket{
		TotalRuns: overallTotal,
		Entries:   entriesFromCounts(overall),
		ByDungeon: make(map[int]DungeonSpecBucket,
 len(perDungeonTotals)),
	}
	for dungeonID, total := range perDungeonTot
als {
		bucket.ByDungeon[dungeonID] = DungeonSpecB
ucket{
			TotalRuns: total,
			Entries:   entriesFromCounts(perDungeon[d
ungeonID]),
		}
	}
	return bucket, nil
}

// queryRunCountsForRuns returns distinct-ru
n counts per dungeon (and the overall
// sum) within scope, used as the denominato
r for the spec-distribution chart's
// "Runs" metric and for the dungeon-distrib
ution chart.
func queryRunCountsForRuns(db *sql.DB, seaso
nNum int, region string) (map[int]int64, int
64, error) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.r
ealm_id"
	}
	sClause, sArgs := seasonClauseAndArg(season
Num, "cr")
	rClause, rArgs := regionClauseAndArg(region
, "r")
	args := append(sArgs, rArgs...)
	q := fmt.Sprintf(`
		SELECT cr.dungeon_id, COUNT(DISTINCT cr.id
)
		FROM challenge_runs cr
		%s
		WHERE 1=1 %s %s
		GROUP BY cr.dungeon_id
	`, regionJoin, sClause, rClause)
	rows, err := db.Query(q, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	perDungeon := make(map[int]int64)
	var total int64
	for rows.Next() {
		var dungeonID int
		var count int64
		if err := rows.Scan(&dungeonID, &count); e
rr != nil {
			return nil, 0, err
		}
		perDungeon[dungeonID] = count
		total += count
	}
	return perDungeon, total, rows.Err()
}

// specRunCount aggregates slot count + dist
inct-run count for a single spec.
type specRunCount struct {
	count, distinctRuns int64
}

// entriesFromCounts converts a (specId -> {
count, distinctRuns}) map into the
// sorted SpecCountEntry slice used by the c
hart, attaching class/spec metadata.
// Sorted descending by count for stable ord
er; the chart re-orders by canonical
// class+spec layout but tooltips/iteration 
work nicer on a deterministic input.
func entriesFromCounts(m map[int]*specRunCou
nt) []SpecCountEntry {
	if m == nil {
		return []SpecCountEntry{}
	}
	out := make([]SpecCountEntry, 0, len(m))
	for specID, c := range m {
		entry := SpecCountEntry{SpecID: specID, Co
unt: c.count, RunsWithSpec: c.distinctRuns}
		if cls, spec, ok := wow.GetClassAndSpec(sp
ecID); ok {
			entry.ClassName = cls
			entry.SpecName = spec
		}
		out = append(out, entry)
	}
	for i := 1; i < len(out); i++ {
		for j := i; j > 0 && out[j].Count > out[j-
1].Count; j-- {
			out[j], out[j-1] = out[j-1], out[j]
		}
	}
	return out
}

// querySpecCountsForTier counts run_member 
spec_ids across runs that beat a per-dungeon
 threshold.
// We can't easily express the per-dungeon t
hreshold in a single SQL query, so instead w
e load
// (run_id, dungeon_id, duration) rows in sc
ope, decide qualification in Go, then count 
specs by
// the qualifying run_id set. We track both 
slot occurrences and distinct-run-with-spec 
counts,
// and a per-dungeon breakdown for the spec-
distribution chart's dungeon filter.
func querySpecCountsForTier(db *sql.DB, seas
onNum int, region string, thresholdF func(du
ngeonThresholds) int64) (SpecCountBucket, er
ror) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.r
ealm_id"
	}
	sClause, sArgs := seasonClauseAndArg(season
Num, "cr")
	rClause, rArgs := regionClauseAndArg(region
, "r")
	args := append(sArgs, rArgs...)
	rows, err := db.Query(fmt.Sprintf(`
		SELECT cr.id, cr.dungeon_id, cr.duration
		FROM challenge_runs cr
		%s
		WHERE 1=1 %s %s
	`, regionJoin, sClause, rClause), args...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	// runId -> dungeonId for runs that beat th
e threshold. Per-dungeon counts
	// fall out of the same map.
	qualifyingDungeon := make(map[int64]int)
	dungeonRunCounts := make(map[int]int64)
	for rows.Next() {
		var id int64
		var dungeonID int
		var duration int64
		if err := rows.Scan(&id, &dungeonID, &dura
tion); err != nil {
			rows.Close()
			return SpecCountBucket{}, err
		}
		threshold, ok := dungeonTimerThresholds[du
ngeonID]
		if !ok {
			continue
		}
		if beatsTier(duration, thresholdF(threshol
d)) {
			qualifyingDungeon[id] = dungeonID
			dungeonRunCounts[dungeonID]++
		}
	}
	rows.Close()
	if len(qualifyingDungeon) == 0 {
		return SpecCountBucket{TotalRuns: 0, Entri
es: []SpecCountEntry{}, ByDungeon: map[int]D
ungeonSpecBucket{}}, nil
	}

	// For qualifying runs, count slot occurren
ces AND distinct runs per spec,
	// both overall and per-dungeon. Per-dungeo
n distinct-run counting reuses
	// the qualifying map (one run -> one dunge
on, no double-count).
	overall := make(map[int]*specAgg)
	perDungeon := make(map[int]map[int]*specAgg
)
	memberRows, err := db.Query(`
		SELECT run_id, spec_id
		FROM run_members
		WHERE spec_id IS NOT NULL
	`)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer memberRows.Close()
	for memberRows.Next() {
		var runID int64
		var specID int
		if err := memberRows.Scan(&runID, &specID)
; err != nil {
			return SpecCountBucket{}, err
		}
		dungeonID, ok := qualifyingDungeon[runID]
		if !ok {
			continue
		}
		o := overall[specID]
		if o == nil {
			o = &specAgg{runs: make(map[int64]struct{
})}
			overall[specID] = o
		}
		o.count++
		o.runs[runID] = struct{}{}

		dm, ok := perDungeon[dungeonID]
		if !ok {
			dm = make(map[int]*specAgg)
			perDungeon[dungeonID] = dm
		}
		da := dm[specID]
		if da == nil {
			da = &specAgg{runs: make(map[int64]struct
{})}
			dm[specID] = da
		}
		da.count++
		da.runs[runID] = struct{}{}
	}

	bucket := SpecCountBucket{
		TotalRuns: int64(len(qualifyingDungeon)),
		Entries:   specAggsToSlice(overall),
		ByDungeon: make(map[int]DungeonSpecBucket,
 len(perDungeon)),
	}
	for dungeonID, total := range dungeonRunCou
nts {
		bucket.ByDungeon[dungeonID] = DungeonSpecB
ucket{
			TotalRuns: total,
			Entries:   specAggsToSlice(perDungeon[dun
geonID]),
		}
	}
	return bucket, nil
}

// querySpecCountsForTop50 counts spec_ids i
n top-50 runs.
// region "global" ? top 50 globally (filter
ed); region "us"/"eu"/etc ? top 50 within th
at region.
// Per-dungeon breakdown is also returned (e
ach dungeon's top 50 contributes 50 runs to 
its bucket).
func querySpecCountsForTop50(db *sql.DB, sea
sonNum int, region string) (SpecCountBucket,
 error) {
	rankingType := "global"
	rankingScope := "filtered"
	if region != "" && region != "global" {
		rankingType = "regional"
		rankingScope = region + "_filtered"
	}
	// Group by (dungeon, spec) so we get both 
per-dungeon and overall totals
	// from a single result set. cr.dungeon_id 
comes from joining challenge_runs.
	q := `
		SELECT cr.dungeon_id, rm.spec_id,
		       COUNT(*) AS slot_count,
		       COUNT(DISTINCT rm.run_id) AS run_co
unt
		FROM run_members rm
		JOIN run_rankings rr ON rm.run_id = rr.run
_id
		JOIN challenge_runs cr ON cr.id = rm.run_i
d
		WHERE rm.spec_id IS NOT NULL
		  AND rr.ranking_type = ?
		  AND rr.ranking_scope = ?
		  AND rr.ranking <= 50
	`
	args := []any{rankingType, rankingScope}
	if seasonNum != 0 {
		q += " AND rr.season_id = ?"
		args = append(args, seasonNum)
	}
	q += " GROUP BY cr.dungeon_id, rm.spec_id"

	rows, err := db.Query(q, args...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer rows.Close()

	perDungeon := make(map[int]map[int]*specRun
Count)
	overall := make(map[int]*specRunCount)
	for rows.Next() {
		var dungeonID, specID int
		var slot, runs int64
		if err := rows.Scan(&dungeonID, &specID, &
slot, &runs); err != nil {
			return SpecCountBucket{}, err
		}
		dm, ok := perDungeon[dungeonID]
		if !ok {
			dm = make(map[int]*specRunCount)
			perDungeon[dungeonID] = dm
		}
		dm[specID] = &specRunCount{count: slot, di
stinctRuns: runs}
		o := overall[specID]
		if o == nil {
			o = &specRunCount{}
			overall[specID] = o
		}
		o.count += slot
		o.distinctRuns += runs
	}
	if err := rows.Err(); err != nil {
		return SpecCountBucket{}, err
	}

	// Per-dungeon distinct top-50 run counts (
and total).
	totalsQ := `
		SELECT cr.dungeon_id, COUNT(DISTINCT rr.ru
n_id)
		FROM run_rankings rr
		JOIN challenge_runs cr ON cr.id = rr.run_i
d
		WHERE rr.ranking_type = ?
		  AND rr.ranking_scope = ?
		  AND rr.ranking <= 50
	`
	totalsArgs := []any{rankingType, rankingSco
pe}
	if seasonNum != 0 {
		totalsQ += " AND rr.season_id = ?"
		totalsArgs = append(totalsArgs, seasonNum)
	}
	totalsQ += " GROUP BY cr.dungeon_id"
	totalRows, err := db.Query(totalsQ, totalsA
rgs...)
	if err != nil {
		return SpecCountBucket{}, err
	}
	defer totalRows.Close()
	dungeonTotals := make(map[int]int64)
	var grandTotal int64
	for totalRows.Next() {
		var dungeonID int
		var count int64
		if err := totalRows.Scan(&dungeonID, &coun
t); err != nil {
			return SpecCountBucket{}, err
		}
		dungeonTotals[dungeonID] = count
		grandTotal += count
	}

	bucket := SpecCountBucket{
		TotalRuns: grandTotal,
		Entries:   entriesFromCounts(overall),
		ByDungeon: make(map[int]DungeonSpecBucket,
 len(dungeonTotals)),
	}
	for dungeonID, total := range dungeonTotals
 {
		bucket.ByDungeon[dungeonID] = DungeonSpecB
ucket{
			TotalRuns: total,
			Entries:   entriesFromCounts(perDungeon[d
ungeonID]),
		}
	}
	return bucket, nil
}

// in-memory aggregation when SQL counting i
s awkward (see querySpecCountsForTier)
type specAgg struct {
	count int64
	runs  map[int64]struct{}
}

func specAggsToSlice(aggs map[int]*specAgg) 
[]SpecCountEntry {
	out := make([]SpecCountEntry, 0, len(aggs))
	for specID, a := range aggs {
		entry := SpecCountEntry{SpecID: specID, Co
unt: a.count, RunsWithSpec: int64(len(a.runs
))}
		if cls, spec, ok := wow.GetClassAndSpec(sp
ecID); ok {
			entry.ClassName = cls
			entry.SpecName = spec
		}
		out = append(out, entry)
	}
	// simple insertion sort by count desc (sma
ll N: ~33 specs)
	for i := 1; i < len(out); i++ {
		for j := i; j > 0 && out[j].Count > out[j-
1].Count; j-- {
			out[j], out[j-1] = out[j-1], out[j]
		}
	}
	return out
}

// queryWeeklyActivity returns runs grouped 
by Monday-start week.
// Timestamps are stored in milliseconds; SQ
Lite date functions need seconds.
func queryWeeklyActivity(db *sql.DB, seasonN
um int, region string) ([]WeeklyActivityEntr
y, error) {
	regionJoin := ""
	if region != "" && region != "global" {
		regionJoin = "JOIN realms r ON r.id = cr.r
ealm_id"
	}
	sClause, sArgs := seasonClauseAndArg(season
Num, "cr")
	rClause, rArgs := regionClauseAndArg(region
, "r")
	args := append(sArgs, rArgs...)
	// 'weekday 1' advances to next Monday; '-7
 days' rolls back to start of current week.
	q := fmt.Sprintf(`
		SELECT
			date(cr.completed_timestamp / 1000, 'unix
epoch', 'weekday 1', '-7 days') AS week_star
t,
			COUNT(*) AS run_count
		FROM challenge_runs cr
		%s
		WHERE cr.completed_timestamp IS NOT NULL %
s %s
		GROUP BY week_start
		ORDER BY week_start
	`, regionJoin, sClause, rClause)
	rows, err := db.Query(q, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []WeeklyActivityEntry
	for rows.Next() {
		var e WeeklyActivityEntry
		if err := rows.Scan(&e.WeekStart, &e.RunCo
unt); err != nil {
			return nil, err
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

func round2(f float64) float64 {
	return float64(int64(f*100+0.5)) / 100
}
 
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET