ManticoreSearchDriver::searchIndexes()   F
last analyzed

Complexity

Conditions 23
Paths 379

Size

Total Lines 166
Code Lines 105

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 105
c 0
b 0
f 0
dl 0
loc 166
rs 0.8766
cc 23
nc 379
nop 4

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace App\Services\Search\Drivers;
4
5
use App\Models\Release;
6
use App\Services\Search\Contracts\SearchDriverInterface;
7
use Illuminate\Support\Facades\Cache;
8
use Illuminate\Support\Facades\DB;
9
use Illuminate\Support\Facades\Log;
10
use Manticoresearch\Client;
11
use Manticoresearch\Exceptions\ResponseException;
12
use Manticoresearch\Exceptions\RuntimeException;
13
use Manticoresearch\Search;
14
15
/**
16
 * ManticoreSearch driver for full-text search functionality.
17
 */
18
class ManticoreSearchDriver implements SearchDriverInterface
19
{
20
    protected array $config;
21
22
    protected array $connection;
23
24
    public Client $manticoreSearch;
25
26
    public Search $search;
27
28
    /**
29
     * Establishes a connection to ManticoreSearch HTTP port.
30
     */
31
    public function __construct(array $config = [])
32
    {
33
        $this->config = ! empty($config) ? $config : config('search.drivers.manticore');
34
        $this->connection = ['host' => $this->config['host'], 'port' => $this->config['port']];
35
        $this->manticoreSearch = new Client($this->connection);
36
        $this->search = new Search($this->manticoreSearch);
37
    }
38
39
    /**
40
     * Get the driver name.
41
     */
42
    public function getDriverName(): string
43
    {
44
        return 'manticore';
45
    }
46
47
    /**
48
     * Check if ManticoreSearch is available.
49
     */
50
    public function isAvailable(): bool
51
    {
52
        try {
53
            $status = $this->manticoreSearch->nodes()->status();
54
55
            return ! empty($status);
56
        } catch (\Throwable $e) {
57
            if (config('app.debug')) {
58
                Log::debug('ManticoreSearch not available: '.$e->getMessage());
59
            }
60
61
            return false;
62
        }
63
    }
64
65
    /**
66
     * Check if autocomplete is enabled.
67
     */
68
    public function isAutocompleteEnabled(): bool
69
    {
70
        return ($this->config['autocomplete']['enabled'] ?? true) === true;
71
    }
72
73
    /**
74
     * Check if suggest is enabled.
75
     */
76
    public function isSuggestEnabled(): bool
77
    {
78
        return ($this->config['suggest']['enabled'] ?? true) === true;
79
    }
80
81
    /**
82
     * Get the releases index name.
83
     */
84
    public function getReleasesIndex(): string
85
    {
86
        return $this->config['indexes']['releases'] ?? 'releases_rt';
87
    }
88
89
    /**
90
     * Get the predb index name.
91
     */
92
    public function getPredbIndex(): string
93
    {
94
        return $this->config['indexes']['predb'] ?? 'predb_rt';
95
    }
96
97
    /**
98
     * Insert release into ManticoreSearch releases_rt realtime index
99
     */
100
    public function insertRelease(array $parameters): void
101
    {
102
        if (empty($parameters['id'])) {
103
            Log::warning('ManticoreSearch: Cannot insert release without ID');
104
105
            return;
106
        }
107
108
        try {
109
            $document = [
110
                'name' => $parameters['name'] ?? '',
111
                'searchname' => $parameters['searchname'] ?? '',
112
                'fromname' => $parameters['fromname'] ?? '',
113
                'categories_id' => (string) ($parameters['categories_id'] ?? ''),
114
                'filename' => $parameters['filename'] ?? '',
115
            ];
116
117
            $this->manticoreSearch->table($this->config['indexes']['releases'])
118
                ->replaceDocument($document, $parameters['id']);
119
120
        } catch (ResponseException $e) {
121
            Log::error('ManticoreSearch insertRelease ResponseException: '.$e->getMessage(), [
122
                'release_id' => $parameters['id'],
123
                'index' => $this->config['indexes']['releases'],
124
            ]);
125
        } catch (RuntimeException $e) {
126
            Log::error('ManticoreSearch insertRelease RuntimeException: '.$e->getMessage(), [
127
                'release_id' => $parameters['id'],
128
            ]);
129
        } catch (\Throwable $e) {
130
            Log::error('ManticoreSearch insertRelease unexpected error: '.$e->getMessage(), [
131
                'release_id' => $parameters['id'],
132
                'trace' => $e->getTraceAsString(),
133
            ]);
134
        }
135
    }
136
137
    /**
138
     * Insert release into Manticore RT table.
139
     */
140
    public function insertPredb(array $parameters): void
141
    {
142
        if (empty($parameters['id'])) {
143
            Log::warning('ManticoreSearch: Cannot insert predb without ID');
144
145
            return;
146
        }
147
148
        try {
149
            $document = [
150
                'title' => $parameters['title'] ?? '',
151
                'filename' => $parameters['filename'] ?? '',
152
                'source' => $parameters['source'] ?? '',
153
            ];
154
155
            $this->manticoreSearch->table($this->config['indexes']['predb'])
156
                ->replaceDocument($document, $parameters['id']);
157
158
        } catch (ResponseException $e) {
159
            Log::error('ManticoreSearch insertPredb ResponseException: '.$e->getMessage(), [
160
                'predb_id' => $parameters['id'],
161
            ]);
162
        } catch (RuntimeException $e) {
163
            Log::error('ManticoreSearch insertPredb RuntimeException: '.$e->getMessage(), [
164
                'predb_id' => $parameters['id'],
165
            ]);
166
        } catch (\Throwable $e) {
167
            Log::error('ManticoreSearch insertPredb unexpected error: '.$e->getMessage(), [
168
                'predb_id' => $parameters['id'],
169
            ]);
170
        }
171
    }
172
173
    /**
174
     * Delete release from Manticore RT tables.
175
     *
176
     * @param  int  $id  Release ID
177
     */
178
    public function deleteRelease(int $id): void
179
    {
180
        if (empty($id)) {
181
            Log::warning('ManticoreSearch: Cannot delete release without ID');
182
183
            return;
184
        }
185
186
        try {
187
            $this->manticoreSearch->table($this->config['indexes']['releases'])
188
                ->deleteDocument($id);
189
        } catch (ResponseException $e) {
190
            Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [
191
                'id' => $id,
192
            ]);
193
        }
194
    }
