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,1641 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "sort"
7
+ "strconv"
8
+ "strings"
9
+ "time"
10
+ )
11
+
12
+ // HistoricalScopeOptions defines a historical traversal scope for cross-match reasoning.
13
+ type HistoricalScopeOptions struct {
14
+ LeagueQuery string
15
+ SeasonQuery string
16
+ TypeQuery string
17
+ GroupQuery string
18
+ DateFrom string
19
+ DateTo string
20
+ MatchLimit int
21
+ }
22
+
23
+ // HistoricalScopeSummary describes the resolved domain scope for one session.
24
+ type HistoricalScopeSummary struct {
25
+ League League
26
+ Season *Season
27
+ Type *SeasonType
28
+ Group *SeasonGroup
29
+ DateFrom string
30
+ DateTo string
31
+ MatchIDs []string
32
+ Warnings []string
33
+ }
34
+
35
+ // HydrationMetrics tracks scoped in-process reuse behavior.
36
+ type HydrationMetrics struct {
37
+ ResolveCacheHits int
38
+ ResolveCacheMisses int
39
+ DomainCacheHits int
40
+ DomainCacheMisses int
41
+ }
42
+
43
+ // HistoricalHydrationServiceConfig configures scoped traversal and hydration behavior.
44
+ type HistoricalHydrationServiceConfig struct {
45
+ Client *Client
46
+ Resolver *Resolver
47
+ }
48
+
49
+ // HistoricalHydrationService provides real-time historical traversal with run-scoped reuse.
50
+ type HistoricalHydrationService struct {
51
+ client *Client
52
+ resolver *Resolver
53
+ ownsResolver bool
54
+ }
55
+
56
+ // NewHistoricalHydrationService builds a scope traversal/hydration service.
57
+ func NewHistoricalHydrationService(cfg HistoricalHydrationServiceConfig) (*HistoricalHydrationService, error) {
58
+ client := cfg.Client
59
+ if client == nil {
60
+ var err error
61
+ client, err = NewClient(Config{})
62
+ if err != nil {
63
+ return nil, err
64
+ }
65
+ }
66
+
67
+ resolver := cfg.Resolver
68
+ ownsResolver := false
69
+ if resolver == nil {
70
+ var err error
71
+ resolver, err = NewResolver(ResolverConfig{Client: client})
72
+ if err != nil {
73
+ return nil, err
74
+ }
75
+ ownsResolver = true
76
+ }
77
+
78
+ return &HistoricalHydrationService{
79
+ client: client,
80
+ resolver: resolver,
81
+ ownsResolver: ownsResolver,
82
+ }, nil
83
+ }
84
+
85
+ // Close persists resolver state when owned by this service.
86
+ func (s *HistoricalHydrationService) Close() error {
87
+ if !s.ownsResolver || s.resolver == nil {
88
+ return nil
89
+ }
90
+ return s.resolver.Close()
91
+ }
92
+
93
+ // BeginScope resolves the requested historical scope and returns a run-scoped hydration session.
94
+ func (s *HistoricalHydrationService) BeginScope(ctx context.Context, opts HistoricalScopeOptions) (*HistoricalScopeSession, error) {
95
+ session := newHistoricalScopeSession(s.client, s.resolver, opts)
96
+
97
+ if err := session.initialize(ctx); err != nil {
98
+ return nil, err
99
+ }
100
+ return session, nil
101
+ }
102
+
103
+ // HistoricalScopeSession keeps one active run scope and reuses hydrated domain data in-process.
104
+ type HistoricalScopeSession struct {
105
+ client *Client
106
+ resolver *Resolver
107
+ opts HistoricalScopeOptions
108
+
109
+ league League
110
+ season *Season
111
+ seasonType *SeasonType
112
+ group *SeasonGroup
113
+ rangeSpec historicalDateRange
114
+
115
+ warnings []string
116
+ matches []Match
117
+
118
+ resolvedDocs map[string]*ResolvedDocument
119
+ statusByRef map[string]matchStatusSnapshot
120
+ teamIdentityByRef map[string]teamIdentity
121
+ teamScoreByRef map[string]string
122
+
123
+ hydratedMatches []Match
124
+ matchWarnings []string
125
+
126
+ inningsByMatch map[string][]Innings
127
+ ingsWarningsByMatch map[string][]string
128
+ playersByMatch map[string][]PlayerMatch
129
+ playerWarningsByMatch map[string][]string
130
+ deliveriesByMatch map[string][]DeliveryEvent
131
+ deliveryWarnByMatch map[string][]string
132
+ partnershipsByMatch map[string][]Partnership
133
+ partnershipWarnByMatch map[string][]string
134
+
135
+ metrics HydrationMetrics
136
+ }
137
+
138
+ func newHistoricalScopeSession(client *Client, resolver *Resolver, opts HistoricalScopeOptions) *HistoricalScopeSession {
139
+ return &HistoricalScopeSession{
140
+ client: client,
141
+ resolver: resolver,
142
+ opts: opts,
143
+ resolvedDocs: map[string]*ResolvedDocument{},
144
+ statusByRef: map[string]matchStatusSnapshot{},
145
+ teamIdentityByRef: map[string]teamIdentity{},
146
+ teamScoreByRef: map[string]string{},
147
+ inningsByMatch: map[string][]Innings{},
148
+ ingsWarningsByMatch: map[string][]string{},
149
+ playersByMatch: map[string][]PlayerMatch{},
150
+ playerWarningsByMatch: map[string][]string{},
151
+ deliveriesByMatch: map[string][]DeliveryEvent{},
152
+ deliveryWarnByMatch: map[string][]string{},
153
+ partnershipsByMatch: map[string][]Partnership{},
154
+ partnershipWarnByMatch: map[string][]string{},
155
+ }
156
+ }
157
+
158
+ // Scope returns resolved scope metadata.
159
+ func (s *HistoricalScopeSession) Scope() HistoricalScopeSummary {
160
+ matchIDs := make([]string, 0, len(s.matches))
161
+ for _, match := range s.matches {
162
+ matchIDs = append(matchIDs, matchCacheKey(match))
163
+ }
164
+
165
+ summary := HistoricalScopeSummary{
166
+ League: s.league,
167
+ DateFrom: strings.TrimSpace(s.opts.DateFrom),
168
+ DateTo: strings.TrimSpace(s.opts.DateTo),
169
+ MatchIDs: matchIDs,
170
+ Warnings: append([]string(nil), s.warnings...),
171
+ }
172
+ if s.season != nil {
173
+ copySeason := *s.season
174
+ summary.Season = &copySeason
175
+ }
176
+ if s.seasonType != nil {
177
+ copyType := *s.seasonType
178
+ summary.Type = &copyType
179
+ }
180
+ if s.group != nil {
181
+ copyGroup := *s.group
182
+ summary.Group = &copyGroup
183
+ }
184
+ return summary
185
+ }
186
+
187
+ // Warnings returns warnings produced while resolving the scope.
188
+ func (s *HistoricalScopeSession) Warnings() []string {
189
+ return append([]string(nil), s.warnings...)
190
+ }
191
+
192
+ // ScopedMatches returns scope matches without additional hydration.
193
+ func (s *HistoricalScopeSession) ScopedMatches() []Match {
194
+ return append([]Match(nil), s.matches...)
195
+ }
196
+
197
+ // Metrics reports in-process reuse stats for the active run.
198
+ func (s *HistoricalScopeSession) Metrics() HydrationMetrics {
199
+ return s.metrics
200
+ }
201
+
202
+ // HydrateMatchSummaries hydrates status/team/score summaries for all scoped matches.
203
+ func (s *HistoricalScopeSession) HydrateMatchSummaries(ctx context.Context) ([]Match, []string, error) {
204
+ if s.hydratedMatches != nil {
205
+ s.metrics.DomainCacheHits++
206
+ return append([]Match(nil), s.hydratedMatches...), append([]string(nil), s.matchWarnings...), nil
207
+ }
208
+ s.metrics.DomainCacheMisses++
209
+
210
+ hydrated := append([]Match(nil), s.matches...)
211
+ warnings := make([]string, 0)
212
+
213
+ for i := range hydrated {
214
+ warnings = append(warnings, s.hydrateMatchSummary(ctx, &hydrated[i])...)
215
+ }
216
+
217
+ s.hydratedMatches = hydrated
218
+ s.matchWarnings = compactWarnings(warnings)
219
+ return append([]Match(nil), hydrated...), append([]string(nil), s.matchWarnings...), nil
220
+ }
221
+
222
+ // HydrateInnings hydrates innings and timeline summaries for one scoped match.
223
+ func (s *HistoricalScopeSession) HydrateInnings(ctx context.Context, matchID string) ([]Innings, []string, error) {
224
+ key, match, err := s.matchByID(matchID)
225
+ if err != nil {
226
+ return nil, nil, err
227
+ }
228
+
229
+ if cached, ok := s.inningsByMatch[key]; ok {
230
+ s.metrics.DomainCacheHits++
231
+ warnings := append([]string(nil), s.ingsWarningsByMatch[key]...)
232
+ return append([]Innings(nil), cached...), warnings, nil
233
+ }
234
+ s.metrics.DomainCacheMisses++
235
+
236
+ items := make([]Innings, 0)
237
+ warnings := make([]string, 0)
238
+ for _, team := range match.Teams {
239
+ teamInnings, teamWarnings := s.collectTeamInnings(ctx, match, team)
240
+ items = append(items, teamInnings...)
241
+ warnings = append(warnings, teamWarnings...)
242
+ }
243
+
244
+ sort.Slice(items, func(i, j int) bool {
245
+ if items[i].TeamID != items[j].TeamID {
246
+ return items[i].TeamID < items[j].TeamID
247
+ }
248
+ if items[i].InningsNumber != items[j].InningsNumber {
249
+ return items[i].InningsNumber < items[j].InningsNumber
250
+ }
251
+ return items[i].Period < items[j].Period
252
+ })
253
+
254
+ s.inningsByMatch[key] = append([]Innings(nil), items...)
255
+ s.ingsWarningsByMatch[key] = compactWarnings(warnings)
256
+ return append([]Innings(nil), items...), append([]string(nil), s.ingsWarningsByMatch[key]...), nil
257
+ }
258
+
259
+ // HydratePlayerMatchSummaries hydrates match-context player summaries for one scoped match.
260
+ func (s *HistoricalScopeSession) HydratePlayerMatchSummaries(ctx context.Context, matchID string) ([]PlayerMatch, []string, error) {
261
+ key, match, err := s.matchByID(matchID)
262
+ if err != nil {
263
+ return nil, nil, err
264
+ }
265
+
266
+ if cached, ok := s.playersByMatch[key]; ok {
267
+ s.metrics.DomainCacheHits++
268
+ warnings := append([]string(nil), s.playerWarningsByMatch[key]...)
269
+ return append([]PlayerMatch(nil), cached...), warnings, nil
270
+ }
271
+ s.metrics.DomainCacheMisses++
272
+
273
+ items := make([]PlayerMatch, 0)
274
+ warnings := make([]string, 0)
275
+ for _, team := range match.Teams {
276
+ team = s.enrichTeamIdentityFromIndex(team)
277
+ rosterRef := nonEmpty(strings.TrimSpace(team.RosterRef), competitorSubresourceRef(match, team.ID, "roster"))
278
+ if rosterRef == "" {
279
+ warnings = append(warnings, fmt.Sprintf("roster route unavailable for team %q", team.ID))
280
+ continue
281
+ }
282
+
283
+ resolved, err := s.resolve(ctx, rosterRef)
284
+ if err != nil {
285
+ warnings = append(warnings, fmt.Sprintf("roster %s: %v", rosterRef, err))
286
+ continue
287
+ }
288
+
289
+ entries, err := NormalizeTeamRosterEntries(resolved.Body, team, TeamScopeMatch, match.ID)
290
+ if err != nil {
291
+ warnings = append(warnings, fmt.Sprintf("roster %s: %v", resolved.CanonicalRef, err))
292
+ continue
293
+ }
294
+
295
+ for _, entry := range entries {
296
+ playerID := strings.TrimSpace(entry.PlayerID)
297
+ if playerID == "" {
298
+ continue
299
+ }
300
+ if strings.TrimSpace(entry.DisplayName) == "" && s.resolver != nil {
301
+ _ = s.resolver.seedPlayerByID(ctx, playerID, match.LeagueID, match.ID)
302
+ }
303
+ entry = s.enrichRosterEntryFromIndex(entry)
304
+
305
+ statsRef := rosterPlayerStatisticsRef(match, team, entry)
306
+ if statsRef == "" {
307
+ warnings = append(warnings, fmt.Sprintf("player %s has no match statistics route", playerID))
308
+ continue
309
+ }
310
+
311
+ statsDoc, err := s.resolve(ctx, statsRef)
312
+ if err != nil {
313
+ warnings = append(warnings, fmt.Sprintf("player statistics %s: %v", statsRef, err))
314
+ continue
315
+ }
316
+
317
+ categories, err := NormalizeStatCategories(statsDoc.Body)
318
+ if err != nil {
319
+ warnings = append(warnings, fmt.Sprintf("player statistics %s: %v", statsDoc.CanonicalRef, err))
320
+ continue
321
+ }
322
+
323
+ batting, bowling, fielding := splitPlayerStatCategories(categories)
324
+ items = append(items, PlayerMatch{
325
+ PlayerID: playerID,
326
+ PlayerRef: entry.PlayerRef,
327
+ PlayerName: nonEmpty(entry.DisplayName, "Unknown Player"),
328
+ MatchID: match.ID,
329
+ CompetitionID: nonEmpty(match.CompetitionID, match.ID),
330
+ EventID: match.EventID,
331
+ LeagueID: match.LeagueID,
332
+ TeamID: team.ID,
333
+ TeamName: nonEmpty(team.ShortName, team.Name, "Unknown Team"),
334
+ StatisticsRef: statsDoc.CanonicalRef,
335
+ LinescoresRef: rosterPlayerLinescoresRef(match, team, entry),
336
+ Batting: batting,
337
+ Bowling: bowling,
338
+ Fielding: fielding,
339
+ Summary: summarizePlayerMatchCategories(categories),
340
+ })
341
+ }
342
+ }
343
+
344
+ sort.Slice(items, func(i, j int) bool {
345
+ if items[i].TeamID != items[j].TeamID {
346
+ return items[i].TeamID < items[j].TeamID
347
+ }
348
+ if items[i].PlayerName != items[j].PlayerName {
349
+ return items[i].PlayerName < items[j].PlayerName
350
+ }
351
+ return items[i].PlayerID < items[j].PlayerID
352
+ })
353
+
354
+ s.playersByMatch[key] = append([]PlayerMatch(nil), items...)
355
+ s.playerWarningsByMatch[key] = compactWarnings(warnings)
356
+ return append([]PlayerMatch(nil), items...), append([]string(nil), s.playerWarningsByMatch[key]...), nil
357
+ }
358
+
359
+ // HydrateDeliverySummaries hydrates delivery events for one scoped match.
360
+ func (s *HistoricalScopeSession) HydrateDeliverySummaries(ctx context.Context, matchID string) ([]DeliveryEvent, []string, error) {
361
+ key, match, err := s.matchByID(matchID)
362
+ if err != nil {
363
+ return nil, nil, err
364
+ }
365
+
366
+ if cached, ok := s.deliveriesByMatch[key]; ok {
367
+ s.metrics.DomainCacheHits++
368
+ warnings := append([]string(nil), s.deliveryWarnByMatch[key]...)
369
+ return append([]DeliveryEvent(nil), cached...), warnings, nil
370
+ }
371
+ s.metrics.DomainCacheMisses++
372
+
373
+ detailsRef := nonEmpty(strings.TrimSpace(match.DetailsRef), matchSubresourceRef(match, "details", "details"))
374
+ if detailsRef == "" {
375
+ s.deliveriesByMatch[key] = []DeliveryEvent{}
376
+ s.deliveryWarnByMatch[key] = []string{fmt.Sprintf("details route unavailable for match %q", key)}
377
+ return []DeliveryEvent{}, append([]string(nil), s.deliveryWarnByMatch[key]...), nil
378
+ }
379
+
380
+ resolved, err := s.resolve(ctx, detailsRef)
381
+ if err != nil {
382
+ return nil, nil, err
383
+ }
384
+
385
+ page, err := DecodePage[Ref](resolved.Body)
386
+ if err != nil {
387
+ return nil, nil, fmt.Errorf("decode details page %q: %w", resolved.CanonicalRef, err)
388
+ }
389
+
390
+ warnings := make([]string, 0)
391
+ pageItems := append([]Ref(nil), page.Items...)
392
+ if page.PageCount > 1 {
393
+ helper := &MatchService{client: s.client, resolver: s.resolver}
394
+ extraItems, pageWarnings, pageErr := helper.resolvePageRefs(ctx, resolved)
395
+ if pageErr != nil {
396
+ warnings = append(warnings, pageErr.Error())
397
+ } else {
398
+ pageItems = extraItems
399
+ warnings = append(warnings, pageWarnings...)
400
+ }
401
+ }
402
+
403
+ helper := &MatchService{client: s.client, resolver: s.resolver}
404
+ loaded, loadWarnings := helper.loadDeliveryEvents(ctx, pageItems)
405
+ warnings = append(warnings, loadWarnings...)
406
+
407
+ items := make([]DeliveryEvent, 0, len(loaded))
408
+ for _, delivery := range loaded {
409
+ delivery.MatchID = nonEmpty(delivery.MatchID, match.ID)
410
+ delivery.CompetitionID = nonEmpty(delivery.CompetitionID, match.CompetitionID, match.ID)
411
+ delivery.EventID = nonEmpty(delivery.EventID, match.EventID)
412
+ delivery.LeagueID = nonEmpty(delivery.LeagueID, match.LeagueID)
413
+ items = append(items, delivery)
414
+ }
415
+
416
+ s.deliveriesByMatch[key] = append([]DeliveryEvent(nil), items...)
417
+ s.deliveryWarnByMatch[key] = compactWarnings(warnings)
418
+ return append([]DeliveryEvent(nil), items...), append([]string(nil), s.deliveryWarnByMatch[key]...), nil
419
+ }
420
+
421
+ // HydratePartnershipSummaries hydrates detailed partnerships for one scoped match.
422
+ func (s *HistoricalScopeSession) HydratePartnershipSummaries(ctx context.Context, matchID string) ([]Partnership, []string, error) {
423
+ key, match, err := s.matchByID(matchID)
424
+ if err != nil {
425
+ return nil, nil, err
426
+ }
427
+
428
+ if cached, ok := s.partnershipsByMatch[key]; ok {
429
+ s.metrics.DomainCacheHits++
430
+ warnings := append([]string(nil), s.partnershipWarnByMatch[key]...)
431
+ return append([]Partnership(nil), cached...), warnings, nil
432
+ }
433
+ s.metrics.DomainCacheMisses++
434
+
435
+ innings, inningsWarnings, err := s.HydrateInnings(ctx, key)
436
+ if err != nil {
437
+ return nil, nil, err
438
+ }
439
+
440
+ items := make([]Partnership, 0)
441
+ warnings := append([]string{}, inningsWarnings...)
442
+ for _, inn := range innings {
443
+ ref := strings.TrimSpace(inn.PartnershipsRef)
444
+ if ref == "" {
445
+ continue
446
+ }
447
+
448
+ pageDoc, err := s.resolve(ctx, ref)
449
+ if err != nil {
450
+ warnings = append(warnings, fmt.Sprintf("partnerships %s: %v", ref, err))
451
+ continue
452
+ }
453
+
454
+ page, err := DecodePage[Ref](pageDoc.Body)
455
+ if err != nil {
456
+ warnings = append(warnings, fmt.Sprintf("decode partnerships page %s: %v", pageDoc.CanonicalRef, err))
457
+ continue
458
+ }
459
+
460
+ for _, item := range page.Items {
461
+ itemRef := strings.TrimSpace(item.URL)
462
+ if itemRef == "" {
463
+ continue
464
+ }
465
+ itemDoc, err := s.resolve(ctx, itemRef)
466
+ if err != nil {
467
+ warnings = append(warnings, fmt.Sprintf("partnership %s: %v", itemRef, err))
468
+ continue
469
+ }
470
+ partnership, err := NormalizePartnership(itemDoc.Body)
471
+ if err != nil {
472
+ warnings = append(warnings, fmt.Sprintf("partnership %s: %v", itemDoc.CanonicalRef, err))
473
+ continue
474
+ }
475
+
476
+ partnership.MatchID = nonEmpty(partnership.MatchID, match.ID)
477
+ partnership.TeamID = nonEmpty(partnership.TeamID, inn.TeamID)
478
+ partnership.TeamName = nonEmpty(partnership.TeamName, inn.TeamName)
479
+ partnership.InningsID = nonEmpty(partnership.InningsID, fmt.Sprintf("%d", inn.InningsNumber))
480
+ partnership.Period = nonEmpty(partnership.Period, fmt.Sprintf("%d", inn.Period))
481
+ if partnership.Order == 0 {
482
+ partnership.Order = partnership.WicketNumber
483
+ }
484
+ items = append(items, *partnership)
485
+ }
486
+ }
487
+
488
+ sort.Slice(items, func(i, j int) bool {
489
+ if items[i].TeamID != items[j].TeamID {
490
+ return items[i].TeamID < items[j].TeamID
491
+ }
492
+ if items[i].InningsID != items[j].InningsID {
493
+ return items[i].InningsID < items[j].InningsID
494
+ }
495
+ if items[i].Period != items[j].Period {
496
+ return items[i].Period < items[j].Period
497
+ }
498
+ if items[i].Order != items[j].Order {
499
+ return items[i].Order < items[j].Order
500
+ }
501
+ if items[i].Runs != items[j].Runs {
502
+ return items[i].Runs > items[j].Runs
503
+ }
504
+ return items[i].ID < items[j].ID
505
+ })
506
+
507
+ s.partnershipsByMatch[key] = append([]Partnership(nil), items...)
508
+ s.partnershipWarnByMatch[key] = compactWarnings(warnings)
509
+ return append([]Partnership(nil), items...), append([]string(nil), s.partnershipWarnByMatch[key]...), nil
510
+ }
511
+
512
+ func (s *HistoricalScopeSession) initialize(ctx context.Context) error {
513
+ leagueQuery := strings.TrimSpace(s.opts.LeagueQuery)
514
+ if leagueQuery == "" {
515
+ return fmt.Errorf("league query is required")
516
+ }
517
+
518
+ league, warnings, err := s.resolveLeague(ctx, leagueQuery)
519
+ if err != nil {
520
+ return err
521
+ }
522
+ s.league = league
523
+ s.warnings = append(s.warnings, warnings...)
524
+
525
+ if strings.TrimSpace(s.opts.SeasonQuery) != "" {
526
+ season, seasonWarnings, seasonErr := s.resolveSeason(ctx, league, s.opts.SeasonQuery)
527
+ if seasonErr != nil {
528
+ return seasonErr
529
+ }
530
+ s.season = season
531
+ s.warnings = append(s.warnings, seasonWarnings...)
532
+ }
533
+
534
+ if strings.TrimSpace(s.opts.TypeQuery) != "" || strings.TrimSpace(s.opts.GroupQuery) != "" {
535
+ if s.season == nil {
536
+ return fmt.Errorf("season query is required when type or group scope is requested")
537
+ }
538
+ }
539
+
540
+ if strings.TrimSpace(s.opts.TypeQuery) != "" {
541
+ seasonType, typeWarnings, typeErr := s.resolveSeasonType(ctx, *s.season, s.opts.TypeQuery)
542
+ if typeErr != nil {
543
+ return typeErr
544
+ }
545
+ s.seasonType = seasonType
546
+ s.warnings = append(s.warnings, typeWarnings...)
547
+ }
548
+
549
+ if strings.TrimSpace(s.opts.GroupQuery) != "" {
550
+ group, groupWarnings, groupErr := s.resolveSeasonGroup(ctx, *s.season, s.seasonType, s.opts.TypeQuery, s.opts.GroupQuery)
551
+ if groupErr != nil {
552
+ return groupErr
553
+ }
554
+ s.group = group
555
+ s.warnings = append(s.warnings, groupWarnings...)
556
+ }
557
+
558
+ dateRange, dateWarnings, err := buildHistoricalDateRange(s.opts, s.season, s.seasonType)
559
+ if err != nil {
560
+ return err
561
+ }
562
+ s.rangeSpec = dateRange
563
+ s.warnings = append(s.warnings, dateWarnings...)
564
+
565
+ groupTeamIDs, groupWarnings, err := s.collectGroupTeamIDs(ctx, s.group)
566
+ if err != nil {
567
+ return err
568
+ }
569
+ s.warnings = append(s.warnings, groupWarnings...)
570
+
571
+ matches, matchWarnings, err := s.collectScopedMatches(ctx, league, dateRange, groupTeamIDs)
572
+ if err != nil {
573
+ return err
574
+ }
575
+ s.warnings = append(s.warnings, matchWarnings...)
576
+
577
+ if s.opts.MatchLimit > 0 && len(matches) > s.opts.MatchLimit {
578
+ matches = matches[:s.opts.MatchLimit]
579
+ }
580
+ s.matches = matches
581
+ s.warnings = compactWarnings(s.warnings)
582
+ return nil
583
+ }
584
+
585
+ func (s *HistoricalScopeSession) resolveLeague(ctx context.Context, query string) (League, []string, error) {
586
+ query = strings.TrimSpace(query)
587
+ searchResult, err := s.resolver.Search(ctx, EntityLeague, query, ResolveOptions{Limit: 5})
588
+ if err != nil {
589
+ return League{}, nil, err
590
+ }
591
+
592
+ warnings := append([]string{}, searchResult.Warnings...)
593
+ if len(searchResult.Entities) > 0 {
594
+ entity := searchResult.Entities[0]
595
+ ref := nonEmpty(strings.TrimSpace(entity.Ref), "/leagues/"+strings.TrimSpace(entity.ID))
596
+ league, err := s.fetchLeagueByRef(ctx, ref)
597
+ return league, warnings, err
598
+ }
599
+
600
+ if isKnownRefQuery(query) {
601
+ league, err := s.fetchLeagueByRef(ctx, query)
602
+ return league, warnings, err
603
+ }
604
+ if isNumeric(query) {
605
+ league, err := s.fetchLeagueByRef(ctx, "/leagues/"+query)
606
+ return league, warnings, err
607
+ }
608
+
609
+ resolved, err := s.resolve(ctx, "/leagues")
610
+ if err != nil {
611
+ return League{}, warnings, err
612
+ }
613
+ page, err := DecodePage[Ref](resolved.Body)
614
+ if err != nil {
615
+ return League{}, warnings, fmt.Errorf("decode /leagues page: %w", err)
616
+ }
617
+ needle := normalizeAlias(query)
618
+ for _, item := range page.Items {
619
+ ref := strings.TrimSpace(item.URL)
620
+ if ref == "" {
621
+ continue
622
+ }
623
+ league, lookupErr := s.fetchLeagueByRef(ctx, ref)
624
+ if lookupErr != nil {
625
+ warnings = append(warnings, fmt.Sprintf("league fallback %s: %v", ref, lookupErr))
626
+ continue
627
+ }
628
+ aliases := []string{league.ID, league.Name, league.Slug}
629
+ for _, alias := range aliases {
630
+ if normalizeAlias(alias) != "" && normalizeAlias(alias) == needle {
631
+ return league, compactWarnings(warnings), nil
632
+ }
633
+ }
634
+ }
635
+
636
+ return League{}, compactWarnings(warnings), fmt.Errorf("no leagues found for %q", query)
637
+ }
638
+
639
+ func (s *HistoricalScopeSession) fetchLeagueByRef(ctx context.Context, ref string) (League, error) {
640
+ resolved, err := s.resolve(ctx, ref)
641
+ if err != nil {
642
+ return League{}, err
643
+ }
644
+ league, err := NormalizeLeague(resolved.Body)
645
+ if err != nil {
646
+ return League{}, fmt.Errorf("normalize league %q: %w", resolved.CanonicalRef, err)
647
+ }
648
+ if strings.TrimSpace(league.ID) == "" {
649
+ league.ID = strings.TrimSpace(refIDs(resolved.CanonicalRef)["leagueId"])
650
+ }
651
+ if strings.TrimSpace(league.Ref) == "" {
652
+ league.Ref = resolved.CanonicalRef
653
+ }
654
+ return *league, nil
655
+ }
656
+
657
+ func (s *HistoricalScopeSession) resolveSeason(ctx context.Context, league League, query string) (*Season, []string, error) {
658
+ seasons, warnings, err := s.fetchLeagueSeasons(ctx, league)
659
+ if err != nil {
660
+ return nil, warnings, err
661
+ }
662
+
663
+ query = strings.TrimSpace(query)
664
+ selectedRef := ""
665
+ queryIDs := refIDs(query)
666
+ for _, season := range seasons {
667
+ ids := refIDs(season.Ref)
668
+ candidates := []string{
669
+ strings.TrimSpace(season.ID),
670
+ strings.TrimSpace(strconv.Itoa(season.Year)),
671
+ strings.TrimSpace(ids["seasonId"]),
672
+ strings.TrimSpace(queryIDs["seasonId"]),
673
+ }
674
+ for _, candidate := range candidates {
675
+ if candidate != "" && strings.EqualFold(candidate, query) {
676
+ selectedRef = strings.TrimSpace(season.Ref)
677
+ break
678
+ }
679
+ }
680
+ if selectedRef != "" {
681
+ break
682
+ }
683
+ }
684
+
685
+ if selectedRef == "" && isKnownRefQuery(query) {
686
+ selectedRef = query
687
+ }
688
+ if selectedRef == "" && isNumeric(query) {
689
+ selectedRef = "/leagues/" + strings.TrimSpace(league.ID) + "/seasons/" + query
690
+ }
691
+ if selectedRef == "" {
692
+ return nil, warnings, fmt.Errorf("season %q not found for league %q", query, league.ID)
693
+ }
694
+
695
+ resolved, err := s.resolve(ctx, selectedRef)
696
+ if err != nil {
697
+ return nil, warnings, err
698
+ }
699
+ season, err := NormalizeSeason(resolved.Body)
700
+ if err != nil {
701
+ return nil, warnings, fmt.Errorf("normalize season %q: %w", resolved.CanonicalRef, err)
702
+ }
703
+ if strings.TrimSpace(season.LeagueID) == "" {
704
+ season.LeagueID = strings.TrimSpace(league.ID)
705
+ }
706
+ return season, warnings, nil
707
+ }
708
+
709
+ func (s *HistoricalScopeSession) fetchLeagueSeasons(ctx context.Context, league League) ([]Season, []string, error) {
710
+ seasonsRef := nonEmpty(extensionRef(league.Extensions, "seasons"), "/leagues/"+strings.TrimSpace(league.ID)+"/seasons")
711
+ resolved, err := s.resolve(ctx, seasonsRef)
712
+ if err != nil {
713
+ return nil, nil, err
714
+ }
715
+ seasons, err := NormalizeSeasonList(resolved.Body)
716
+ if err != nil {
717
+ return nil, nil, fmt.Errorf("normalize seasons list %q: %w", resolved.CanonicalRef, err)
718
+ }
719
+ for i := range seasons {
720
+ if strings.TrimSpace(seasons[i].LeagueID) == "" {
721
+ seasons[i].LeagueID = strings.TrimSpace(league.ID)
722
+ }
723
+ }
724
+ return seasons, nil, nil
725
+ }
726
+
727
+ func (s *HistoricalScopeSession) resolveSeasonType(ctx context.Context, season Season, query string) (*SeasonType, []string, error) {
728
+ types, warnings, err := s.fetchSeasonTypes(ctx, season)
729
+ if err != nil {
730
+ return nil, warnings, err
731
+ }
732
+
733
+ query = strings.TrimSpace(query)
734
+ queryIDs := refIDs(query)
735
+ queryNorm := normalizeAlias(query)
736
+ for _, seasonType := range types {
737
+ candidates := []string{
738
+ strings.TrimSpace(seasonType.ID),
739
+ strings.TrimSpace(refIDs(seasonType.Ref)["typeId"]),
740
+ strings.TrimSpace(queryIDs["typeId"]),
741
+ }
742
+ for _, candidate := range candidates {
743
+ if candidate != "" && strings.EqualFold(candidate, query) {
744
+ typed := seasonType
745
+ return &typed, warnings, nil
746
+ }
747
+ }
748
+
749
+ names := []string{seasonType.Name, seasonType.Abbreviation}
750
+ for _, name := range names {
751
+ if normalizeAlias(name) != "" && normalizeAlias(name) == queryNorm {
752
+ typed := seasonType
753
+ return &typed, warnings, nil
754
+ }
755
+ }
756
+ }
757
+
758
+ return nil, warnings, fmt.Errorf("season type %q not found for season %q", query, season.ID)
759
+ }
760
+
761
+ func (s *HistoricalScopeSession) fetchSeasonTypes(ctx context.Context, season Season) ([]SeasonType, []string, error) {
762
+ typesRef := seasonTypesRef(season)
763
+ if strings.TrimSpace(typesRef) == "" {
764
+ return []SeasonType{}, nil, fmt.Errorf("season types route unavailable for season %q", season.ID)
765
+ }
766
+
767
+ resolved, err := s.resolve(ctx, typesRef)
768
+ if err != nil {
769
+ return nil, nil, err
770
+ }
771
+
772
+ page, err := DecodePage[Ref](resolved.Body)
773
+ if err != nil {
774
+ return nil, nil, fmt.Errorf("decode season types page %q: %w", resolved.CanonicalRef, err)
775
+ }
776
+
777
+ items := make([]SeasonType, 0, len(page.Items))
778
+ warnings := make([]string, 0)
779
+ for _, item := range page.Items {
780
+ ref := strings.TrimSpace(item.URL)
781
+ if ref == "" {
782
+ continue
783
+ }
784
+ itemDoc, err := s.resolve(ctx, ref)
785
+ if err != nil {
786
+ warnings = append(warnings, fmt.Sprintf("season type %s: %v", ref, err))
787
+ continue
788
+ }
789
+ seasonType, err := NormalizeSeasonType(itemDoc.Body)
790
+ if err != nil {
791
+ warnings = append(warnings, fmt.Sprintf("season type %s: %v", itemDoc.CanonicalRef, err))
792
+ continue
793
+ }
794
+ if seasonType.SeasonID == "" {
795
+ seasonType.SeasonID = season.ID
796
+ }
797
+ if seasonType.LeagueID == "" {
798
+ seasonType.LeagueID = season.LeagueID
799
+ }
800
+ items = append(items, *seasonType)
801
+ }
802
+
803
+ return items, compactWarnings(warnings), nil
804
+ }
805
+
806
+ func (s *HistoricalScopeSession) resolveSeasonGroup(
807
+ ctx context.Context,
808
+ season Season,
809
+ seasonType *SeasonType,
810
+ typeQuery,
811
+ groupQuery string,
812
+ ) (*SeasonGroup, []string, error) {
813
+ typeQuery = strings.TrimSpace(typeQuery)
814
+ groupQuery = strings.TrimSpace(groupQuery)
815
+ if groupQuery == "" {
816
+ return nil, nil, nil
817
+ }
818
+
819
+ candidateTypes := make([]SeasonType, 0)
820
+ warnings := make([]string, 0)
821
+ if seasonType != nil {
822
+ candidateTypes = append(candidateTypes, *seasonType)
823
+ } else if typeQuery != "" {
824
+ selected, selectedWarnings, err := s.resolveSeasonType(ctx, season, typeQuery)
825
+ if err != nil {
826
+ return nil, selectedWarnings, err
827
+ }
828
+ warnings = append(warnings, selectedWarnings...)
829
+ candidateTypes = append(candidateTypes, *selected)
830
+ s.seasonType = selected
831
+ } else {
832
+ items, typeWarnings, err := s.fetchSeasonTypes(ctx, season)
833
+ if err != nil {
834
+ return nil, typeWarnings, err
835
+ }
836
+ warnings = append(warnings, typeWarnings...)
837
+ candidateTypes = append(candidateTypes, items...)
838
+ }
839
+
840
+ if len(candidateTypes) == 0 {
841
+ return nil, warnings, fmt.Errorf("no season types available for season %q", season.ID)
842
+ }
843
+
844
+ queryIDs := refIDs(groupQuery)
845
+ queryNorm := normalizeAlias(groupQuery)
846
+ for _, candidateType := range candidateTypes {
847
+ groups, groupWarnings, err := s.fetchSeasonGroups(ctx, candidateType)
848
+ warnings = append(warnings, groupWarnings...)
849
+ if err != nil {
850
+ warnings = append(warnings, err.Error())
851
+ continue
852
+ }
853
+
854
+ for _, group := range groups {
855
+ candidates := []string{
856
+ strings.TrimSpace(group.ID),
857
+ strings.TrimSpace(refIDs(group.Ref)["groupId"]),
858
+ strings.TrimSpace(queryIDs["groupId"]),
859
+ }
860
+ for _, id := range candidates {
861
+ if id != "" && strings.EqualFold(id, groupQuery) {
862
+ selected := group
863
+ if s.seasonType == nil {
864
+ typed := candidateType
865
+ s.seasonType = &typed
866
+ }
867
+ return &selected, compactWarnings(warnings), nil
868
+ }
869
+ }
870
+
871
+ names := []string{group.Name, group.Abbreviation}
872
+ for _, name := range names {
873
+ if normalizeAlias(name) != "" && normalizeAlias(name) == queryNorm {
874
+ selected := group
875
+ if s.seasonType == nil {
876
+ typed := candidateType
877
+ s.seasonType = &typed
878
+ }
879
+ return &selected, compactWarnings(warnings), nil
880
+ }
881
+ }
882
+ }
883
+ }
884
+
885
+ return nil, compactWarnings(warnings), fmt.Errorf("season group %q not found", groupQuery)
886
+ }
887
+
888
+ func (s *HistoricalScopeSession) fetchSeasonGroups(ctx context.Context, seasonType SeasonType) ([]SeasonGroup, []string, error) {
889
+ groupsRef := seasonGroupsRef(seasonType)
890
+ if strings.TrimSpace(groupsRef) == "" {
891
+ return []SeasonGroup{}, nil, fmt.Errorf("season groups route unavailable for season type %q", seasonType.ID)
892
+ }
893
+
894
+ resolved, err := s.resolve(ctx, groupsRef)
895
+ if err != nil {
896
+ return nil, nil, err
897
+ }
898
+
899
+ page, err := DecodePage[Ref](resolved.Body)
900
+ if err != nil {
901
+ return nil, nil, fmt.Errorf("decode season groups page %q: %w", resolved.CanonicalRef, err)
902
+ }
903
+
904
+ items := make([]SeasonGroup, 0, len(page.Items))
905
+ warnings := make([]string, 0)
906
+ for _, item := range page.Items {
907
+ ref := strings.TrimSpace(item.URL)
908
+ if ref == "" {
909
+ continue
910
+ }
911
+
912
+ itemDoc, err := s.resolve(ctx, ref)
913
+ if err != nil {
914
+ warnings = append(warnings, fmt.Sprintf("season group %s: %v", ref, err))
915
+ continue
916
+ }
917
+
918
+ group, err := NormalizeSeasonGroup(itemDoc.Body)
919
+ if err != nil {
920
+ warnings = append(warnings, fmt.Sprintf("season group %s: %v", itemDoc.CanonicalRef, err))
921
+ continue
922
+ }
923
+ if group.SeasonID == "" {
924
+ group.SeasonID = seasonType.SeasonID
925
+ }
926
+ if group.LeagueID == "" {
927
+ group.LeagueID = seasonType.LeagueID
928
+ }
929
+ if group.TypeID == "" {
930
+ group.TypeID = seasonType.ID
931
+ }
932
+ items = append(items, *group)
933
+ }
934
+
935
+ return items, compactWarnings(warnings), nil
936
+ }
937
+
938
+ func (s *HistoricalScopeSession) collectGroupTeamIDs(ctx context.Context, group *SeasonGroup) (map[string]struct{}, []string, error) {
939
+ if group == nil || strings.TrimSpace(group.StandingsRef) == "" {
940
+ return nil, nil, nil
941
+ }
942
+
943
+ groups, warnings, err := s.collectStandingsGroups(ctx, group.StandingsRef, map[string]struct{}{}, 0)
944
+ if err != nil {
945
+ return nil, warnings, err
946
+ }
947
+
948
+ teamIDs := map[string]struct{}{}
949
+ for _, item := range groups {
950
+ for _, entry := range item.Entries {
951
+ teamID := strings.TrimSpace(entry.ID)
952
+ if teamID == "" {
953
+ teamID = strings.TrimSpace(refIDs(entry.Ref)["teamId"])
954
+ }
955
+ if teamID == "" {
956
+ teamID = strings.TrimSpace(refIDs(entry.Ref)["competitorId"])
957
+ }
958
+ if teamID == "" {
959
+ continue
960
+ }
961
+ teamIDs[teamID] = struct{}{}
962
+ }
963
+ }
964
+
965
+ if len(teamIDs) == 0 {
966
+ warnings = append(warnings, fmt.Sprintf("season group %q did not yield any standings team ids", group.ID))
967
+ }
968
+ return teamIDs, compactWarnings(warnings), nil
969
+ }
970
+
971
+ func (s *HistoricalScopeSession) collectStandingsGroups(
972
+ ctx context.Context,
973
+ ref string,
974
+ visited map[string]struct{},
975
+ depth int,
976
+ ) ([]StandingsGroup, []string, error) {
977
+ if depth > maxStandingsTraversalDepth {
978
+ return nil, nil, fmt.Errorf("standings traversal exceeded max depth for %q", ref)
979
+ }
980
+
981
+ resolved, err := s.resolve(ctx, ref)
982
+ if err != nil {
983
+ return nil, nil, err
984
+ }
985
+
986
+ canonical := strings.TrimSpace(resolved.CanonicalRef)
987
+ if canonical == "" {
988
+ canonical = strings.TrimSpace(ref)
989
+ }
990
+ if _, ok := visited[canonical]; ok {
991
+ return nil, nil, nil
992
+ }
993
+ visited[canonical] = struct{}{}
994
+
995
+ payload, err := decodePayloadMap(resolved.Body)
996
+ if err != nil {
997
+ return nil, nil, fmt.Errorf("decode standings payload %q: %w", canonical, err)
998
+ }
999
+
1000
+ groups := make([]StandingsGroup, 0)
1001
+ warnings := make([]string, 0)
1002
+ if hasStandaloneStandingsPayload(payload) {
1003
+ group := NormalizeStandingsGroupFromMap(payload)
1004
+ if group != nil {
1005
+ groups = append(groups, *group)
1006
+ }
1007
+ }
1008
+
1009
+ for _, childRef := range standingsChildRefs(payload) {
1010
+ childGroups, childWarnings, childErr := s.collectStandingsGroups(ctx, childRef, visited, depth+1)
1011
+ if childErr != nil {
1012
+ warnings = append(warnings, fmt.Sprintf("standings child %s: %v", childRef, childErr))
1013
+ continue
1014
+ }
1015
+ groups = append(groups, childGroups...)
1016
+ warnings = append(warnings, childWarnings...)
1017
+ }
1018
+
1019
+ groups = dedupeStandingsGroups(groups)
1020
+ return groups, compactWarnings(warnings), nil
1021
+ }
1022
+
1023
+ func (s *HistoricalScopeSession) collectScopedMatches(
1024
+ ctx context.Context,
1025
+ league League,
1026
+ rangeSpec historicalDateRange,
1027
+ groupTeamIDs map[string]struct{},
1028
+ ) ([]Match, []string, error) {
1029
+ eventsRef := nonEmpty(extensionRef(league.Extensions, "events"), "/leagues/"+strings.TrimSpace(league.ID)+"/events")
1030
+ resolved, err := s.resolve(ctx, eventsRef)
1031
+ if err != nil {
1032
+ return nil, nil, err
1033
+ }
1034
+
1035
+ page, err := DecodePage[Ref](resolved.Body)
1036
+ if err != nil {
1037
+ return nil, nil, fmt.Errorf("decode league events page %q: %w", resolved.CanonicalRef, err)
1038
+ }
1039
+
1040
+ warnings := make([]string, 0)
1041
+ seen := map[string]struct{}{}
1042
+ matches := make([]Match, 0)
1043
+ for _, item := range page.Items {
1044
+ ref := strings.TrimSpace(item.URL)
1045
+ if ref == "" {
1046
+ continue
1047
+ }
1048
+
1049
+ eventDoc, err := s.resolve(ctx, ref)
1050
+ if err != nil {
1051
+ warnings = append(warnings, fmt.Sprintf("event %s: %v", ref, err))
1052
+ continue
1053
+ }
1054
+
1055
+ eventMatches, err := NormalizeMatchesFromEvent(eventDoc.Body)
1056
+ if err != nil {
1057
+ warnings = append(warnings, fmt.Sprintf("event %s: %v", eventDoc.CanonicalRef, err))
1058
+ continue
1059
+ }
1060
+
1061
+ for _, match := range eventMatches {
1062
+ if strings.TrimSpace(match.LeagueID) == "" {
1063
+ match.LeagueID = strings.TrimSpace(league.ID)
1064
+ }
1065
+
1066
+ allowed, reason := matchAllowedInScope(match, rangeSpec, groupTeamIDs)
1067
+ if !allowed {
1068
+ if reason != "" {
1069
+ warnings = append(warnings, reason)
1070
+ }
1071
+ continue
1072
+ }
1073
+
1074
+ key := matchCacheKey(match)
1075
+ if key == "" {
1076
+ continue
1077
+ }
1078
+ if _, ok := seen[key]; ok {
1079
+ continue
1080
+ }
1081
+ seen[key] = struct{}{}
1082
+ matches = append(matches, match)
1083
+ }
1084
+ }
1085
+
1086
+ sort.Slice(matches, func(i, j int) bool {
1087
+ leftTime, leftOK := parseMatchTime(matches[i])
1088
+ rightTime, rightOK := parseMatchTime(matches[j])
1089
+ if leftOK && rightOK {
1090
+ if !leftTime.Equal(rightTime) {
1091
+ return leftTime.Before(rightTime)
1092
+ }
1093
+ }
1094
+ if matches[i].Date != matches[j].Date {
1095
+ return matches[i].Date < matches[j].Date
1096
+ }
1097
+ return matchCacheKey(matches[i]) < matchCacheKey(matches[j])
1098
+ })
1099
+
1100
+ return matches, compactWarnings(warnings), nil
1101
+ }
1102
+
1103
+ func (s *HistoricalScopeSession) hydrateMatchSummary(ctx context.Context, match *Match) []string {
1104
+ if match == nil {
1105
+ return nil
1106
+ }
1107
+
1108
+ warnings := make([]string, 0)
1109
+ if statusRef := strings.TrimSpace(match.StatusRef); statusRef != "" {
1110
+ snapshot, err := s.resolveStatus(ctx, statusRef)
1111
+ if err != nil {
1112
+ warnings = append(warnings, fmt.Sprintf("status %s: %v", statusRef, err))
1113
+ } else {
1114
+ match.MatchState = nonEmpty(match.MatchState, snapshot.stateSummary())
1115
+ if strings.TrimSpace(match.Note) == "" {
1116
+ match.Note = snapshot.longSummary
1117
+ }
1118
+ if match.Extensions == nil {
1119
+ match.Extensions = map[string]any{}
1120
+ }
1121
+ match.Extensions["statusState"] = snapshot.state
1122
+ match.Extensions["statusDetail"] = snapshot.detail
1123
+ match.Extensions["statusShortDetail"] = snapshot.shortDetail
1124
+ }
1125
+ }
1126
+
1127
+ for i := range match.Teams {
1128
+ team := &match.Teams[i]
1129
+ if strings.TrimSpace(team.Name) == "" || strings.TrimSpace(team.ShortName) == "" {
1130
+ identity, err := s.resolveTeamIdentity(ctx, team)
1131
+ if err != nil {
1132
+ warnings = append(warnings, fmt.Sprintf("team %s: %v", nonEmpty(team.Ref, team.ID), err))
1133
+ } else {
1134
+ if strings.TrimSpace(team.Name) == "" {
1135
+ team.Name = identity.name
1136
+ }
1137
+ if strings.TrimSpace(team.ShortName) == "" {
1138
+ team.ShortName = identity.shortName
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ if strings.TrimSpace(team.ScoreSummary) == "" && strings.TrimSpace(team.ScoreRef) != "" {
1144
+ score, err := s.resolveTeamScore(ctx, team.ScoreRef)
1145
+ if err != nil {
1146
+ warnings = append(warnings, fmt.Sprintf("score %s: %v", team.ScoreRef, err))
1147
+ } else {
1148
+ team.ScoreSummary = score
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ match.ScoreSummary = matchScoreSummary(match.Teams)
1154
+ return compactWarnings(warnings)
1155
+ }
1156
+
1157
+ func (s *HistoricalScopeSession) resolveStatus(ctx context.Context, ref string) (matchStatusSnapshot, error) {
1158
+ ref = strings.TrimSpace(ref)
1159
+ if cached, ok := s.statusByRef[ref]; ok {
1160
+ return cached, nil
1161
+ }
1162
+
1163
+ resolved, err := s.resolve(ctx, ref)
1164
+ if err != nil {
1165
+ return matchStatusSnapshot{}, err
1166
+ }
1167
+
1168
+ payload, err := decodePayloadMap(resolved.Body)
1169
+ if err != nil {
1170
+ return matchStatusSnapshot{}, err
1171
+ }
1172
+
1173
+ typed := mapField(payload, "type")
1174
+ snapshot := matchStatusSnapshot{
1175
+ summary: stringField(payload, "summary"),
1176
+ longSummary: stringField(payload, "longSummary"),
1177
+ state: stringField(typed, "state"),
1178
+ detail: stringField(typed, "detail"),
1179
+ shortDetail: stringField(typed, "shortDetail"),
1180
+ description: stringField(typed, "description"),
1181
+ }
1182
+ s.statusByRef[ref] = snapshot
1183
+ return snapshot, nil
1184
+ }
1185
+
1186
+ func (s *HistoricalScopeSession) resolveTeamIdentity(ctx context.Context, team *Team) (teamIdentity, error) {
1187
+ if team == nil {
1188
+ return teamIdentity{}, fmt.Errorf("team is nil")
1189
+ }
1190
+
1191
+ ref := strings.TrimSpace(team.Ref)
1192
+ if ref == "" && strings.TrimSpace(team.ID) != "" {
1193
+ ref = "/teams/" + strings.TrimSpace(team.ID)
1194
+ }
1195
+ if ref == "" {
1196
+ return teamIdentity{}, fmt.Errorf("team ref is empty")
1197
+ }
1198
+ if cached, ok := s.teamIdentityByRef[ref]; ok {
1199
+ return cached, nil
1200
+ }
1201
+
1202
+ resolved, err := s.resolve(ctx, ref)
1203
+ if err != nil {
1204
+ return teamIdentity{}, err
1205
+ }
1206
+
1207
+ payload, err := decodePayloadMap(resolved.Body)
1208
+ if err != nil {
1209
+ return teamIdentity{}, err
1210
+ }
1211
+
1212
+ identity := teamIdentity{
1213
+ name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "name"), strings.TrimSpace(team.ID)),
1214
+ shortName: nonEmpty(stringField(payload, "shortDisplayName"), stringField(payload, "shortName"), stringField(payload, "abbreviation")),
1215
+ }
1216
+ s.teamIdentityByRef[ref] = identity
1217
+ return identity, nil
1218
+ }
1219
+
1220
+ func (s *HistoricalScopeSession) resolveTeamScore(ctx context.Context, ref string) (string, error) {
1221
+ ref = strings.TrimSpace(ref)
1222
+ if ref == "" {
1223
+ return "", fmt.Errorf("score ref is empty")
1224
+ }
1225
+ if cached, ok := s.teamScoreByRef[ref]; ok {
1226
+ return cached, nil
1227
+ }
1228
+
1229
+ resolved, err := s.resolve(ctx, ref)
1230
+ if err != nil {
1231
+ return "", err
1232
+ }
1233
+
1234
+ payload, err := decodePayloadMap(resolved.Body)
1235
+ if err != nil {
1236
+ return "", err
1237
+ }
1238
+ score := nonEmpty(stringField(payload, "displayValue"), stringField(payload, "value"), stringField(payload, "summary"))
1239
+ s.teamScoreByRef[ref] = score
1240
+ return score, nil
1241
+ }
1242
+
1243
+ func (s *HistoricalScopeSession) collectTeamInnings(ctx context.Context, match Match, team Team) ([]Innings, []string) {
1244
+ candidates := compactWarnings([]string{
1245
+ strings.TrimSpace(team.LinescoresRef),
1246
+ strings.TrimSpace(competitorSubresourceRef(match, team.ID, "linescores")),
1247
+ })
1248
+ if len(candidates) == 0 {
1249
+ return nil, []string{fmt.Sprintf("linescores route unavailable for team %q", team.ID)}
1250
+ }
1251
+
1252
+ warnings := make([]string, 0)
1253
+ seen := map[string]struct{}{}
1254
+ for _, ref := range candidates {
1255
+ if _, ok := seen[ref]; ok {
1256
+ continue
1257
+ }
1258
+ seen[ref] = struct{}{}
1259
+
1260
+ resolved, err := s.resolve(ctx, ref)
1261
+ if err != nil {
1262
+ warnings = append(warnings, fmt.Sprintf("linescores %s: %v", ref, err))
1263
+ continue
1264
+ }
1265
+
1266
+ innings, collectWarnings, err := s.collectInningsFromPayload(ctx, resolved.Body)
1267
+ warnings = append(warnings, collectWarnings...)
1268
+ if err != nil {
1269
+ warnings = append(warnings, fmt.Sprintf("linescores %s: %v", ref, err))
1270
+ continue
1271
+ }
1272
+
1273
+ for i := range innings {
1274
+ innings[i].TeamID = nonEmpty(strings.TrimSpace(team.ID), innings[i].TeamID)
1275
+ innings[i].TeamName = nonEmpty(team.ShortName, team.Name, team.ID, innings[i].TeamName)
1276
+ innings[i].MatchID = nonEmpty(innings[i].MatchID, match.ID)
1277
+ innings[i].CompetitionID = nonEmpty(innings[i].CompetitionID, match.CompetitionID, match.ID)
1278
+ innings[i].EventID = nonEmpty(innings[i].EventID, match.EventID)
1279
+ innings[i].LeagueID = nonEmpty(innings[i].LeagueID, match.LeagueID)
1280
+ if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "statistics/0"); scopedRef != "" {
1281
+ innings[i].StatisticsRef = scopedRef
1282
+ }
1283
+ if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "partnerships"); scopedRef != "" {
1284
+ innings[i].PartnershipsRef = scopedRef
1285
+ }
1286
+ if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "fow"); scopedRef != "" {
1287
+ innings[i].FallOfWicketRef = scopedRef
1288
+ }
1289
+ warnings = append(warnings, s.hydrateInningsTimeline(ctx, &innings[i])...)
1290
+ }
1291
+
1292
+ if len(innings) > 0 {
1293
+ return innings, compactWarnings(warnings)
1294
+ }
1295
+ }
1296
+
1297
+ return nil, compactWarnings(warnings)
1298
+ }
1299
+
1300
+ func (s *HistoricalScopeSession) collectInningsFromPayload(ctx context.Context, body []byte) ([]Innings, []string, error) {
1301
+ payload, err := decodePayloadMap(body)
1302
+ if err != nil {
1303
+ return nil, nil, err
1304
+ }
1305
+
1306
+ warnings := make([]string, 0)
1307
+ items := make([]Innings, 0)
1308
+
1309
+ appendInningsMap := func(row map[string]any) {
1310
+ if row == nil {
1311
+ return
1312
+ }
1313
+ if stringField(row, "$ref") == "" && intField(row, "period") == 0 && intField(row, "runs") == 0 && intField(row, "wickets") == 0 && stringField(row, "score") == "" {
1314
+ return
1315
+ }
1316
+ items = append(items, *normalizeInningsFromMap(row))
1317
+ }
1318
+
1319
+ rows := mapSliceField(payload, "items")
1320
+ if len(rows) > 0 {
1321
+ for _, row := range rows {
1322
+ itemRef := strings.TrimSpace(stringField(row, "$ref"))
1323
+ if itemRef != "" && intField(row, "period") == 0 && stringField(row, "score") == "" && intField(row, "runs") == 0 && intField(row, "wickets") == 0 {
1324
+ itemDoc, err := s.resolve(ctx, itemRef)
1325
+ if err != nil {
1326
+ warnings = append(warnings, fmt.Sprintf("innings %s: %v", itemRef, err))
1327
+ continue
1328
+ }
1329
+ normalized, err := NormalizeInnings(itemDoc.Body)
1330
+ if err != nil {
1331
+ warnings = append(warnings, fmt.Sprintf("innings %s: %v", itemDoc.CanonicalRef, err))
1332
+ continue
1333
+ }
1334
+ items = append(items, *normalized)
1335
+ continue
1336
+ }
1337
+ appendInningsMap(row)
1338
+ }
1339
+ return items, compactWarnings(warnings), nil
1340
+ }
1341
+
1342
+ appendInningsMap(payload)
1343
+ return items, compactWarnings(warnings), nil
1344
+ }
1345
+
1346
+ func (s *HistoricalScopeSession) hydrateInningsTimeline(ctx context.Context, innings *Innings) []string {
1347
+ if innings == nil || strings.TrimSpace(innings.StatisticsRef) == "" {
1348
+ return nil
1349
+ }
1350
+
1351
+ resolved, err := s.resolve(ctx, innings.StatisticsRef)
1352
+ if err != nil {
1353
+ return []string{fmt.Sprintf("period statistics %s: %v", innings.StatisticsRef, err)}
1354
+ }
1355
+
1356
+ overs, wickets, err := NormalizeInningsPeriodStatistics(resolved.Body)
1357
+ if err != nil {
1358
+ return []string{fmt.Sprintf("period statistics %s: %v", resolved.CanonicalRef, err)}
1359
+ }
1360
+
1361
+ innings.OverTimeline = overs
1362
+ innings.WicketTimeline = wickets
1363
+ return nil
1364
+ }
1365
+
1366
+ func (s *HistoricalScopeSession) matchByID(matchID string) (string, Match, error) {
1367
+ matchID = strings.TrimSpace(matchID)
1368
+ if matchID == "" {
1369
+ if len(s.matches) == 0 {
1370
+ return "", Match{}, fmt.Errorf("scope produced no matches")
1371
+ }
1372
+ first := s.matches[0]
1373
+ return matchCacheKey(first), first, nil
1374
+ }
1375
+
1376
+ for _, match := range s.matches {
1377
+ ids := []string{
1378
+ matchCacheKey(match),
1379
+ strings.TrimSpace(match.ID),
1380
+ strings.TrimSpace(match.CompetitionID),
1381
+ strings.TrimSpace(refIDs(match.Ref)["competitionId"]),
1382
+ strings.TrimSpace(refIDs(match.Ref)["eventId"]),
1383
+ }
1384
+ for _, id := range ids {
1385
+ if id != "" && id == matchID {
1386
+ return matchCacheKey(match), match, nil
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ return "", Match{}, fmt.Errorf("match %q is outside the active scope", matchID)
1392
+ }
1393
+
1394
+ func (s *HistoricalScopeSession) resolve(ctx context.Context, ref string) (*ResolvedDocument, error) {
1395
+ ref = strings.TrimSpace(ref)
1396
+ if ref == "" {
1397
+ return nil, fmt.Errorf("ref is empty")
1398
+ }
1399
+
1400
+ if cached, ok := s.resolvedDocs[ref]; ok {
1401
+ s.metrics.ResolveCacheHits++
1402
+ return cached, nil
1403
+ }
1404
+
1405
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
1406
+ if err != nil {
1407
+ return nil, err
1408
+ }
1409
+ s.metrics.ResolveCacheMisses++
1410
+
1411
+ copied := *resolved
1412
+ pointer := &copied
1413
+ keys := compactWarnings([]string{ref, copied.RequestedRef, copied.CanonicalRef})
1414
+ for _, key := range keys {
1415
+ s.resolvedDocs[key] = pointer
1416
+ }
1417
+ return pointer, nil
1418
+ }
1419
+
1420
+ type historicalDateRange struct {
1421
+ from time.Time
1422
+ to time.Time
1423
+ hasFrom bool
1424
+ hasTo bool
1425
+ }
1426
+
1427
+ func buildHistoricalDateRange(opts HistoricalScopeOptions, season *Season, seasonType *SeasonType) (historicalDateRange, []string, error) {
1428
+ rangeSpec := historicalDateRange{}
1429
+ warnings := make([]string, 0)
1430
+
1431
+ if rawFrom := strings.TrimSpace(opts.DateFrom); rawFrom != "" {
1432
+ parsed, err := parseScopeDate(rawFrom, false)
1433
+ if err != nil {
1434
+ return rangeSpec, warnings, fmt.Errorf("invalid --date-from value %q: %w", rawFrom, err)
1435
+ }
1436
+ rangeSpec.from = parsed
1437
+ rangeSpec.hasFrom = true
1438
+ }
1439
+ if rawTo := strings.TrimSpace(opts.DateTo); rawTo != "" {
1440
+ parsed, err := parseScopeDate(rawTo, true)
1441
+ if err != nil {
1442
+ return rangeSpec, warnings, fmt.Errorf("invalid --date-to value %q: %w", rawTo, err)
1443
+ }
1444
+ rangeSpec.to = parsed
1445
+ rangeSpec.hasTo = true
1446
+ }
1447
+
1448
+ if season != nil && season.Year > 0 {
1449
+ seasonStart := time.Date(season.Year, time.January, 1, 0, 0, 0, 0, time.UTC)
1450
+ seasonEnd := time.Date(season.Year, time.December, 31, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
1451
+ rangeSpec = intersectDateRange(rangeSpec, seasonStart, seasonEnd)
1452
+ }
1453
+
1454
+ if seasonType != nil {
1455
+ if rawStart := strings.TrimSpace(seasonType.StartDate); rawStart != "" {
1456
+ parsed, err := parseScopeDate(rawStart, false)
1457
+ if err != nil {
1458
+ warnings = append(warnings, fmt.Sprintf("unable to parse season type start date %q: %v", rawStart, err))
1459
+ } else {
1460
+ rangeSpec = intersectDateRange(rangeSpec, parsed, time.Time{})
1461
+ }
1462
+ }
1463
+ if rawEnd := strings.TrimSpace(seasonType.EndDate); rawEnd != "" {
1464
+ parsed, err := parseScopeDate(rawEnd, true)
1465
+ if err != nil {
1466
+ warnings = append(warnings, fmt.Sprintf("unable to parse season type end date %q: %v", rawEnd, err))
1467
+ } else {
1468
+ rangeSpec = intersectDateRange(rangeSpec, time.Time{}, parsed)
1469
+ }
1470
+ }
1471
+ }
1472
+
1473
+ if rangeSpec.hasFrom && rangeSpec.hasTo && rangeSpec.from.After(rangeSpec.to) {
1474
+ return rangeSpec, warnings, fmt.Errorf("date range is invalid: from %s is after %s", rangeSpec.from.Format(time.RFC3339), rangeSpec.to.Format(time.RFC3339))
1475
+ }
1476
+
1477
+ return rangeSpec, compactWarnings(warnings), nil
1478
+ }
1479
+
1480
+ func parseScopeDate(raw string, endOfDay bool) (time.Time, error) {
1481
+ raw = strings.TrimSpace(raw)
1482
+ if raw == "" {
1483
+ return time.Time{}, fmt.Errorf("empty date")
1484
+ }
1485
+
1486
+ formats := []string{time.RFC3339, "2006-01-02"}
1487
+ for _, layout := range formats {
1488
+ parsed, err := time.Parse(layout, raw)
1489
+ if err != nil {
1490
+ continue
1491
+ }
1492
+ parsed = parsed.UTC()
1493
+ if layout == "2006-01-02" && endOfDay {
1494
+ parsed = parsed.Add(24*time.Hour - time.Nanosecond)
1495
+ }
1496
+ return parsed, nil
1497
+ }
1498
+
1499
+ return time.Time{}, fmt.Errorf("expected RFC3339 or YYYY-MM-DD")
1500
+ }
1501
+
1502
+ func intersectDateRange(existing historicalDateRange, from, to time.Time) historicalDateRange {
1503
+ if !from.IsZero() {
1504
+ if !existing.hasFrom || from.After(existing.from) {
1505
+ existing.from = from
1506
+ existing.hasFrom = true
1507
+ }
1508
+ }
1509
+ if !to.IsZero() {
1510
+ if !existing.hasTo || to.Before(existing.to) {
1511
+ existing.to = to
1512
+ existing.hasTo = true
1513
+ }
1514
+ }
1515
+ return existing
1516
+ }
1517
+
1518
+ func matchAllowedInScope(match Match, rangeSpec historicalDateRange, groupTeamIDs map[string]struct{}) (bool, string) {
1519
+ if len(groupTeamIDs) > 0 {
1520
+ matched := false
1521
+ for _, team := range match.Teams {
1522
+ teamID := strings.TrimSpace(team.ID)
1523
+ if teamID == "" {
1524
+ teamID = strings.TrimSpace(refIDs(team.Ref)["teamId"])
1525
+ }
1526
+ if teamID == "" {
1527
+ teamID = strings.TrimSpace(refIDs(team.Ref)["competitorId"])
1528
+ }
1529
+ if teamID == "" {
1530
+ continue
1531
+ }
1532
+ if _, ok := groupTeamIDs[teamID]; ok {
1533
+ matched = true
1534
+ break
1535
+ }
1536
+ }
1537
+ if !matched {
1538
+ return false, ""
1539
+ }
1540
+ }
1541
+
1542
+ if !rangeSpec.hasFrom && !rangeSpec.hasTo {
1543
+ return true, ""
1544
+ }
1545
+
1546
+ matchTime, ok := parseMatchTime(match)
1547
+ if !ok {
1548
+ return false, fmt.Sprintf("skip match %s: unable to parse date %q", matchCacheKey(match), match.Date)
1549
+ }
1550
+ if rangeSpec.hasFrom && matchTime.Before(rangeSpec.from) {
1551
+ return false, ""
1552
+ }
1553
+ if rangeSpec.hasTo && matchTime.After(rangeSpec.to) {
1554
+ return false, ""
1555
+ }
1556
+ return true, ""
1557
+ }
1558
+
1559
+ func parseMatchTime(match Match) (time.Time, bool) {
1560
+ value := strings.TrimSpace(match.Date)
1561
+ if value == "" {
1562
+ return time.Time{}, false
1563
+ }
1564
+
1565
+ formats := []string{
1566
+ time.RFC3339,
1567
+ "2006-01-02T15:04Z07:00",
1568
+ "2006-01-02T15:04Z",
1569
+ "2006-01-02",
1570
+ }
1571
+ for _, layout := range formats {
1572
+ parsed, err := time.Parse(layout, value)
1573
+ if err != nil {
1574
+ continue
1575
+ }
1576
+ return parsed.UTC(), true
1577
+ }
1578
+
1579
+ return time.Time{}, false
1580
+ }
1581
+
1582
+ func matchCacheKey(match Match) string {
1583
+ return firstNonEmptyString(
1584
+ strings.TrimSpace(match.ID),
1585
+ strings.TrimSpace(match.CompetitionID),
1586
+ strings.TrimSpace(refIDs(match.Ref)["competitionId"]),
1587
+ strings.TrimSpace(refIDs(match.Ref)["eventId"]),
1588
+ strings.TrimSpace(match.Ref),
1589
+ )
1590
+ }
1591
+
1592
+ func (s *HistoricalScopeSession) enrichRosterEntryFromIndex(entry TeamRosterEntry) TeamRosterEntry {
1593
+ if s == nil || s.resolver == nil || s.resolver.index == nil {
1594
+ return entry
1595
+ }
1596
+ playerID := strings.TrimSpace(entry.PlayerID)
1597
+ if playerID == "" {
1598
+ return entry
1599
+ }
1600
+ player, ok := s.resolver.index.FindByID(EntityPlayer, playerID)
1601
+ if !ok {
1602
+ return entry
1603
+ }
1604
+ if strings.TrimSpace(entry.DisplayName) == "" {
1605
+ entry.DisplayName = nonEmpty(player.Name, player.ShortName)
1606
+ }
1607
+ if strings.TrimSpace(entry.PlayerRef) == "" {
1608
+ entry.PlayerRef = strings.TrimSpace(player.Ref)
1609
+ }
1610
+ return entry
1611
+ }
1612
+
1613
+ func (s *HistoricalScopeSession) enrichTeamIdentityFromIndex(team Team) Team {
1614
+ if s == nil || s.resolver == nil || s.resolver.index == nil {
1615
+ return team
1616
+ }
1617
+ teamID := strings.TrimSpace(team.ID)
1618
+ if teamID == "" {
1619
+ teamID = strings.TrimSpace(refIDs(team.Ref)["teamId"])
1620
+ }
1621
+ if teamID == "" {
1622
+ teamID = strings.TrimSpace(refIDs(team.Ref)["competitorId"])
1623
+ }
1624
+ if teamID == "" {
1625
+ return team
1626
+ }
1627
+ indexed, ok := s.resolver.index.FindByID(EntityTeam, teamID)
1628
+ if !ok {
1629
+ return team
1630
+ }
1631
+ if strings.TrimSpace(team.Name) == "" {
1632
+ team.Name = strings.TrimSpace(indexed.Name)
1633
+ }
1634
+ if strings.TrimSpace(team.ShortName) == "" {
1635
+ team.ShortName = strings.TrimSpace(indexed.ShortName)
1636
+ }
1637
+ if strings.TrimSpace(team.Ref) == "" {
1638
+ team.Ref = strings.TrimSpace(indexed.Ref)
1639
+ }
1640
+ return team
1641
+ }