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,1210 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "sort"
7
+ "strconv"
8
+ "strings"
9
+ )
10
+
11
+ const (
12
+ defaultLeagueListLimit = 20
13
+ defaultLeagueEventsLimit = 20
14
+ defaultLeagueAthletesLimit = 20
15
+ maxStandingsTraversalDepth = 12
16
+ )
17
+
18
+ // LeagueServiceConfig configures league/season/standings command behavior.
19
+ type LeagueServiceConfig struct {
20
+ Client *Client
21
+ Resolver *Resolver
22
+ }
23
+
24
+ // LeagueListOptions controls list-style league command behavior.
25
+ type LeagueListOptions struct {
26
+ Limit int
27
+ }
28
+
29
+ // SeasonLookupOptions controls season, type, and group traversal behavior.
30
+ type SeasonLookupOptions struct {
31
+ SeasonQuery string
32
+ TypeQuery string
33
+ }
34
+
35
+ // LeagueService implements league, season, and standings navigation commands.
36
+ type LeagueService struct {
37
+ client *Client
38
+ resolver *Resolver
39
+ ownsResolver bool
40
+ }
41
+
42
+ // NewLeagueService builds a league service using default client/resolver when omitted.
43
+ func NewLeagueService(cfg LeagueServiceConfig) (*LeagueService, error) {
44
+ client := cfg.Client
45
+ if client == nil {
46
+ var err error
47
+ client, err = NewClient(Config{})
48
+ if err != nil {
49
+ return nil, err
50
+ }
51
+ }
52
+
53
+ resolver := cfg.Resolver
54
+ ownsResolver := false
55
+ if resolver == nil {
56
+ var err error
57
+ resolver, err = NewResolver(ResolverConfig{Client: client})
58
+ if err != nil {
59
+ return nil, err
60
+ }
61
+ ownsResolver = true
62
+ }
63
+
64
+ return &LeagueService{
65
+ client: client,
66
+ resolver: resolver,
67
+ ownsResolver: ownsResolver,
68
+ }, nil
69
+ }
70
+
71
+ // Close persists resolver cache when owned by this service.
72
+ func (s *LeagueService) Close() error {
73
+ if !s.ownsResolver || s.resolver == nil {
74
+ return nil
75
+ }
76
+ return s.resolver.Close()
77
+ }
78
+
79
+ // List resolves league refs from /leagues into normalized league entries.
80
+ func (s *LeagueService) List(ctx context.Context, opts LeagueListOptions) (NormalizedResult, error) {
81
+ resolved, err := s.client.ResolveRefChain(ctx, "/leagues")
82
+ if err != nil {
83
+ return NewTransportErrorResult(EntityLeague, "/leagues", err), nil
84
+ }
85
+
86
+ page, err := DecodePage[Ref](resolved.Body)
87
+ if err != nil {
88
+ return NormalizedResult{}, fmt.Errorf("decode /leagues page: %w", err)
89
+ }
90
+
91
+ limit := opts.Limit
92
+ if limit <= 0 {
93
+ limit = defaultLeagueListLimit
94
+ }
95
+ if limit > len(page.Items) {
96
+ limit = len(page.Items)
97
+ }
98
+
99
+ items := make([]any, 0, limit)
100
+ warnings := make([]string, 0)
101
+ for i := 0; i < limit; i++ {
102
+ ref := strings.TrimSpace(page.Items[i].URL)
103
+ if ref == "" {
104
+ warnings = append(warnings, "skip league item with empty ref")
105
+ continue
106
+ }
107
+
108
+ league, _, warning, lookupErr := s.fetchLeagueByRef(ctx, ref)
109
+ if lookupErr != nil {
110
+ warnings = append(warnings, fmt.Sprintf("league %s: %v", ref, lookupErr))
111
+ continue
112
+ }
113
+ if warning != "" {
114
+ warnings = append(warnings, warning)
115
+ }
116
+ s.upsertLeagueEntity(*league)
117
+ items = append(items, *league)
118
+ }
119
+
120
+ result := NewListResult(EntityLeague, items)
121
+ if compact := compactWarnings(warnings); len(compact) > 0 {
122
+ result = NewPartialListResult(EntityLeague, items, compact...)
123
+ }
124
+ result.RequestedRef = resolved.RequestedRef
125
+ result.CanonicalRef = resolved.CanonicalRef
126
+ return result, nil
127
+ }
128
+
129
+ // Show resolves one league by id/ref/alias and returns a normalized league payload.
130
+ func (s *LeagueService) Show(ctx context.Context, leagueQuery string) (NormalizedResult, error) {
131
+ lookup, passthrough := s.resolveLeagueLookup(ctx, leagueQuery)
132
+ if passthrough != nil {
133
+ return *passthrough, nil
134
+ }
135
+
136
+ result := NewDataResult(EntityLeague, lookup.league)
137
+ if len(lookup.warnings) > 0 {
138
+ result = NewPartialResult(EntityLeague, lookup.league, lookup.warnings...)
139
+ }
140
+ result.RequestedRef = lookup.resolved.RequestedRef
141
+ result.CanonicalRef = lookup.resolved.CanonicalRef
142
+ return result, nil
143
+ }
144
+
145
+ // Events resolves one league and lists normalized match entries from /leagues/{id}/events traversal.
146
+ func (s *LeagueService) Events(ctx context.Context, leagueQuery string, opts LeagueListOptions) (NormalizedResult, error) {
147
+ lookup, passthrough := s.resolveLeagueLookup(ctx, leagueQuery)
148
+ if passthrough != nil {
149
+ passthrough.Kind = EntityMatch
150
+ return *passthrough, nil
151
+ }
152
+
153
+ eventsRef := nonEmpty(extensionRef(lookup.league.Extensions, "events"), "/leagues/"+strings.TrimSpace(lookup.league.ID)+"/events")
154
+ resolved, err := s.client.ResolveRefChain(ctx, eventsRef)
155
+ if err != nil {
156
+ return NewTransportErrorResult(EntityMatch, eventsRef, err), nil
157
+ }
158
+
159
+ page, err := DecodePage[Ref](resolved.Body)
160
+ if err != nil {
161
+ return NormalizedResult{}, fmt.Errorf("decode league events page %q: %w", resolved.CanonicalRef, err)
162
+ }
163
+
164
+ limit := opts.Limit
165
+ if limit <= 0 {
166
+ limit = defaultLeagueEventsLimit
167
+ }
168
+
169
+ items := make([]any, 0, limit)
170
+ warnings := append([]string{}, lookup.warnings...)
171
+ for _, eventRef := range page.Items {
172
+ if len(items) >= limit {
173
+ break
174
+ }
175
+
176
+ ref := strings.TrimSpace(eventRef.URL)
177
+ if ref == "" {
178
+ warnings = append(warnings, "skip event item with empty ref")
179
+ continue
180
+ }
181
+
182
+ eventResolved, eventErr := s.client.ResolveRefChain(ctx, ref)
183
+ if eventErr != nil {
184
+ warnings = append(warnings, fmt.Sprintf("event %s: %v", ref, eventErr))
185
+ continue
186
+ }
187
+
188
+ matches, normalizeErr := NormalizeMatchesFromEvent(eventResolved.Body)
189
+ if normalizeErr != nil {
190
+ warnings = append(warnings, fmt.Sprintf("event %s: %v", eventResolved.CanonicalRef, normalizeErr))
191
+ continue
192
+ }
193
+ for _, match := range matches {
194
+ if strings.TrimSpace(match.LeagueID) == "" {
195
+ match.LeagueID = strings.TrimSpace(lookup.league.ID)
196
+ }
197
+ items = append(items, match)
198
+ if len(items) >= limit {
199
+ break
200
+ }
201
+ }
202
+ }
203
+
204
+ result := NewListResult(EntityMatch, items)
205
+ if compact := compactWarnings(warnings); len(compact) > 0 {
206
+ result = NewPartialListResult(EntityMatch, items, compact...)
207
+ }
208
+ result.RequestedRef = resolved.RequestedRef
209
+ result.CanonicalRef = resolved.CanonicalRef
210
+ return result, nil
211
+ }
212
+
213
+ // Calendar resolves one league and normalizes section-shaped calendar routes into calendar-day entries.
214
+ func (s *LeagueService) Calendar(ctx context.Context, leagueQuery string) (NormalizedResult, error) {
215
+ lookup, passthrough := s.resolveLeagueLookup(ctx, leagueQuery)
216
+ if passthrough != nil {
217
+ passthrough.Kind = EntityCalendarDay
218
+ return *passthrough, nil
219
+ }
220
+
221
+ calendarRef := "/leagues/" + strings.TrimSpace(lookup.league.ID) + "/calendar"
222
+ resolved, err := s.client.ResolveRefChain(ctx, calendarRef)
223
+ if err != nil {
224
+ return NewTransportErrorResult(EntityCalendarDay, calendarRef, err), nil
225
+ }
226
+
227
+ page, err := DecodePage[Ref](resolved.Body)
228
+ if err != nil {
229
+ return NormalizedResult{}, fmt.Errorf("decode calendar root %q: %w", resolved.CanonicalRef, err)
230
+ }
231
+
232
+ items := make([]CalendarDay, 0)
233
+ warnings := append([]string{}, lookup.warnings...)
234
+ for _, item := range page.Items {
235
+ ref := strings.TrimSpace(item.URL)
236
+ if ref == "" {
237
+ continue
238
+ }
239
+
240
+ itemResolved, itemErr := s.client.ResolveRefChain(ctx, ref)
241
+ if itemErr != nil {
242
+ warnings = append(warnings, fmt.Sprintf("calendar section %s: %v", ref, itemErr))
243
+ continue
244
+ }
245
+
246
+ days, normalizeErr := NormalizeCalendarDays(itemResolved.Body)
247
+ if normalizeErr != nil {
248
+ warnings = append(warnings, fmt.Sprintf("calendar section %s: %v", itemResolved.CanonicalRef, normalizeErr))
249
+ continue
250
+ }
251
+ items = append(items, days...)
252
+ }
253
+
254
+ sort.Slice(items, func(i, j int) bool {
255
+ if items[i].Date != items[j].Date {
256
+ return items[i].Date < items[j].Date
257
+ }
258
+ return items[i].DayType < items[j].DayType
259
+ })
260
+
261
+ renderItems := make([]any, 0, len(items))
262
+ for _, day := range items {
263
+ renderItems = append(renderItems, day)
264
+ }
265
+
266
+ result := NewListResult(EntityCalendarDay, renderItems)
267
+ if compact := compactWarnings(warnings); len(compact) > 0 {
268
+ result = NewPartialListResult(EntityCalendarDay, renderItems, compact...)
269
+ }
270
+ result.RequestedRef = resolved.RequestedRef
271
+ result.CanonicalRef = resolved.CanonicalRef
272
+ return result, nil
273
+ }
274
+
275
+ // Athletes resolves one league and returns league-athlete views, falling back to event-roster traversal when needed.
276
+ func (s *LeagueService) Athletes(ctx context.Context, leagueQuery string, opts LeagueListOptions) (NormalizedResult, error) {
277
+ lookup, passthrough := s.resolveLeagueLookup(ctx, leagueQuery)
278
+ if passthrough != nil {
279
+ passthrough.Kind = EntityPlayer
280
+ return *passthrough, nil
281
+ }
282
+
283
+ limit := opts.Limit
284
+ if limit <= 0 {
285
+ limit = defaultLeagueAthletesLimit
286
+ }
287
+
288
+ warnings := append([]string{}, lookup.warnings...)
289
+ players, directWarnings, directErr := s.playersFromLeagueAthletePage(ctx, *lookup.league, limit)
290
+ warnings = append(warnings, directWarnings...)
291
+ if directErr != nil {
292
+ warnings = append(warnings, directErr.Error())
293
+ }
294
+
295
+ if len(players) == 0 {
296
+ fallbackPlayers, fallbackWarnings := s.playersFromLeagueEventRosters(ctx, *lookup.league, limit)
297
+ players = append(players, fallbackPlayers...)
298
+ warnings = append(warnings, fallbackWarnings...)
299
+ }
300
+
301
+ items := make([]any, 0, len(players))
302
+ for _, player := range players {
303
+ items = append(items, player)
304
+ }
305
+
306
+ result := NewListResult(EntityPlayer, items)
307
+ if compact := compactWarnings(warnings); len(compact) > 0 {
308
+ result = NewPartialListResult(EntityPlayer, items, compact...)
309
+ }
310
+ result.RequestedRef = "/leagues/" + strings.TrimSpace(lookup.league.ID) + "/athletes"
311
+ result.CanonicalRef = result.RequestedRef
312
+ return result, nil
313
+ }
314
+
315
+ // Standings resolves one league and hides multi-hop standings traversal behind a single command response.
316
+ func (s *LeagueService) Standings(ctx context.Context, leagueQuery string) (NormalizedResult, error) {
317
+ lookup, passthrough := s.resolveLeagueLookup(ctx, leagueQuery)
318
+ if passthrough != nil {
319
+ passthrough.Kind = EntityStandingsGroup
320
+ return *passthrough, nil
321
+ }
322
+
323
+ standingsRef := "/leagues/" + strings.TrimSpace(lookup.league.ID) + "/standings"
324
+ groups, warnings, err := s.collectStandingsGroups(ctx, standingsRef, map[string]struct{}{}, 0)
325
+ if err != nil {
326
+ return NewTransportErrorResult(EntityStandingsGroup, standingsRef, err), nil
327
+ }
328
+ s.hydrateStandingsTeamNames(ctx, groups, &warnings)
329
+
330
+ items := make([]any, 0, len(groups))
331
+ for _, group := range groups {
332
+ items = append(items, group)
333
+ }
334
+
335
+ result := NewListResult(EntityStandingsGroup, items)
336
+ combinedWarnings := append([]string{}, lookup.warnings...)
337
+ combinedWarnings = append(combinedWarnings, warnings...)
338
+ if compact := compactWarnings(combinedWarnings); len(compact) > 0 {
339
+ result = NewPartialListResult(EntityStandingsGroup, items, compact...)
340
+ }
341
+ result.RequestedRef = standingsRef
342
+ result.CanonicalRef = standingsRef
343
+ return result, nil
344
+ }
345
+
346
+ // Seasons resolves one league and returns normalized season refs.
347
+ func (s *LeagueService) Seasons(ctx context.Context, leagueQuery string) (NormalizedResult, error) {
348
+ lookup, passthrough := s.resolveLeagueLookup(ctx, leagueQuery)
349
+ if passthrough != nil {
350
+ passthrough.Kind = EntitySeason
351
+ return *passthrough, nil
352
+ }
353
+
354
+ resolved, seasons, warnings, err := s.fetchLeagueSeasons(ctx, *lookup.league)
355
+ if err != nil {
356
+ return NewTransportErrorResult(EntitySeason, "/leagues/"+lookup.league.ID+"/seasons", err), nil
357
+ }
358
+
359
+ items := make([]any, 0, len(seasons))
360
+ for _, season := range seasons {
361
+ items = append(items, season)
362
+ }
363
+
364
+ combinedWarnings := append([]string{}, lookup.warnings...)
365
+ combinedWarnings = append(combinedWarnings, warnings...)
366
+ result := NewListResult(EntitySeason, items)
367
+ if compact := compactWarnings(combinedWarnings); len(compact) > 0 {
368
+ result = NewPartialListResult(EntitySeason, items, compact...)
369
+ }
370
+ result.RequestedRef = resolved.RequestedRef
371
+ result.CanonicalRef = resolved.CanonicalRef
372
+ return result, nil
373
+ }
374
+
375
+ // SeasonShow resolves one league season selection and returns the normalized season payload.
376
+ func (s *LeagueService) SeasonShow(ctx context.Context, leagueQuery string, opts SeasonLookupOptions) (NormalizedResult, error) {
377
+ selection, passthrough := s.resolveSeasonSelection(ctx, leagueQuery, opts.SeasonQuery)
378
+ if passthrough != nil {
379
+ return *passthrough, nil
380
+ }
381
+
382
+ result := NewDataResult(EntitySeason, selection.season)
383
+ if len(selection.warnings) > 0 {
384
+ result = NewPartialResult(EntitySeason, selection.season, selection.warnings...)
385
+ }
386
+ result.RequestedRef = selection.resolved.RequestedRef
387
+ result.CanonicalRef = selection.resolved.CanonicalRef
388
+ return result, nil
389
+ }
390
+
391
+ // SeasonTypes resolves one league season selection and returns normalized season-type entries.
392
+ func (s *LeagueService) SeasonTypes(ctx context.Context, leagueQuery string, opts SeasonLookupOptions) (NormalizedResult, error) {
393
+ selection, passthrough := s.resolveSeasonSelection(ctx, leagueQuery, opts.SeasonQuery)
394
+ if passthrough != nil {
395
+ passthrough.Kind = EntitySeasonType
396
+ return *passthrough, nil
397
+ }
398
+
399
+ resolved, types, warnings, err := s.fetchSeasonTypes(ctx, selection.season)
400
+ if err != nil {
401
+ return NewTransportErrorResult(EntitySeasonType, seasonTypesRef(selection.season), err), nil
402
+ }
403
+
404
+ items := make([]any, 0, len(types))
405
+ for _, seasonType := range types {
406
+ items = append(items, seasonType)
407
+ }
408
+
409
+ combinedWarnings := append([]string{}, selection.warnings...)
410
+ combinedWarnings = append(combinedWarnings, warnings...)
411
+ result := NewListResult(EntitySeasonType, items)
412
+ if compact := compactWarnings(combinedWarnings); len(compact) > 0 {
413
+ result = NewPartialListResult(EntitySeasonType, items, compact...)
414
+ }
415
+ result.RequestedRef = resolved.RequestedRef
416
+ result.CanonicalRef = resolved.CanonicalRef
417
+ return result, nil
418
+ }
419
+
420
+ // SeasonGroups resolves one league season+type selection and returns normalized season-group entries.
421
+ func (s *LeagueService) SeasonGroups(ctx context.Context, leagueQuery string, opts SeasonLookupOptions) (NormalizedResult, error) {
422
+ selection, passthrough := s.resolveSeasonSelection(ctx, leagueQuery, opts.SeasonQuery)
423
+ if passthrough != nil {
424
+ passthrough.Kind = EntitySeasonGroup
425
+ return *passthrough, nil
426
+ }
427
+
428
+ typeSelection, typePassthrough := s.resolveSeasonTypeSelection(ctx, selection.season, opts.TypeQuery)
429
+ if typePassthrough != nil {
430
+ return *typePassthrough, nil
431
+ }
432
+
433
+ resolved, groups, warnings, err := s.fetchSeasonGroups(ctx, typeSelection.seasonType)
434
+ if err != nil {
435
+ return NewTransportErrorResult(EntitySeasonGroup, seasonGroupsRef(typeSelection.seasonType), err), nil
436
+ }
437
+
438
+ items := make([]any, 0, len(groups))
439
+ for _, group := range groups {
440
+ items = append(items, group)
441
+ }
442
+
443
+ combinedWarnings := append([]string{}, selection.warnings...)
444
+ combinedWarnings = append(combinedWarnings, typeSelection.warnings...)
445
+ combinedWarnings = append(combinedWarnings, warnings...)
446
+ result := NewListResult(EntitySeasonGroup, items)
447
+ if compact := compactWarnings(combinedWarnings); len(compact) > 0 {
448
+ result = NewPartialListResult(EntitySeasonGroup, items, compact...)
449
+ }
450
+ result.RequestedRef = resolved.RequestedRef
451
+ result.CanonicalRef = resolved.CanonicalRef
452
+ return result, nil
453
+ }
454
+
455
+ type leagueLookup struct {
456
+ league *League
457
+ resolved *ResolvedDocument
458
+ warnings []string
459
+ }
460
+
461
+ type seasonSelection struct {
462
+ league *League
463
+ season Season
464
+ resolved *ResolvedDocument
465
+ warnings []string
466
+ }
467
+
468
+ type seasonTypeSelection struct {
469
+ seasonType SeasonType
470
+ warnings []string
471
+ }
472
+
473
+ func (s *LeagueService) resolveLeagueLookup(ctx context.Context, query string) (*leagueLookup, *NormalizedResult) {
474
+ query = strings.TrimSpace(query)
475
+ if query == "" {
476
+ result := NormalizedResult{
477
+ Kind: EntityLeague,
478
+ Status: ResultStatusEmpty,
479
+ Message: "league query is required",
480
+ }
481
+ return nil, &result
482
+ }
483
+
484
+ warnings := make([]string, 0)
485
+ searchResult, err := s.resolver.Search(ctx, EntityLeague, query, ResolveOptions{Limit: 5})
486
+ if err != nil {
487
+ result := NewTransportErrorResult(EntityLeague, query, err)
488
+ return nil, &result
489
+ }
490
+ warnings = append(warnings, searchResult.Warnings...)
491
+
492
+ if len(searchResult.Entities) > 0 {
493
+ entity := searchResult.Entities[0]
494
+ ref := nonEmpty(strings.TrimSpace(entity.Ref), "/leagues/"+strings.TrimSpace(entity.ID))
495
+ league, resolved, warning, lookupErr := s.fetchLeagueByRef(ctx, ref)
496
+ if lookupErr != nil {
497
+ result := NewTransportErrorResult(EntityLeague, ref, lookupErr)
498
+ return nil, &result
499
+ }
500
+ if warning != "" {
501
+ warnings = append(warnings, warning)
502
+ }
503
+ s.upsertLeagueEntity(*league)
504
+ return &leagueLookup{
505
+ league: league,
506
+ resolved: resolved,
507
+ warnings: compactWarnings(warnings),
508
+ }, nil
509
+ }
510
+
511
+ if isKnownRefQuery(query) || isNumeric(query) {
512
+ ref := strings.TrimSpace(query)
513
+ if isNumeric(query) {
514
+ ref = "/leagues/" + strings.TrimSpace(query)
515
+ }
516
+ league, resolved, warning, lookupErr := s.fetchLeagueByRef(ctx, ref)
517
+ if lookupErr != nil {
518
+ result := NewTransportErrorResult(EntityLeague, ref, lookupErr)
519
+ return nil, &result
520
+ }
521
+ if warning != "" {
522
+ warnings = append(warnings, warning)
523
+ }
524
+ s.upsertLeagueEntity(*league)
525
+ return &leagueLookup{
526
+ league: league,
527
+ resolved: resolved,
528
+ warnings: compactWarnings(warnings),
529
+ }, nil
530
+ }
531
+
532
+ ref, fallbackWarnings := s.findLeagueRefByNameFallback(ctx, query)
533
+ warnings = append(warnings, fallbackWarnings...)
534
+ if ref != "" {
535
+ league, resolved, warning, lookupErr := s.fetchLeagueByRef(ctx, ref)
536
+ if lookupErr != nil {
537
+ result := NewTransportErrorResult(EntityLeague, ref, lookupErr)
538
+ return nil, &result
539
+ }
540
+ if warning != "" {
541
+ warnings = append(warnings, warning)
542
+ }
543
+ s.upsertLeagueEntity(*league)
544
+ return &leagueLookup{
545
+ league: league,
546
+ resolved: resolved,
547
+ warnings: compactWarnings(warnings),
548
+ }, nil
549
+ }
550
+
551
+ result := NormalizedResult{
552
+ Kind: EntityLeague,
553
+ Status: ResultStatusEmpty,
554
+ Message: fmt.Sprintf("no leagues found for %q", query),
555
+ }
556
+ return nil, &result
557
+ }
558
+
559
+ func (s *LeagueService) fetchLeagueByRef(ctx context.Context, ref string) (*League, *ResolvedDocument, string, error) {
560
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
561
+ if err != nil {
562
+ return nil, nil, "", err
563
+ }
564
+ league, err := NormalizeLeague(resolved.Body)
565
+ if err != nil {
566
+ return nil, nil, "", fmt.Errorf("normalize league %q: %w", resolved.CanonicalRef, err)
567
+ }
568
+ if strings.TrimSpace(league.ID) == "" {
569
+ league.ID = strings.TrimSpace(refIDs(resolved.CanonicalRef)["leagueId"])
570
+ }
571
+ return league, resolved, "", nil
572
+ }
573
+
574
+ func (s *LeagueService) findLeagueRefByNameFallback(ctx context.Context, query string) (string, []string) {
575
+ resolved, err := s.client.ResolveRefChain(ctx, "/leagues")
576
+ if err != nil {
577
+ return "", []string{fmt.Sprintf("league fallback scan failed: %v", err)}
578
+ }
579
+
580
+ page, err := DecodePage[Ref](resolved.Body)
581
+ if err != nil {
582
+ return "", []string{fmt.Sprintf("decode /leagues page during fallback scan: %v", err)}
583
+ }
584
+
585
+ target := normalizeAlias(query)
586
+ if target == "" {
587
+ return "", nil
588
+ }
589
+
590
+ warnings := make([]string, 0)
591
+ for _, item := range page.Items {
592
+ ref := strings.TrimSpace(item.URL)
593
+ if ref == "" {
594
+ continue
595
+ }
596
+ league, _, warning, lookupErr := s.fetchLeagueByRef(ctx, ref)
597
+ if lookupErr != nil {
598
+ warnings = append(warnings, fmt.Sprintf("league fallback %s: %v", ref, lookupErr))
599
+ continue
600
+ }
601
+ if warning != "" {
602
+ warnings = append(warnings, warning)
603
+ }
604
+ s.upsertLeagueEntity(*league)
605
+
606
+ aliases := []string{
607
+ strings.TrimSpace(league.ID),
608
+ strings.TrimSpace(league.Name),
609
+ strings.TrimSpace(league.Slug),
610
+ }
611
+ for _, alias := range aliases {
612
+ if normalizeAlias(alias) == target {
613
+ return ref, compactWarnings(warnings)
614
+ }
615
+ }
616
+ }
617
+
618
+ return "", compactWarnings(warnings)
619
+ }
620
+
621
+ func (s *LeagueService) fetchLeagueSeasons(ctx context.Context, league League) (*ResolvedDocument, []Season, []string, error) {
622
+ seasonsRef := nonEmpty(extensionRef(league.Extensions, "seasons"), "/leagues/"+strings.TrimSpace(league.ID)+"/seasons")
623
+ resolved, err := s.client.ResolveRefChain(ctx, seasonsRef)
624
+ if err != nil {
625
+ return nil, nil, nil, err
626
+ }
627
+
628
+ seasons, err := NormalizeSeasonList(resolved.Body)
629
+ if err != nil {
630
+ return nil, nil, nil, fmt.Errorf("normalize seasons list %q: %w", resolved.CanonicalRef, err)
631
+ }
632
+ for i := range seasons {
633
+ if strings.TrimSpace(seasons[i].LeagueID) == "" {
634
+ seasons[i].LeagueID = strings.TrimSpace(league.ID)
635
+ }
636
+ }
637
+ return resolved, seasons, nil, nil
638
+ }
639
+
640
+ func (s *LeagueService) resolveSeasonSelection(ctx context.Context, leagueQuery, seasonQuery string) (*seasonSelection, *NormalizedResult) {
641
+ lookup, passthrough := s.resolveLeagueLookup(ctx, leagueQuery)
642
+ if passthrough != nil {
643
+ passthrough.Kind = EntitySeason
644
+ return nil, passthrough
645
+ }
646
+
647
+ seasonQuery = strings.TrimSpace(seasonQuery)
648
+ if seasonQuery == "" {
649
+ result := NormalizedResult{
650
+ Kind: EntitySeason,
651
+ Status: ResultStatusEmpty,
652
+ Message: "--season is required",
653
+ }
654
+ return nil, &result
655
+ }
656
+
657
+ _, seasons, seasonWarnings, err := s.fetchLeagueSeasons(ctx, *lookup.league)
658
+ if err != nil {
659
+ result := NewTransportErrorResult(EntitySeason, "/leagues/"+lookup.league.ID+"/seasons", err)
660
+ return nil, &result
661
+ }
662
+
663
+ selectedRef := ""
664
+ queryIDs := refIDs(seasonQuery)
665
+ for _, season := range seasons {
666
+ ids := refIDs(season.Ref)
667
+ candidates := []string{
668
+ strings.TrimSpace(season.ID),
669
+ strings.TrimSpace(strconv.Itoa(season.Year)),
670
+ strings.TrimSpace(ids["seasonId"]),
671
+ strings.TrimSpace(queryIDs["seasonId"]),
672
+ }
673
+ for _, candidate := range candidates {
674
+ if candidate != "" && strings.EqualFold(candidate, strings.TrimSpace(seasonQuery)) {
675
+ selectedRef = strings.TrimSpace(season.Ref)
676
+ break
677
+ }
678
+ }
679
+ if selectedRef != "" {
680
+ break
681
+ }
682
+ }
683
+
684
+ if selectedRef == "" && isKnownRefQuery(seasonQuery) {
685
+ selectedRef = strings.TrimSpace(seasonQuery)
686
+ }
687
+ if selectedRef == "" && isNumeric(seasonQuery) {
688
+ selectedRef = "/leagues/" + strings.TrimSpace(lookup.league.ID) + "/seasons/" + strings.TrimSpace(seasonQuery)
689
+ }
690
+ if selectedRef == "" {
691
+ result := NormalizedResult{
692
+ Kind: EntitySeason,
693
+ Status: ResultStatusEmpty,
694
+ Message: fmt.Sprintf("season %q not found for league %q", seasonQuery, lookup.league.ID),
695
+ }
696
+ return nil, &result
697
+ }
698
+
699
+ resolved, err := s.client.ResolveRefChain(ctx, selectedRef)
700
+ if err != nil {
701
+ result := NewTransportErrorResult(EntitySeason, selectedRef, err)
702
+ return nil, &result
703
+ }
704
+
705
+ season, err := NormalizeSeason(resolved.Body)
706
+ if err != nil {
707
+ return nil, &NormalizedResult{
708
+ Kind: EntitySeason,
709
+ Status: ResultStatusError,
710
+ Message: fmt.Sprintf("normalize season %q: %v", resolved.CanonicalRef, err),
711
+ }
712
+ }
713
+
714
+ if strings.TrimSpace(season.LeagueID) == "" {
715
+ season.LeagueID = strings.TrimSpace(lookup.league.ID)
716
+ }
717
+
718
+ warnings := append([]string{}, lookup.warnings...)
719
+ warnings = append(warnings, seasonWarnings...)
720
+ return &seasonSelection{
721
+ league: lookup.league,
722
+ season: *season,
723
+ resolved: resolved,
724
+ warnings: compactWarnings(warnings),
725
+ }, nil
726
+ }
727
+
728
+ func (s *LeagueService) fetchSeasonTypes(ctx context.Context, season Season) (*ResolvedDocument, []SeasonType, []string, error) {
729
+ typesRef := seasonTypesRef(season)
730
+ resolved, err := s.client.ResolveRefChain(ctx, typesRef)
731
+ if err != nil {
732
+ return nil, nil, nil, err
733
+ }
734
+
735
+ page, err := DecodePage[Ref](resolved.Body)
736
+ if err != nil {
737
+ return nil, nil, nil, fmt.Errorf("decode season types page %q: %w", resolved.CanonicalRef, err)
738
+ }
739
+
740
+ types := make([]SeasonType, 0, len(page.Items))
741
+ warnings := make([]string, 0)
742
+ for _, item := range page.Items {
743
+ ref := strings.TrimSpace(item.URL)
744
+ if ref == "" {
745
+ continue
746
+ }
747
+ itemResolved, itemErr := s.client.ResolveRefChain(ctx, ref)
748
+ if itemErr != nil {
749
+ warnings = append(warnings, fmt.Sprintf("season type %s: %v", ref, itemErr))
750
+ continue
751
+ }
752
+ seasonType, normalizeErr := NormalizeSeasonType(itemResolved.Body)
753
+ if normalizeErr != nil {
754
+ warnings = append(warnings, fmt.Sprintf("season type %s: %v", itemResolved.CanonicalRef, normalizeErr))
755
+ continue
756
+ }
757
+ if seasonType.SeasonID == "" {
758
+ seasonType.SeasonID = season.ID
759
+ }
760
+ if seasonType.LeagueID == "" {
761
+ seasonType.LeagueID = season.LeagueID
762
+ }
763
+ types = append(types, *seasonType)
764
+ }
765
+
766
+ return resolved, types, compactWarnings(warnings), nil
767
+ }
768
+
769
+ func (s *LeagueService) resolveSeasonTypeSelection(ctx context.Context, season Season, typeQuery string) (*seasonTypeSelection, *NormalizedResult) {
770
+ typeQuery = strings.TrimSpace(typeQuery)
771
+ if typeQuery == "" {
772
+ result := NormalizedResult{
773
+ Kind: EntitySeasonType,
774
+ Status: ResultStatusEmpty,
775
+ Message: "--type is required",
776
+ }
777
+ return nil, &result
778
+ }
779
+
780
+ _, types, warnings, err := s.fetchSeasonTypes(ctx, season)
781
+ if err != nil {
782
+ result := NewTransportErrorResult(EntitySeasonType, seasonTypesRef(season), err)
783
+ return nil, &result
784
+ }
785
+ if len(types) == 0 {
786
+ result := NormalizedResult{
787
+ Kind: EntitySeasonType,
788
+ Status: ResultStatusEmpty,
789
+ Message: fmt.Sprintf("no season types found for season %q", season.ID),
790
+ }
791
+ return nil, &result
792
+ }
793
+
794
+ queryIDs := refIDs(typeQuery)
795
+ queryNorm := normalizeAlias(typeQuery)
796
+ for _, seasonType := range types {
797
+ candidates := []string{
798
+ strings.TrimSpace(seasonType.ID),
799
+ strings.TrimSpace(refIDs(seasonType.Ref)["typeId"]),
800
+ strings.TrimSpace(queryIDs["typeId"]),
801
+ }
802
+ for _, candidate := range candidates {
803
+ if candidate != "" && strings.EqualFold(candidate, typeQuery) {
804
+ return &seasonTypeSelection{seasonType: seasonType, warnings: warnings}, nil
805
+ }
806
+ }
807
+ names := []string{seasonType.Name, seasonType.Abbreviation}
808
+ for _, name := range names {
809
+ if normalizeAlias(name) != "" && normalizeAlias(name) == queryNorm {
810
+ return &seasonTypeSelection{seasonType: seasonType, warnings: warnings}, nil
811
+ }
812
+ }
813
+ }
814
+
815
+ result := NormalizedResult{
816
+ Kind: EntitySeasonType,
817
+ Status: ResultStatusEmpty,
818
+ Message: fmt.Sprintf("season type %q not found for season %q", typeQuery, season.ID),
819
+ }
820
+ return nil, &result
821
+ }
822
+
823
+ func (s *LeagueService) fetchSeasonGroups(ctx context.Context, seasonType SeasonType) (*ResolvedDocument, []SeasonGroup, []string, error) {
824
+ groupsRef := seasonGroupsRef(seasonType)
825
+ resolved, err := s.client.ResolveRefChain(ctx, groupsRef)
826
+ if err != nil {
827
+ return nil, nil, nil, err
828
+ }
829
+
830
+ page, err := DecodePage[Ref](resolved.Body)
831
+ if err != nil {
832
+ return nil, nil, nil, fmt.Errorf("decode season groups page %q: %w", resolved.CanonicalRef, err)
833
+ }
834
+
835
+ groups := make([]SeasonGroup, 0, len(page.Items))
836
+ warnings := make([]string, 0)
837
+ for _, item := range page.Items {
838
+ ref := strings.TrimSpace(item.URL)
839
+ if ref == "" {
840
+ continue
841
+ }
842
+ itemResolved, itemErr := s.client.ResolveRefChain(ctx, ref)
843
+ if itemErr != nil {
844
+ warnings = append(warnings, fmt.Sprintf("season group %s: %v", ref, itemErr))
845
+ continue
846
+ }
847
+ group, normalizeErr := NormalizeSeasonGroup(itemResolved.Body)
848
+ if normalizeErr != nil {
849
+ warnings = append(warnings, fmt.Sprintf("season group %s: %v", itemResolved.CanonicalRef, normalizeErr))
850
+ continue
851
+ }
852
+ if group.SeasonID == "" {
853
+ group.SeasonID = seasonType.SeasonID
854
+ }
855
+ if group.LeagueID == "" {
856
+ group.LeagueID = seasonType.LeagueID
857
+ }
858
+ if group.TypeID == "" {
859
+ group.TypeID = seasonType.ID
860
+ }
861
+ groups = append(groups, *group)
862
+ }
863
+
864
+ return resolved, groups, compactWarnings(warnings), nil
865
+ }
866
+
867
+ func (s *LeagueService) playersFromLeagueAthletePage(ctx context.Context, league League, limit int) ([]Player, []string, error) {
868
+ athletesRef := nonEmpty(extensionRef(league.Extensions, "athletes"), "/leagues/"+strings.TrimSpace(league.ID)+"/athletes")
869
+ resolved, err := s.client.ResolveRefChain(ctx, athletesRef)
870
+ if err != nil {
871
+ return nil, nil, err
872
+ }
873
+
874
+ page, err := DecodePage[Ref](resolved.Body)
875
+ if err != nil {
876
+ return nil, nil, fmt.Errorf("decode league athletes page %q: %w", resolved.CanonicalRef, err)
877
+ }
878
+
879
+ if len(page.Items) == 0 {
880
+ return nil, []string{"league athletes page returned no item refs; falling back to event-roster traversal"}, nil
881
+ }
882
+
883
+ players := make([]Player, 0, minInt(limit, len(page.Items)))
884
+ warnings := make([]string, 0)
885
+ for _, item := range page.Items {
886
+ if len(players) >= limit {
887
+ break
888
+ }
889
+ ref := strings.TrimSpace(item.URL)
890
+ if ref == "" {
891
+ continue
892
+ }
893
+ itemResolved, itemErr := s.client.ResolveRefChain(ctx, ref)
894
+ if itemErr != nil {
895
+ warnings = append(warnings, fmt.Sprintf("league athlete %s: %v", ref, itemErr))
896
+ continue
897
+ }
898
+ player, normalizeErr := NormalizePlayer(itemResolved.Body)
899
+ if normalizeErr != nil {
900
+ warnings = append(warnings, fmt.Sprintf("league athlete %s: %v", itemResolved.CanonicalRef, normalizeErr))
901
+ continue
902
+ }
903
+ players = append(players, *player)
904
+ }
905
+
906
+ return players, compactWarnings(warnings), nil
907
+ }
908
+
909
+ func (s *LeagueService) playersFromLeagueEventRosters(ctx context.Context, league League, limit int) ([]Player, []string) {
910
+ eventsRef := nonEmpty(extensionRef(league.Extensions, "events"), "/leagues/"+strings.TrimSpace(league.ID)+"/events")
911
+ resolved, err := s.client.ResolveRefChain(ctx, eventsRef)
912
+ if err != nil {
913
+ return nil, []string{fmt.Sprintf("fallback events traversal failed: %v", err)}
914
+ }
915
+
916
+ page, err := DecodePage[Ref](resolved.Body)
917
+ if err != nil {
918
+ return nil, []string{fmt.Sprintf("decode fallback events page %q: %v", resolved.CanonicalRef, err)}
919
+ }
920
+
921
+ players := make([]Player, 0, limit)
922
+ warnings := make([]string, 0)
923
+ seenPlayers := map[string]struct{}{}
924
+
925
+ for _, eventRef := range page.Items {
926
+ if len(players) >= limit {
927
+ break
928
+ }
929
+
930
+ ref := strings.TrimSpace(eventRef.URL)
931
+ if ref == "" {
932
+ continue
933
+ }
934
+ eventResolved, eventErr := s.client.ResolveRefChain(ctx, ref)
935
+ if eventErr != nil {
936
+ warnings = append(warnings, fmt.Sprintf("fallback event %s: %v", ref, eventErr))
937
+ continue
938
+ }
939
+ matches, normalizeErr := NormalizeMatchesFromEvent(eventResolved.Body)
940
+ if normalizeErr != nil {
941
+ warnings = append(warnings, fmt.Sprintf("fallback event %s: %v", eventResolved.CanonicalRef, normalizeErr))
942
+ continue
943
+ }
944
+
945
+ for _, match := range matches {
946
+ if len(players) >= limit {
947
+ break
948
+ }
949
+ for _, team := range match.Teams {
950
+ if len(players) >= limit {
951
+ break
952
+ }
953
+ rosterRef := nonEmpty(strings.TrimSpace(team.RosterRef), competitorSubresourceRef(match, team.ID, "roster"))
954
+ if rosterRef == "" {
955
+ continue
956
+ }
957
+ rosterResolved, rosterErr := s.client.ResolveRefChain(ctx, rosterRef)
958
+ if rosterErr != nil {
959
+ warnings = append(warnings, fmt.Sprintf("fallback roster %s: %v", rosterRef, rosterErr))
960
+ continue
961
+ }
962
+
963
+ entries, entryErr := NormalizeTeamRosterEntries(rosterResolved.Body, team, TeamScopeMatch, match.ID)
964
+ if entryErr != nil {
965
+ warnings = append(warnings, fmt.Sprintf("fallback roster %s: %v", rosterResolved.CanonicalRef, entryErr))
966
+ continue
967
+ }
968
+ for _, entry := range entries {
969
+ if len(players) >= limit {
970
+ break
971
+ }
972
+ playerID := strings.TrimSpace(entry.PlayerID)
973
+ if playerID == "" {
974
+ continue
975
+ }
976
+ if _, ok := seenPlayers[playerID]; ok {
977
+ continue
978
+ }
979
+
980
+ player, playerWarning, playerErr := s.fetchLeagueScopedPlayer(ctx, league.ID, playerID)
981
+ if playerErr != nil {
982
+ warnings = append(warnings, fmt.Sprintf("fallback athlete %s: %v", playerID, playerErr))
983
+ continue
984
+ }
985
+ if playerWarning != "" {
986
+ warnings = append(warnings, playerWarning)
987
+ }
988
+
989
+ seenPlayers[playerID] = struct{}{}
990
+ players = append(players, *player)
991
+ }
992
+ }
993
+ }
994
+ }
995
+
996
+ return players, compactWarnings(warnings)
997
+ }
998
+
999
+ func (s *LeagueService) fetchLeagueScopedPlayer(ctx context.Context, leagueID, playerID string) (*Player, string, error) {
1000
+ leagueRef := "/leagues/" + strings.TrimSpace(leagueID) + "/athletes/" + strings.TrimSpace(playerID)
1001
+ resolved, err := s.client.ResolveRefChain(ctx, leagueRef)
1002
+ if err == nil {
1003
+ player, normalizeErr := NormalizePlayer(resolved.Body)
1004
+ if normalizeErr != nil {
1005
+ return nil, "", normalizeErr
1006
+ }
1007
+ return player, "", nil
1008
+ }
1009
+
1010
+ globalRef := "/athletes/" + strings.TrimSpace(playerID)
1011
+ globalResolved, globalErr := s.client.ResolveRefChain(ctx, globalRef)
1012
+ if globalErr != nil {
1013
+ return nil, "", globalErr
1014
+ }
1015
+ player, normalizeErr := NormalizePlayer(globalResolved.Body)
1016
+ if normalizeErr != nil {
1017
+ return nil, "", normalizeErr
1018
+ }
1019
+ warning := fmt.Sprintf("league-athlete route unavailable for %s; used global athlete profile", playerID)
1020
+ return player, warning, nil
1021
+ }
1022
+
1023
+ func (s *LeagueService) collectStandingsGroups(
1024
+ ctx context.Context,
1025
+ ref string,
1026
+ visited map[string]struct{},
1027
+ depth int,
1028
+ ) ([]StandingsGroup, []string, error) {
1029
+ if depth > maxStandingsTraversalDepth {
1030
+ return nil, nil, fmt.Errorf("standings traversal exceeded max depth for %q", ref)
1031
+ }
1032
+
1033
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
1034
+ if err != nil {
1035
+ return nil, nil, err
1036
+ }
1037
+ canonical := strings.TrimSpace(resolved.CanonicalRef)
1038
+ if canonical == "" {
1039
+ canonical = strings.TrimSpace(ref)
1040
+ }
1041
+ if _, ok := visited[canonical]; ok {
1042
+ return nil, nil, nil
1043
+ }
1044
+ visited[canonical] = struct{}{}
1045
+
1046
+ payload, err := decodePayloadMap(resolved.Body)
1047
+ if err != nil {
1048
+ return nil, nil, fmt.Errorf("decode standings payload %q: %w", canonical, err)
1049
+ }
1050
+
1051
+ groups := make([]StandingsGroup, 0)
1052
+ warnings := make([]string, 0)
1053
+
1054
+ if hasStandaloneStandingsPayload(payload) {
1055
+ group := NormalizeStandingsGroupFromMap(payload)
1056
+ if group != nil {
1057
+ groups = append(groups, *group)
1058
+ }
1059
+ }
1060
+
1061
+ for _, childRef := range standingsChildRefs(payload) {
1062
+ childGroups, childWarnings, childErr := s.collectStandingsGroups(ctx, childRef, visited, depth+1)
1063
+ if childErr != nil {
1064
+ warnings = append(warnings, fmt.Sprintf("standings child %s: %v", childRef, childErr))
1065
+ continue
1066
+ }
1067
+ groups = append(groups, childGroups...)
1068
+ warnings = append(warnings, childWarnings...)
1069
+ }
1070
+
1071
+ groups = dedupeStandingsGroups(groups)
1072
+ return groups, compactWarnings(warnings), nil
1073
+ }
1074
+
1075
+ func (s *LeagueService) hydrateStandingsTeamNames(ctx context.Context, groups []StandingsGroup, warnings *[]string) {
1076
+ cache := map[string]teamIdentity{}
1077
+ matchHelper := &MatchService{client: s.client}
1078
+
1079
+ for groupIndex := range groups {
1080
+ for teamIndex := range groups[groupIndex].Entries {
1081
+ team := &groups[groupIndex].Entries[teamIndex]
1082
+ if strings.TrimSpace(team.Name) != "" && strings.TrimSpace(team.ShortName) != "" {
1083
+ continue
1084
+ }
1085
+ identity, err := matchHelper.fetchTeamIdentity(ctx, team, cache)
1086
+ if err != nil {
1087
+ if warnings != nil {
1088
+ *warnings = append(*warnings, fmt.Sprintf("standings team %s: %v", nonEmpty(team.Ref, team.ID), err))
1089
+ }
1090
+ continue
1091
+ }
1092
+ if strings.TrimSpace(team.Name) == "" {
1093
+ team.Name = identity.name
1094
+ }
1095
+ if strings.TrimSpace(team.ShortName) == "" {
1096
+ team.ShortName = identity.shortName
1097
+ }
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ func hasStandaloneStandingsPayload(payload map[string]any) bool {
1103
+ return len(mapSliceField(payload, "standings")) > 0 || len(mapSliceField(payload, "entries")) > 0
1104
+ }
1105
+
1106
+ func standingsChildRefs(payload map[string]any) []string {
1107
+ refs := make([]string, 0)
1108
+ addRef := func(value string) {
1109
+ value = strings.TrimSpace(value)
1110
+ if value == "" {
1111
+ return
1112
+ }
1113
+ refs = append(refs, value)
1114
+ }
1115
+
1116
+ for _, item := range mapSliceField(payload, "items") {
1117
+ addRef(stringField(item, "$ref"))
1118
+ addRef(refFromField(item, "standings"))
1119
+ }
1120
+
1121
+ for _, key := range []string{"standings", "groups", "children"} {
1122
+ value := payload[key]
1123
+ switch typed := value.(type) {
1124
+ case map[string]any:
1125
+ addRef(stringField(typed, "$ref"))
1126
+ case []any:
1127
+ for _, item := range typed {
1128
+ asMap, ok := item.(map[string]any)
1129
+ if !ok {
1130
+ continue
1131
+ }
1132
+ addRef(stringField(asMap, "$ref"))
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ return compactWarnings(refs)
1138
+ }
1139
+
1140
+ func dedupeStandingsGroups(groups []StandingsGroup) []StandingsGroup {
1141
+ seen := map[string]struct{}{}
1142
+ out := make([]StandingsGroup, 0, len(groups))
1143
+ for _, group := range groups {
1144
+ key := strings.TrimSpace(group.Ref)
1145
+ if key == "" {
1146
+ key = strings.TrimSpace(group.SeasonID + ":" + group.GroupID + ":" + group.ID)
1147
+ }
1148
+ if key == "" {
1149
+ key = strings.TrimSpace(group.ID)
1150
+ }
1151
+ if key == "" {
1152
+ continue
1153
+ }
1154
+ if _, ok := seen[key]; ok {
1155
+ continue
1156
+ }
1157
+ seen[key] = struct{}{}
1158
+ out = append(out, group)
1159
+ }
1160
+ return out
1161
+ }
1162
+
1163
+ func seasonTypesRef(season Season) string {
1164
+ if ref := extensionRef(season.Extensions, "types"); ref != "" {
1165
+ return ref
1166
+ }
1167
+ if season.LeagueID == "" || season.ID == "" {
1168
+ return ""
1169
+ }
1170
+ return "/leagues/" + strings.TrimSpace(season.LeagueID) + "/seasons/" + strings.TrimSpace(season.ID) + "/types"
1171
+ }
1172
+
1173
+ func seasonGroupsRef(seasonType SeasonType) string {
1174
+ if ref := strings.TrimSpace(seasonType.GroupsRef); ref != "" {
1175
+ return ref
1176
+ }
1177
+ if seasonType.LeagueID == "" || seasonType.SeasonID == "" || seasonType.ID == "" {
1178
+ return ""
1179
+ }
1180
+ return "/leagues/" + strings.TrimSpace(seasonType.LeagueID) + "/seasons/" + strings.TrimSpace(seasonType.SeasonID) + "/types/" + strings.TrimSpace(seasonType.ID) + "/groups"
1181
+ }
1182
+
1183
+ func (s *LeagueService) upsertLeagueEntity(league League) {
1184
+ if s.resolver == nil || s.resolver.index == nil {
1185
+ return
1186
+ }
1187
+ leagueID := strings.TrimSpace(league.ID)
1188
+ if leagueID == "" {
1189
+ return
1190
+ }
1191
+ _ = s.resolver.index.Upsert(IndexedEntity{
1192
+ Kind: EntityLeague,
1193
+ ID: leagueID,
1194
+ Ref: strings.TrimSpace(league.Ref),
1195
+ Name: strings.TrimSpace(league.Name),
1196
+ ShortName: strings.TrimSpace(league.Slug),
1197
+ Aliases: []string{
1198
+ strings.TrimSpace(league.Name),
1199
+ strings.TrimSpace(league.Slug),
1200
+ leagueID,
1201
+ },
1202
+ })
1203
+ }
1204
+
1205
+ func minInt(a, b int) int {
1206
+ if a < b {
1207
+ return a
1208
+ }
1209
+ return b
1210
+ }