195
196
    /**
197
     * Delete a predb record from the index.
198
     *
199
     * @param  int  $id  Predb ID
200
     */
201
    public function deletePreDb(int $id): void
202
    {
203
        if (empty($id)) {
204
            Log::warning('ManticoreSearch: Cannot delete predb without ID');
205
206
            return;
207
        }
208
209
        try {
210
            $this->manticoreSearch->table($this->config['indexes']['predb'])
211
                ->deleteDocument($id);
212
        } catch (ResponseException $e) {
213
            Log::error('ManticoreSearch deletePreDb error: '.$e->getMessage(), [
214
                'id' => $id,
215
            ]);
216
        }
217
    }
218
219
    /**
220
     * Bulk insert multiple releases into the index.
221
     *
222
     * @param  array  $releases  Array of release data arrays
223
     * @return array Results with 'success' and 'errors' counts
224
     */
225
    public function bulkInsertReleases(array $releases): array
226
    {
227
        if (empty($releases)) {
228
            return ['success' => 0, 'errors' => 0];
229
        }
230
231
        $success = 0;
232
        $errors = 0;
233
234
        $documents = [];
235
        foreach ($releases as $release) {
236
            if (empty($release['id'])) {
237
                $errors++;
238
                continue;
239
            }
240
241
            $documents[] = [
242
                'id' => $release['id'],
243
                'name' => $release['name'] ?? '',
244
                'searchname' => $release['searchname'] ?? '',
245
                'fromname' => $release['fromname'] ?? '',
246
                'categories_id' => (string) ($release['categories_id'] ?? ''),
247
                'filename' => $release['filename'] ?? '',
248
            ];
249
        }
250
251
        if (! empty($documents)) {
252
            try {
253
                $this->manticoreSearch->table($this->config['indexes']['releases'])
254
                    ->replaceDocuments($documents);
255
                $success = count($documents);
256
            } catch (\Throwable $e) {
257
                Log::error('ManticoreSearch bulkInsertReleases error: '.$e->getMessage());
258
                $errors += count($documents);
259
            }
260
        }
261
262
        return ['success' => $success, 'errors' => $errors];
263
    }
