cricinfo-cli-go 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. package/scripts/postinstall.js +153 -0
@@ -0,0 +1,1401 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "sort"
8
+ "strconv"
9
+ "strings"
10
+ )
11
+
12
+ const (
13
+ analysisScopeMatch = "match"
14
+ analysisScopeSeason = "season"
15
+ analysisScopeSeasons = "seasons"
16
+
17
+ analysisMetricEconomy = "economy"
18
+ analysisMetricDots = "dots"
19
+ analysisMetricSixesConceded = "sixes-conceded"
20
+ analysisMetricFours = "fours"
21
+ analysisMetricSixes = "sixes"
22
+ analysisMetricStrikeRate = "strike-rate"
23
+ )
24
+
25
+ var (
26
+ analysisGroupDismissAllowed = map[string]struct{}{
27
+ "player": {},
28
+ "team": {},
29
+ "league": {},
30
+ "season": {},
31
+ "dismissal-type": {},
32
+ "innings": {},
33
+ }
34
+ analysisGroupBowlingAllowed = map[string]struct{}{
35
+ "player": {},
36
+ "team": {},
37
+ "league": {},
38
+ "season": {},
39
+ }
40
+ analysisGroupBattingAllowed = map[string]struct{}{
41
+ "player": {},
42
+ "team": {},
43
+ "league": {},
44
+ "season": {},
45
+ }
46
+ analysisGroupPartnershipAllowed = map[string]struct{}{
47
+ "team": {},
48
+ "league": {},
49
+ "season": {},
50
+ "innings": {},
51
+ }
52
+ )
53
+
54
+ // AnalysisServiceConfig configures analysis command behavior.
55
+ type AnalysisServiceConfig struct {
56
+ Client *Client
57
+ Resolver *Resolver
58
+ Hydration *HistoricalHydrationService
59
+ }
60
+
61
+ // AnalysisDismissalOptions configures dismissal analysis execution.
62
+ type AnalysisDismissalOptions struct {
63
+ LeagueQuery string
64
+ Seasons string
65
+ TypeQuery string
66
+ GroupQuery string
67
+ DateFrom string
68
+ DateTo string
69
+ MatchLimit int
70
+ GroupBy string
71
+ TeamQuery string
72
+ PlayerQuery string
73
+ DismissalType string
74
+ Innings int
75
+ Period int
76
+ Top int
77
+ }
78
+
79
+ // AnalysisMetricOptions configures bowling/batting/partnership analysis execution.
80
+ type AnalysisMetricOptions struct {
81
+ Metric string
82
+ Scope string
83
+ LeagueQuery string
84
+ TypeQuery string
85
+ GroupQuery string
86
+ DateFrom string
87
+ DateTo string
88
+ MatchLimit int
89
+ GroupBy string
90
+ TeamQuery string
91
+ PlayerQuery string
92
+ DismissalType string
93
+ Innings int
94
+ Period int
95
+ Top int
96
+ }
97
+
98
+ // AnalysisService derives ranked cricket analysis over normalized hydrated data.
99
+ type AnalysisService struct {
100
+ client *Client
101
+ resolver *Resolver
102
+ hydration *HistoricalHydrationService
103
+ ownsResolver bool
104
+ ownsHydration bool
105
+ }
106
+
107
+ // NewAnalysisService builds an analysis service using default client/resolver/hydration when omitted.
108
+ func NewAnalysisService(cfg AnalysisServiceConfig) (*AnalysisService, error) {
109
+ client := cfg.Client
110
+ if client == nil {
111
+ var err error
112
+ client, err = NewClient(Config{})
113
+ if err != nil {
114
+ return nil, err
115
+ }
116
+ }
117
+
118
+ resolver := cfg.Resolver
119
+ ownsResolver := false
120
+ if resolver == nil {
121
+ var err error
122
+ resolver, err = NewResolver(ResolverConfig{Client: client})
123
+ if err != nil {
124
+ return nil, err
125
+ }
126
+ ownsResolver = true
127
+ }
128
+
129
+ hydration := cfg.Hydration
130
+ ownsHydration := false
131
+ if hydration == nil {
132
+ var err error
133
+ hydration, err = NewHistoricalHydrationService(HistoricalHydrationServiceConfig{
134
+ Client: client,
135
+ Resolver: resolver,
136
+ })
137
+ if err != nil {
138
+ if ownsResolver {
139
+ _ = resolver.Close()
140
+ }
141
+ return nil, err
142
+ }
143
+ ownsHydration = true
144
+ }
145
+
146
+ return &AnalysisService{
147
+ client: client,
148
+ resolver: resolver,
149
+ hydration: hydration,
150
+ ownsResolver: ownsResolver,
151
+ ownsHydration: ownsHydration,
152
+ }, nil
153
+ }
154
+
155
+ // Close persists resolver state when owned by this service.
156
+ func (s *AnalysisService) Close() error {
157
+ var errs []error
158
+ if s.ownsHydration && s.hydration != nil {
159
+ if err := s.hydration.Close(); err != nil {
160
+ errs = append(errs, err)
161
+ }
162
+ }
163
+ if s.ownsResolver && s.resolver != nil {
164
+ if err := s.resolver.Close(); err != nil {
165
+ errs = append(errs, err)
166
+ }
167
+ }
168
+ if len(errs) == 0 {
169
+ return nil
170
+ }
171
+ return errors.Join(errs...)
172
+ }
173
+
174
+ // Dismissals ranks dismissal patterns across league+season scope.
175
+ func (s *AnalysisService) Dismissals(ctx context.Context, opts AnalysisDismissalOptions) (NormalizedResult, error) {
176
+ leagueQuery := strings.TrimSpace(opts.LeagueQuery)
177
+ if leagueQuery == "" {
178
+ return NormalizedResult{
179
+ Kind: EntityAnalysisDismiss,
180
+ Status: ResultStatusEmpty,
181
+ Message: "--league is required",
182
+ }, nil
183
+ }
184
+
185
+ seasons, err := parseSeasonRange(opts.Seasons)
186
+ if err != nil {
187
+ return NormalizedResult{
188
+ Kind: EntityAnalysisDismiss,
189
+ Status: ResultStatusEmpty,
190
+ Message: err.Error(),
191
+ }, nil
192
+ }
193
+
194
+ groupBy, err := parseGroupBy(opts.GroupBy, []string{"dismissal-type"}, analysisGroupDismissAllowed)
195
+ if err != nil {
196
+ return NormalizedResult{Kind: EntityAnalysisDismiss, Status: ResultStatusEmpty, Message: err.Error()}, nil
197
+ }
198
+ filters := analysisFiltersFromDismissal(opts)
199
+ top := limitOrDefault(opts.Top, 20)
200
+
201
+ agg := map[string]*analysisAggregate{}
202
+ warnings := make([]string, 0)
203
+ combinedMatchIDs := make([]string, 0)
204
+ combinedMetrics := HydrationMetrics{}
205
+ leagueName := ""
206
+
207
+ for _, seasonQuery := range seasons {
208
+ session, scopeSummary, beginWarnings, passthrough := s.beginSeasonScope(ctx, analysisSeasonScopeRequest{
209
+ LeagueQuery: leagueQuery,
210
+ SeasonQuery: seasonQuery,
211
+ TypeQuery: opts.TypeQuery,
212
+ GroupQuery: opts.GroupQuery,
213
+ DateFrom: opts.DateFrom,
214
+ DateTo: opts.DateTo,
215
+ MatchLimit: opts.MatchLimit,
216
+ }, EntityAnalysisDismiss)
217
+ if passthrough != nil {
218
+ return *passthrough, nil
219
+ }
220
+ warnings = append(warnings, beginWarnings...)
221
+
222
+ scope := scopeSummary
223
+ if strings.TrimSpace(leagueName) == "" {
224
+ leagueName = strings.TrimSpace(scope.League.Name)
225
+ }
226
+ combinedMatchIDs = append(combinedMatchIDs, scope.MatchIDs...)
227
+ metrics := session.Metrics()
228
+ combinedMetrics.ResolveCacheHits += metrics.ResolveCacheHits
229
+ combinedMetrics.ResolveCacheMisses += metrics.ResolveCacheMisses
230
+ combinedMetrics.DomainCacheHits += metrics.DomainCacheHits
231
+ combinedMetrics.DomainCacheMisses += metrics.DomainCacheMisses
232
+
233
+ seasonID := seasonIdentifier(scope, seasonQuery)
234
+ matches := session.ScopedMatches()
235
+ for _, match := range matches {
236
+ playerByID, playerWarnings := s.playerNameMapForMatch(ctx, session, match.ID)
237
+ warnings = append(warnings, playerWarnings...)
238
+
239
+ innings, inningsWarnings, hydrateErr := session.HydrateInnings(ctx, match.ID)
240
+ if hydrateErr != nil {
241
+ if statusErr := analysisTransportResult(EntityAnalysisDismiss, match.ID, hydrateErr); statusErr != nil {
242
+ return *statusErr, nil
243
+ }
244
+ warnings = append(warnings, fmt.Sprintf("match %s innings: %v", match.ID, hydrateErr))
245
+ continue
246
+ }
247
+ warnings = append(warnings, inningsWarnings...)
248
+
249
+ for _, inn := range innings {
250
+ for _, wicket := range inn.WicketTimeline {
251
+ dismissalType := firstNonEmptyString(wicket.FOWType, wicket.DismissalCard)
252
+ if dismissalType == "" {
253
+ continue
254
+ }
255
+ playerID := strings.TrimSpace(refIDs(wicket.AthleteRef)["athleteId"])
256
+ row := analysisSourceRow{
257
+ MatchID: strings.TrimSpace(match.ID),
258
+ LeagueID: strings.TrimSpace(match.LeagueID),
259
+ SeasonID: seasonID,
260
+ TeamID: strings.TrimSpace(inn.TeamID),
261
+ TeamName: strings.TrimSpace(inn.TeamName),
262
+ PlayerID: playerID,
263
+ PlayerName: strings.TrimSpace(playerByID[playerID]),
264
+ DismissalType: dismissalType,
265
+ InningsNumber: inn.InningsNumber,
266
+ Period: inn.Period,
267
+ CountValue: 1,
268
+ }
269
+ if row.PlayerName == "" {
270
+ row.PlayerName = row.PlayerID
271
+ }
272
+
273
+ if !filters.matches(row) {
274
+ continue
275
+ }
276
+ key, dims := buildAnalysisGroup(row, groupBy)
277
+ entry := agg[key]
278
+ if entry == nil {
279
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
280
+ agg[key] = entry
281
+ }
282
+ entry.count += row.CountValue
283
+ entry.matchIDs[row.MatchID] = struct{}{}
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ rows := make([]AnalysisRow, 0, len(agg))
290
+ for key, entry := range agg {
291
+ row := entry.row
292
+ row.Key = key
293
+ row.Metric = "dismissals"
294
+ row.Value = float64(entry.count)
295
+ row.Count = entry.count
296
+ row.Matches = len(entry.matchIDs)
297
+ rows = append(rows, row)
298
+ }
299
+ rows = rankAnalysisRows(rows, false)
300
+ rows = trimAnalysisRows(rows, top)
301
+
302
+ view := AnalysisView{
303
+ Command: "dismissals",
304
+ Metric: "dismissals",
305
+ Scope: AnalysisScope{
306
+ Mode: analysisScopeSeasons,
307
+ LeagueID: leagueQuery,
308
+ LeagueName: leagueName,
309
+ Seasons: seasons,
310
+ MatchIDs: dedupeStrings(combinedMatchIDs),
311
+ MatchCount: len(dedupeStrings(combinedMatchIDs)),
312
+ DateFrom: strings.TrimSpace(opts.DateFrom),
313
+ DateTo: strings.TrimSpace(opts.DateTo),
314
+ TypeQuery: strings.TrimSpace(opts.TypeQuery),
315
+ GroupQuery: strings.TrimSpace(opts.GroupQuery),
316
+ HydrationMetric: combinedMetrics,
317
+ },
318
+ GroupBy: groupBy,
319
+ Filters: AnalysisFilters{
320
+ TeamQuery: strings.TrimSpace(opts.TeamQuery),
321
+ PlayerQuery: strings.TrimSpace(opts.PlayerQuery),
322
+ DismissalType: strings.TrimSpace(opts.DismissalType),
323
+ Innings: opts.Innings,
324
+ Period: opts.Period,
325
+ },
326
+ Rows: rows,
327
+ }
328
+
329
+ return analysisResult(EntityAnalysisDismiss, view, warnings), nil
330
+ }
331
+
332
+ // Bowling ranks bowling metrics over match or season scope.
333
+ func (s *AnalysisService) Bowling(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
334
+ metric, err := normalizeBowlingMetric(opts.Metric)
335
+ if err != nil {
336
+ return NormalizedResult{Kind: EntityAnalysisBowl, Status: ResultStatusEmpty, Message: err.Error()}, nil
337
+ }
338
+ groupBy, err := parseGroupBy(opts.GroupBy, []string{"player"}, analysisGroupBowlingAllowed)
339
+ if err != nil {
340
+ return NormalizedResult{Kind: EntityAnalysisBowl, Status: ResultStatusEmpty, Message: err.Error()}, nil
341
+ }
342
+ filters := analysisFiltersFromMetric(opts)
343
+ top := limitOrDefault(opts.Top, 20)
344
+
345
+ run, passthrough := s.resolveMetricScope(ctx, opts, EntityAnalysisBowl)
346
+ if passthrough != nil {
347
+ return *passthrough, nil
348
+ }
349
+
350
+ agg := map[string]*analysisAggregate{}
351
+ warnings := append([]string{}, run.warnings...)
352
+ for _, match := range run.session.ScopedMatches() {
353
+ seasonID := seasonForMatch(match, run.seasonHint)
354
+ players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
355
+ if hydrateErr != nil {
356
+ if statusErr := analysisTransportResult(EntityAnalysisBowl, match.ID, hydrateErr); statusErr != nil {
357
+ return *statusErr, nil
358
+ }
359
+ warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
360
+ continue
361
+ }
362
+ warnings = append(warnings, playerWarnings...)
363
+
364
+ for _, player := range players {
365
+ totals := extractBowlingTotals(player)
366
+ playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
367
+ teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
368
+ row := analysisSourceRow{
369
+ MatchID: strings.TrimSpace(player.MatchID),
370
+ LeagueID: strings.TrimSpace(player.LeagueID),
371
+ SeasonID: seasonID,
372
+ TeamID: strings.TrimSpace(player.TeamID),
373
+ TeamName: strings.TrimSpace(teamName),
374
+ PlayerID: strings.TrimSpace(player.PlayerID),
375
+ PlayerName: strings.TrimSpace(playerName),
376
+ CountValue: 1,
377
+ Dots: totals.dots,
378
+ SixesConceded: totals.sixesConceded,
379
+ Balls: totals.balls,
380
+ RunsConceded: totals.conceded,
381
+ EconomySample: totals.economy,
382
+ }
383
+ if !filters.matches(row) {
384
+ continue
385
+ }
386
+ key, dims := buildAnalysisGroup(row, groupBy)
387
+ entry := agg[key]
388
+ if entry == nil {
389
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
390
+ agg[key] = entry
391
+ }
392
+ entry.matchIDs[row.MatchID] = struct{}{}
393
+ entry.dots += row.Dots
394
+ entry.sixesConceded += row.SixesConceded
395
+ entry.balls += row.Balls
396
+ entry.runsConceded += row.RunsConceded
397
+ if row.EconomySample > 0 {
398
+ entry.economyTotal += row.EconomySample
399
+ entry.economyCount++
400
+ }
401
+ }
402
+ }
403
+
404
+ rows := make([]AnalysisRow, 0, len(agg))
405
+ for key, entry := range agg {
406
+ if entry == nil {
407
+ continue
408
+ }
409
+ if !hasBowlingActivity(entry) {
410
+ continue
411
+ }
412
+ row := entry.row
413
+ row.Key = key
414
+ row.Metric = metric
415
+ row.Matches = len(entry.matchIDs)
416
+
417
+ switch metric {
418
+ case analysisMetricEconomy:
419
+ row.Value = economyFromAggregate(entry)
420
+ row.Extras = map[string]any{
421
+ "runsConceded": entry.runsConceded,
422
+ "balls": entry.balls,
423
+ "dots": entry.dots,
424
+ "sixesConceded": entry.sixesConceded,
425
+ }
426
+ case analysisMetricDots:
427
+ row.Value = float64(entry.dots)
428
+ row.Count = entry.dots
429
+ case analysisMetricSixesConceded:
430
+ row.Value = float64(entry.sixesConceded)
431
+ row.Count = entry.sixesConceded
432
+ }
433
+ rows = append(rows, row)
434
+ }
435
+
436
+ rows = rankAnalysisRows(rows, metric == analysisMetricEconomy)
437
+ rows = trimAnalysisRows(rows, top)
438
+
439
+ view := AnalysisView{
440
+ Command: "bowling",
441
+ Metric: metric,
442
+ Scope: buildSingleScope(run, opts),
443
+ GroupBy: groupBy,
444
+ Filters: AnalysisFilters{TeamQuery: strings.TrimSpace(opts.TeamQuery), PlayerQuery: strings.TrimSpace(opts.PlayerQuery)},
445
+ Rows: rows,
446
+ }
447
+ return analysisResult(EntityAnalysisBowl, view, warnings), nil
448
+ }
449
+
450
+ // Batting ranks batting metrics over match or season scope.
451
+ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
452
+ metric, err := normalizeBattingMetric(opts.Metric)
453
+ if err != nil {
454
+ return NormalizedResult{Kind: EntityAnalysisBat, Status: ResultStatusEmpty, Message: err.Error()}, nil
455
+ }
456
+ groupBy, err := parseGroupBy(opts.GroupBy, []string{"player"}, analysisGroupBattingAllowed)
457
+ if err != nil {
458
+ return NormalizedResult{Kind: EntityAnalysisBat, Status: ResultStatusEmpty, Message: err.Error()}, nil
459
+ }
460
+ filters := analysisFiltersFromMetric(opts)
461
+ top := limitOrDefault(opts.Top, 20)
462
+
463
+ run, passthrough := s.resolveMetricScope(ctx, opts, EntityAnalysisBat)
464
+ if passthrough != nil {
465
+ return *passthrough, nil
466
+ }
467
+
468
+ agg := map[string]*analysisAggregate{}
469
+ warnings := append([]string{}, run.warnings...)
470
+ for _, match := range run.session.ScopedMatches() {
471
+ seasonID := seasonForMatch(match, run.seasonHint)
472
+ players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
473
+ if hydrateErr != nil {
474
+ if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
475
+ return *statusErr, nil
476
+ }
477
+ warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
478
+ continue
479
+ }
480
+ warnings = append(warnings, playerWarnings...)
481
+
482
+ for _, player := range players {
483
+ totals := extractBattingTotals(player)
484
+ playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
485
+ teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
486
+ row := analysisSourceRow{
487
+ MatchID: strings.TrimSpace(player.MatchID),
488
+ LeagueID: strings.TrimSpace(player.LeagueID),
489
+ SeasonID: seasonID,
490
+ TeamID: strings.TrimSpace(player.TeamID),
491
+ TeamName: strings.TrimSpace(teamName),
492
+ PlayerID: strings.TrimSpace(player.PlayerID),
493
+ PlayerName: strings.TrimSpace(playerName),
494
+ CountValue: 1,
495
+ Fours: totals.fours,
496
+ BattingSixes: totals.sixes,
497
+ RunsScored: totals.runs,
498
+ BallsFaced: totals.balls,
499
+ StrikeSample: totals.strikeRate,
500
+ }
501
+ if !filters.matches(row) {
502
+ continue
503
+ }
504
+ key, dims := buildAnalysisGroup(row, groupBy)
505
+ entry := agg[key]
506
+ if entry == nil {
507
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
508
+ agg[key] = entry
509
+ }
510
+ entry.matchIDs[row.MatchID] = struct{}{}
511
+ entry.fours += row.Fours
512
+ entry.battingSixes += row.BattingSixes
513
+ entry.runsScored += row.RunsScored
514
+ entry.ballsFaced += row.BallsFaced
515
+ if row.StrikeSample > 0 {
516
+ entry.strikeRateTotal += row.StrikeSample
517
+ entry.strikeRateCount++
518
+ }
519
+ }
520
+ }
521
+
522
+ rows := make([]AnalysisRow, 0, len(agg))
523
+ for key, entry := range agg {
524
+ row := entry.row
525
+ row.Key = key
526
+ row.Metric = metric
527
+ row.Matches = len(entry.matchIDs)
528
+
529
+ switch metric {
530
+ case analysisMetricFours:
531
+ row.Value = float64(entry.fours)
532
+ row.Count = entry.fours
533
+ case analysisMetricSixes:
534
+ row.Value = float64(entry.battingSixes)
535
+ row.Count = entry.battingSixes
536
+ case analysisMetricStrikeRate:
537
+ row.Value = strikeRateFromAggregate(entry)
538
+ }
539
+ row.Extras = map[string]any{
540
+ "runs": entry.runsScored,
541
+ "ballsFaced": entry.ballsFaced,
542
+ "fours": entry.fours,
543
+ "sixes": entry.battingSixes,
544
+ }
545
+ rows = append(rows, row)
546
+ }
547
+
548
+ rows = rankAnalysisRows(rows, false)
549
+ rows = trimAnalysisRows(rows, top)
550
+
551
+ view := AnalysisView{
552
+ Command: "batting",
553
+ Metric: metric,
554
+ Scope: buildSingleScope(run, opts),
555
+ GroupBy: groupBy,
556
+ Filters: AnalysisFilters{TeamQuery: strings.TrimSpace(opts.TeamQuery), PlayerQuery: strings.TrimSpace(opts.PlayerQuery)},
557
+ Rows: rows,
558
+ }
559
+ return analysisResult(EntityAnalysisBat, view, warnings), nil
560
+ }
561
+
562
+ // Partnerships ranks partnerships over match or season scope.
563
+ func (s *AnalysisService) Partnerships(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
564
+ groupBy, err := parseGroupBy(opts.GroupBy, []string{"innings"}, analysisGroupPartnershipAllowed)
565
+ if err != nil {
566
+ return NormalizedResult{Kind: EntityAnalysisPart, Status: ResultStatusEmpty, Message: err.Error()}, nil
567
+ }
568
+ filters := analysisFiltersFromMetric(opts)
569
+ top := limitOrDefault(opts.Top, 20)
570
+
571
+ run, passthrough := s.resolveMetricScope(ctx, opts, EntityAnalysisPart)
572
+ if passthrough != nil {
573
+ return *passthrough, nil
574
+ }
575
+
576
+ agg := map[string]*analysisAggregate{}
577
+ warnings := append([]string{}, run.warnings...)
578
+ for _, match := range run.session.ScopedMatches() {
579
+ seasonID := seasonForMatch(match, run.seasonHint)
580
+ partnerships, partnershipWarnings, hydrateErr := run.session.HydratePartnershipSummaries(ctx, match.ID)
581
+ if hydrateErr != nil {
582
+ if statusErr := analysisTransportResult(EntityAnalysisPart, match.ID, hydrateErr); statusErr != nil {
583
+ return *statusErr, nil
584
+ }
585
+ warnings = append(warnings, fmt.Sprintf("match %s partnerships: %v", match.ID, hydrateErr))
586
+ continue
587
+ }
588
+ warnings = append(warnings, partnershipWarnings...)
589
+
590
+ for _, partnership := range partnerships {
591
+ inningsNumber := parseInt(partnership.InningsID)
592
+ period := parseInt(partnership.Period)
593
+ row := analysisSourceRow{
594
+ MatchID: strings.TrimSpace(partnership.MatchID),
595
+ LeagueID: strings.TrimSpace(match.LeagueID),
596
+ SeasonID: seasonID,
597
+ TeamID: strings.TrimSpace(partnership.TeamID),
598
+ TeamName: strings.TrimSpace(partnership.TeamName),
599
+ InningsNumber: inningsNumber,
600
+ Period: period,
601
+ RunsScored: partnership.Runs,
602
+ CountValue: 1,
603
+ }
604
+ if !filters.matchesPartnership(row) {
605
+ continue
606
+ }
607
+ key, dims := buildAnalysisGroup(row, groupBy)
608
+ entry := agg[key]
609
+ if entry == nil {
610
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
611
+ agg[key] = entry
612
+ }
613
+ entry.matchIDs[row.MatchID] = struct{}{}
614
+ entry.runsScored += row.RunsScored
615
+ entry.count += row.CountValue
616
+ }
617
+ }
618
+
619
+ rows := make([]AnalysisRow, 0, len(agg))
620
+ for key, entry := range agg {
621
+ row := entry.row
622
+ row.Key = key
623
+ row.Metric = "partnership-runs"
624
+ row.Value = float64(entry.runsScored)
625
+ row.Count = entry.count
626
+ row.Matches = len(entry.matchIDs)
627
+ rows = append(rows, row)
628
+ }
629
+ rows = rankAnalysisRows(rows, false)
630
+ rows = trimAnalysisRows(rows, top)
631
+
632
+ view := AnalysisView{
633
+ Command: "partnerships",
634
+ Metric: "partnership-runs",
635
+ Scope: buildSingleScope(run, opts),
636
+ GroupBy: groupBy,
637
+ Filters: AnalysisFilters{TeamQuery: strings.TrimSpace(opts.TeamQuery), Innings: opts.Innings},
638
+ Rows: rows,
639
+ }
640
+ return analysisResult(EntityAnalysisPart, view, warnings), nil
641
+ }
642
+
643
+ type analysisSeasonScopeRequest struct {
644
+ LeagueQuery string
645
+ SeasonQuery string
646
+ TypeQuery string
647
+ GroupQuery string
648
+ DateFrom string
649
+ DateTo string
650
+ MatchLimit int
651
+ }
652
+
653
+ type analysisScopeRun struct {
654
+ session *HistoricalScopeSession
655
+ scope HistoricalScopeSummary
656
+ mode string
657
+ seasonHint string
658
+ warnings []string
659
+ }
660
+
661
+ func (s *AnalysisService) beginSeasonScope(
662
+ ctx context.Context,
663
+ req analysisSeasonScopeRequest,
664
+ kind EntityKind,
665
+ ) (*HistoricalScopeSession, HistoricalScopeSummary, []string, *NormalizedResult) {
666
+ session, err := s.hydration.BeginScope(ctx, HistoricalScopeOptions{
667
+ LeagueQuery: strings.TrimSpace(req.LeagueQuery),
668
+ SeasonQuery: strings.TrimSpace(req.SeasonQuery),
669
+ TypeQuery: strings.TrimSpace(req.TypeQuery),
670
+ GroupQuery: strings.TrimSpace(req.GroupQuery),
671
+ DateFrom: strings.TrimSpace(req.DateFrom),
672
+ DateTo: strings.TrimSpace(req.DateTo),
673
+ MatchLimit: req.MatchLimit,
674
+ })
675
+ if err != nil {
676
+ if transport := analysisTransportResult(kind, req.LeagueQuery, err); transport != nil {
677
+ return nil, HistoricalScopeSummary{}, nil, transport
678
+ }
679
+ result := NormalizedResult{Kind: kind, Status: ResultStatusError, Message: err.Error()}
680
+ return nil, HistoricalScopeSummary{}, nil, &result
681
+ }
682
+ scope := session.Scope()
683
+ return session, scope, scope.Warnings, nil
684
+ }
685
+
686
+ func (s *AnalysisService) resolveMetricScope(ctx context.Context, opts AnalysisMetricOptions, kind EntityKind) (*analysisScopeRun, *NormalizedResult) {
687
+ scopeMode, scopeQuery, err := parseMetricScope(opts.Scope)
688
+ if err != nil {
689
+ result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: err.Error()}
690
+ return nil, &result
691
+ }
692
+
693
+ switch scopeMode {
694
+ case analysisScopeSeason:
695
+ if strings.TrimSpace(opts.LeagueQuery) == "" {
696
+ result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: "--league is required for season scope"}
697
+ return nil, &result
698
+ }
699
+ session, scope, warnings, passthrough := s.beginSeasonScope(ctx, analysisSeasonScopeRequest{
700
+ LeagueQuery: opts.LeagueQuery,
701
+ SeasonQuery: scopeQuery,
702
+ TypeQuery: opts.TypeQuery,
703
+ GroupQuery: opts.GroupQuery,
704
+ DateFrom: opts.DateFrom,
705
+ DateTo: opts.DateTo,
706
+ MatchLimit: opts.MatchLimit,
707
+ }, kind)
708
+ if passthrough != nil {
709
+ return nil, passthrough
710
+ }
711
+ return &analysisScopeRun{session: session, scope: scope, mode: analysisScopeSeason, seasonHint: seasonIdentifier(scope, scopeQuery), warnings: warnings}, nil
712
+ case analysisScopeMatch:
713
+ match, warnings, err := s.resolveMatchByQuery(ctx, scopeQuery, opts.LeagueQuery)
714
+ if err != nil {
715
+ if transport := analysisTransportResult(kind, scopeQuery, err); transport != nil {
716
+ return nil, transport
717
+ }
718
+ result := NormalizedResult{Kind: kind, Status: ResultStatusError, Message: err.Error()}
719
+ return nil, &result
720
+ }
721
+
722
+ session := newHistoricalScopeSession(s.client, s.resolver, HistoricalScopeOptions{
723
+ LeagueQuery: strings.TrimSpace(nonEmpty(match.LeagueID, opts.LeagueQuery)),
724
+ })
725
+ session.matches = []Match{*match}
726
+ session.league = League{ID: strings.TrimSpace(nonEmpty(match.LeagueID, opts.LeagueQuery))}
727
+ session.warnings = compactWarnings(warnings)
728
+ scope := session.Scope()
729
+ return &analysisScopeRun{session: session, scope: scope, mode: analysisScopeMatch, seasonHint: seasonForMatch(*match, ""), warnings: warnings}, nil
730
+ default:
731
+ result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: "unsupported scope"}
732
+ return nil, &result
733
+ }
734
+ }
735
+
736
+ func (s *AnalysisService) resolveMatchByQuery(ctx context.Context, query, leagueHint string) (*Match, []string, error) {
737
+ query = strings.TrimSpace(query)
738
+ if query == "" {
739
+ return nil, nil, fmt.Errorf("match scope query is required")
740
+ }
741
+
742
+ searchResult, err := s.resolver.Search(ctx, EntityMatch, query, ResolveOptions{
743
+ Limit: 5,
744
+ LeagueID: strings.TrimSpace(leagueHint),
745
+ })
746
+ if err != nil {
747
+ return nil, nil, err
748
+ }
749
+ if len(searchResult.Entities) == 0 {
750
+ return nil, searchResult.Warnings, fmt.Errorf("no matches found for %q", query)
751
+ }
752
+
753
+ entity := searchResult.Entities[0]
754
+ ref := buildMatchRef(entity)
755
+ if strings.TrimSpace(ref) == "" {
756
+ return nil, searchResult.Warnings, fmt.Errorf("unable to resolve match ref for %q", query)
757
+ }
758
+
759
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
760
+ if err != nil {
761
+ return nil, searchResult.Warnings, err
762
+ }
763
+ match, err := NormalizeMatch(resolved.Body)
764
+ if err != nil {
765
+ return nil, searchResult.Warnings, fmt.Errorf("normalize match %q: %w", resolved.CanonicalRef, err)
766
+ }
767
+ return match, searchResult.Warnings, nil
768
+ }
769
+
770
+ func (s *AnalysisService) playerNameMapForMatch(ctx context.Context, session *HistoricalScopeSession, matchID string) (map[string]string, []string) {
771
+ players, warnings, err := session.HydratePlayerMatchSummaries(ctx, matchID)
772
+ if err != nil {
773
+ return map[string]string{}, []string{fmt.Sprintf("match %s player names: %v", matchID, err)}
774
+ }
775
+ out := map[string]string{}
776
+ for _, player := range players {
777
+ id := strings.TrimSpace(player.PlayerID)
778
+ if id == "" {
779
+ continue
780
+ }
781
+ if _, ok := out[id]; ok {
782
+ continue
783
+ }
784
+ out[id] = strings.TrimSpace(player.PlayerName)
785
+ }
786
+ return out, warnings
787
+ }
788
+
789
+ func analysisResult(kind EntityKind, view AnalysisView, warnings []string) NormalizedResult {
790
+ if len(view.Rows) == 0 {
791
+ result := NormalizedResult{
792
+ Kind: kind,
793
+ Status: ResultStatusEmpty,
794
+ Message: "no analysis rows found for selected scope",
795
+ Data: view,
796
+ }
797
+ if compact := compactWarnings(warnings); len(compact) > 0 {
798
+ result.Status = ResultStatusPartial
799
+ result.Warnings = compact
800
+ }
801
+ return result
802
+ }
803
+
804
+ if compact := compactWarnings(warnings); len(compact) > 0 {
805
+ return NewPartialResult(kind, view, compact...)
806
+ }
807
+ return NewDataResult(kind, view)
808
+ }
809
+
810
+ func buildSingleScope(run *analysisScopeRun, opts AnalysisMetricOptions) AnalysisScope {
811
+ scope := run.scope
812
+ seasons := []string{}
813
+ if run.mode == analysisScopeSeason {
814
+ seasons = append(seasons, seasonIdentifier(scope, run.seasonHint))
815
+ } else if run.seasonHint != "" {
816
+ seasons = append(seasons, strings.TrimSpace(run.seasonHint))
817
+ }
818
+
819
+ return AnalysisScope{
820
+ Mode: run.mode,
821
+ RequestedLeagueID: strings.TrimSpace(opts.LeagueQuery),
822
+ LeagueID: strings.TrimSpace(scope.League.ID),
823
+ LeagueName: strings.TrimSpace(scope.League.Name),
824
+ Seasons: compactWarnings(seasons),
825
+ MatchIDs: scope.MatchIDs,
826
+ MatchCount: len(scope.MatchIDs),
827
+ DateFrom: strings.TrimSpace(nonEmpty(scope.DateFrom, opts.DateFrom)),
828
+ DateTo: strings.TrimSpace(nonEmpty(scope.DateTo, opts.DateTo)),
829
+ TypeQuery: strings.TrimSpace(opts.TypeQuery),
830
+ GroupQuery: strings.TrimSpace(opts.GroupQuery),
831
+ HydrationMetric: run.session.Metrics(),
832
+ }
833
+ }
834
+
835
+ func analysisTransportResult(kind EntityKind, requestedRef string, err error) *NormalizedResult {
836
+ var statusErr *HTTPStatusError
837
+ if errors.As(err, &statusErr) {
838
+ result := NewTransportErrorResult(kind, requestedRef, err)
839
+ return &result
840
+ }
841
+ return nil
842
+ }
843
+
844
+ func normalizeBowlingMetric(raw string) (string, error) {
845
+ metric := strings.ToLower(strings.TrimSpace(raw))
846
+ metric = strings.ReplaceAll(metric, "_", "-")
847
+ metric = strings.ReplaceAll(metric, " ", "-")
848
+ switch metric {
849
+ case analysisMetricEconomy, analysisMetricDots, analysisMetricSixesConceded:
850
+ return metric, nil
851
+ default:
852
+ return "", fmt.Errorf("--metric must be one of: economy, dots, sixes-conceded")
853
+ }
854
+ }
855
+
856
+ func normalizeBattingMetric(raw string) (string, error) {
857
+ metric := strings.ToLower(strings.TrimSpace(raw))
858
+ metric = strings.ReplaceAll(metric, "_", "-")
859
+ metric = strings.ReplaceAll(metric, " ", "-")
860
+ switch metric {
861
+ case analysisMetricFours, analysisMetricSixes, analysisMetricStrikeRate:
862
+ return metric, nil
863
+ default:
864
+ return "", fmt.Errorf("--metric must be one of: fours, sixes, strike-rate")
865
+ }
866
+ }
867
+
868
+ func parseMetricScope(raw string) (string, string, error) {
869
+ raw = strings.TrimSpace(raw)
870
+ if raw == "" {
871
+ return "", "", fmt.Errorf("--scope is required (match:<match> or season:<season>)")
872
+ }
873
+
874
+ parts := strings.SplitN(raw, ":", 2)
875
+ if len(parts) != 2 {
876
+ return "", "", fmt.Errorf("--scope must use explicit mode: match:<match> or season:<season>")
877
+ }
878
+ mode := strings.ToLower(strings.TrimSpace(parts[0]))
879
+ query := strings.TrimSpace(parts[1])
880
+ if query == "" {
881
+ return "", "", fmt.Errorf("scope query is required")
882
+ }
883
+ switch mode {
884
+ case analysisScopeMatch, analysisScopeSeason:
885
+ return mode, query, nil
886
+ default:
887
+ return "", "", fmt.Errorf("unsupported --scope mode %q (expected match or season)", mode)
888
+ }
889
+ }
890
+
891
+ func parseSeasonRange(raw string) ([]string, error) {
892
+ raw = strings.TrimSpace(raw)
893
+ if raw == "" {
894
+ return nil, fmt.Errorf("--seasons is required (example: 2023-2025)")
895
+ }
896
+
897
+ items := strings.Split(raw, ",")
898
+ out := make([]string, 0)
899
+ for _, item := range items {
900
+ item = strings.TrimSpace(item)
901
+ if item == "" {
902
+ continue
903
+ }
904
+ if strings.Contains(item, "-") {
905
+ parts := strings.SplitN(item, "-", 2)
906
+ left := parseYear(parts[0])
907
+ right := parseYear(parts[1])
908
+ if left == 0 || right == 0 {
909
+ return nil, fmt.Errorf("invalid season range %q", item)
910
+ }
911
+ if left > right {
912
+ left, right = right, left
913
+ }
914
+ for year := left; year <= right; year++ {
915
+ out = append(out, fmt.Sprintf("%d", year))
916
+ }
917
+ continue
918
+ }
919
+ if parseYear(item) == 0 {
920
+ return nil, fmt.Errorf("invalid season %q", item)
921
+ }
922
+ out = append(out, item)
923
+ }
924
+ if len(out) == 0 {
925
+ return nil, fmt.Errorf("--seasons did not resolve any seasons")
926
+ }
927
+ return dedupeStrings(out), nil
928
+ }
929
+
930
+ func parseGroupBy(raw string, defaults []string, allowed map[string]struct{}) ([]string, error) {
931
+ raw = strings.TrimSpace(raw)
932
+ if raw == "" {
933
+ return append([]string{}, defaults...), nil
934
+ }
935
+
936
+ parts := strings.Split(raw, ",")
937
+ out := make([]string, 0, len(parts))
938
+ seen := map[string]struct{}{}
939
+ for _, part := range parts {
940
+ normalized := normalizeGroupField(part)
941
+ if normalized == "" {
942
+ continue
943
+ }
944
+ if _, ok := allowed[normalized]; !ok {
945
+ return nil, fmt.Errorf("unsupported --group-by field %q", strings.TrimSpace(part))
946
+ }
947
+ if _, ok := seen[normalized]; ok {
948
+ continue
949
+ }
950
+ seen[normalized] = struct{}{}
951
+ out = append(out, normalized)
952
+ }
953
+ if len(out) == 0 {
954
+ return append([]string{}, defaults...), nil
955
+ }
956
+ return out, nil
957
+ }
958
+
959
+ func normalizeGroupField(raw string) string {
960
+ field := strings.ToLower(strings.TrimSpace(raw))
961
+ field = strings.ReplaceAll(field, "_", "-")
962
+ field = strings.ReplaceAll(field, " ", "-")
963
+ switch field {
964
+ case "dismissal", "dismissals", "dismissaltype", "dismissal-type":
965
+ return "dismissal-type"
966
+ case "inning", "innings", "innings-period":
967
+ return "innings"
968
+ default:
969
+ return field
970
+ }
971
+ }
972
+
973
+ type analysisSourceRow struct {
974
+ MatchID string
975
+ LeagueID string
976
+ SeasonID string
977
+ TeamID string
978
+ TeamName string
979
+ PlayerID string
980
+ PlayerName string
981
+ DismissalType string
982
+ InningsNumber int
983
+ Period int
984
+ CountValue int
985
+
986
+ Dots int
987
+ SixesConceded int
988
+ Balls int
989
+ RunsConceded int
990
+ EconomySample float64
991
+
992
+ Fours int
993
+ BattingSixes int
994
+ RunsScored int
995
+ BallsFaced int
996
+ StrikeSample float64
997
+ }
998
+
999
+ type analysisAggregate struct {
1000
+ row AnalysisRow
1001
+ matchIDs map[string]struct{}
1002
+ count int
1003
+
1004
+ dots int
1005
+ sixesConceded int
1006
+ balls int
1007
+ runsConceded int
1008
+ economyTotal float64
1009
+ economyCount int
1010
+
1011
+ fours int
1012
+ battingSixes int
1013
+ runsScored int
1014
+ ballsFaced int
1015
+ strikeRateTotal float64
1016
+ strikeRateCount int
1017
+ }
1018
+
1019
+ func buildAnalysisGroup(row analysisSourceRow, groupBy []string) (string, AnalysisRow) {
1020
+ parts := make([]string, 0, len(groupBy))
1021
+ dims := AnalysisRow{}
1022
+
1023
+ for _, field := range groupBy {
1024
+ switch field {
1025
+ case "player":
1026
+ label := firstNonEmptyString(row.PlayerName, row.PlayerID)
1027
+ if label == "" {
1028
+ label = "unknown-player"
1029
+ }
1030
+ parts = append(parts, "player="+label)
1031
+ dims.PlayerID = row.PlayerID
1032
+ dims.PlayerName = row.PlayerName
1033
+ case "team":
1034
+ label := firstNonEmptyString(row.TeamName, row.TeamID)
1035
+ if label == "" {
1036
+ label = "unknown-team"
1037
+ }
1038
+ parts = append(parts, "team="+label)
1039
+ dims.TeamID = row.TeamID
1040
+ dims.TeamName = row.TeamName
1041
+ case "league":
1042
+ label := firstNonEmptyString(row.LeagueID)
1043
+ if label == "" {
1044
+ label = "unknown-league"
1045
+ }
1046
+ parts = append(parts, "league="+label)
1047
+ dims.LeagueID = row.LeagueID
1048
+ case "season":
1049
+ label := firstNonEmptyString(row.SeasonID)
1050
+ if label == "" {
1051
+ label = "unknown-season"
1052
+ }
1053
+ parts = append(parts, "season="+label)
1054
+ dims.SeasonID = row.SeasonID
1055
+ case "dismissal-type":
1056
+ label := firstNonEmptyString(row.DismissalType)
1057
+ if label == "" {
1058
+ label = "unknown-dismissal"
1059
+ }
1060
+ parts = append(parts, "dismissal="+label)
1061
+ dims.DismissalType = row.DismissalType
1062
+ case "innings":
1063
+ label := fmt.Sprintf("%d/%d", row.InningsNumber, row.Period)
1064
+ if row.InningsNumber <= 0 {
1065
+ label = "unknown-innings"
1066
+ }
1067
+ parts = append(parts, "innings="+label)
1068
+ dims.InningsNumber = row.InningsNumber
1069
+ dims.Period = row.Period
1070
+ }
1071
+ }
1072
+
1073
+ if len(parts) == 0 {
1074
+ return "all", dims
1075
+ }
1076
+ return strings.Join(parts, " | "), dims
1077
+ }
1078
+
1079
+ func rankAnalysisRows(rows []AnalysisRow, asc bool) []AnalysisRow {
1080
+ sort.Slice(rows, func(i, j int) bool {
1081
+ if rows[i].Value != rows[j].Value {
1082
+ if asc {
1083
+ return rows[i].Value < rows[j].Value
1084
+ }
1085
+ return rows[i].Value > rows[j].Value
1086
+ }
1087
+ if rows[i].Count != rows[j].Count {
1088
+ return rows[i].Count > rows[j].Count
1089
+ }
1090
+ return rows[i].Key < rows[j].Key
1091
+ })
1092
+ for i := range rows {
1093
+ rows[i].Rank = i + 1
1094
+ }
1095
+ return rows
1096
+ }
1097
+
1098
+ func trimAnalysisRows(rows []AnalysisRow, top int) []AnalysisRow {
1099
+ if top <= 0 || top >= len(rows) {
1100
+ return rows
1101
+ }
1102
+ return append([]AnalysisRow(nil), rows[:top]...)
1103
+ }
1104
+
1105
+ func economyFromAggregate(agg *analysisAggregate) float64 {
1106
+ if agg == nil {
1107
+ return 0
1108
+ }
1109
+ if agg.balls > 0 {
1110
+ overs := float64(agg.balls) / 6.0
1111
+ if overs > 0 {
1112
+ return float64(agg.runsConceded) / overs
1113
+ }
1114
+ }
1115
+ if agg.economyCount > 0 {
1116
+ return agg.economyTotal / float64(agg.economyCount)
1117
+ }
1118
+ return 0
1119
+ }
1120
+
1121
+ func hasBowlingActivity(agg *analysisAggregate) bool {
1122
+ if agg == nil {
1123
+ return false
1124
+ }
1125
+ return agg.balls > 0 || agg.runsConceded > 0 || agg.dots > 0 || agg.sixesConceded > 0 || agg.economyCount > 0
1126
+ }
1127
+
1128
+ func strikeRateFromAggregate(agg *analysisAggregate) float64 {
1129
+ if agg == nil {
1130
+ return 0
1131
+ }
1132
+ if agg.ballsFaced > 0 {
1133
+ return (float64(agg.runsScored) * 100.0) / float64(agg.ballsFaced)
1134
+ }
1135
+ if agg.strikeRateCount > 0 {
1136
+ return agg.strikeRateTotal / float64(agg.strikeRateCount)
1137
+ }
1138
+ return 0
1139
+ }
1140
+
1141
+ type bowlingTotals struct {
1142
+ dots int
1143
+ sixesConceded int
1144
+ balls int
1145
+ conceded int
1146
+ economy float64
1147
+ }
1148
+
1149
+ func extractBowlingTotals(player PlayerMatch) bowlingTotals {
1150
+ totals := bowlingTotals{
1151
+ dots: player.Summary.Dots,
1152
+ sixesConceded: player.Summary.SixesConceded,
1153
+ economy: player.Summary.EconomyRate,
1154
+ }
1155
+
1156
+ for _, category := range player.Bowling {
1157
+ for _, stat := range category.Stats {
1158
+ switch normalizeStatName(stat.Name) {
1159
+ case "dots":
1160
+ totals.dots += statAsInt(stat)
1161
+ case "sixesconceded":
1162
+ totals.sixesConceded += statAsInt(stat)
1163
+ case "balls":
1164
+ totals.balls += statAsInt(stat)
1165
+ case "conceded":
1166
+ totals.conceded += statAsInt(stat)
1167
+ case "economyrate":
1168
+ if value := statAsFloat(stat); value > 0 {
1169
+ totals.economy = value
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ // summary already includes merged dots/sixes values in most payloads; avoid double counting.
1176
+ if totals.dots > 0 && player.Summary.Dots > 0 {
1177
+ totals.dots = analysisMaxInt(totals.dots, player.Summary.Dots)
1178
+ }
1179
+ if totals.sixesConceded > 0 && player.Summary.SixesConceded > 0 {
1180
+ totals.sixesConceded = analysisMaxInt(totals.sixesConceded, player.Summary.SixesConceded)
1181
+ }
1182
+ return totals
1183
+ }
1184
+
1185
+ type battingTotals struct {
1186
+ fours int
1187
+ sixes int
1188
+ runs int
1189
+ balls int
1190
+ strikeRate float64
1191
+ }
1192
+
1193
+ func extractBattingTotals(player PlayerMatch) battingTotals {
1194
+ totals := battingTotals{strikeRate: player.Summary.StrikeRate, balls: player.Summary.BallsFaced}
1195
+ for _, category := range player.Batting {
1196
+ for _, stat := range category.Stats {
1197
+ switch normalizeStatName(stat.Name) {
1198
+ case "fours":
1199
+ totals.fours += statAsInt(stat)
1200
+ case "sixes":
1201
+ totals.sixes += statAsInt(stat)
1202
+ case "runs":
1203
+ totals.runs += statAsInt(stat)
1204
+ case "ballsfaced":
1205
+ totals.balls += statAsInt(stat)
1206
+ case "strikerate":
1207
+ if value := statAsFloat(stat); value > 0 {
1208
+ totals.strikeRate = value
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1213
+ return totals
1214
+ }
1215
+
1216
+ func seasonIdentifier(scope HistoricalScopeSummary, fallback string) string {
1217
+ if scope.Season != nil {
1218
+ if strings.TrimSpace(scope.Season.ID) != "" {
1219
+ return strings.TrimSpace(scope.Season.ID)
1220
+ }
1221
+ if scope.Season.Year > 0 {
1222
+ return fmt.Sprintf("%d", scope.Season.Year)
1223
+ }
1224
+ }
1225
+ fallback = strings.TrimSpace(fallback)
1226
+ if fallback != "" {
1227
+ return fallback
1228
+ }
1229
+ return ""
1230
+ }
1231
+
1232
+ func seasonForMatch(match Match, fallback string) string {
1233
+ if date := strings.TrimSpace(match.Date); date != "" {
1234
+ if parsed, ok := parseMatchTime(match); ok {
1235
+ return fmt.Sprintf("%d", parsed.UTC().Year())
1236
+ }
1237
+ if len(date) >= 4 {
1238
+ if _, err := strconv.Atoi(date[:4]); err == nil {
1239
+ return date[:4]
1240
+ }
1241
+ }
1242
+ }
1243
+ return strings.TrimSpace(fallback)
1244
+ }
1245
+
1246
+ func dedupeStrings(values []string) []string {
1247
+ seen := map[string]struct{}{}
1248
+ out := make([]string, 0, len(values))
1249
+ for _, value := range values {
1250
+ value = strings.TrimSpace(value)
1251
+ if value == "" {
1252
+ continue
1253
+ }
1254
+ if _, ok := seen[value]; ok {
1255
+ continue
1256
+ }
1257
+ seen[value] = struct{}{}
1258
+ out = append(out, value)
1259
+ }
1260
+ return out
1261
+ }
1262
+
1263
+ func analysisMaxInt(a, b int) int {
1264
+ if a > b {
1265
+ return a
1266
+ }
1267
+ return b
1268
+ }
1269
+
1270
+ func analysisDisplayPlayerName(resolver *Resolver, playerID, fallback string) string {
1271
+ name := strings.TrimSpace(fallback)
1272
+ if name != "" {
1273
+ return name
1274
+ }
1275
+ if resolver != nil && resolver.index != nil {
1276
+ if indexed, ok := resolver.index.FindByID(EntityPlayer, strings.TrimSpace(playerID)); ok {
1277
+ name = nonEmpty(indexed.Name, indexed.ShortName)
1278
+ }
1279
+ }
1280
+ return strings.TrimSpace(name)
1281
+ }
1282
+
1283
+ func analysisDisplayTeamName(resolver *Resolver, teamID, fallback string) string {
1284
+ name := strings.TrimSpace(fallback)
1285
+ if name != "" {
1286
+ return name
1287
+ }
1288
+ if resolver != nil && resolver.index != nil {
1289
+ if indexed, ok := resolver.index.FindByID(EntityTeam, strings.TrimSpace(teamID)); ok {
1290
+ name = nonEmpty(indexed.ShortName, indexed.Name)
1291
+ }
1292
+ }
1293
+ return strings.TrimSpace(name)
1294
+ }
1295
+
1296
+ type analysisFilterSpec struct {
1297
+ teamQuery string
1298
+ playerQuery string
1299
+ dismissalType string
1300
+ innings int
1301
+ period int
1302
+ }
1303
+
1304
+ func analysisFiltersFromDismissal(opts AnalysisDismissalOptions) analysisFilterSpec {
1305
+ return analysisFilterSpec{
1306
+ teamQuery: strings.TrimSpace(opts.TeamQuery),
1307
+ playerQuery: strings.TrimSpace(opts.PlayerQuery),
1308
+ dismissalType: strings.TrimSpace(opts.DismissalType),
1309
+ innings: opts.Innings,
1310
+ period: opts.Period,
1311
+ }
1312
+ }
1313
+
1314
+ func analysisFiltersFromMetric(opts AnalysisMetricOptions) analysisFilterSpec {
1315
+ return analysisFilterSpec{
1316
+ teamQuery: strings.TrimSpace(opts.TeamQuery),
1317
+ playerQuery: strings.TrimSpace(opts.PlayerQuery),
1318
+ dismissalType: strings.TrimSpace(opts.DismissalType),
1319
+ innings: opts.Innings,
1320
+ period: opts.Period,
1321
+ }
1322
+ }
1323
+
1324
+ func (f analysisFilterSpec) matches(row analysisSourceRow) bool {
1325
+ if !f.matchesTeam(row) {
1326
+ return false
1327
+ }
1328
+ if !f.matchesPlayer(row) {
1329
+ return false
1330
+ }
1331
+ if !f.matchesDismissal(row) {
1332
+ return false
1333
+ }
1334
+ if !f.matchesInnings(row) {
1335
+ return false
1336
+ }
1337
+ if !f.matchesPeriod(row) {
1338
+ return false
1339
+ }
1340
+ return true
1341
+ }
1342
+
1343
+ func (f analysisFilterSpec) matchesPartnership(row analysisSourceRow) bool {
1344
+ if !f.matchesTeam(row) {
1345
+ return false
1346
+ }
1347
+ if !f.matchesInnings(row) {
1348
+ return false
1349
+ }
1350
+ return true
1351
+ }
1352
+
1353
+ func (f analysisFilterSpec) matchesTeam(row analysisSourceRow) bool {
1354
+ query := normalizeAlias(f.teamQuery)
1355
+ if query == "" {
1356
+ return true
1357
+ }
1358
+ candidates := []string{normalizeAlias(row.TeamID), normalizeAlias(row.TeamName)}
1359
+ for _, candidate := range candidates {
1360
+ if candidate != "" && candidate == query {
1361
+ return true
1362
+ }
1363
+ }
1364
+ return false
1365
+ }
1366
+
1367
+ func (f analysisFilterSpec) matchesPlayer(row analysisSourceRow) bool {
1368
+ query := normalizeAlias(f.playerQuery)
1369
+ if query == "" {
1370
+ return true
1371
+ }
1372
+ candidates := []string{normalizeAlias(row.PlayerID), normalizeAlias(row.PlayerName)}
1373
+ for _, candidate := range candidates {
1374
+ if candidate != "" && candidate == query {
1375
+ return true
1376
+ }
1377
+ }
1378
+ return false
1379
+ }
1380
+
1381
+ func (f analysisFilterSpec) matchesDismissal(row analysisSourceRow) bool {
1382
+ query := normalizeAlias(f.dismissalType)
1383
+ if query == "" {
1384
+ return true
1385
+ }
1386
+ return normalizeAlias(row.DismissalType) == query
1387
+ }
1388
+
1389
+ func (f analysisFilterSpec) matchesInnings(row analysisSourceRow) bool {
1390
+ if f.innings <= 0 {
1391
+ return true
1392
+ }
1393
+ return row.InningsNumber == f.innings
1394
+ }
1395
+
1396
+ func (f analysisFilterSpec) matchesPeriod(row analysisSourceRow) bool {
1397
+ if f.period <= 0 {
1398
+ return true
1399
+ }
1400
+ return row.Period == f.period
1401
+ }