< Summary

Information
Class: Chronicis.Api.Services.ExternalLinks.BlobExternalLinkProvider
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/BlobExternalLinkProvider.cs
Line coverage
100%
Covered lines: 58
Uncovered lines: 0
Coverable lines: 58
Total lines: 785
Line coverage: 100%
Branch coverage
100%
Covered branches: 10
Total branches: 10
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%66100%
get_Key()100%11100%
CreateEmptyContent(...)100%11100%
CacheBlobPathMapping(...)100%11100%
BuildCacheKey(...)100%22100%
FilterChildFiles(...)100%22100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/BlobExternalLinkProvider.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Text.Json;
 3using Azure.Storage.Blobs;
 4using Microsoft.Extensions.Caching.Memory;
 5
 6namespace Chronicis.Api.Services.ExternalLinks;
 7
 8/// <summary>
 9/// External link provider backed by Azure Blob Storage.
 10/// Supports progressive category drill-down: typing "[[ros" shows top-level categories,
 11/// "[[ros/bestiary" shows bestiary's children, and so on at any depth.
 12/// Cross-category text search is supported at the top level (no slash).
 13/// </summary>
 14public sealed class BlobExternalLinkProvider : IExternalLinkProvider
 15{
 16    private readonly BlobExternalLinkProviderOptions _options;
 17    private readonly BlobContainerClient _containerClient;
 18    private readonly IMemoryCache _cache;
 19    private readonly ILogger<BlobExternalLinkProvider> _logger;
 20
 21    public BlobExternalLinkProvider(
 22        BlobExternalLinkProviderOptions options,
 23        IMemoryCache cache,
 24        ILogger<BlobExternalLinkProvider> logger)
 25    {
 926        _options = options ?? throw new ArgumentNullException(nameof(options));
 827        _cache = cache ?? throw new ArgumentNullException(nameof(cache));
 728        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 29
 630        var blobServiceClient = new BlobServiceClient(_options.ConnectionString);
 631        _containerClient = blobServiceClient.GetBlobContainerClient(_options.ContainerName);
 32
 633        _logger.LogTraceSanitized(
 634            "Initialized BlobExternalLinkProvider: Key={Key}, DisplayName={DisplayName}, RootPrefix={RootPrefix}",
 635            _options.Key, _options.DisplayName, _options.RootPrefix);
 636    }
 37
 138    public string Key => _options.Key;
 39
 40    // ==================================================================================
 41    // PUBLIC API
 42    // ==================================================================================
 43
 44    public async Task<IReadOnlyList<ExternalLinkSuggestion>> SearchAsync(string query, CancellationToken ct)
 45    {
 46        query = query?.Trim() ?? string.Empty;
 47
 48        var slashIndex = query.IndexOf('/');
 49
 50        // Case A: No slash — top-level behavior
 51        // Empty query → show top-level categories only
 52        // Has text  → cross-category search (categories + items)
 53        if (slashIndex < 0)
 54        {
 55            if (string.IsNullOrWhiteSpace(query))
 56            {
 57                return await GetTopLevelCategorySuggestionsAsync(ct);
 58            }
 59
 60            return await SearchAcrossAllCategoriesAsync(query, ct);
 61        }
 62
 63        // Case B: Has slash — progressive drill-down
 64        // Split into path prefix and trailing text after last slash
 65        // Example: "bestiary/beast/abo" → pathPrefix="bestiary/beast", trailingText="abo"
 66        // Example: "bestiary/"          → pathPrefix="bestiary",       trailingText=""
 67        // Example: "bestiary/beast/"    → pathPrefix="bestiary/beast", trailingText=""
 68        var lastSlashIdx = query.LastIndexOf('/');
 69        var pathPrefix = query[..lastSlashIdx];
 70        var trailingText = query[(lastSlashIdx + 1)..];
 71
 72        // Get the children at the path prefix
 73        var children = await GetChildrenAtPathAsync(pathPrefix, ct);
 74
 75        if (children == null)
 76        {
 77            // Path doesn't exist — try partial matching against parent's children
 78            // Example: "bestiary/bea" → parent is "", trailing is "bestiary/bea"
 79            //   We need to find if "bestiary" partially matches a top-level folder
 80            return await SearchPartialPathAsync(query, ct);
 81        }
 82
 83        var results = new List<ExternalLinkSuggestion>();
 84
 85        // Build child folder suggestions (always shown first)
 86        var folderSuggestions = children.ChildFolders
 87            .Where(f => string.IsNullOrWhiteSpace(trailingText)
 88                || f.Slug.Contains(trailingText, StringComparison.OrdinalIgnoreCase))
 89            .Select(folder =>
 90            {
 91                var fullPath = string.IsNullOrEmpty(pathPrefix) ? folder.Slug : $"{pathPrefix}/{folder.Slug}";
 92                var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 93                return new ExternalLinkSuggestion
 94                {
 95                    Source = _options.Key,
 96                    Id = $"_category/{fullPath}",
 97                    Title = title,
 98                    Subtitle = $"Browse {title}",
 99                    Category = "_category",
 100                    Icon = null,
 101                    Href = null
 102                };
 103            })
 104            .ToList();
 105
 106        results.AddRange(folderSuggestions);
 107
 108        // Build child file suggestions (shown after folders)
 109        // Use multi-token AND search if trailing text contains spaces
 110        var fileSuggestions = FilterChildFiles(children.ChildFiles, trailingText, pathPrefix)
 111            .Take(_options.MaxSuggestions - results.Count)
 112            .ToList();
 113
 114        results.AddRange(fileSuggestions);
 115
 116        _logger.LogTraceSanitized(
 117            "Drill-down search - Provider={Key}, Path={Path}, Trailing={Trailing}, Folders={FolderCount}, Files={FileCou
 118            _options.Key, pathPrefix, trailingText, folderSuggestions.Count, fileSuggestions.Count);
 119
 120        return results;
 121    }
 122
 123    public async Task<ExternalLinkContent> GetContentAsync(string id, CancellationToken ct)
 124    {
 125        // Step 1: Validate ID format
 126        if (!BlobIdValidator.IsValid(id, out var validationError))
 127        {
 128            _logger.LogWarningSanitized(
 129                "Invalid content ID - Provider={Key}, Id={Id}, Error={Error}",
 130                _options.Key, id, validationError);
 131            return CreateEmptyContent(id);
 132        }
 133
 134        // Step 2: Parse category and slug
 135        var (category, slug) = BlobIdValidator.ParseId(id);
 136        if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(slug))
 137        {
 138            _logger.LogWarningSanitized(
 139                "Failed to parse content ID after validation - Provider={Key}, Id={Id}",
 140                _options.Key, id);
 141            return CreateEmptyContent(id);
 142        }
 143
 144        // Step 3: Check content cache
 145        var cacheKey = BuildCacheKey("Content", id);
 146        if (_cache.TryGetValue<ExternalLinkContent>(cacheKey, out var cached) && cached != null)
 147        {
 148            _logger.LogTraceSanitized("Content cache hit - Provider={Key}, Id={Id}", _options.Key, id);
 149            return cached;
 150        }
 151
 152        // Step 4: Load category index to find the blob name
 153        // GetCategoryIndexAsync no longer validates against a flat category list —
 154        // it directly queries blob storage at the given path.
 155        var index = await GetCategoryIndexAsync(category, ct);
 156        var item = index.FirstOrDefault(i => i.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
 157
 158        if (item == null)
 159        {
 160            _logger.LogWarningSanitized(
 161                "Content ID not found in category index - Provider={Key}, Id={Id}, Category={Category}, IndexCount={Inde
 162                _options.Key, id, category, index.Count);
 163            return CreateEmptyContent(id);
 164        }
 165
 166        // Step 5: Fetch blob content and render
 167        try
 168        {
 169            var blobClient = _containerClient.GetBlobClient(item.BlobName);
 170            var response = await blobClient.DownloadContentAsync(ct);
 171            var content = response.Value.Content;
 172
 173            // Handle UTF-8 BOM
 174            var jsonBytes = content.ToMemory();
 175            var span = jsonBytes.Span;
 176            if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF)
 177            {
 178                jsonBytes = jsonBytes.Slice(3);
 179            }
 180
 181            using var json = JsonDocument.Parse(jsonBytes);
 182
 183            // Capture raw JSON for client-side structured rendering
 184            var rawJson = System.Text.Encoding.UTF8.GetString(jsonBytes.Span);
 185
 186            var markdown = GenericJsonMarkdownRenderer.RenderMarkdown(
 187                json, _options.DisplayName, item.Title);
 188
 189            var result = new ExternalLinkContent
 190            {
 191                Source = _options.Key,
 192                Id = id,
 193                Title = item.Title,
 194                Kind = BlobFilenameParser.PrettifySlug(category),
 195                Markdown = markdown,
 196                Attribution = $"Source: {_options.DisplayName}",
 197                ExternalUrl = null,
 198                JsonData = rawJson
 199            };
 200
 201            _cache.Set(cacheKey, result, new MemoryCacheEntryOptions
 202            {
 203                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.ContentCacheTtl)
 204            });
 205
 206            _logger.LogTraceSanitized(
 207                "Content retrieved and rendered - Provider={Key}, Id={Id}, BlobName={BlobName}",
 208                _options.Key, id, item.BlobName);
 209
 210            return result;
 211        }
 212        catch (Exception ex)
 213        {
 214            _logger.LogErrorSanitized(ex,
 215                "Failed to retrieve or render content - Provider={Key}, Id={Id}, BlobName={BlobName}",
 216                _options.Key, id, item.BlobName);
 217            return CreateEmptyContent(id);
 218        }
 219    }
 220
 221    private ExternalLinkContent CreateEmptyContent(string id)
 222    {
 1223        return new ExternalLinkContent
 1224        {
 1225            Source = _options.Key,
 1226            Id = id,
 1227            Title = "Content Not Found",
 1228            Kind = "Unknown",
 1229            Markdown = "The requested content could not be found.",
 1230            Attribution = $"Source: {_options.DisplayName}",
 1231            ExternalUrl = null
 1232        };
 233    }
 234
 235    // ==================================================================================
 236    // PRIVATE METHODS - Progressive Discovery
 237    // ==================================================================================
 238
 239    /// <summary>
 240    /// Result of discovering direct children at a given path.
 241    /// ChildFolders carry both the original blob name and the lowercase slug for display/IDs.
 242    /// ChildFiles are CategoryItem records for JSON files at this level.
 243    /// </summary>
 244    private record PathChildren(List<ChildFolder> ChildFolders, List<CategoryItem> ChildFiles);
 245
 246    /// <summary>
 247    /// A subfolder discovered at a given path level.
 248    /// BlobName is the original casing as stored in blob (needed for subsequent queries).
 249    /// Slug is the lowercase-normalized name used in IDs and display.
 250    /// </summary>
 251    private record ChildFolder(string BlobName, string Slug);
 252
 253    /// <summary>
 254    /// Discovers the direct children (subfolders and files) at a given relative path
 255    /// within the provider's root prefix. Uses GetBlobsByHierarchy with "/" delimiter
 256    /// to get exactly one level of the hierarchy.
 257    ///
 258    /// Returns null if the path has no children (doesn't exist or is empty).
 259    /// Results are cached per path.
 260    /// </summary>
 261    /// <param name="relativePath">
 262    /// Path relative to RootPrefix. Empty string for root level.
 263    /// Can be in original blob casing OR lowercase — the method resolves via cached mappings.
 264    /// Example: "bestiary" or "Bestiary" or "bestiary/beast" or "items/armor/light"
 265    /// </param>
 266    private async Task<PathChildren?> GetChildrenAtPathAsync(string relativePath, CancellationToken ct)
 267    {
 268        var normalizedPath = relativePath.Trim('/');
 269
 270        // Resolve the path to its original blob casing
 271        var blobPath = await ResolveBlobPathAsync(normalizedPath, ct);
 272
 273        var cacheKey = BuildCacheKey("Children", normalizedPath.ToLowerInvariant());
 274
 275        if (_cache.TryGetValue<PathChildren>(cacheKey, out var cached) && cached != null)
 276        {
 277            return cached;
 278        }
 279
 280        // Build the blob prefix using the ORIGINAL casing from blob storage
 281        var prefix = string.IsNullOrEmpty(blobPath)
 282            ? _options.RootPrefix
 283            : $"{_options.RootPrefix}{blobPath}/";
 284
 285        var childFolders = new List<ChildFolder>();
 286        var childFiles = new List<CategoryItem>();
 287
 288        await foreach (var item in _containerClient.GetBlobsByHierarchyAsync(
 289            prefix: prefix,
 290            delimiter: "/",
 291            cancellationToken: ct))
 292        {
 293            if (item.IsPrefix && item.Prefix != null)
 294            {
 295                // Subfolder — extract the folder name in its ORIGINAL casing
 296                var originalFolderName = item.Prefix[prefix.Length..].TrimEnd('/');
 297                if (!string.IsNullOrWhiteSpace(originalFolderName))
 298                {
 299                    var slug = originalFolderName.ToLowerInvariant();
 300                    childFolders.Add(new ChildFolder(originalFolderName, slug));
 301
 302                    // Cache the slug → blob path mapping for this child
 303                    var childSlugPath = string.IsNullOrEmpty(normalizedPath)
 304                        ? slug
 305                        : $"{normalizedPath.ToLowerInvariant()}/{slug}";
 306                    var childBlobPath = string.IsNullOrEmpty(blobPath)
 307                        ? originalFolderName
 308                        : $"{blobPath}/{originalFolderName}";
 309                    CacheBlobPathMapping(childSlugPath, childBlobPath);
 310                }
 311            }
 312            else if (item.IsBlob && item.Blob != null)
 313            {
 314                var blobName = item.Blob.Name;
 315                if (!blobName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
 316                    continue;
 317
 318                if (item.Blob.Properties.ContentLength.HasValue &&
 319                    item.Blob.Properties.ContentLength.Value > 5_000_000)
 320                    continue;
 321
 322                var lastSlash = blobName.LastIndexOf('/');
 323                var filename = lastSlash >= 0 ? blobName[(lastSlash + 1)..] : blobName;
 324                var slug = BlobFilenameParser.DeriveSlug(filename);
 325
 326                if (string.IsNullOrWhiteSpace(slug))
 327                    continue;
 328
 329                // IDs are always lowercase
 330                var id = string.IsNullOrEmpty(normalizedPath)
 331                    ? slug.ToLowerInvariant()
 332                    : $"{normalizedPath.ToLowerInvariant()}/{slug.ToLowerInvariant()}";
 333                var title = BlobFilenameParser.PrettifySlug(slug);
 334
 335                childFiles.Add(new CategoryItem(id, title, blobName, Pk: null));
 336            }
 337        }
 338
 339        if (childFolders.Count == 0 && childFiles.Count == 0)
 340        {
 341            return null;
 342        }
 343
 344        childFolders.Sort((a, b) => string.Compare(a.Slug, b.Slug, StringComparison.OrdinalIgnoreCase));
 345        childFiles = childFiles
 346            .OrderBy(f => f.Title, StringComparer.OrdinalIgnoreCase)
 347            .ThenBy(f => f.Id)
 348            .ToList();
 349
 350        var result = new PathChildren(childFolders, childFiles);
 351
 352        _cache.Set(cacheKey, result, new MemoryCacheEntryOptions
 353        {
 354            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoriesCacheTtl)
 355        });
 356
 357        _logger.LogTraceSanitized(
 358            "Children discovered - Provider={Key}, Path={Path}, BlobPath={BlobPath}, Folders={FolderCount}, Files={FileC
 359            _options.Key, normalizedPath, blobPath, childFolders.Count, childFiles.Count);
 360
 361        return result;
 362    }
 363
 364    /// <summary>
 365    /// Resolves a lowercase slug path back to its original blob casing.
 366    /// Uses cached mappings built during discovery.
 367    /// Falls back to the input path if no mapping exists (first-time root discovery).
 368    /// </summary>
 369    private async Task<string> ResolveBlobPathAsync(string slugPath, CancellationToken ct)
 370    {
 371        if (string.IsNullOrEmpty(slugPath))
 372            return string.Empty;
 373
 374        var mappingKey = BuildCacheKey("BlobPathMap", slugPath.ToLowerInvariant());
 375        if (_cache.TryGetValue<string>(mappingKey, out var blobPath) && blobPath != null)
 376        {
 377            return blobPath;
 378        }
 379
 380        // No cached mapping — this can happen if the cache expired or on first access
 381        // to a deep path. Walk from root to rebuild mappings.
 382        var segments = slugPath.Split('/');
 383        var currentSlugPath = "";
 384        var currentBlobPath = "";
 385
 386        for (var i = 0; i < segments.Length; i++)
 387        {
 388            // Ensure parent is discovered (this populates child mappings)
 389            var parentChildren = await GetChildrenAtPathAsync(currentBlobPath, ct);
 390            if (parentChildren == null)
 391            {
 392                // Parent doesn't exist — return input as-is
 393                return slugPath;
 394            }
 395
 396            var targetSlug = segments[i].ToLowerInvariant();
 397            var matchedFolder = parentChildren.ChildFolders
 398                .FirstOrDefault(f => f.Slug.Equals(targetSlug, StringComparison.OrdinalIgnoreCase));
 399
 400            if (matchedFolder == null)
 401            {
 402                // Segment not found — return input as-is
 403                return slugPath;
 404            }
 405
 406            currentSlugPath = string.IsNullOrEmpty(currentSlugPath)
 407                ? matchedFolder.Slug
 408                : $"{currentSlugPath}/{matchedFolder.Slug}";
 409            currentBlobPath = string.IsNullOrEmpty(currentBlobPath)
 410                ? matchedFolder.BlobName
 411                : $"{currentBlobPath}/{matchedFolder.BlobName}";
 412        }
 413
 414        return currentBlobPath;
 415    }
 416
 417    /// <summary>
 418    /// Caches a mapping from lowercase slug path to original blob path.
 419    /// </summary>
 420    private void CacheBlobPathMapping(string slugPath, string blobPath)
 421    {
 1422        var mappingKey = BuildCacheKey("BlobPathMap", slugPath.ToLowerInvariant());
 1423        _cache.Set(mappingKey, blobPath, new MemoryCacheEntryOptions
 1424        {
 1425            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoriesCacheTtl)
 1426        });
 1427    }
 428
 429    /// <summary>
 430    /// Returns suggestions for top-level categories only (no children expanded).
 431    /// Used when the user types "[[ros" with no slash and no text.
 432    /// </summary>
 433    private async Task<List<ExternalLinkSuggestion>> GetTopLevelCategorySuggestionsAsync(CancellationToken ct)
 434    {
 435        var children = await GetChildrenAtPathAsync("", ct);
 436        if (children == null)
 437        {
 438            return new List<ExternalLinkSuggestion>();
 439        }
 440
 441        var suggestions = new List<ExternalLinkSuggestion>();
 442
 443        foreach (var folder in children.ChildFolders)
 444        {
 445            var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 446            suggestions.Add(new ExternalLinkSuggestion
 447            {
 448                Source = _options.Key,
 449                Id = $"_category/{folder.Slug}",
 450                Title = title,
 451                Subtitle = $"Browse {title}",
 452                Category = "_category",
 453                Icon = null,
 454                Href = null
 455            });
 456        }
 457
 458        // Also include any files at the root level (unlikely but possible)
 459        foreach (var file in children.ChildFiles.Take(_options.MaxSuggestions - suggestions.Count))
 460        {
 461            suggestions.Add(new ExternalLinkSuggestion
 462            {
 463                Source = _options.Key,
 464                Id = file.Id,
 465                Title = file.Title,
 466                Subtitle = _options.DisplayName,
 467                Category = "",
 468                Icon = null,
 469                Href = null
 470            });
 471        }
 472
 473        return suggestions;
 474    }
 475
 476    /// <summary>
 477    /// Handles partial path matching when the typed path doesn't match a real folder.
 478    /// Example: "besti" → finds "bestiary" in parent's children.
 479    /// Example: "bestiary/bea" → finds "beast" under "bestiary".
 480    /// </summary>
 481    private async Task<List<ExternalLinkSuggestion>> SearchPartialPathAsync(string query, CancellationToken ct)
 482    {
 483        return await SearchPartialPathInternalAsync(query, 0, ct);
 484    }
 485
 486    private async Task<List<ExternalLinkSuggestion>> SearchPartialPathInternalAsync(
 487        string query, int depth, CancellationToken ct)
 488    {
 489        if (depth > _options.MaxDrillDownDepth)
 490            return new List<ExternalLinkSuggestion>();
 491
 492        // Split into parent path and partial segment
 493        var lastSlashIdx = query.LastIndexOf('/');
 494        var parentPath = lastSlashIdx >= 0 ? query[..lastSlashIdx] : "";
 495        var partialSegment = lastSlashIdx >= 0 ? query[(lastSlashIdx + 1)..] : query;
 496
 497        var children = await GetChildrenAtPathAsync(parentPath, ct);
 498        if (children == null)
 499        {
 500            // Parent path doesn't exist either — recurse up if there's still a slash
 501            if (lastSlashIdx > 0)
 502            {
 503                return await SearchPartialPathInternalAsync(parentPath, depth + 1, ct);
 504            }
 505
 506            return new List<ExternalLinkSuggestion>();
 507        }
 508
 509        var results = new List<ExternalLinkSuggestion>();
 510
 511        // Match folders that contain the partial segment
 512        var matchingFolders = children.ChildFolders
 513            .Where(f => f.Slug.Contains(partialSegment, StringComparison.OrdinalIgnoreCase))
 514            .ToList();
 515
 516        foreach (var folder in matchingFolders)
 517        {
 518            var fullPath = string.IsNullOrEmpty(parentPath) ? folder.Slug : $"{parentPath}/{folder.Slug}";
 519            var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 520            results.Add(new ExternalLinkSuggestion
 521            {
 522                Source = _options.Key,
 523                Id = $"_category/{fullPath}",
 524                Title = title,
 525                Subtitle = $"Browse {title}",
 526                Category = "_category",
 527                Icon = null,
 528                Href = null
 529            });
 530        }
 531
 532        // Also match files at this level
 533        var matchingFiles = children.ChildFiles
 534            .Where(f => f.Title.Contains(partialSegment, StringComparison.OrdinalIgnoreCase))
 535            .Take(_options.MaxSuggestions - results.Count);
 536
 537        foreach (var file in matchingFiles)
 538        {
 539            results.Add(new ExternalLinkSuggestion
 540            {
 541                Source = _options.Key,
 542                Id = file.Id,
 543                Title = file.Title,
 544                Subtitle = BlobFilenameParser.PrettifySlug(parentPath),
 545                Category = parentPath,
 546                Icon = null,
 547                Href = null
 548            });
 549        }
 550
 551        return results;
 552    }
 553
 554    /// <summary>
 555    /// Searches across ALL leaf categories for items matching the query.
 556    /// Also returns matching category names at the top.
 557    /// Used only for top-level text search (no slash in query).
 558    /// </summary>
 559    private async Task<List<ExternalLinkSuggestion>> SearchAcrossAllCategoriesAsync(string query, CancellationToken ct)
 560    {
 561        var results = new List<ExternalLinkSuggestion>();
 562
 563        // Part 1: Check top-level categories that match the query text
 564        var topChildren = await GetChildrenAtPathAsync("", ct);
 565        if (topChildren != null)
 566        {
 567            var matchingFolders = topChildren.ChildFolders
 568                .Where(f => f.Slug.Contains(query, StringComparison.OrdinalIgnoreCase))
 569                .ToList();
 570
 571            foreach (var folder in matchingFolders)
 572            {
 573                var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 574                results.Add(new ExternalLinkSuggestion
 575                {
 576                    Source = _options.Key,
 577                    Id = $"_category/{folder.Slug}",
 578                    Title = title,
 579                    Subtitle = $"Browse {title}",
 580                    Category = "_category",
 581                    Icon = null,
 582                    Href = null
 583                });
 584            }
 585        }
 586
 587        // Part 2: Search items across all leaf categories
 588        var leafCategories = await GetAllLeafCategoriesAsync(ct);
 589
 590        var itemResults = new List<ExternalLinkSuggestion>();
 591        foreach (var leafPath in leafCategories)
 592        {
 593            var index = await GetCategoryIndexAsync(leafPath, ct);
 594
 595            var matchingItems = index
 596                .Where(item => item.Title.Contains(query, StringComparison.OrdinalIgnoreCase))
 597                .Take(5)
 598                .Select(item => new ExternalLinkSuggestion
 599                {
 600                    Source = _options.Key,
 601                    Id = item.Id,
 602                    Title = item.Title,
 603                    Subtitle = BlobFilenameParser.PrettifySlug(leafPath),
 604                    Category = leafPath,
 605                    Icon = null,
 606                    Href = null
 607                });
 608
 609            itemResults.AddRange(matchingItems);
 610        }
 611
 612        results.AddRange(itemResults.Take(_options.MaxSuggestions - results.Count));
 613
 614        _logger.LogTraceSanitized(
 615            "Cross-category search - Provider={Key}, Query={Query}, LeafCategories={LeafCount}, ItemMatches={ItemCount}"
 616            _options.Key, query, leafCategories.Count, itemResults.Count);
 617
 618        return results;
 619    }
 620
 621    /// <summary>
 622    /// Recursively discovers all leaf category paths (folders that contain files).
 623    /// A leaf category is a path where GetChildrenAtPathAsync returns files.
 624    /// Cached for CategoriesCacheTtl minutes.
 625    /// </summary>
 626    private async Task<List<string>> GetAllLeafCategoriesAsync(CancellationToken ct)
 627    {
 628        var cacheKey = BuildCacheKey("AllLeafCategories");
 629
 630        if (_cache.TryGetValue<List<string>>(cacheKey, out var cached) && cached != null)
 631        {
 632            return cached;
 633        }
 634
 635        var sw = Stopwatch.StartNew();
 636        var leaves = new List<string>();
 637
 638        await CollectLeafCategoriesAsync("", leaves, 0, ct);
 639
 640        leaves.Sort(StringComparer.OrdinalIgnoreCase);
 641
 642        sw.Stop();
 643        _logger.LogTraceSanitized(
 644            "Leaf categories discovered - Provider={Key}, Count={Count}, Elapsed={Elapsed}ms",
 645            _options.Key, leaves.Count, sw.ElapsedMilliseconds);
 646
 647        _cache.Set(cacheKey, leaves, new MemoryCacheEntryOptions
 648        {
 649            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoriesCacheTtl)
 650        });
 651
 652        return leaves;
 653    }
 654
 655    /// <summary>
 656    /// Recursive helper to walk the folder tree and collect all paths that contain files.
 657    /// </summary>
 658    private async Task CollectLeafCategoriesAsync(
 659        string path, List<string> leaves, int depth, CancellationToken ct)
 660    {
 661        if (depth > _options.MaxDrillDownDepth)
 662        {
 663            _logger.LogWarningSanitized(
 664                "Max drill-down depth reached - Provider={Key}, Path={Path}, Depth={Depth}",
 665                _options.Key, path, depth);
 666            return;
 667        }
 668
 669        var children = await GetChildrenAtPathAsync(path, ct);
 670        if (children == null)
 671            return;
 672
 673        // If this path has files and is not root, it's a leaf (or mixed) category
 674        // Skip root-level files since they'd produce IDs without a category prefix
 675        if (children.ChildFiles.Count > 0 && !string.IsNullOrEmpty(path))
 676        {
 677            leaves.Add(path);
 678        }
 679
 680        // Recurse into subfolders
 681        foreach (var folder in children.ChildFolders)
 682        {
 683            var childPath = string.IsNullOrEmpty(path) ? folder.Slug : $"{path}/{folder.Slug}";
 684            await CollectLeafCategoriesAsync(childPath, leaves, depth + 1, ct);
 685        }
 686    }
 687
 688    // ==================================================================================
 689    // PRIVATE METHODS - Index Building & Filtering
 690    // ==================================================================================
 691
 692    private string BuildCacheKey(string type, string? key = null)
 693    {
 3694        var cacheKey = $"ExternalLinks:{_options.Key}:{type}";
 3695        if (!string.IsNullOrEmpty(key))
 696        {
 2697            cacheKey += $":{key}";
 698        }
 3699        return cacheKey;
 700    }
 701
 702    /// <summary>
 703    /// Builds and caches the index of files for a specific category path.
 704    /// Delegates to GetChildrenAtPathAsync to ensure consistent blob path resolution
 705    /// (handling mixed-case folder names in blob storage).
 706    /// Returns only the files (not subfolders) at the given path.
 707    /// </summary>
 708    private async Task<List<CategoryItem>> GetCategoryIndexAsync(string category, CancellationToken ct)
 709    {
 710        var cacheKey = BuildCacheKey("CategoryIndex", category.ToLowerInvariant());
 711
 712        if (_cache.TryGetValue<List<CategoryItem>>(cacheKey, out var cached) && cached != null)
 713        {
 714            return cached;
 715        }
 716
 717        // Delegate to GetChildrenAtPathAsync which handles blob path resolution
 718        var children = await GetChildrenAtPathAsync(category, ct);
 719        if (children == null)
 720        {
 721            _logger.LogTraceSanitized(
 722                "Category index empty (path not found) - Provider={Key}, Category={Category}",
 723                _options.Key, category);
 724            return new List<CategoryItem>();
 725        }
 726
 727        // The ChildFiles are already sorted and have correct lowercase IDs
 728        var items = children.ChildFiles;
 729
 730        _logger.LogTraceSanitized(
 731            "Category index built (via children) - Provider={Key}, Category={Category}, Count={Count}",
 732            _options.Key, category, items.Count);
 733
 734        _cache.Set(cacheKey, items, new MemoryCacheEntryOptions
 735        {
 736            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoryIndexCacheTtl)
 737        });
 738
 739        return items;
 740    }
 741
 742    /// <summary>
 743    /// Filters child files by search term and converts to suggestions.
 744    /// Supports multi-token AND search (space-separated tokens all must match).
 745    /// Empty search term returns all files.
 746    /// </summary>
 747    private IEnumerable<ExternalLinkSuggestion> FilterChildFiles(
 748        List<CategoryItem> files,
 749        string searchTerm,
 750        string categoryPath)
 751    {
 2752        if (string.IsNullOrWhiteSpace(searchTerm))
 753        {
 1754            return files.Select(item => new ExternalLinkSuggestion
 1755            {
 1756                Source = _options.Key,
 1757                Id = item.Id,
 1758                Title = item.Title,
 1759                Subtitle = BlobFilenameParser.PrettifySlug(categoryPath),
 1760                Category = categoryPath,
 1761                Icon = null,
 1762                Href = null
 1763            });
 764        }
 765
 1766        var tokens = searchTerm
 1767            .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 1768            .Where(t => !string.IsNullOrWhiteSpace(t))
 1769            .ToList();
 770
 1771        return files
 1772            .Where(item => tokens.All(token =>
 1773                item.Title.Contains(token, StringComparison.OrdinalIgnoreCase)))
 1774            .Select(item => new ExternalLinkSuggestion
 1775            {
 1776                Source = _options.Key,
 1777                Id = item.Id,
 1778                Title = item.Title,
 1779                Subtitle = BlobFilenameParser.PrettifySlug(categoryPath),
 1780                Category = categoryPath,
 1781                Icon = null,
 1782                Href = null
 1783            });
 784    }
 785}