264
265
    /**
266
     * Delete release from Manticore RT tables by GUID.
267
     *
268
     * @param  array  $identifiers  ['g' => Release GUID(mandatory), 'id' => ReleaseID(optional, pass false)]
269
     */
270
    public function deleteReleaseByGuid(array $identifiers): void
271
    {
272
        if (empty($identifiers['g'])) {
273
            Log::warning('ManticoreSearch: Cannot delete release without GUID');
274
275
            return;
276
        }
277
278
        try {
279
            if ($identifiers['i'] === false || empty($identifiers['i'])) {
280
                $release = Release::query()->where('guid', $identifiers['g'])->first(['id']);
281
                $identifiers['i'] = $release?->id;
282
            }
283
284
            if (! empty($identifiers['i'])) {
285
                $this->manticoreSearch->table($this->config['indexes']['releases'])
286
                    ->deleteDocument($identifiers['i']);
287
            } else {
288
                Log::warning('ManticoreSearch: Could not find release ID for deletion', [
289
                    'guid' => $identifiers['g'],
290
                ]);
291
            }
292
        } catch (ResponseException $e) {
293
            Log::error('ManticoreSearch deleteRelease error: '.$e->getMessage(), [
294
                'guid' => $identifiers['g'],
295
                'id' => $identifiers['i'] ?? null,
296
            ]);
297
        }
298
    }
299
300
    /**
301
     * Escapes characters that are treated as special operators by the query language parser.
302
     */
303
    public static function escapeString(string $string): string
304
    {
305
        if ($string === '*' || empty($string)) {
306
            return '';
307
        }
308
309
        $from = ['\\', '(', ')', '@', '~', '"', '&', '/', '$', '=', "'", '--', '[', ']', '!', '-'];
310
        $to = ['\\\\', '\(', '\)', '\@', '\~', '\"', '\&', '\/', '\$', '\=', "\'", '\--', '\[', '\]', '\!', '\-'];
311
312
        $string = str_replace($from, $to, $string);
313
314
        // Clean up trailing special characters
315
        $string = rtrim($string, '-!');
316
317
        return trim($string);
318
    }
319
320
    public function updateRelease(int|string $releaseID): void
321
    {
322
        if (empty($releaseID)) {
323
            Log::warning('ManticoreSearch: Cannot update release without ID');
324
325
            return;
326
        }
327
328
        try {
329
            $release = Release::query()
330
                ->where('releases.id', $releaseID)
331
                ->leftJoin('release_files as rf', 'releases.id', '=', 'rf.releases_id')
332
                ->select([
333
                    'releases.id',
334
                    'releases.name',
335
                    'releases.searchname',
336
                    'releases.fromname',
337
                    'releases.categories_id',
338
                    DB::raw('IFNULL(GROUP_CONCAT(rf.name SEPARATOR " "),"") filename'),
339
                ])
340
                ->groupBy('releases.id')
341
                ->first();
342
343
            if ($release !== null) {
344
                $this->insertRelease($release->toArray());
345
            } else {
346
                Log::warning('ManticoreSearch: Release not found for update', ['id' => $releaseID]);
347
            }
348
        } catch (\Throwable $e) {
349
            Log::error('ManticoreSearch updateRelease error: '.$e->getMessage(), [
350
                'release_id' => $releaseID,
351
            ]);
352
        }
353
    }
354
355
    /**
356
     * Update Manticore Predb index for given predb_id.
357
     */
358
    public function updatePreDb(array $parameters): void
359
    {
360
        if (empty($parameters)) {
361
            Log::warning('ManticoreSearch: Cannot update predb with empty parameters');
362
363
            return;
364
        }
365
366
        $this->insertPredb($parameters);
367
    }
368
369
    public function truncateRTIndex(array $indexes = []): bool
