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,1508 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/url"
8
+ "strconv"
9
+ "strings"
10
+ "sync"
11
+ )
12
+
13
+ const defaultMatchListLimit = 20
14
+ const deliveryFetchConcurrency = 12
15
+
16
+ // MatchServiceConfig configures match discovery and lookup behavior.
17
+ type MatchServiceConfig struct {
18
+ Client *Client
19
+ Resolver *Resolver
20
+ }
21
+
22
+ // MatchListOptions controls list/live traversal behavior.
23
+ type MatchListOptions struct {
24
+ Limit int
25
+ }
26
+
27
+ // MatchLookupOptions controls resolver-backed single match lookup.
28
+ type MatchLookupOptions struct {
29
+ LeagueID string
30
+ }
31
+
32
+ // MatchInningsOptions controls innings-depth lookup behavior.
33
+ type MatchInningsOptions struct {
34
+ LeagueID string
35
+ TeamQuery string
36
+ Innings int
37
+ Period int
38
+ }
39
+
40
+ // MatchService implements domain-level match discovery and lookup commands.
41
+ type MatchService struct {
42
+ client *Client
43
+ resolver *Resolver
44
+ ownsResolver bool
45
+ }
46
+
47
+ // NewMatchService builds a match service using default client/resolver when omitted.
48
+ func NewMatchService(cfg MatchServiceConfig) (*MatchService, error) {
49
+ client := cfg.Client
50
+ if client == nil {
51
+ var err error
52
+ client, err = NewClient(Config{})
53
+ if err != nil {
54
+ return nil, err
55
+ }
56
+ }
57
+
58
+ resolver := cfg.Resolver
59
+ ownsResolver := false
60
+ if resolver == nil {
61
+ var err error
62
+ resolver, err = NewResolver(ResolverConfig{Client: client})
63
+ if err != nil {
64
+ return nil, err
65
+ }
66
+ ownsResolver = true
67
+ }
68
+
69
+ return &MatchService{
70
+ client: client,
71
+ resolver: resolver,
72
+ ownsResolver: ownsResolver,
73
+ }, nil
74
+ }
75
+
76
+ // Close persists resolver cache when owned by this service.
77
+ func (s *MatchService) Close() error {
78
+ if !s.ownsResolver || s.resolver == nil {
79
+ return nil
80
+ }
81
+ return s.resolver.Close()
82
+ }
83
+
84
+ // List discovers current matches from /events.
85
+ func (s *MatchService) List(ctx context.Context, opts MatchListOptions) (NormalizedResult, error) {
86
+ return s.listFromEvents(ctx, opts, false)
87
+ }
88
+
89
+ // Live discovers current in-progress matches from /events.
90
+ func (s *MatchService) Live(ctx context.Context, opts MatchListOptions) (NormalizedResult, error) {
91
+ return s.listFromEvents(ctx, opts, true)
92
+ }
93
+
94
+ // Show resolves and returns one match with normalized summary fields.
95
+ func (s *MatchService) Show(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
96
+ return s.lookupMatch(ctx, query, opts, false)
97
+ }
98
+
99
+ // Status resolves and returns one match with status-focused summary fields.
100
+ func (s *MatchService) Status(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
101
+ return s.lookupMatch(ctx, query, opts, true)
102
+ }
103
+
104
+ // Scorecard resolves and returns matchcards rendered as batting/bowling/partnership views.
105
+ func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
106
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
107
+ if passthrough != nil {
108
+ passthrough.Kind = EntityMatchScorecard
109
+ return *passthrough, nil
110
+ }
111
+
112
+ scorecardRef := matchSubresourceRef(*lookup.match, "matchcards", "matchcards")
113
+ if scorecardRef == "" {
114
+ return NormalizedResult{
115
+ Kind: EntityMatchScorecard,
116
+ Status: ResultStatusEmpty,
117
+ Message: fmt.Sprintf("scorecard route unavailable for match %q", lookup.match.ID),
118
+ }, nil
119
+ }
120
+
121
+ resolved, err := s.client.ResolveRefChain(ctx, scorecardRef)
122
+ if err != nil {
123
+ return NewTransportErrorResult(EntityMatchScorecard, scorecardRef, err), nil
124
+ }
125
+
126
+ scorecard, err := NormalizeMatchScorecard(resolved.Body, *lookup.match)
127
+ if err != nil {
128
+ return NormalizedResult{}, fmt.Errorf("normalize matchcards %q: %w", resolved.CanonicalRef, err)
129
+ }
130
+
131
+ warnings := append([]string{}, lookup.warnings...)
132
+ result := NewDataResult(EntityMatchScorecard, scorecard)
133
+ if len(warnings) > 0 {
134
+ result = NewPartialResult(EntityMatchScorecard, scorecard, warnings...)
135
+ }
136
+ result.RequestedRef = resolved.RequestedRef
137
+ result.CanonicalRef = resolved.CanonicalRef
138
+ return result, nil
139
+ }
140
+
141
+ // Details resolves and returns normalized delivery events from the details route.
142
+ func (s *MatchService) Details(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
143
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
144
+ if passthrough != nil {
145
+ passthrough.Kind = EntityDeliveryEvent
146
+ return *passthrough, nil
147
+ }
148
+
149
+ detailsRef := nonEmpty(strings.TrimSpace(lookup.match.DetailsRef), matchSubresourceRef(*lookup.match, "details", "details"))
150
+ if detailsRef == "" {
151
+ return NormalizedResult{
152
+ Kind: EntityDeliveryEvent,
153
+ Status: ResultStatusEmpty,
154
+ Message: fmt.Sprintf("details route unavailable for match %q", lookup.match.ID),
155
+ }, nil
156
+ }
157
+
158
+ return s.deliveryEventsFromRoute(ctx, detailsRef, lookup.warnings)
159
+ }
160
+
161
+ // Plays resolves and returns normalized delivery events from the plays route.
162
+ func (s *MatchService) Plays(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
163
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
164
+ if passthrough != nil {
165
+ passthrough.Kind = EntityDeliveryEvent
166
+ return *passthrough, nil
167
+ }
168
+
169
+ playsRef := matchSubresourceRef(*lookup.match, "plays", "plays")
170
+ if playsRef == "" {
171
+ return NormalizedResult{
172
+ Kind: EntityDeliveryEvent,
173
+ Status: ResultStatusEmpty,
174
+ Message: fmt.Sprintf("plays route unavailable for match %q", lookup.match.ID),
175
+ }, nil
176
+ }
177
+
178
+ return s.deliveryEventsFromRoute(ctx, playsRef, lookup.warnings)
179
+ }
180
+
181
+ // Situation resolves and returns normalized match situation data.
182
+ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
183
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
184
+ if passthrough != nil {
185
+ passthrough.Kind = EntityMatchSituation
186
+ return *passthrough, nil
187
+ }
188
+
189
+ situationRef := matchSubresourceRef(*lookup.match, "situation", "situation")
190
+ if situationRef == "" {
191
+ return NormalizedResult{
192
+ Kind: EntityMatchSituation,
193
+ Status: ResultStatusEmpty,
194
+ Message: fmt.Sprintf("situation route unavailable for match %q", lookup.match.ID),
195
+ }, nil
196
+ }
197
+
198
+ resolved, err := s.client.ResolveRefChain(ctx, situationRef)
199
+ if err != nil {
200
+ return NewTransportErrorResult(EntityMatchSituation, situationRef, err), nil
201
+ }
202
+
203
+ situation, err := NormalizeMatchSituation(resolved.Body, *lookup.match)
204
+ if err != nil {
205
+ return NormalizedResult{}, fmt.Errorf("normalize situation %q: %w", resolved.CanonicalRef, err)
206
+ }
207
+
208
+ if isSparseSituation(situation) {
209
+ result := NormalizedResult{
210
+ Kind: EntityMatchSituation,
211
+ Status: ResultStatusEmpty,
212
+ RequestedRef: resolved.RequestedRef,
213
+ CanonicalRef: resolved.CanonicalRef,
214
+ Message: "no situation data available for this match",
215
+ }
216
+ return result, nil
217
+ }
218
+
219
+ result := NewDataResult(EntityMatchSituation, situation)
220
+ if len(lookup.warnings) > 0 {
221
+ result = NewPartialResult(EntityMatchSituation, situation, lookup.warnings...)
222
+ }
223
+ result.RequestedRef = resolved.RequestedRef
224
+ result.CanonicalRef = resolved.CanonicalRef
225
+ return result, nil
226
+ }
227
+
228
+ // Innings resolves and returns innings summaries with over and wicket timelines when period statistics are available.
229
+ func (s *MatchService) Innings(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
230
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
231
+ if passthrough != nil {
232
+ passthrough.Kind = EntityInnings
233
+ return *passthrough, nil
234
+ }
235
+ statusCache := map[string]matchStatusSnapshot{}
236
+ teamCache := map[string]teamIdentity{}
237
+ scoreCache := map[string]string{}
238
+ lookup.warnings = append(lookup.warnings, s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)...)
239
+
240
+ teams, teamWarnings, teamResult := s.selectTeamsFromMatch(ctx, *lookup.match, opts.TeamQuery, opts.LeagueID)
241
+ if teamResult != nil {
242
+ teamResult.Kind = EntityInnings
243
+ return *teamResult, nil
244
+ }
245
+
246
+ warnings := append([]string{}, lookup.warnings...)
247
+ warnings = append(warnings, teamWarnings...)
248
+
249
+ items := make([]any, 0)
250
+ for _, team := range teams {
251
+ innings, resolvedRef, inningsWarnings := s.fetchTeamInnings(ctx, *lookup.match, team)
252
+ warnings = append(warnings, inningsWarnings...)
253
+ for i := range innings {
254
+ if strings.TrimSpace(team.ID) != "" {
255
+ innings[i].TeamID = strings.TrimSpace(team.ID)
256
+ }
257
+ innings[i].TeamName = nonEmpty(team.ShortName, team.Name, team.ID, innings[i].TeamName)
258
+ innings[i].MatchID = nonEmpty(innings[i].MatchID, lookup.match.ID)
259
+ innings[i].CompetitionID = nonEmpty(innings[i].CompetitionID, lookup.match.CompetitionID, lookup.match.ID)
260
+ innings[i].EventID = nonEmpty(innings[i].EventID, lookup.match.EventID)
261
+ innings[i].LeagueID = nonEmpty(innings[i].LeagueID, lookup.match.LeagueID)
262
+
263
+ statsWarnings := s.hydrateInningsTimelines(ctx, &innings[i])
264
+ warnings = append(warnings, statsWarnings...)
265
+ items = append(items, innings[i])
266
+ }
267
+ if strings.TrimSpace(resolvedRef) != "" && len(items) == 0 {
268
+ warnings = append(warnings, fmt.Sprintf("no innings found at %s", resolvedRef))
269
+ }
270
+ }
271
+
272
+ result := NewListResult(EntityInnings, items)
273
+ if len(warnings) > 0 {
274
+ result = NewPartialListResult(EntityInnings, items, warnings...)
275
+ }
276
+ result.RequestedRef = lookup.resolved.RequestedRef
277
+ result.CanonicalRef = lookup.resolved.CanonicalRef
278
+ if len(items) == 0 && strings.TrimSpace(result.Message) == "" {
279
+ result.Message = "no innings available for selected scope"
280
+ }
281
+ return result, nil
282
+ }
283
+
284
+ // Partnerships resolves detailed partnership objects for a selected team/innings/period.
285
+ func (s *MatchService) Partnerships(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
286
+ selected, passthrough := s.resolveSelectedInnings(ctx, query, opts, true)
287
+ if passthrough != nil {
288
+ passthrough.Kind = EntityPartnership
289
+ return *passthrough, nil
290
+ }
291
+ if strings.TrimSpace(selected.innings.PartnershipsRef) == "" {
292
+ return NormalizedResult{
293
+ Kind: EntityPartnership,
294
+ Status: ResultStatusEmpty,
295
+ Message: fmt.Sprintf("partnership route unavailable for team %q innings %d/%d", selected.team.ID, selected.innings.InningsNumber, selected.innings.Period),
296
+ }, nil
297
+ }
298
+
299
+ resolved, items, warnings, err := s.fetchDetailedRefCollection(
300
+ ctx,
301
+ selected.innings.PartnershipsRef,
302
+ func(itemBody []byte) (any, error) {
303
+ partnership, normalizeErr := NormalizePartnership(itemBody)
304
+ if normalizeErr != nil {
305
+ return nil, normalizeErr
306
+ }
307
+ if strings.TrimSpace(selected.team.ID) != "" {
308
+ partnership.TeamID = strings.TrimSpace(selected.team.ID)
309
+ }
310
+ partnership.TeamName = nonEmpty(selected.team.ShortName, selected.team.Name, selected.team.ID, partnership.TeamName)
311
+ partnership.MatchID = nonEmpty(partnership.MatchID, selected.match.ID)
312
+ partnership.InningsID = nonEmpty(partnership.InningsID, fmt.Sprintf("%d", selected.innings.InningsNumber))
313
+ partnership.Period = nonEmpty(partnership.Period, fmt.Sprintf("%d", selected.innings.Period))
314
+ if partnership.Order == 0 {
315
+ partnership.Order = partnership.WicketNumber
316
+ }
317
+ return *partnership, nil
318
+ },
319
+ )
320
+ if err != nil {
321
+ return NewTransportErrorResult(EntityPartnership, selected.innings.PartnershipsRef, err), nil
322
+ }
323
+
324
+ warnings = append(selected.warnings, warnings...)
325
+ result := NewListResult(EntityPartnership, items)
326
+ if len(warnings) > 0 {
327
+ result = NewPartialListResult(EntityPartnership, items, warnings...)
328
+ }
329
+ result.RequestedRef = resolved.RequestedRef
330
+ result.CanonicalRef = resolved.CanonicalRef
331
+ return result, nil
332
+ }
333
+
334
+ // FallOfWicket resolves detailed fall-of-wicket objects for a selected team/innings/period.
335
+ func (s *MatchService) FallOfWicket(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
336
+ selected, passthrough := s.resolveSelectedInnings(ctx, query, opts, true)
337
+ if passthrough != nil {
338
+ passthrough.Kind = EntityFallOfWicket
339
+ return *passthrough, nil
340
+ }
341
+ if strings.TrimSpace(selected.innings.FallOfWicketRef) == "" {
342
+ return NormalizedResult{
343
+ Kind: EntityFallOfWicket,
344
+ Status: ResultStatusEmpty,
345
+ Message: fmt.Sprintf("fall-of-wicket route unavailable for team %q innings %d/%d", selected.team.ID, selected.innings.InningsNumber, selected.innings.Period),
346
+ }, nil
347
+ }
348
+
349
+ resolved, items, warnings, err := s.fetchDetailedRefCollection(
350
+ ctx,
351
+ selected.innings.FallOfWicketRef,
352
+ func(itemBody []byte) (any, error) {
353
+ fow, normalizeErr := NormalizeFallOfWicket(itemBody)
354
+ if normalizeErr != nil {
355
+ return nil, normalizeErr
356
+ }
357
+ if strings.TrimSpace(selected.team.ID) != "" {
358
+ fow.TeamID = strings.TrimSpace(selected.team.ID)
359
+ }
360
+ fow.TeamName = nonEmpty(selected.team.ShortName, selected.team.Name, selected.team.ID, fow.TeamName)
361
+ fow.MatchID = nonEmpty(fow.MatchID, selected.match.ID)
362
+ fow.InningsID = nonEmpty(fow.InningsID, fmt.Sprintf("%d", selected.innings.InningsNumber))
363
+ fow.Period = nonEmpty(fow.Period, fmt.Sprintf("%d", selected.innings.Period))
364
+ return *fow, nil
365
+ },
366
+ )
367
+ if err != nil {
368
+ return NewTransportErrorResult(EntityFallOfWicket, selected.innings.FallOfWicketRef, err), nil
369
+ }
370
+
371
+ warnings = append(selected.warnings, warnings...)
372
+ result := NewListResult(EntityFallOfWicket, items)
373
+ if len(warnings) > 0 {
374
+ result = NewPartialListResult(EntityFallOfWicket, items, warnings...)
375
+ }
376
+ result.RequestedRef = resolved.RequestedRef
377
+ result.CanonicalRef = resolved.CanonicalRef
378
+ return result, nil
379
+ }
380
+
381
+ // Deliveries resolves period statistics into over and wicket timelines for a selected team/innings/period.
382
+ func (s *MatchService) Deliveries(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
383
+ selected, passthrough := s.resolveSelectedInnings(ctx, query, opts, true)
384
+ if passthrough != nil {
385
+ passthrough.Kind = EntityInnings
386
+ return *passthrough, nil
387
+ }
388
+ if strings.TrimSpace(selected.innings.StatisticsRef) == "" {
389
+ return NormalizedResult{
390
+ Kind: EntityInnings,
391
+ Status: ResultStatusEmpty,
392
+ Message: fmt.Sprintf("period statistics route unavailable for team %q innings %d/%d", selected.team.ID, selected.innings.InningsNumber, selected.innings.Period),
393
+ }, nil
394
+ }
395
+
396
+ resolved, err := s.client.ResolveRefChain(ctx, selected.innings.StatisticsRef)
397
+ if err != nil {
398
+ return NewTransportErrorResult(EntityInnings, selected.innings.StatisticsRef, err), nil
399
+ }
400
+
401
+ overs, wickets, err := NormalizeInningsPeriodStatistics(resolved.Body)
402
+ if err != nil {
403
+ return NormalizedResult{}, fmt.Errorf("normalize period statistics %q: %w", resolved.CanonicalRef, err)
404
+ }
405
+
406
+ innings := selected.innings
407
+ innings.OverTimeline = overs
408
+ innings.WicketTimeline = wickets
409
+
410
+ result := NewDataResult(EntityInnings, innings)
411
+ if len(selected.warnings) > 0 {
412
+ result = NewPartialResult(EntityInnings, innings, selected.warnings...)
413
+ }
414
+ result.RequestedRef = resolved.RequestedRef
415
+ result.CanonicalRef = resolved.CanonicalRef
416
+ return result, nil
417
+ }
418
+
419
+ func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions, liveOnly bool) (NormalizedResult, error) {
420
+ resolved, err := s.client.ResolveRefChain(ctx, "/events")
421
+ if err != nil {
422
+ return NewTransportErrorResult(EntityMatch, "/events", err), nil
423
+ }
424
+
425
+ page, err := DecodePage[Ref](resolved.Body)
426
+ if err != nil {
427
+ return NormalizedResult{}, fmt.Errorf("decode /events page: %w", err)
428
+ }
429
+
430
+ limit := opts.Limit
431
+ if limit <= 0 {
432
+ limit = defaultMatchListLimit
433
+ }
434
+
435
+ statusCache := map[string]matchStatusSnapshot{}
436
+
437
+ matches := make([]Match, 0, limit)
438
+ warnings := make([]string, 0)
439
+ for _, eventRef := range page.Items {
440
+ if len(matches) >= limit {
441
+ break
442
+ }
443
+
444
+ eventMatches, eventWarnings, eventErr := s.matchesFromEventRef(ctx, eventRef.URL)
445
+ if eventErr != nil {
446
+ warnings = append(warnings, fmt.Sprintf("event %s: %v", strings.TrimSpace(eventRef.URL), eventErr))
447
+ continue
448
+ }
449
+ warnings = append(warnings, eventWarnings...)
450
+
451
+ for _, eventMatch := range eventMatches {
452
+ match := eventMatch
453
+ s.enrichMatchTeamsFromIndex(&match)
454
+ if liveOnly && !isLiveMatch(match) {
455
+ warnings = append(warnings, s.hydrateMatchStatusOnly(ctx, &match, statusCache)...)
456
+ }
457
+ if liveOnly && !isLiveMatch(match) {
458
+ continue
459
+ }
460
+ match.ScoreSummary = matchScoreSummary(match.Teams)
461
+ matches = append(matches, match)
462
+ if len(matches) >= limit {
463
+ break
464
+ }
465
+ }
466
+ }
467
+
468
+ items := make([]any, 0, len(matches))
469
+ for i := range matches {
470
+ items = append(items, matches[i])
471
+ }
472
+
473
+ result := NewListResult(EntityMatch, items)
474
+ if len(warnings) > 0 {
475
+ result = NewPartialListResult(EntityMatch, items, warnings...)
476
+ }
477
+ result.RequestedRef = resolved.RequestedRef
478
+ result.CanonicalRef = resolved.CanonicalRef
479
+ return result, nil
480
+ }
481
+
482
+ func (s *MatchService) lookupMatch(ctx context.Context, query string, opts MatchLookupOptions, statusOnly bool) (NormalizedResult, error) {
483
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
484
+ if passthrough != nil {
485
+ return *passthrough, nil
486
+ }
487
+
488
+ statusCache := map[string]matchStatusSnapshot{}
489
+ teamCache := map[string]teamIdentity{}
490
+ scoreCache := map[string]string{}
491
+ warnings := make([]string, 0, len(lookup.warnings)+2)
492
+ warnings = append(warnings, lookup.warnings...)
493
+
494
+ hydrationWarnings := s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)
495
+ warnings = append(warnings, hydrationWarnings...)
496
+
497
+ if statusOnly {
498
+ lookup.match.Extensions = nil
499
+ }
500
+
501
+ result := NewDataResult(EntityMatch, lookup.match)
502
+ if len(warnings) > 0 {
503
+ result = NewPartialResult(EntityMatch, lookup.match, warnings...)
504
+ }
505
+ result.RequestedRef = lookup.resolved.RequestedRef
506
+ result.CanonicalRef = lookup.resolved.CanonicalRef
507
+ return result, nil
508
+ }
509
+
510
+ type matchLookup struct {
511
+ match *Match
512
+ resolved *ResolvedDocument
513
+ warnings []string
514
+ }
515
+
516
+ func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opts MatchLookupOptions) (*matchLookup, *NormalizedResult) {
517
+ query = strings.TrimSpace(query)
518
+ if query == "" {
519
+ result := NormalizedResult{
520
+ Kind: EntityMatch,
521
+ Status: ResultStatusEmpty,
522
+ Message: "match query is required",
523
+ }
524
+ return nil, &result
525
+ }
526
+
527
+ searchResult, err := s.resolver.Search(ctx, EntityMatch, query, ResolveOptions{
528
+ Limit: 5,
529
+ LeagueID: strings.TrimSpace(opts.LeagueID),
530
+ })
531
+ if err != nil {
532
+ result := NewTransportErrorResult(EntityMatch, query, err)
533
+ return nil, &result
534
+ }
535
+ if len(searchResult.Entities) == 0 {
536
+ result := NormalizedResult{
537
+ Kind: EntityMatch,
538
+ Status: ResultStatusEmpty,
539
+ Message: fmt.Sprintf("no matches found for %q", query),
540
+ }
541
+ return nil, &result
542
+ }
543
+
544
+ entity := searchResult.Entities[0]
545
+ ref := buildMatchRef(entity)
546
+ if ref == "" {
547
+ result := NormalizedResult{
548
+ Kind: EntityMatch,
549
+ Status: ResultStatusEmpty,
550
+ Message: fmt.Sprintf("unable to resolve match ref for %q", query),
551
+ }
552
+ return nil, &result
553
+ }
554
+
555
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
556
+ if err != nil {
557
+ result := NewTransportErrorResult(EntityMatch, ref, err)
558
+ return nil, &result
559
+ }
560
+
561
+ match, err := NormalizeMatch(resolved.Body)
562
+ if err != nil {
563
+ result := NormalizedResult{
564
+ Kind: EntityMatch,
565
+ Status: ResultStatusError,
566
+ Message: fmt.Sprintf("normalize competition match %q: %v", resolved.CanonicalRef, err),
567
+ }
568
+ return nil, &result
569
+ }
570
+
571
+ return &matchLookup{
572
+ match: match,
573
+ resolved: resolved,
574
+ warnings: searchResult.Warnings,
575
+ }, nil
576
+ }
577
+
578
+ func (s *MatchService) deliveryEventsFromRoute(ctx context.Context, ref string, baseWarnings []string) (NormalizedResult, error) {
579
+ resolved, err := s.resolveRefChainResilient(ctx, ref)
580
+ if err != nil {
581
+ return NewTransportErrorResult(EntityDeliveryEvent, ref, err), nil
582
+ }
583
+
584
+ pageItems, pageWarnings, err := s.resolvePageRefs(ctx, resolved)
585
+ if err != nil {
586
+ return NormalizedResult{}, err
587
+ }
588
+
589
+ events := make([]any, 0, len(pageItems))
590
+ warnings := make([]string, 0, len(baseWarnings))
591
+ warnings = append(warnings, baseWarnings...)
592
+ warnings = append(warnings, pageWarnings...)
593
+ loaded, loadWarnings := s.loadDeliveryEvents(ctx, pageItems)
594
+ warnings = append(warnings, loadWarnings...)
595
+ for _, delivery := range loaded {
596
+ events = append(events, delivery)
597
+ }
598
+
599
+ result := NewListResult(EntityDeliveryEvent, events)
600
+ if len(warnings) > 0 {
601
+ result = NewPartialListResult(EntityDeliveryEvent, events, warnings...)
602
+ }
603
+ result.RequestedRef = resolved.RequestedRef
604
+ result.CanonicalRef = resolved.CanonicalRef
605
+ return result, nil
606
+ }
607
+
608
+ func (s *MatchService) loadDeliveryEvents(ctx context.Context, refs []Ref) ([]DeliveryEvent, []string) {
609
+ type deliveryLoadResult struct {
610
+ index int
611
+ delivery *DeliveryEvent
612
+ warning string
613
+ }
614
+
615
+ results := make([]deliveryLoadResult, len(refs))
616
+ sem := make(chan struct{}, deliveryFetchConcurrency)
617
+ var wg sync.WaitGroup
618
+ for i, item := range refs {
619
+ wg.Add(1)
620
+ go func(index int, item Ref) {
621
+ defer wg.Done()
622
+ sem <- struct{}{}
623
+ defer func() { <-sem }()
624
+
625
+ itemRef := strings.TrimSpace(item.URL)
626
+ if itemRef == "" {
627
+ results[index] = deliveryLoadResult{index: index, warning: "skip detail item with empty ref"}
628
+ return
629
+ }
630
+
631
+ itemResolved, itemErr := s.resolveRefChainResilient(ctx, itemRef)
632
+ if itemErr != nil {
633
+ results[index] = deliveryLoadResult{index: index, warning: fmt.Sprintf("detail %s: %v", itemRef, itemErr)}
634
+ return
635
+ }
636
+
637
+ delivery, normalizeErr := NormalizeDeliveryEvent(itemResolved.Body)
638
+ if normalizeErr != nil {
639
+ results[index] = deliveryLoadResult{index: index, warning: fmt.Sprintf("detail %s: %v", itemRef, normalizeErr)}
640
+ return
641
+ }
642
+ results[index] = deliveryLoadResult{index: index, delivery: delivery}
643
+ }(i, item)
644
+ }
645
+ wg.Wait()
646
+
647
+ deliveries := make([]DeliveryEvent, 0, len(results))
648
+ warnings := make([]string, 0)
649
+ for _, result := range results {
650
+ if result.warning != "" {
651
+ warnings = append(warnings, result.warning)
652
+ continue
653
+ }
654
+ if result.delivery != nil {
655
+ deliveries = append(deliveries, *result.delivery)
656
+ }
657
+ }
658
+ return deliveries, compactWarnings(warnings)
659
+ }
660
+
661
+ type selectedInningsContext struct {
662
+ match Match
663
+ team Team
664
+ innings Innings
665
+ warnings []string
666
+ }
667
+
668
+ func (s *MatchService) resolveSelectedInnings(
669
+ ctx context.Context,
670
+ query string,
671
+ opts MatchInningsOptions,
672
+ requireTeam bool,
673
+ ) (*selectedInningsContext, *NormalizedResult) {
674
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
675
+ if passthrough != nil {
676
+ return nil, passthrough
677
+ }
678
+ statusCache := map[string]matchStatusSnapshot{}
679
+ teamCache := map[string]teamIdentity{}
680
+ scoreCache := map[string]string{}
681
+ lookup.warnings = append(lookup.warnings, s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)...)
682
+
683
+ teamQuery := strings.TrimSpace(opts.TeamQuery)
684
+ if requireTeam && teamQuery == "" {
685
+ result := NormalizedResult{
686
+ Kind: EntityInnings,
687
+ Status: ResultStatusEmpty,
688
+ Message: "--team is required",
689
+ }
690
+ return nil, &result
691
+ }
692
+
693
+ teams, teamWarnings, teamResult := s.selectTeamsFromMatch(ctx, *lookup.match, teamQuery, opts.LeagueID)
694
+ if teamResult != nil {
695
+ return nil, teamResult
696
+ }
697
+ if len(teams) == 0 {
698
+ result := NormalizedResult{
699
+ Kind: EntityInnings,
700
+ Status: ResultStatusEmpty,
701
+ Message: "no teams available for match selection",
702
+ }
703
+ return nil, &result
704
+ }
705
+
706
+ team := teams[0]
707
+ inningsList, _, inningsWarnings := s.fetchTeamInnings(ctx, *lookup.match, team)
708
+ if len(inningsWarnings) > 0 {
709
+ teamWarnings = append(teamWarnings, inningsWarnings...)
710
+ }
711
+
712
+ if len(inningsList) == 0 {
713
+ result := NormalizedResult{
714
+ Kind: EntityInnings,
715
+ Status: ResultStatusEmpty,
716
+ Message: fmt.Sprintf("no innings found for team %q", team.ID),
717
+ }
718
+ return nil, &result
719
+ }
720
+
721
+ requestedInnings := opts.Innings
722
+ requestedPeriod := opts.Period
723
+ if requestedInnings <= 0 || requestedPeriod <= 0 {
724
+ result := NormalizedResult{
725
+ Kind: EntityInnings,
726
+ Status: ResultStatusEmpty,
727
+ Message: "--innings and --period are required",
728
+ }
729
+ return nil, &result
730
+ }
731
+
732
+ var selected *Innings
733
+ for i := range inningsList {
734
+ if inningsList[i].InningsNumber == requestedInnings && inningsList[i].Period == requestedPeriod {
735
+ candidate := inningsList[i]
736
+ if strings.TrimSpace(team.ID) != "" {
737
+ candidate.TeamID = strings.TrimSpace(team.ID)
738
+ }
739
+ candidate.TeamName = nonEmpty(team.ShortName, team.Name, team.ID, candidate.TeamName)
740
+ candidate.MatchID = nonEmpty(candidate.MatchID, lookup.match.ID)
741
+ candidate.CompetitionID = nonEmpty(candidate.CompetitionID, lookup.match.CompetitionID, lookup.match.ID)
742
+ candidate.EventID = nonEmpty(candidate.EventID, lookup.match.EventID)
743
+ candidate.LeagueID = nonEmpty(candidate.LeagueID, lookup.match.LeagueID)
744
+ selected = &candidate
745
+ break
746
+ }
747
+ }
748
+
749
+ if selected == nil {
750
+ result := NormalizedResult{
751
+ Kind: EntityInnings,
752
+ Status: ResultStatusEmpty,
753
+ Message: fmt.Sprintf(
754
+ "requested innings/period %d/%d was not found; available: %s",
755
+ requestedInnings,
756
+ requestedPeriod,
757
+ availableInningsPeriods(inningsList),
758
+ ),
759
+ }
760
+ return nil, &result
761
+ }
762
+
763
+ warnings := append([]string{}, lookup.warnings...)
764
+ warnings = append(warnings, teamWarnings...)
765
+ return &selectedInningsContext{
766
+ match: *lookup.match,
767
+ team: team,
768
+ innings: *selected,
769
+ warnings: compactWarnings(warnings),
770
+ }, nil
771
+ }
772
+
773
+ func (s *MatchService) selectTeamsFromMatch(
774
+ ctx context.Context,
775
+ match Match,
776
+ teamQuery string,
777
+ leagueID string,
778
+ ) ([]Team, []string, *NormalizedResult) {
779
+ teamQuery = strings.TrimSpace(teamQuery)
780
+ if teamQuery == "" {
781
+ teams := make([]Team, 0, len(match.Teams))
782
+ for _, team := range match.Teams {
783
+ if strings.TrimSpace(team.ID) == "" {
784
+ continue
785
+ }
786
+ teams = append(teams, team)
787
+ }
788
+ if len(teams) == 0 {
789
+ result := NormalizedResult{
790
+ Kind: EntityInnings,
791
+ Status: ResultStatusEmpty,
792
+ Message: "no teams available in match competitors",
793
+ }
794
+ return nil, nil, &result
795
+ }
796
+ return teams, nil, nil
797
+ }
798
+
799
+ if direct := findTeamInMatch(match, teamQuery); direct != nil {
800
+ return []Team{*direct}, nil, nil
801
+ }
802
+
803
+ searchResult, err := s.resolver.Search(ctx, EntityTeam, teamQuery, ResolveOptions{
804
+ Limit: 5,
805
+ LeagueID: strings.TrimSpace(leagueID),
806
+ MatchID: strings.TrimSpace(match.ID),
807
+ })
808
+ if err != nil {
809
+ result := NewTransportErrorResult(EntityTeam, teamQuery, err)
810
+ return nil, nil, &result
811
+ }
812
+
813
+ for _, entity := range searchResult.Entities {
814
+ if found := matchTeamByID(match, entity.ID); found != nil {
815
+ return []Team{*found}, searchResult.Warnings, nil
816
+ }
817
+ }
818
+
819
+ result := NormalizedResult{
820
+ Kind: EntityTeam,
821
+ Status: ResultStatusEmpty,
822
+ Message: fmt.Sprintf("team %q not found in match; available: %s", teamQuery, availableMatchTeams(match)),
823
+ }
824
+ return nil, searchResult.Warnings, &result
825
+ }
826
+
827
+ func (s *MatchService) fetchTeamInnings(ctx context.Context, match Match, team Team) ([]Innings, string, []string) {
828
+ candidates := compactWarnings([]string{
829
+ strings.TrimSpace(team.LinescoresRef),
830
+ strings.TrimSpace(competitorSubresourceRef(match, team.ID, "linescores")),
831
+ })
832
+ if len(candidates) == 0 {
833
+ return []Innings{}, "", []string{fmt.Sprintf("linescores route unavailable for team %q", team.ID)}
834
+ }
835
+
836
+ seen := map[string]struct{}{}
837
+ warnings := make([]string, 0)
838
+ for _, ref := range candidates {
839
+ if _, ok := seen[ref]; ok {
840
+ continue
841
+ }
842
+ seen[ref] = struct{}{}
843
+
844
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
845
+ if err != nil {
846
+ warnings = append(warnings, fmt.Sprintf("linescores %s: %v", ref, err))
847
+ continue
848
+ }
849
+
850
+ innings, collectWarnings, collectErr := s.collectInningsFromPayload(ctx, resolved.Body)
851
+ warnings = append(warnings, collectWarnings...)
852
+ if collectErr != nil {
853
+ warnings = append(warnings, fmt.Sprintf("linescores %s: %v", ref, collectErr))
854
+ continue
855
+ }
856
+
857
+ for i := range innings {
858
+ if strings.TrimSpace(team.ID) != "" {
859
+ innings[i].TeamID = strings.TrimSpace(team.ID)
860
+ }
861
+ innings[i].TeamName = nonEmpty(team.ShortName, team.Name, team.ID, innings[i].TeamName)
862
+ innings[i].MatchID = nonEmpty(innings[i].MatchID, match.ID)
863
+ innings[i].CompetitionID = nonEmpty(innings[i].CompetitionID, match.CompetitionID, match.ID)
864
+ innings[i].EventID = nonEmpty(innings[i].EventID, match.EventID)
865
+ innings[i].LeagueID = nonEmpty(innings[i].LeagueID, match.LeagueID)
866
+ if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "statistics/0"); scopedRef != "" {
867
+ innings[i].StatisticsRef = scopedRef
868
+ }
869
+ if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "partnerships"); scopedRef != "" {
870
+ innings[i].PartnershipsRef = scopedRef
871
+ }
872
+ if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "fow"); scopedRef != "" {
873
+ innings[i].FallOfWicketRef = scopedRef
874
+ }
875
+ }
876
+
877
+ if len(innings) > 0 {
878
+ return innings, resolved.CanonicalRef, compactWarnings(warnings)
879
+ }
880
+ }
881
+
882
+ return []Innings{}, "", compactWarnings(warnings)
883
+ }
884
+
885
+ func (s *MatchService) collectInningsFromPayload(ctx context.Context, body []byte) ([]Innings, []string, error) {
886
+ payload, err := decodePayloadMap(body)
887
+ if err != nil {
888
+ return nil, nil, err
889
+ }
890
+
891
+ warnings := make([]string, 0)
892
+ innings := make([]Innings, 0)
893
+
894
+ appendInningsMap := func(item map[string]any) {
895
+ if item == nil {
896
+ return
897
+ }
898
+ if stringField(item, "$ref") == "" && intField(item, "period") == 0 && intField(item, "runs") == 0 && intField(item, "wickets") == 0 && stringField(item, "score") == "" {
899
+ return
900
+ }
901
+ innings = append(innings, *normalizeInningsFromMap(item))
902
+ }
903
+
904
+ items := mapSliceField(payload, "items")
905
+ if len(items) > 0 {
906
+ for _, item := range items {
907
+ itemRef := strings.TrimSpace(stringField(item, "$ref"))
908
+ if itemRef != "" && intField(item, "period") == 0 && stringField(item, "score") == "" && intField(item, "runs") == 0 && intField(item, "wickets") == 0 {
909
+ resolved, itemErr := s.client.ResolveRefChain(ctx, itemRef)
910
+ if itemErr != nil {
911
+ warnings = append(warnings, fmt.Sprintf("innings %s: %v", itemRef, itemErr))
912
+ continue
913
+ }
914
+ normalized, normalizeErr := NormalizeInnings(resolved.Body)
915
+ if normalizeErr != nil {
916
+ warnings = append(warnings, fmt.Sprintf("innings %s: %v", itemRef, normalizeErr))
917
+ continue
918
+ }
919
+ innings = append(innings, *normalized)
920
+ continue
921
+ }
922
+ appendInningsMap(item)
923
+ }
924
+ return innings, compactWarnings(warnings), nil
925
+ }
926
+
927
+ appendInningsMap(payload)
928
+ return innings, compactWarnings(warnings), nil
929
+ }
930
+
931
+ func (s *MatchService) hydrateInningsTimelines(ctx context.Context, innings *Innings) []string {
932
+ if innings == nil || strings.TrimSpace(innings.StatisticsRef) == "" {
933
+ return nil
934
+ }
935
+
936
+ resolved, err := s.client.ResolveRefChain(ctx, innings.StatisticsRef)
937
+ if err != nil {
938
+ return []string{fmt.Sprintf("period statistics %s: %v", innings.StatisticsRef, err)}
939
+ }
940
+
941
+ overs, wickets, err := NormalizeInningsPeriodStatistics(resolved.Body)
942
+ if err != nil {
943
+ return []string{fmt.Sprintf("period statistics %s: %v", resolved.CanonicalRef, err)}
944
+ }
945
+ innings.OverTimeline = overs
946
+ innings.WicketTimeline = wickets
947
+ return nil
948
+ }
949
+
950
+ func (s *MatchService) fetchDetailedRefCollection(
951
+ ctx context.Context,
952
+ ref string,
953
+ normalize func(itemBody []byte) (any, error),
954
+ ) (*ResolvedDocument, []any, []string, error) {
955
+ resolved, err := s.resolveRefChainResilient(ctx, ref)
956
+ if err != nil {
957
+ return nil, nil, nil, err
958
+ }
959
+
960
+ pageItems, warnings, err := s.resolvePageRefs(ctx, resolved)
961
+ if err != nil {
962
+ return nil, nil, nil, err
963
+ }
964
+
965
+ items := make([]any, 0, len(pageItems))
966
+ for _, item := range pageItems {
967
+ itemRef := strings.TrimSpace(item.URL)
968
+ if itemRef == "" {
969
+ warnings = append(warnings, "skip item with empty ref")
970
+ continue
971
+ }
972
+
973
+ itemResolved, itemErr := s.resolveRefChainResilient(ctx, itemRef)
974
+ if itemErr != nil {
975
+ warnings = append(warnings, fmt.Sprintf("item %s: %v", itemRef, itemErr))
976
+ continue
977
+ }
978
+
979
+ normalized, normalizeErr := normalize(itemResolved.Body)
980
+ if normalizeErr != nil {
981
+ warnings = append(warnings, fmt.Sprintf("item %s: %v", itemRef, normalizeErr))
982
+ continue
983
+ }
984
+ items = append(items, normalized)
985
+ }
986
+
987
+ return resolved, items, compactWarnings(warnings), nil
988
+ }
989
+
990
+ func (s *MatchService) resolvePageRefs(ctx context.Context, first *ResolvedDocument) ([]Ref, []string, error) {
991
+ if first == nil {
992
+ return nil, nil, fmt.Errorf("resolved page is nil")
993
+ }
994
+ page, err := DecodePage[Ref](first.Body)
995
+ if err != nil {
996
+ return nil, nil, fmt.Errorf("decode page %q: %w", first.CanonicalRef, err)
997
+ }
998
+
999
+ items := append([]Ref(nil), page.Items...)
1000
+ if page.PageCount <= 1 {
1001
+ return items, nil, nil
1002
+ }
1003
+
1004
+ warnings := make([]string, 0)
1005
+ baseRef := firstNonEmptyString(first.CanonicalRef, first.RequestedRef)
1006
+ for pageIndex := 2; pageIndex <= page.PageCount; pageIndex++ {
1007
+ pageRef := pagedRef(baseRef, pageIndex)
1008
+ if pageRef == "" {
1009
+ warnings = append(warnings, fmt.Sprintf("page %d unavailable for %s", pageIndex, baseRef))
1010
+ continue
1011
+ }
1012
+ pageDoc, pageErr := s.resolveRefChainResilient(ctx, pageRef)
1013
+ if pageErr != nil {
1014
+ warnings = append(warnings, fmt.Sprintf("page %d %s: %v", pageIndex, pageRef, pageErr))
1015
+ continue
1016
+ }
1017
+ nextPage, decodeErr := DecodePage[Ref](pageDoc.Body)
1018
+ if decodeErr != nil {
1019
+ warnings = append(warnings, fmt.Sprintf("page %d %s: %v", pageIndex, pageDoc.CanonicalRef, decodeErr))
1020
+ continue
1021
+ }
1022
+ items = append(items, nextPage.Items...)
1023
+ }
1024
+
1025
+ return items, compactWarnings(warnings), nil
1026
+ }
1027
+
1028
+ func pagedRef(ref string, page int) string {
1029
+ ref = strings.TrimSpace(ref)
1030
+ if ref == "" || page <= 1 {
1031
+ return ref
1032
+ }
1033
+ parsed, err := url.Parse(ref)
1034
+ if err != nil {
1035
+ separator := "?"
1036
+ if strings.Contains(ref, "?") {
1037
+ separator = "&"
1038
+ }
1039
+ return ref + separator + "page=" + strconv.Itoa(page)
1040
+ }
1041
+ query := parsed.Query()
1042
+ query.Set("page", strconv.Itoa(page))
1043
+ parsed.RawQuery = query.Encode()
1044
+ return parsed.String()
1045
+ }
1046
+
1047
+ func (s *MatchService) resolveRefChainResilient(ctx context.Context, ref string) (*ResolvedDocument, error) {
1048
+ var lastErr error
1049
+ for attempt := 0; attempt < 3; attempt++ {
1050
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
1051
+ if err == nil {
1052
+ return resolved, nil
1053
+ }
1054
+ lastErr = err
1055
+ var statusErr *HTTPStatusError
1056
+ if !errors.As(err, &statusErr) && !strings.Contains(strings.ToLower(err.Error()), "context deadline exceeded") {
1057
+ break
1058
+ }
1059
+ if statusErr != nil && statusErr.StatusCode != 503 {
1060
+ break
1061
+ }
1062
+ }
1063
+ return nil, lastErr
1064
+ }
1065
+
1066
+ func availableInningsPeriods(innings []Innings) string {
1067
+ if len(innings) == 0 {
1068
+ return "none"
1069
+ }
1070
+ parts := make([]string, 0, len(innings))
1071
+ seen := map[string]struct{}{}
1072
+ for _, item := range innings {
1073
+ if item.InningsNumber == 0 || item.Period == 0 {
1074
+ continue
1075
+ }
1076
+ label := fmt.Sprintf("%d/%d", item.InningsNumber, item.Period)
1077
+ if _, ok := seen[label]; ok {
1078
+ continue
1079
+ }
1080
+ seen[label] = struct{}{}
1081
+ parts = append(parts, label)
1082
+ }
1083
+ if len(parts) == 0 {
1084
+ return "none"
1085
+ }
1086
+ return strings.Join(parts, ", ")
1087
+ }
1088
+
1089
+ func findTeamInMatch(match Match, query string) *Team {
1090
+ query = normalizeAlias(query)
1091
+ queryTokens := strings.Fields(query)
1092
+ if query == "" {
1093
+ return nil
1094
+ }
1095
+
1096
+ bestScore := 0
1097
+ var best *Team
1098
+ for i := range match.Teams {
1099
+ candidate := &match.Teams[i]
1100
+ values := []string{
1101
+ strings.TrimSpace(candidate.ID),
1102
+ strings.TrimSpace(candidate.Name),
1103
+ strings.TrimSpace(candidate.ShortName),
1104
+ strings.TrimSpace(candidate.Abbreviation),
1105
+ strings.TrimSpace(refIDs(candidate.Ref)["teamId"]),
1106
+ strings.TrimSpace(refIDs(candidate.Ref)["competitorId"]),
1107
+ }
1108
+ for _, value := range values {
1109
+ normalized := normalizeAlias(value)
1110
+ if normalized == "" {
1111
+ continue
1112
+ }
1113
+ score := aliasMatchScore(normalized, query, queryTokens)
1114
+ if score > bestScore {
1115
+ bestScore = score
1116
+ best = candidate
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ if bestScore >= 300 {
1122
+ return best
1123
+ }
1124
+ return nil
1125
+ }
1126
+
1127
+ func availableMatchTeams(match Match) string {
1128
+ parts := make([]string, 0, len(match.Teams))
1129
+ for _, team := range match.Teams {
1130
+ name := nonEmpty(team.ShortName, team.Name, team.ID)
1131
+ if name == "" {
1132
+ continue
1133
+ }
1134
+ parts = append(parts, name)
1135
+ }
1136
+ if len(parts) == 0 {
1137
+ return "none"
1138
+ }
1139
+ return strings.Join(parts, ", ")
1140
+ }
1141
+
1142
+ func inningsSubresourceRef(match Match, teamID string, innings, period int, suffix string) string {
1143
+ base := competitorSubresourceRef(match, teamID, "")
1144
+ if base == "" || innings <= 0 || period <= 0 {
1145
+ return ""
1146
+ }
1147
+
1148
+ suffix = strings.Trim(strings.TrimSpace(suffix), "/")
1149
+ ref := fmt.Sprintf("%s/linescores/%d/%d", strings.TrimRight(base, "/"), innings, period)
1150
+ if suffix != "" {
1151
+ ref += "/" + suffix
1152
+ }
1153
+ return ref
1154
+ }
1155
+
1156
+ func (s *MatchService) matchesFromEventRef(ctx context.Context, ref string) ([]Match, []string, error) {
1157
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
1158
+ if err != nil {
1159
+ return nil, nil, err
1160
+ }
1161
+
1162
+ matches, err := NormalizeMatchesFromEvent(resolved.Body)
1163
+ if err != nil {
1164
+ return nil, nil, err
1165
+ }
1166
+
1167
+ return matches, nil, nil
1168
+ }
1169
+
1170
+ func (s *MatchService) hydrateMatch(
1171
+ ctx context.Context,
1172
+ match *Match,
1173
+ statusCache map[string]matchStatusSnapshot,
1174
+ teamCache map[string]teamIdentity,
1175
+ scoreCache map[string]string,
1176
+ ) []string {
1177
+ if match == nil {
1178
+ return nil
1179
+ }
1180
+
1181
+ warnings := make([]string, 0)
1182
+
1183
+ if statusRef := strings.TrimSpace(match.StatusRef); statusRef != "" {
1184
+ snapshot, err := s.fetchStatus(ctx, statusRef, statusCache)
1185
+ if err != nil {
1186
+ warnings = append(warnings, fmt.Sprintf("status %s: %v", statusRef, err))
1187
+ } else {
1188
+ match.MatchState = nonEmpty(match.MatchState, snapshot.stateSummary())
1189
+ if strings.TrimSpace(match.Note) == "" {
1190
+ match.Note = snapshot.longSummary
1191
+ }
1192
+ if match.Extensions == nil {
1193
+ match.Extensions = map[string]any{}
1194
+ }
1195
+ match.Extensions["statusState"] = snapshot.state
1196
+ match.Extensions["statusDetail"] = snapshot.detail
1197
+ match.Extensions["statusShortDetail"] = snapshot.shortDetail
1198
+ }
1199
+ }
1200
+
1201
+ for i := range match.Teams {
1202
+ team := &match.Teams[i]
1203
+
1204
+ if strings.TrimSpace(team.Name) == "" || strings.TrimSpace(team.ShortName) == "" {
1205
+ identity, err := s.fetchTeamIdentity(ctx, team, teamCache)
1206
+ if err != nil {
1207
+ warnings = append(warnings, fmt.Sprintf("team %s: %v", nonEmpty(team.Ref, team.ID), err))
1208
+ } else {
1209
+ if team.Name == "" {
1210
+ team.Name = identity.name
1211
+ }
1212
+ if team.ShortName == "" {
1213
+ team.ShortName = identity.shortName
1214
+ }
1215
+ }
1216
+ }
1217
+
1218
+ if strings.TrimSpace(team.ScoreSummary) == "" && strings.TrimSpace(team.ScoreRef) != "" {
1219
+ score, err := s.fetchTeamScore(ctx, team.ScoreRef, scoreCache)
1220
+ if err != nil {
1221
+ warnings = append(warnings, fmt.Sprintf("score %s: %v", team.ScoreRef, err))
1222
+ } else {
1223
+ team.ScoreSummary = score
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ match.ScoreSummary = matchScoreSummary(match.Teams)
1229
+ return compactWarnings(warnings)
1230
+ }
1231
+
1232
+ func (s *MatchService) hydrateMatchStatusOnly(
1233
+ ctx context.Context,
1234
+ match *Match,
1235
+ statusCache map[string]matchStatusSnapshot,
1236
+ ) []string {
1237
+ if match == nil {
1238
+ return nil
1239
+ }
1240
+ statusRef := strings.TrimSpace(match.StatusRef)
1241
+ if statusRef == "" {
1242
+ return nil
1243
+ }
1244
+ snapshot, err := s.fetchStatus(ctx, statusRef, statusCache)
1245
+ if err != nil {
1246
+ return []string{fmt.Sprintf("status %s: %v", statusRef, err)}
1247
+ }
1248
+ match.MatchState = nonEmpty(match.MatchState, snapshot.stateSummary())
1249
+ if strings.TrimSpace(match.Note) == "" {
1250
+ match.Note = snapshot.longSummary
1251
+ }
1252
+ if match.Extensions == nil {
1253
+ match.Extensions = map[string]any{}
1254
+ }
1255
+ match.Extensions["statusState"] = snapshot.state
1256
+ match.Extensions["statusDetail"] = snapshot.detail
1257
+ match.Extensions["statusShortDetail"] = snapshot.shortDetail
1258
+ return nil
1259
+ }
1260
+
1261
+ func (s *MatchService) enrichMatchTeamsFromIndex(match *Match) {
1262
+ if match == nil || s == nil || s.resolver == nil || s.resolver.index == nil {
1263
+ return
1264
+ }
1265
+ for i := range match.Teams {
1266
+ team := &match.Teams[i]
1267
+ teamID := strings.TrimSpace(team.ID)
1268
+ if teamID == "" {
1269
+ teamID = strings.TrimSpace(refIDs(team.Ref)["teamId"])
1270
+ }
1271
+ if teamID == "" {
1272
+ teamID = strings.TrimSpace(refIDs(team.Ref)["competitorId"])
1273
+ }
1274
+ if teamID == "" {
1275
+ continue
1276
+ }
1277
+ cached, ok := s.resolver.index.FindByID(EntityTeam, teamID)
1278
+ if !ok {
1279
+ continue
1280
+ }
1281
+ if strings.TrimSpace(team.Name) == "" {
1282
+ team.Name = strings.TrimSpace(cached.Name)
1283
+ }
1284
+ if strings.TrimSpace(team.ShortName) == "" {
1285
+ team.ShortName = strings.TrimSpace(cached.ShortName)
1286
+ }
1287
+ if strings.TrimSpace(team.Ref) == "" {
1288
+ team.Ref = strings.TrimSpace(cached.Ref)
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ func (s *MatchService) fetchStatus(ctx context.Context, ref string, cache map[string]matchStatusSnapshot) (matchStatusSnapshot, error) {
1294
+ ref = strings.TrimSpace(ref)
1295
+ if ref == "" {
1296
+ return matchStatusSnapshot{}, fmt.Errorf("status ref is empty")
1297
+ }
1298
+ if cached, ok := cache[ref]; ok {
1299
+ return cached, nil
1300
+ }
1301
+
1302
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
1303
+ if err != nil {
1304
+ return matchStatusSnapshot{}, err
1305
+ }
1306
+
1307
+ payload, err := decodePayloadMap(resolved.Body)
1308
+ if err != nil {
1309
+ return matchStatusSnapshot{}, err
1310
+ }
1311
+
1312
+ typed := mapField(payload, "type")
1313
+ status := matchStatusSnapshot{
1314
+ summary: stringField(payload, "summary"),
1315
+ longSummary: stringField(payload, "longSummary"),
1316
+ state: stringField(typed, "state"),
1317
+ detail: stringField(typed, "detail"),
1318
+ shortDetail: stringField(typed, "shortDetail"),
1319
+ description: stringField(typed, "description"),
1320
+ }
1321
+ cache[ref] = status
1322
+ return status, nil
1323
+ }
1324
+
1325
+ func (s *MatchService) fetchTeamIdentity(ctx context.Context, team *Team, cache map[string]teamIdentity) (teamIdentity, error) {
1326
+ if team == nil {
1327
+ return teamIdentity{}, fmt.Errorf("team is nil")
1328
+ }
1329
+
1330
+ ref := strings.TrimSpace(team.Ref)
1331
+ if ref == "" && strings.TrimSpace(team.ID) != "" {
1332
+ ref = "/teams/" + strings.TrimSpace(team.ID)
1333
+ }
1334
+ if ref == "" {
1335
+ return teamIdentity{}, fmt.Errorf("team ref is empty")
1336
+ }
1337
+
1338
+ if cached, ok := cache[ref]; ok {
1339
+ return cached, nil
1340
+ }
1341
+
1342
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
1343
+ if err != nil {
1344
+ return teamIdentity{}, err
1345
+ }
1346
+
1347
+ payload, err := decodePayloadMap(resolved.Body)
1348
+ if err != nil {
1349
+ return teamIdentity{}, err
1350
+ }
1351
+
1352
+ identity := teamIdentity{
1353
+ name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "name")),
1354
+ shortName: nonEmpty(stringField(payload, "shortDisplayName"), stringField(payload, "shortName"), stringField(payload, "abbreviation")),
1355
+ }
1356
+ if identity.name == "" && strings.TrimSpace(team.ID) != "" {
1357
+ identity.name = team.ID
1358
+ }
1359
+ cache[ref] = identity
1360
+ return identity, nil
1361
+ }
1362
+
1363
+ func (s *MatchService) fetchTeamScore(ctx context.Context, ref string, cache map[string]string) (string, error) {
1364
+ ref = strings.TrimSpace(ref)
1365
+ if ref == "" {
1366
+ return "", fmt.Errorf("score ref is empty")
1367
+ }
1368
+ if cached, ok := cache[ref]; ok {
1369
+ return cached, nil
1370
+ }
1371
+
1372
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
1373
+ if err != nil {
1374
+ return "", err
1375
+ }
1376
+
1377
+ payload, err := decodePayloadMap(resolved.Body)
1378
+ if err != nil {
1379
+ return "", err
1380
+ }
1381
+
1382
+ score := nonEmpty(stringField(payload, "displayValue"), stringField(payload, "value"), stringField(payload, "summary"))
1383
+ cache[ref] = score
1384
+ return score, nil
1385
+ }
1386
+
1387
+ func buildMatchRef(entity IndexedEntity) string {
1388
+ if strings.TrimSpace(entity.Ref) != "" {
1389
+ return entity.Ref
1390
+ }
1391
+
1392
+ leagueID := strings.TrimSpace(entity.LeagueID)
1393
+ eventID := strings.TrimSpace(entity.EventID)
1394
+ matchID := strings.TrimSpace(entity.ID)
1395
+ if leagueID == "" || eventID == "" || matchID == "" {
1396
+ return ""
1397
+ }
1398
+
1399
+ return fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueID, eventID, matchID)
1400
+ }
1401
+
1402
+ func matchSubresourceRef(match Match, extensionKey, suffix string) string {
1403
+ extensionKey = strings.TrimSpace(extensionKey)
1404
+ suffix = strings.Trim(strings.TrimSpace(suffix), "/")
1405
+
1406
+ if extensionKey != "" {
1407
+ if ref := extensionRef(match.Extensions, extensionKey); ref != "" {
1408
+ return ref
1409
+ }
1410
+ }
1411
+
1412
+ base := strings.TrimSpace(match.Ref)
1413
+ if base != "" {
1414
+ if suffix == "" {
1415
+ return base
1416
+ }
1417
+ return strings.TrimRight(base, "/") + "/" + suffix
1418
+ }
1419
+
1420
+ leagueID := strings.TrimSpace(match.LeagueID)
1421
+ eventID := strings.TrimSpace(match.EventID)
1422
+ competitionID := strings.TrimSpace(match.CompetitionID)
1423
+ if competitionID == "" {
1424
+ competitionID = strings.TrimSpace(match.ID)
1425
+ }
1426
+ if leagueID == "" || eventID == "" || competitionID == "" {
1427
+ return ""
1428
+ }
1429
+
1430
+ base = fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueID, eventID, competitionID)
1431
+ if suffix == "" {
1432
+ return base
1433
+ }
1434
+ return base + "/" + suffix
1435
+ }
1436
+
1437
+ func extensionRef(extensions map[string]any, key string) string {
1438
+ if extensions == nil {
1439
+ return ""
1440
+ }
1441
+ raw, ok := extensions[key]
1442
+ if !ok || raw == nil {
1443
+ return ""
1444
+ }
1445
+ refMap, ok := raw.(map[string]any)
1446
+ if !ok {
1447
+ return ""
1448
+ }
1449
+ return strings.TrimSpace(stringField(refMap, "$ref"))
1450
+ }
1451
+
1452
+ func isSparseSituation(situation *MatchSituation) bool {
1453
+ if situation == nil {
1454
+ return true
1455
+ }
1456
+ return len(situation.Data) == 0
1457
+ }
1458
+
1459
+ func isLiveMatch(match Match) bool {
1460
+ if state := strings.ToLower(strings.TrimSpace(statusString(match.Extensions, "statusState"))); state == "in" {
1461
+ return true
1462
+ }
1463
+ if detail := strings.ToLower(strings.TrimSpace(statusString(match.Extensions, "statusDetail"))); detail == "live" {
1464
+ return true
1465
+ }
1466
+ if detail := strings.ToLower(strings.TrimSpace(statusString(match.Extensions, "statusShortDetail"))); detail == "live" {
1467
+ return true
1468
+ }
1469
+
1470
+ state := strings.ToLower(strings.TrimSpace(match.MatchState))
1471
+ if strings.Contains(state, "live") {
1472
+ return true
1473
+ }
1474
+ return strings.Contains(state, " in progress") || strings.Contains(state, "stumps")
1475
+ }
1476
+
1477
+ func statusString(extensions map[string]any, key string) string {
1478
+ if extensions == nil {
1479
+ return ""
1480
+ }
1481
+ raw, ok := extensions[key]
1482
+ if !ok || raw == nil {
1483
+ return ""
1484
+ }
1485
+ value, ok := raw.(string)
1486
+ if !ok {
1487
+ return ""
1488
+ }
1489
+ return value
1490
+ }
1491
+
1492
+ type matchStatusSnapshot struct {
1493
+ summary string
1494
+ longSummary string
1495
+ state string
1496
+ detail string
1497
+ shortDetail string
1498
+ description string
1499
+ }
1500
+
1501
+ func (s matchStatusSnapshot) stateSummary() string {
1502
+ return nonEmpty(s.summary, s.longSummary, s.shortDetail, s.detail, s.description, s.state)
1503
+ }
1504
+
1505
+ type teamIdentity struct {
1506
+ name string
1507
+ shortName string
1508
+ }