1package usecase
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "time"
8
9 "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"
12 historydomain "github.com/bnema/dumber/internal/domain/history"
13 "github.com/bnema/dumber/internal/domain/repository"
14 domainurl "github.com/bnema/dumber/internal/domain/url"
15 "github.com/bnema/dumber/internal/logging"
16)
17
18// Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
19var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
20
21const historyWindowDuration = 24 * time.Hour
22
23// SearchHistoryUseCase handles history search and retrieval operations.
24type SearchHistoryUseCase struct {
25 historyRepo repository.HistoryRepository
26}
27
28// NewSearchHistoryUseCase creates a new history search use case.
29func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHistoryUseCase {
30 return &SearchHistoryUseCase{
31 historyRepo: historyRepo,
32 }
33}
34
35// SearchInput is an alias for dto.HistorySearchInput.
36type SearchInput = dto.HistorySearchInput
37
38// SearchOutput is an alias for dto.HistorySearchOutput.
39type SearchOutput = dto.HistorySearchOutput
40
41// Search performs a full-text search on history entries using SQLite FTS5.
42// Returns only entries that actually match the query terms.
43func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
44 log := logging.FromContext(ctx)
45
46 if input.Query == "" {
47 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
48 }
49
50 limit := input.Limit
51 if limit <= 0 {
52 limit = 20 // Default limit
53 }
54
55 // Use repository's FTS5 search
56 matches, err := uc.historyRepo.Search(ctx, input.Query, limit)
57 if err != nil {
58 return nil, fmt.Errorf("failed to search history: %w", err)
59 }
60
61 log.Debug().
62 Str("query", input.Query).
63 Int("matches", len(matches)).
64 Msg("FTS5 search completed")
65
66 return &SearchOutput{Matches: matches}, nil
67}
68
69// GetRecent retrieves recent history entries. A zero limit means all entries;
70// negative limits retain the historical default page size.
71func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
72 if limit < 0 {
73 limit = 50 // Default limit for invalid negative values.
74 }
75
76 entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
77 if err != nil {
78 return nil, fmt.Errorf("failed to get recent history: %w", err)
79 }
80
81 return entries, nil
82}
83
84// GetRecentByDomain retrieves recent history entries for a canonical domain. A
85// zero limit means all matching entries; negative limits use the default page size.
86func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
87 if limit < 0 {
88 limit = 50
89 }
90 domain, err := canonicalHistoryDomain(domain)
91 if err != nil {
92 return nil, err
93 }
94
95 entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
96 if err != nil {
97 return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
98 }
99 return entries, nil
100}
101
102// canonicalHistoryDomain normalizes a domain and rejects empty values before
103// the domain reaches exact-match persistence queries.
104func canonicalHistoryDomain(domain string) (string, error) {
105 domain = domainurl.CanonicalDomain(domain)
106 if domain == "" {
107 return "", fmt.Errorf("domain is required")
108 }
109 return domain, nil
110}
111
112// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.
113func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
114 if before.IsZero() {
115 before = time.Now()
116 }
117 before = before.UTC()
118 after := before.Add(-historyWindowDuration)
119
120 var (
121 entries []*entity.HistoryEntry
122 hasMore bool
123 err error
124 )
125 originalDomain := domain
126 domain = domainurl.CanonicalDomain(domain)
127 if strings.TrimSpace(originalDomain) != "" && domain == "" {
128 return nil, fmt.Errorf("domain is required")
129 }
130 if domain != "" {
131 entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
132 if err == nil {
133 hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after)
134 }
135 } else {
136 entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)
137 if err == nil {
138 hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
139 }
140 }
141 if err != nil {
142 return nil, fmt.Errorf("failed to get recent history window: %w", err)
143 }
144
145 return &entity.HistoryWindow{
146 Entries: entries,
147 Before: before,
148 After: after,
149 HasMore: hasMore,
150 }, nil
151}
152
153// GetRecentSince retrieves history entries visited within the last N days.
154// If days is 0, returns all history entries.
155// If days is negative, defaults to 30 days.
156func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
157 if days < 0 {
158 days = 30 // Default to 30 days for negative values
159 }
160
161 var entries []*entity.HistoryEntry
162 var err error
163
164 switch days {
165 case 0:
166 entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
167 default:
168 entries, err = uc.historyRepo.GetRecentSince(ctx, days)
169 }
170
171 if err != nil {
172 return nil, fmt.Errorf("failed to get recent history: %w", err)
173 }
174
175 return entries, nil
176}
177
178// GetMostVisited retrieves history entries sorted by visit count within the last N days.
179// If days is 0, returns all history entries sorted by visit count.
180// If days is negative, defaults to 30 days.
181func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
182 if days < 0 {
183 days = 30 // Default to 30 days for negative values
184 }
185
186 var entries []*entity.HistoryEntry
187 var err error
188
189 switch days {
190 case 0:
191 entries, err = uc.historyRepo.GetAllMostVisited(ctx)
192 default:
193 entries, err = uc.historyRepo.GetMostVisited(ctx, days)
194 }
195
196 if err != nil {
197 return nil, fmt.Errorf("failed to get most visited history: %w", err)
198 }
199
200 return entries, nil
201}
202
203// FindByURL retrieves a history entry by its URL.
204func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
205 log := logging.FromContext(ctx)
206 log.Debug().Str("url", url).Msg("finding history entry by URL")
207
208 entry, err := uc.historyRepo.FindByURL(ctx, url)
209 if err != nil {
210 return nil, fmt.Errorf("failed to find history entry: %w", err)
211 }
212
213 if entry == nil {
214 log.Debug().Str("url", url).Msg("history entry not found")
215 }
216
217 return entry, nil
218}
219
220// ClearOlderThan deletes history entries older than the specified time.
221func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.Time) error {
222 log := logging.FromContext(ctx)
223 log.Debug().Time("before", before).Msg("clearing old history")
224
225 if err := uc.historyRepo.DeleteOlderThan(ctx, before); err != nil {
226 return fmt.Errorf("failed to clear history: %w", err)
227 }
228
229 log.Info().Time("before", before).Msg("old history cleared")
230 return nil
231}
232
233// ClearRange deletes history entries inside a named recent range.
234func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
235 cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
236 if !ok {
237 return fmt.Errorf("unknown history delete range: %q", rangeID)
238 }
239 if all {
240 return uc.ClearAll(ctx)
241 }
242
243 log := logging.FromContext(ctx)
244 log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
245 if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
246 return fmt.Errorf("failed to clear history range: %w", err)
247 }
248 log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
249 return nil
250}
251
252// ClearAll deletes all history entries.
253func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
254 log := logging.FromContext(ctx)
255 log.Debug().Msg("clearing all history")
256
257 if err := uc.historyRepo.DeleteAll(ctx); err != nil {
258 return fmt.Errorf("failed to clear all history: %w", err)
259 }
260
261 log.Info().Msg("all history cleared")
262 return nil
263}
264
265// Delete removes a single history entry by ID.
266func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
267 log := logging.FromContext(ctx)
268 log.Debug().Int64("id", id).Msg("deleting history entry")
269
270 if err := uc.historyRepo.Delete(ctx, id); err != nil {
271 return fmt.Errorf("failed to delete history entry: %w", err)
272 }
273
274 log.Debug().Int64("id", id).Msg("history entry deleted")
275 return nil
276}
277
278// DeleteByDomain removes all history entries for a domain.
279func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
280 domain, err := canonicalHistoryDomain(domain)
281 if err != nil {
282 return err
283 }
284
285 log := logging.FromContext(ctx)
286 log.Debug().Str("domain", domain).Msg("deleting history by domain")
287
288 if err := uc.historyRepo.DeleteByDomain(ctx, domain); err != nil {
289 return fmt.Errorf("failed to delete history by domain: %w", err)
290 }
291
292 log.Info().Str("domain", domain).Msg("history deleted for domain")
293 return nil
294}
295
296// GetDomainStats retrieves per-domain visit statistics.
297func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
298 log := logging.FromContext(ctx)
299 log.Debug().Int("limit", limit).Msg("getting domain stats")
300
301 if limit <= 0 {
302 limit = 20
303 }
304
305 return uc.historyRepo.GetDomainStats(ctx, limit)
306}
307
308// GetStats retrieves lightweight aggregate history statistics.
309func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
310 stats, err := uc.historyRepo.GetStats(ctx)
311 if err != nil {
312 return nil, fmt.Errorf("failed to get history stats: %w", err)
313 }
314 return stats, nil
315}
316
317// GetAnalytics retrieves aggregated history analytics for the homepage.
318func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
319 log := logging.FromContext(ctx)
320 log.Debug().Msg("getting history analytics")
321
322 // Get basic stats
323 stats, err := uc.historyRepo.GetStats(ctx)
324 if err != nil {
325 return nil, fmt.Errorf("failed to get history stats: %w", err)
326 }
327
328 // Get top domains
329 domains, err := uc.historyRepo.GetDomainStats(ctx, 10)
330 if err != nil {
331 return nil, fmt.Errorf("failed to get domain stats: %w", err)
332 }
333
334 // Get daily visits for last 30 days
335 dailyVisits, err := uc.historyRepo.GetDailyVisitCount(ctx, "-30 days")
336 if err != nil {
337 return nil, fmt.Errorf("failed to get daily visits: %w", err)
338 }
339
340 // Get hourly distribution
341 hourlyDist, err := uc.historyRepo.GetHourlyDistribution(ctx)
342 if err != nil {
343 return nil, fmt.Errorf("failed to get hourly distribution: %w", err)
344 }
345
346 return &entity.HistoryAnalytics{
347 TotalEntries: stats.TotalEntries,
348 TotalVisits: stats.TotalVisits,
349 UniqueDays: stats.UniqueDays,
350 TopDomains: domains,
351 DailyVisits: dailyVisits,
352 HourlyDistribution: hourlyDist,
353 }, nil
354}