370
    {
371
        if (empty($indexes)) {
372
            $this->cli->error('You need to provide index name to truncate');
0 ignored issues
show
Bug Best Practice introduced by
The property cli does not exist on App\Services\Search\Drivers\ManticoreSearchDriver. Did you maybe forget to declare it?
Loading history...
373
374
            return false;
375
        }
376
377
        $success = true;
378
        foreach ($indexes as $index) {
379
            if (! \in_array($index, $this->config['indexes'], true)) {
380
                $this->cli->error('Unsupported index: '.$index);
381
                $success = false;
382
383
                continue;
384
            }
385
386
            try {
387
                $this->manticoreSearch->table($index)->truncate();
388
                $this->cli->info('Truncating index '.$index.' finished.');
389
            } catch (ResponseException $e) {
390
                if ($e->getMessage() === 'Invalid index') {
391
                    $this->createIndexIfNotExists($index);
392
                } else {
393
                    $this->cli->error('Error truncating index '.$index.': '.$e->getMessage());
394
                    $success = false;
395
                }
396
            } catch (\Throwable $e) {
397
                $this->cli->error('Unexpected error truncating index '.$index.': '.$e->getMessage());
398
                $success = false;
399
            }
400
        }
401
402
        return $success;
403
    }
404
405
    /**
406
     * Truncate/clear an index (remove all documents).
407
     * Implements SearchServiceInterface::truncateIndex
408
     *
409
     * @param  array|string  $indexes  Index name(s) to truncate
410
     */
411
    public function truncateIndex(array|string $indexes): void
412
    {
413
        $indexArray = is_array($indexes) ? $indexes : [$indexes];
0 ignored issues
show
introduced by
The condition is_array($indexes) is always true.
Loading history...
414
        $this->truncateRTIndex($indexArray);
415
    }
416
417
    /**
418
     * Create index if it doesn't exist
419
     */
420
    private function createIndexIfNotExists(string $index): void
421
    {
422
        try {
423
            if ($index === 'releases_rt') {
424
                $this->manticoreSearch->table($index)->create([
425
                    'name' => ['type' => 'string'],
426
                    'searchname' => ['type' => 'string'],
427
                    'fromname' => ['type' => 'string'],
428
                    'filename' => ['type' => 'string'],
429
                    'categories_id' => ['type' => 'integer'],
430
                ]);
431
                $this->cli->info('Created releases_rt index');
0 ignored issues
show
Bug Best Practice introduced by
The property cli does not exist on App\Services\Search\Drivers\ManticoreSearchDriver. Did you maybe forget to declare it?
Loading history...
432
            } elseif ($index === 'predb_rt') {
433
                $this->manticoreSearch->table($index)->create([
434
                    'title' => ['type' => 'string'],
435
                    'filename' => ['type' => 'string'],
436
                    'source' => ['type' => 'string'],
437
                ]);
438
                $this->cli->info('Created predb_rt index');
439
            }
440
        } catch (\Throwable $e) {
441
            $this->cli->error('Error creating index '.$index.': '.$e->getMessage());
442
        }
443
    }
444
445
    /**
446
     * Optimize the RT indices.
447
     */
448
    public function optimizeRTIndex(): bool
449
    {
450
        $success = true;
451
452
        foreach ($this->config['indexes'] as $index) {
453
            try {
454
                $this->manticoreSearch->table($index)->flush();
455
                $this->manticoreSearch->table($index)->optimize();
456
                Log::info("Successfully optimized index: {$index}");
457
            } catch (ResponseException $e) {
458
                Log::error('Failed to optimize index '.$index.': '.$e->getMessage());
459
                $success = false;
460
            } catch (\Throwable $e) {
461
                Log::error('Unexpected error optimizing index '.$index.': '.$e->getMessage());
462
                $success = false;
463
            }
464
        }
465
466
        return $success;
467
    }
468
469
    /**
470
     * Optimize index for better search performance.
471
     * Implements SearchServiceInterface::optimizeIndex
472
     */
473
    public function optimizeIndex(): void
474
    {
475
        $this->optimizeRTIndex();
476
    }
477
478
    /**
479
     * Search releases index.
480
     *
481
     * @param  array|string  $phrases  Search phrases - can be a string, indexed array of terms, or associative array with field names
482
     * @param  int  $limit  Maximum number of results
483
     * @return array Array of release IDs
484
     */
485
    public function searchReleases(array|string $phrases, int $limit = 1000): array
486
    {
487
        if (is_string($phrases)) {
0 ignored issues
show
introduced by
The condition is_string($phrases) is always false.
Loading history...
488
            // Simple string search - search in searchname field
489
            $searchArray = ['searchname' => $phrases];
490
        } elseif (is_array($phrases)) {
0 ignored issues
show
introduced by
The condition is_array($phrases) is always true.
Loading history...
491
            // Check if it's an associative array (has string keys like 'searchname')
492
            $isAssociative = count(array_filter(array_keys($phrases), 'is_string')) > 0;
493
494
            if ($isAssociative) {
495
                // Already has field names as keys
496
                $searchArray = $phrases;
497
            } else {
498
                // Indexed array - combine values and search in searchname
499
                $searchArray = ['searchname' => implode(' ', $phrases)];
500
            }
501
        } else {
502
            return [];
503
        }
504
505
        $result = $this->searchIndexes($this->getReleasesIndex(), '', [], $searchArray);
506
507
        return ! empty($result) ? ($result['id'] ?? []) : [];
508
    }
509
510
    /**
511
     * Search predb index.
512
     *
513
     * @param  array|string  $searchTerm  Search term(s)
514
     * @return array Array of predb records
515
     */
516
    public function searchPredb(array|string $searchTerm): array
517
    {
518
        $searchString = is_array($searchTerm) ? implode(' ', $searchTerm) : $searchTerm;
0 ignored issues
show
introduced by
The condition is_array($searchTerm) is always true.
Loading history...
519
520
        $result = $this->searchIndexes($this->getPredbIndex(), $searchString, ['title', 'filename'], []);
521
522
        return $result['data'] ?? [];
523
    }
524
525
    public function searchIndexes(string $rt_index, ?string $searchString, array $column = [], array $searchArray = []): array
526
    {
527
        if (empty($rt_index)) {
528
            Log::warning('ManticoreSearch: Index name is required for search');
529
530
            return [];
531
        }
532
533
        if (config('app.debug')) {
534
            Log::debug('ManticoreSearch::searchIndexes called', [
535
                'rt_index' => $rt_index,
536
                'searchString' => $searchString,
537
                'column' => $column,
538
                'searchArray' => $searchArray,
539
            ]);
540
        }
541
542
        // Create cache key for search results
543
        $cacheKey = md5(serialize([
544
            'index' => $rt_index,
545
            'search' => $searchString,
546
            'columns' => $column,
547
            'array' => $searchArray,
548
        ]));
549
550
        $cached = Cache::get($cacheKey);
551
        if ($cached !== null) {
552
            if (config('app.debug')) {
553
                Log::debug('ManticoreSearch::searchIndexes returning cached result', [
554
                    'cacheKey' => $cacheKey,
555
                    'cached_ids_count' => count($cached['id'] ?? []),
556
                ]);
557
            }
558
559
            return $cached;
560
        }
561
562
        // Build query string once so we can retry if needed
563
        $searchExpr = null;
564
        if (! empty($searchArray)) {
565
            $terms = [];
566
            foreach ($searchArray as $key => $value) {
567
                if (! empty($value)) {
568
                    $escapedValue = self::escapeString($value);
569
                    if (! empty($escapedValue)) {
570
                        $terms[] = '@@relaxed @'.$key.' '.$escapedValue;
571
                    }
572
                }
573
            }
574
            if (! empty($terms)) {
575
                $searchExpr = implode(' ', $terms);
576
            } else {
577
                if (config('app.debug')) {
578
                    Log::debug('ManticoreSearch::searchIndexes no terms after escaping searchArray');
579
                }
580
581
                return [];
582
            }
583
        } elseif (! empty($searchString)) {
584
            $escapedSearch = self::escapeString($searchString);
585
            if (empty($escapedSearch)) {
586
                if (config('app.debug')) {
587
                    Log::debug('ManticoreSearch::searchIndexes escapedSearch is empty');
588
                }
589
590
                return [];
591
            }
592
593
            $searchColumns = '';
594
            if (! empty($column)) {
595
                if (count($column) > 1) {
596
                    $searchColumns = '@('.implode(',', $column).')';
597
                } else {
598
                    $searchColumns = '@'.$column[0];
599
                }
600
            }
601
602
            $searchExpr = '@@relaxed '.$searchColumns.' '.$escapedSearch;
603
        } else {
604
            return [];
605
        }
606
607
        // Avoid explicit sort for predb_rt to prevent Manticore's "too many sort-by attributes" error
608
        $avoidSortForIndex = ($rt_index === 'predb_rt');
609
610
        try {
611
            // Use a fresh Search instance for every query to avoid parameter accumulation across calls
612
            $query = (new Search($this->manticoreSearch))
613
                ->setTable($rt_index)
614
                ->option('ranker', 'sph04')
615
                ->maxMatches(10000)
616
                ->limit(10000)
617
                ->stripBadUtf8(true)
618
                ->search($searchExpr);
619
620
            if (! $avoidSortForIndex) {
621
                $query->sort('id', 'desc');
622
            }
623
624
            $results = $query->get();
625
        } catch (ResponseException $e) {
626
            // If we hit Manticore's "too many sort-by attributes" limit, retry once without explicit sorting
627
            if (stripos($e->getMessage(), 'too many sort-by attributes') !== false) {
628
                try {
629
                    $query = (new Search($this->manticoreSearch))
630
                        ->setTable($rt_index)
631
                        ->option('ranker', 'sph04')
632
                        ->maxMatches(10000)
633
                        ->limit(10000)
634
                        ->stripBadUtf8(true)
635
                        ->search($searchExpr);
636
637
                    $results = $query->get();
638
639
                    Log::warning('ManticoreSearch: Retried search without sorting due to sort-by attributes limit', [
640
                        'index' => $rt_index,
641
                    ]);
642
                } catch (ResponseException $e2) {
643
                    Log::error('ManticoreSearch searchIndexes ResponseException after retry: '.$e2->getMessage(), [
644
                        'index' => $rt_index,
645
                        'search' => $searchString,
646
                    ]);
647
648
                    return [];
649
                }
650
            } else {
651
                Log::error('ManticoreSearch searchIndexes ResponseException: '.$e->getMessage(), [
652
                    'index' => $rt_index,
653
                    'search' => $searchString,
654
                ]);
655
656
                return [];
657
            }
658
        } catch (RuntimeException $e) {
659
            Log::error('ManticoreSearch searchIndexes RuntimeException: '.$e->getMessage(), [
660
                'index' => $rt_index,
661
                'search' => $searchString,
662
            ]);
663
664
            return [];
665
        } catch (\Throwable $e) {
666
            Log::error('ManticoreSearch searchIndexes unexpected error: '.$e->getMessage(), [
667
                'index' => $rt_index,
668
                'search' => $searchString,
669
            ]);
670
671
            return [];
672
        }
673
674
        // Parse results and cache
675
        $resultIds = [];
676
        $resultData = [];
677
        foreach ($results as $doc) {
678
            $resultIds[] = $doc->getId();
679
            $resultData[] = $doc->getData();
680
        }
681
682
        $result = [
683
            'id' => $resultIds,
684
            'data' => $resultData,
685
        ];
686
687
        // Cache results for 5 minutes
688
        Cache::put($cacheKey, $result, now()->addMinutes($this->config['cache_minutes'] ?? 5));
689
690
        return $result;
691
    }
692
693
    /**
694
     * Get autocomplete suggestions for a search query.
695
     * Searches the releases index and returns matching searchnames.
696
     *
697
     * @param  string  $query  The partial search query
698
     * @param  string|null  $index  Index to search (defaults to releases index)
699
     * @return array<array{suggest: string, distance: int, docs: int}>
700
     */
701
    public function autocomplete(string $query, ?string $index = null): array
702
    {
703
        $autocompleteConfig = $this->config['autocomplete'] ?? [
704
            'enabled' => true,
705
            'min_length' => 2,
706
            'max_results' => 10,
707
            'cache_minutes' => 10,
708
        ];
709
710
        if (! ($autocompleteConfig['enabled'] ?? true)) {
711
            return [];
712
        }
713
714
        $query = trim($query);
715
        $minLength = $autocompleteConfig['min_length'] ?? 2;
716
        if (strlen($query) < $minLength) {
717
            return [];
718
        }
719
720
        $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
721
        $cacheKey = 'manticore:autocomplete:'.md5($index.$query);
722
723
        $cached = Cache::get($cacheKey);
724
        if ($cached !== null) {
725
            return $cached;
726
        }
727
728
        $suggestions = [];
729
        $maxResults = $autocompleteConfig['max_results'] ?? 10;
730
731
        try {
732
            // Search releases index for matching searchnames
733
            $escapedQuery = self::escapeString($query);
734
            if (empty($escapedQuery)) {
735
                return [];
736
            }
737
738
            // Use relaxed search on searchname field
739
            $searchExpr = '@@relaxed @searchname '.$escapedQuery;
740
741
            $search = (new Search($this->manticoreSearch))
742
                ->setTable($index)
743
                ->search($searchExpr)
744
                ->sort('id', 'desc')
745
                ->limit($maxResults * 3)
746
                ->stripBadUtf8(true);
747
748
            $results = $search->get();
749
750
            $seen = [];
751
            foreach ($results as $doc) {
752
                $data = $doc->getData();
753
                $searchname = $data['searchname'] ?? '';
754
755
                if (empty($searchname)) {
756
                    continue;
757
                }
758
759
                // Create a clean suggestion from the searchname
760
                $suggestion = $this->extractSuggestion($searchname, $query);
761
762
                if (! empty($suggestion) && ! isset($seen[strtolower($suggestion)])) {
763
                    $seen[strtolower($suggestion)] = true;
764
                    $suggestions[] = [
765
                        'suggest' => $suggestion,
766
                        'distance' => 0,
767
                        'docs' => 1,
768
                    ];
769
                }
770
771
                if (count($suggestions) >= $maxResults) {
772
                    break;
773
                }
774
            }
775
        } catch (\Throwable $e) {
776
            if (config('app.debug')) {
777
                Log::warning('ManticoreSearch autocomplete error: '.$e->getMessage());
778
            }
779
        }
780
781
        if (! empty($suggestions)) {
782
            $cacheMinutes = (int) ($autocompleteConfig['cache_minutes'] ?? 10);
783
            Cache::put($cacheKey, $suggestions, now()->addMinutes($cacheMinutes));
784
        }
785
786
        return $suggestions;
787
    }
788
789
    /**
790
     * Extract a clean suggestion from a searchname.
791
     *
792
     * @param  string  $searchname  The full searchname
793
     * @param  string  $query  The user's query
794
     * @return string|null The extracted suggestion
795
     */
796
    private function extractSuggestion(string $searchname, string $query): ?string
797
    {
798
        // Clean up the searchname - remove file extensions, quality tags at the end
799
        $clean = preg_replace('/\.(mkv|avi|mp4|wmv|nfo|nzb|par2|rar|zip|r\d+)$/i', '', $searchname);
800
801
        // Replace dots and underscores with spaces for readability
802
        $clean = str_replace(['.', '_'], ' ', $clean);
803
804
        // Remove multiple spaces
805
        $clean = preg_replace('/\s+/', ' ', $clean);
806
        $clean = trim($clean);
807
808
        if (empty($clean)) {
809
            return null;
810
        }
811
812
        // If the clean name is reasonable length, use it
813
        if (strlen($clean) <= 80) {
814
            return $clean;
815
        }
816
817
        // For very long names, try to extract the relevant part
818
        // Find where the query matches and extract context around it
819
        $pos = stripos($clean, $query);
820
        if ($pos !== false) {
821
            // Get up to 80 chars starting from the match position, or from beginning if match is early
822
            $start = max(0, $pos - 10);
823
            $extracted = substr($clean, $start, 80);
824
825
            // Clean up - don't cut mid-word
826
            if ($start > 0) {
827
                $extracted = preg_replace('/^\S*\s/', '', $extracted);
828
            }
829
            $extracted = preg_replace('/\s\S*$/', '', $extracted);
830
831
            return trim($extracted);
832
        }
833
834
        // Fallback: just truncate
835
        return substr($clean, 0, 80);
836
    }
837
838
    /**
839
     * Get spell correction suggestions ("Did you mean?").
840
     *
841
     * @param  string  $query  The search query to check
842
     * @param  string|null  $index  Index to use for suggestions
843
     * @return array<array{suggest: string, distance: int, docs: int}>
844
     */
845
    public function suggest(string $query, ?string $index = null): array
846
    {
847
        $suggestConfig = $this->config['suggest'] ?? [
848
            'enabled' => true,
849
            'max_edits' => 4,
850
        ];
851
852
        if (! ($suggestConfig['enabled'] ?? true)) {
853
            return [];
854
        }
855
856
        $query = trim($query);
857
        if (empty($query)) {
858
            return [];
859
        }
860
861
        $index = $index ?? ($this->config['indexes']['releases'] ?? 'releases_rt');
862
        $cacheKey = 'manticore:suggest:'.md5($index.$query);
863
864
        $cached = Cache::get($cacheKey);
865
        if ($cached !== null) {
866
            return $cached;
867
        }
868
869
        $suggestions = [];
870
871
        try {
872
            // Try native CALL SUGGEST first
873
            $result = $this->manticoreSearch->suggest([
874
                'table' => $index,
875
                'body' => [
876
                    'query' => $query,
877
                    'options' => [
878
                        'limit' => 5,
879
                        'max_edits' => $suggestConfig['max_edits'] ?? 4,
880
                    ],
881
                ],
882
            ]);
883
884
            if (! empty($result) && is_array($result)) {
885
                foreach ($result as $item) {
886
                    if (isset($item['suggest']) && $item['suggest'] !== $query) {
887
                        $suggestions[] = [
888
                            'suggest' => $item['suggest'],
889
                            'distance' => $item['distance'] ?? 0,
890
                            'docs' => $item['docs'] ?? 0,
891
                        ];
892
                    }
893
                }
894
            }
895
        } catch (\Throwable $e) {
896
            if (config('app.debug')) {
897
                Log::debug('ManticoreSearch native suggest failed: '.$e->getMessage());
898
            }
899
        }
900
901
        // If native suggest didn't return results, try a fuzzy search fallback
902
        if (empty($suggestions)) {
903
            $suggestions = $this->suggestFallback($query, $index);
904
        }
905
906
        if (! empty($suggestions)) {
907
            Cache::put($cacheKey, $suggestions, now()->addMinutes($this->config['cache_minutes'] ?? 5));
908
        }
909
910
        return $suggestions;
911
    }
912
913
    /**
914
     * Fallback suggest using similar searchname matches.
915
     *
916
     * @param  string  $query  The search query
917
     * @param  string  $index  Index to search
918
     * @return array<array{suggest: string, distance: int, docs: int}>
919
     */
920
    private function suggestFallback(string $query, string $index): array
921
    {
922
        try {
923
            $escapedQuery = self::escapeString($query);
924
            if (empty($escapedQuery)) {
925
                return [];
926
            }
927
928
            // Use relaxed search to find partial matches
929
            $searchExpr = '@@relaxed @searchname '.$escapedQuery;
930
931
            $search = (new Search($this->manticoreSearch))
932
                ->setTable($index)
933
                ->search($searchExpr)
934
                ->limit(20)
935
                ->stripBadUtf8(true);
936
937
            $results = $search->get();
938
939
            // Extract common terms from the results that differ from the query
940
            $termCounts = [];
941
            foreach ($results as $doc) {
942
                $data = $doc->getData();
943
                $searchname = $data['searchname'] ?? '';
944
945
                // Extract words from searchname
946
                $words = preg_split('/[\s.\-_]+/', strtolower($searchname));
947
                foreach ($words as $word) {
948
                    if (strlen($word) >= 3 && $word !== strtolower($query)) {
949
                        // Check if word is similar to query (within edit distance)
950
                        $distance = levenshtein(strtolower($query), $word);
951
                        if ($distance > 0 && $distance <= 3) {
952
                            if (! isset($termCounts[$word])) {
953
                                $termCounts[$word] = ['count' => 0, 'distance' => $distance];
954
                            }
955
                            $termCounts[$word]['count']++;
956
                        }
957
                    }
958
                }
959
            }
960
961
            // Sort by count (most common first)
962
            uasort($termCounts, fn ($a, $b) => $b['count'] - $a['count']);
963
964
            $suggestions = [];
965
            foreach (array_slice($termCounts, 0, 5, true) as $term => $data) {
966
                $suggestions[] = [
967
                    'suggest' => $term,
968
                    'distance' => $data['distance'],
969
                    'docs' => $data['count'],
970
                ];
971
            }
972
973
            return $suggestions;
974
        } catch (\Throwable $e) {
975
            if (config('app.debug')) {
976
                Log::debug('ManticoreSearch suggest fallback error: '.$e->getMessage());
977
            }
978
979
            return [];
980
        }
981
    }
982
}
983
984