< 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
0%
Covered lines: 0
Uncovered lines: 416
Coverable lines: 416
Total lines: 789
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 152
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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 Chronicis.Shared.Extensions;
 5using Microsoft.Extensions.Caching.Memory;
 6
 7namespace Chronicis.Api.Services.ExternalLinks;
 8
 9/// <summary>
 10/// External link provider backed by Azure Blob Storage.
 11/// Supports progressive category drill-down: typing "[[ros" shows top-level categories,
 12/// "[[ros/bestiary" shows bestiary's children, and so on at any depth.
 13/// Cross-category text search is supported at the top level (no slash).
 14/// </summary>
 15public class BlobExternalLinkProvider : IExternalLinkProvider
 16{
 17    private readonly BlobExternalLinkProviderOptions _options;
 18    private readonly BlobContainerClient _containerClient;
 19    private readonly IMemoryCache _cache;
 20    private readonly ILogger<BlobExternalLinkProvider> _logger;
 21
 022    public BlobExternalLinkProvider(
 023        BlobExternalLinkProviderOptions options,
 024        IMemoryCache cache,
 025        ILogger<BlobExternalLinkProvider> logger)
 26    {
 027        _options = options ?? throw new ArgumentNullException(nameof(options));
 028        _cache = cache ?? throw new ArgumentNullException(nameof(cache));
 029        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 30
 031        var blobServiceClient = new BlobServiceClient(_options.ConnectionString);
 032        _containerClient = blobServiceClient.GetBlobContainerClient(_options.ContainerName);
 33
 034        _logger.LogDebug(
 035            "Initialized BlobExternalLinkProvider: Key={Key}, DisplayName={DisplayName}, RootPrefix={RootPrefix}",
 036            _options.Key, _options.DisplayName, _options.RootPrefix);
 037    }
 38
 039    public string Key => _options.Key;
 40
 41    // ==================================================================================
 42    // PUBLIC API
 43    // ==================================================================================
 44
 45    public async Task<IReadOnlyList<ExternalLinkSuggestion>> SearchAsync(string query, CancellationToken ct)
 46    {
 047        query = query?.Trim() ?? string.Empty;
 48
 049        var slashIndex = query.IndexOf('/');
 50
 51        // Case A: No slash — top-level behavior
 52        // Empty query → show top-level categories only
 53        // Has text  → cross-category search (categories + items)
 054        if (slashIndex < 0)
 55        {
 056            if (string.IsNullOrWhiteSpace(query))
 57            {
 058                return await GetTopLevelCategorySuggestionsAsync(ct);
 59            }
 60
 061            return await SearchAcrossAllCategoriesAsync(query, ct);
 62        }
 63
 64        // Case B: Has slash — progressive drill-down
 65        // Split into path prefix and trailing text after last slash
 66        // Example: "bestiary/beast/abo" → pathPrefix="bestiary/beast", trailingText="abo"
 67        // Example: "bestiary/"          → pathPrefix="bestiary",       trailingText=""
 68        // Example: "bestiary/beast/"    → pathPrefix="bestiary/beast", trailingText=""
 069        var lastSlashIdx = query.LastIndexOf('/');
 070        var pathPrefix = query[..lastSlashIdx];
 071        var trailingText = query[(lastSlashIdx + 1)..];
 72
 73        // Get the children at the path prefix
 074        var children = await GetChildrenAtPathAsync(pathPrefix, ct);
 75
 076        if (children == null)
 77        {
 78            // Path doesn't exist — try partial matching against parent's children
 79            // Example: "bestiary/bea" → parent is "", trailing is "bestiary/bea"
 80            //   We need to find if "bestiary" partially matches a top-level folder
 081            return await SearchPartialPathAsync(query, ct);
 82        }
 83
 084        var results = new List<ExternalLinkSuggestion>();
 85
 86        // Build child folder suggestions (always shown first)
 087        var folderSuggestions = children.ChildFolders
 088            .Where(f => string.IsNullOrWhiteSpace(trailingText)
 089                || f.Slug.Contains(trailingText, StringComparison.OrdinalIgnoreCase))
 090            .Select(folder =>
 091            {
 092                var fullPath = string.IsNullOrEmpty(pathPrefix) ? folder.Slug : $"{pathPrefix}/{folder.Slug}";
 093                var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 094                return new ExternalLinkSuggestion
 095                {
 096                    Source = _options.Key,
 097                    Id = $"_category/{fullPath}",
 098                    Title = title,
 099                    Subtitle = $"Browse {title}",
 0100                    Category = "_category",
 0101                    Icon = null,
 0102                    Href = null
 0103                };
 0104            })
 0105            .ToList();
 106
 0107        results.AddRange(folderSuggestions);
 108
 109        // Build child file suggestions (shown after folders)
 110        // Use multi-token AND search if trailing text contains spaces
 0111        var fileSuggestions = FilterChildFiles(children.ChildFiles, trailingText, pathPrefix)
 0112            .Take(_options.MaxSuggestions - results.Count)
 0113            .ToList();
 114
 0115        results.AddRange(fileSuggestions);
 116
 0117        _logger.LogDebugSanitized(
 0118            "Drill-down search - Provider={Key}, Path={Path}, Trailing={Trailing}, Folders={FolderCount}, Files={FileCou
 0119            _options.Key, pathPrefix, trailingText, folderSuggestions.Count, fileSuggestions.Count);
 120
 0121        return results;
 0122    }
 123
 124    public async Task<ExternalLinkContent> GetContentAsync(string id, CancellationToken ct)
 125    {
 126        // Step 1: Validate ID format
 0127        if (!BlobIdValidator.IsValid(id, out var validationError))
 128        {
 0129            _logger.LogWarningSanitized(
 0130                "Invalid content ID - Provider={Key}, Id={Id}, Error={Error}",
 0131                _options.Key, id, validationError);
 0132            return CreateEmptyContent(id);
 133        }
 134
 135        // Step 2: Parse category and slug
 0136        var (category, slug) = BlobIdValidator.ParseId(id);
 0137        if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(slug))
 138        {
 0139            _logger.LogWarningSanitized(
 0140                "Failed to parse content ID after validation - Provider={Key}, Id={Id}",
 0141                _options.Key, id);
 0142            return CreateEmptyContent(id);
 143        }
 144
 145        // Step 3: Check content cache
 0146        var cacheKey = BuildCacheKey("Content", id);
 0147        if (_cache.TryGetValue<ExternalLinkContent>(cacheKey, out var cached) && cached != null)
 148        {
 0149            _logger.LogDebugSanitized("Content cache hit - Provider={Key}, Id={Id}", _options.Key, id);
 0150            return cached;
 151        }
 152
 153        // Step 4: Load category index to find the blob name
 154        // GetCategoryIndexAsync no longer validates against a flat category list —
 155        // it directly queries blob storage at the given path.
 0156        var index = await GetCategoryIndexAsync(category, ct);
 0157        var item = index.FirstOrDefault(i => i.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
 158
 0159        if (item == null)
 160        {
 0161            _logger.LogWarningSanitized(
 0162                "Content ID not found in category index - Provider={Key}, Id={Id}, Category={Category}, IndexCount={Inde
 0163                _options.Key, id, category, index.Count);
 0164            return CreateEmptyContent(id);
 165        }
 166
 167        // Step 5: Fetch blob content and render
 168        try
 169        {
 0170            var blobClient = _containerClient.GetBlobClient(item.BlobName);
 0171            var response = await blobClient.DownloadContentAsync(ct);
 0172            var content = response.Value.Content;
 173
 174            // Handle UTF-8 BOM
 0175            var jsonBytes = content.ToMemory();
 0176            var span = jsonBytes.Span;
 0177            if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF)
 178            {
 0179                jsonBytes = jsonBytes.Slice(3);
 180            }
 181
 0182            using var json = JsonDocument.Parse(jsonBytes);
 183
 184            // Capture raw JSON for client-side structured rendering
 0185            var rawJson = System.Text.Encoding.UTF8.GetString(jsonBytes.Span);
 186
 0187            var markdown = GenericJsonMarkdownRenderer.RenderMarkdown(
 0188                json, _options.DisplayName, item.Title);
 189
 0190            var result = new ExternalLinkContent
 0191            {
 0192                Source = _options.Key,
 0193                Id = id,
 0194                Title = item.Title,
 0195                Kind = BlobFilenameParser.PrettifySlug(category),
 0196                Markdown = markdown,
 0197                Attribution = $"Source: {_options.DisplayName}",
 0198                ExternalUrl = null,
 0199                JsonData = rawJson
 0200            };
 201
 0202            _cache.Set(cacheKey, result, new MemoryCacheEntryOptions
 0203            {
 0204                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.ContentCacheTtl)
 0205            });
 206
 0207            _logger.LogDebugSanitized(
 0208                "Content retrieved and rendered - Provider={Key}, Id={Id}, BlobName={BlobName}",
 0209                _options.Key, id, item.BlobName);
 210
 0211            return result;
 212        }
 0213        catch (Exception ex)
 214        {
 0215            _logger.LogErrorSanitized(ex,
 0216                "Failed to retrieve or render content - Provider={Key}, Id={Id}, BlobName={BlobName}",
 0217                _options.Key, id, item.BlobName);
 0218            return CreateEmptyContent(id);
 219        }
 0220    }
 221
 222    private ExternalLinkContent CreateEmptyContent(string id)
 223    {
 0224        return new ExternalLinkContent
 0225        {
 0226            Source = _options.Key,
 0227            Id = id,
 0228            Title = "Content Not Found",
 0229            Kind = "Unknown",
 0230            Markdown = "The requested content could not be found.",
 0231            Attribution = $"Source: {_options.DisplayName}",
 0232            ExternalUrl = null
 0233        };
 234    }
 235
 236    // ==================================================================================
 237    // PRIVATE METHODS - Progressive Discovery
 238    // ==================================================================================
 239
 240    /// <summary>
 241    /// Result of discovering direct children at a given path.
 242    /// ChildFolders carry both the original blob name and the lowercase slug for display/IDs.
 243    /// ChildFiles are CategoryItem records for JSON files at this level.
 244    /// </summary>
 0245    private record PathChildren(List<ChildFolder> ChildFolders, List<CategoryItem> ChildFiles);
 246
 247    /// <summary>
 248    /// A subfolder discovered at a given path level.
 249    /// BlobName is the original casing as stored in blob (needed for subsequent queries).
 250    /// Slug is the lowercase-normalized name used in IDs and display.
 251    /// </summary>
 0252    private record ChildFolder(string BlobName, string Slug);
 253
 254    /// <summary>
 255    /// Discovers the direct children (subfolders and files) at a given relative path
 256    /// within the provider's root prefix. Uses GetBlobsByHierarchy with "/" delimiter
 257    /// to get exactly one level of the hierarchy.
 258    ///
 259    /// Returns null if the path has no children (doesn't exist or is empty).
 260    /// Results are cached per path.
 261    /// </summary>
 262    /// <param name="relativePath">
 263    /// Path relative to RootPrefix. Empty string for root level.
 264    /// Can be in original blob casing OR lowercase — the method resolves via cached mappings.
 265    /// Example: "bestiary" or "Bestiary" or "bestiary/beast" or "items/armor/light"
 266    /// </param>
 267    private async Task<PathChildren?> GetChildrenAtPathAsync(string relativePath, CancellationToken ct)
 268    {
 0269        var normalizedPath = relativePath.Trim('/');
 270
 271        // Resolve the path to its original blob casing
 0272        var blobPath = await ResolveBlobPathAsync(normalizedPath, ct);
 273
 0274        var cacheKey = BuildCacheKey("Children", normalizedPath.ToLowerInvariant());
 275
 0276        if (_cache.TryGetValue<PathChildren>(cacheKey, out var cached) && cached != null)
 277        {
 0278            return cached;
 279        }
 280
 281        // Build the blob prefix using the ORIGINAL casing from blob storage
 0282        var prefix = string.IsNullOrEmpty(blobPath)
 0283            ? _options.RootPrefix
 0284            : $"{_options.RootPrefix}{blobPath}/";
 285
 0286        var childFolders = new List<ChildFolder>();
 0287        var childFiles = new List<CategoryItem>();
 288
 0289        await foreach (var item in _containerClient.GetBlobsByHierarchyAsync(
 0290            prefix: prefix,
 0291            delimiter: "/",
 0292            cancellationToken: ct))
 293        {
 0294            if (item.IsPrefix && item.Prefix != null)
 295            {
 296                // Subfolder — extract the folder name in its ORIGINAL casing
 0297                var originalFolderName = item.Prefix[prefix.Length..].TrimEnd('/');
 0298                if (!string.IsNullOrWhiteSpace(originalFolderName))
 299                {
 0300                    var slug = originalFolderName.ToLowerInvariant();
 0301                    childFolders.Add(new ChildFolder(originalFolderName, slug));
 302
 303                    // Cache the slug → blob path mapping for this child
 0304                    var childSlugPath = string.IsNullOrEmpty(normalizedPath)
 0305                        ? slug
 0306                        : $"{normalizedPath.ToLowerInvariant()}/{slug}";
 0307                    var childBlobPath = string.IsNullOrEmpty(blobPath)
 0308                        ? originalFolderName
 0309                        : $"{blobPath}/{originalFolderName}";
 0310                    CacheBlobPathMapping(childSlugPath, childBlobPath);
 311                }
 312            }
 0313            else if (item.IsBlob && item.Blob != null)
 314            {
 0315                var blobName = item.Blob.Name;
 0316                if (!blobName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
 317                    continue;
 318
 0319                if (item.Blob.Properties.ContentLength.HasValue &&
 0320                    item.Blob.Properties.ContentLength.Value > 5_000_000)
 321                    continue;
 322
 0323                var lastSlash = blobName.LastIndexOf('/');
 0324                var filename = lastSlash >= 0 ? blobName[(lastSlash + 1)..] : blobName;
 0325                var slug = BlobFilenameParser.DeriveSlug(filename);
 326
 0327                if (string.IsNullOrWhiteSpace(slug))
 328                    continue;
 329
 330                // IDs are always lowercase
 0331                var id = string.IsNullOrEmpty(normalizedPath)
 0332                    ? slug.ToLowerInvariant()
 0333                    : $"{normalizedPath.ToLowerInvariant()}/{slug.ToLowerInvariant()}";
 0334                var title = BlobFilenameParser.PrettifySlug(slug);
 335
 0336                childFiles.Add(new CategoryItem(id, title, blobName, Pk: null));
 337            }
 338        }
 339
 0340        if (childFolders.Count == 0 && childFiles.Count == 0)
 341        {
 0342            return null;
 343        }
 344
 0345        childFolders.Sort((a, b) => string.Compare(a.Slug, b.Slug, StringComparison.OrdinalIgnoreCase));
 0346        childFiles = childFiles
 0347            .OrderBy(f => f.Title, StringComparer.OrdinalIgnoreCase)
 0348            .ThenBy(f => f.Id)
 0349            .ToList();
 350
 0351        var result = new PathChildren(childFolders, childFiles);
 352
 0353        _cache.Set(cacheKey, result, new MemoryCacheEntryOptions
 0354        {
 0355            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoriesCacheTtl)
 0356        });
 357
 0358        _logger.LogDebugSanitized(
 0359            "Children discovered - Provider={Key}, Path={Path}, BlobPath={BlobPath}, Folders={FolderCount}, Files={FileC
 0360            _options.Key, normalizedPath, blobPath, childFolders.Count, childFiles.Count);
 361
 0362        return result;
 0363    }
 364
 365    /// <summary>
 366    /// Resolves a lowercase slug path back to its original blob casing.
 367    /// Uses cached mappings built during discovery.
 368    /// Falls back to the input path if no mapping exists (first-time root discovery).
 369    /// </summary>
 370    private async Task<string> ResolveBlobPathAsync(string slugPath, CancellationToken ct)
 371    {
 0372        if (string.IsNullOrEmpty(slugPath))
 0373            return string.Empty;
 374
 0375        var mappingKey = BuildCacheKey("BlobPathMap", slugPath.ToLowerInvariant());
 0376        if (_cache.TryGetValue<string>(mappingKey, out var blobPath) && blobPath != null)
 377        {
 0378            return blobPath;
 379        }
 380
 381        // No cached mapping — this can happen if the cache expired or on first access
 382        // to a deep path. Walk from root to rebuild mappings.
 0383        var segments = slugPath.Split('/');
 0384        var currentSlugPath = "";
 0385        var currentBlobPath = "";
 386
 0387        for (var i = 0; i < segments.Length; i++)
 388        {
 389            // Ensure parent is discovered (this populates child mappings)
 0390            var parentChildren = await GetChildrenAtPathAsync(currentBlobPath, ct);
 0391            if (parentChildren == null)
 392            {
 393                // Parent doesn't exist — return input as-is
 0394                return slugPath;
 395            }
 396
 0397            var targetSlug = segments[i].ToLowerInvariant();
 0398            var matchedFolder = parentChildren.ChildFolders
 0399                .FirstOrDefault(f => f.Slug.Equals(targetSlug, StringComparison.OrdinalIgnoreCase));
 400
 0401            if (matchedFolder == null)
 402            {
 403                // Segment not found — return input as-is
 0404                return slugPath;
 405            }
 406
 0407            currentSlugPath = string.IsNullOrEmpty(currentSlugPath)
 0408                ? matchedFolder.Slug
 0409                : $"{currentSlugPath}/{matchedFolder.Slug}";
 0410            currentBlobPath = string.IsNullOrEmpty(currentBlobPath)
 0411                ? matchedFolder.BlobName
 0412                : $"{currentBlobPath}/{matchedFolder.BlobName}";
 0413        }
 414
 0415        return currentBlobPath;
 0416    }
 417
 418    /// <summary>
 419    /// Caches a mapping from lowercase slug path to original blob path.
 420    /// </summary>
 421    private void CacheBlobPathMapping(string slugPath, string blobPath)
 422    {
 0423        var mappingKey = BuildCacheKey("BlobPathMap", slugPath.ToLowerInvariant());
 0424        _cache.Set(mappingKey, blobPath, new MemoryCacheEntryOptions
 0425        {
 0426            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoriesCacheTtl)
 0427        });
 0428    }
 429
 430    /// <summary>
 431    /// Returns suggestions for top-level categories only (no children expanded).
 432    /// Used when the user types "[[ros" with no slash and no text.
 433    /// </summary>
 434    private async Task<List<ExternalLinkSuggestion>> GetTopLevelCategorySuggestionsAsync(CancellationToken ct)
 435    {
 0436        var children = await GetChildrenAtPathAsync("", ct);
 0437        if (children == null)
 438        {
 0439            return new List<ExternalLinkSuggestion>();
 440        }
 441
 0442        var suggestions = new List<ExternalLinkSuggestion>();
 443
 0444        foreach (var folder in children.ChildFolders)
 445        {
 0446            var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 0447            suggestions.Add(new ExternalLinkSuggestion
 0448            {
 0449                Source = _options.Key,
 0450                Id = $"_category/{folder.Slug}",
 0451                Title = title,
 0452                Subtitle = $"Browse {title}",
 0453                Category = "_category",
 0454                Icon = null,
 0455                Href = null
 0456            });
 457        }
 458
 459        // Also include any files at the root level (unlikely but possible)
 0460        foreach (var file in children.ChildFiles.Take(_options.MaxSuggestions - suggestions.Count))
 461        {
 0462            suggestions.Add(new ExternalLinkSuggestion
 0463            {
 0464                Source = _options.Key,
 0465                Id = file.Id,
 0466                Title = file.Title,
 0467                Subtitle = _options.DisplayName,
 0468                Category = "",
 0469                Icon = null,
 0470                Href = null
 0471            });
 472        }
 473
 0474        return suggestions;
 0475    }
 476
 477    /// <summary>
 478    /// Handles partial path matching when the typed path doesn't match a real folder.
 479    /// Example: "besti" → finds "bestiary" in parent's children.
 480    /// Example: "bestiary/bea" → finds "beast" under "bestiary".
 481    /// </summary>
 482    private async Task<List<ExternalLinkSuggestion>> SearchPartialPathAsync(string query, CancellationToken ct)
 483    {
 0484        return await SearchPartialPathInternalAsync(query, 0, ct);
 0485    }
 486
 487    private async Task<List<ExternalLinkSuggestion>> SearchPartialPathInternalAsync(
 488        string query, int depth, CancellationToken ct)
 489    {
 0490        if (depth > _options.MaxDrillDownDepth)
 0491            return new List<ExternalLinkSuggestion>();
 492
 493        // Split into parent path and partial segment
 0494        var lastSlashIdx = query.LastIndexOf('/');
 0495        var parentPath = lastSlashIdx >= 0 ? query[..lastSlashIdx] : "";
 0496        var partialSegment = lastSlashIdx >= 0 ? query[(lastSlashIdx + 1)..] : query;
 497
 0498        var children = await GetChildrenAtPathAsync(parentPath, ct);
 0499        if (children == null)
 500        {
 501            // Parent path doesn't exist either — recurse up if there's still a slash
 0502            if (lastSlashIdx > 0)
 503            {
 0504                return await SearchPartialPathInternalAsync(parentPath, depth + 1, ct);
 505            }
 506
 0507            return new List<ExternalLinkSuggestion>();
 508        }
 509
 0510        var results = new List<ExternalLinkSuggestion>();
 511
 512        // Match folders that contain the partial segment
 0513        var matchingFolders = children.ChildFolders
 0514            .Where(f => f.Slug.Contains(partialSegment, StringComparison.OrdinalIgnoreCase))
 0515            .ToList();
 516
 0517        foreach (var folder in matchingFolders)
 518        {
 0519            var fullPath = string.IsNullOrEmpty(parentPath) ? folder.Slug : $"{parentPath}/{folder.Slug}";
 0520            var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 0521            results.Add(new ExternalLinkSuggestion
 0522            {
 0523                Source = _options.Key,
 0524                Id = $"_category/{fullPath}",
 0525                Title = title,
 0526                Subtitle = $"Browse {title}",
 0527                Category = "_category",
 0528                Icon = null,
 0529                Href = null
 0530            });
 531        }
 532
 533        // Also match files at this level
 0534        var matchingFiles = children.ChildFiles
 0535            .Where(f => f.Title.Contains(partialSegment, StringComparison.OrdinalIgnoreCase))
 0536            .Take(_options.MaxSuggestions - results.Count);
 537
 0538        foreach (var file in matchingFiles)
 539        {
 0540            results.Add(new ExternalLinkSuggestion
 0541            {
 0542                Source = _options.Key,
 0543                Id = file.Id,
 0544                Title = file.Title,
 0545                Subtitle = BlobFilenameParser.PrettifySlug(parentPath),
 0546                Category = parentPath,
 0547                Icon = null,
 0548                Href = null
 0549            });
 550        }
 551
 0552        return results;
 0553    }
 554
 555    /// <summary>
 556    /// Searches across ALL leaf categories for items matching the query.
 557    /// Also returns matching category names at the top.
 558    /// Used only for top-level text search (no slash in query).
 559    /// </summary>
 560    private async Task<List<ExternalLinkSuggestion>> SearchAcrossAllCategoriesAsync(string query, CancellationToken ct)
 561    {
 0562        var results = new List<ExternalLinkSuggestion>();
 563
 564        // Part 1: Check top-level categories that match the query text
 0565        var topChildren = await GetChildrenAtPathAsync("", ct);
 0566        if (topChildren != null)
 567        {
 0568            var matchingFolders = topChildren.ChildFolders
 0569                .Where(f => f.Slug.Contains(query, StringComparison.OrdinalIgnoreCase))
 0570                .ToList();
 571
 0572            foreach (var folder in matchingFolders)
 573            {
 0574                var title = BlobFilenameParser.PrettifySlug(folder.Slug);
 0575                results.Add(new ExternalLinkSuggestion
 0576                {
 0577                    Source = _options.Key,
 0578                    Id = $"_category/{folder.Slug}",
 0579                    Title = title,
 0580                    Subtitle = $"Browse {title}",
 0581                    Category = "_category",
 0582                    Icon = null,
 0583                    Href = null
 0584                });
 585            }
 586        }
 587
 588        // Part 2: Search items across all leaf categories
 0589        var leafCategories = await GetAllLeafCategoriesAsync(ct);
 590
 0591        var itemResults = new List<ExternalLinkSuggestion>();
 0592        foreach (var leafPath in leafCategories)
 593        {
 0594            var index = await GetCategoryIndexAsync(leafPath, ct);
 595
 0596            var matchingItems = index
 0597                .Where(item => item.Title.Contains(query, StringComparison.OrdinalIgnoreCase))
 0598                .Take(5)
 0599                .Select(item => new ExternalLinkSuggestion
 0600                {
 0601                    Source = _options.Key,
 0602                    Id = item.Id,
 0603                    Title = item.Title,
 0604                    Subtitle = BlobFilenameParser.PrettifySlug(leafPath),
 0605                    Category = leafPath,
 0606                    Icon = null,
 0607                    Href = null
 0608                });
 609
 0610            itemResults.AddRange(matchingItems);
 0611        }
 612
 0613        results.AddRange(itemResults.Take(_options.MaxSuggestions - results.Count));
 614
 0615        _logger.LogDebugSanitized(
 0616            "Cross-category search - Provider={Key}, Query={Query}, LeafCategories={LeafCount}, ItemMatches={ItemCount}"
 0617            _options.Key, query, leafCategories.Count, itemResults.Count);
 618
 0619        return results;
 0620    }
 621
 622    /// <summary>
 623    /// Recursively discovers all leaf category paths (folders that contain files).
 624    /// A leaf category is a path where GetChildrenAtPathAsync returns files.
 625    /// Cached for CategoriesCacheTtl minutes.
 626    /// </summary>
 627    private async Task<List<string>> GetAllLeafCategoriesAsync(CancellationToken ct)
 628    {
 0629        var cacheKey = BuildCacheKey("AllLeafCategories");
 630
 0631        if (_cache.TryGetValue<List<string>>(cacheKey, out var cached) && cached != null)
 632        {
 0633            return cached;
 634        }
 635
 0636        var sw = Stopwatch.StartNew();
 0637        var leaves = new List<string>();
 638
 0639        await CollectLeafCategoriesAsync("", leaves, 0, ct);
 640
 0641        leaves.Sort(StringComparer.OrdinalIgnoreCase);
 642
 0643        sw.Stop();
 0644        _logger.LogDebug(
 0645            "Leaf categories discovered - Provider={Key}, Count={Count}, Elapsed={Elapsed}ms",
 0646            _options.Key, leaves.Count, sw.ElapsedMilliseconds);
 647
 0648        _cache.Set(cacheKey, leaves, new MemoryCacheEntryOptions
 0649        {
 0650            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoriesCacheTtl)
 0651        });
 652
 0653        return leaves;
 0654    }
 655
 656    /// <summary>
 657    /// Recursive helper to walk the folder tree and collect all paths that contain files.
 658    /// </summary>
 659    private async Task CollectLeafCategoriesAsync(
 660        string path, List<string> leaves, int depth, CancellationToken ct)
 661    {
 0662        if (depth > _options.MaxDrillDownDepth)
 663        {
 0664            _logger.LogWarningSanitized(
 0665                "Max drill-down depth reached - Provider={Key}, Path={Path}, Depth={Depth}",
 0666                _options.Key, path, depth);
 0667            return;
 668        }
 669
 0670        var children = await GetChildrenAtPathAsync(path, ct);
 0671        if (children == null)
 0672            return;
 673
 674        // If this path has files and is not root, it's a leaf (or mixed) category
 675        // Skip root-level files since they'd produce IDs without a category prefix
 0676        if (children.ChildFiles.Count > 0 && !string.IsNullOrEmpty(path))
 677        {
 0678            leaves.Add(path);
 679        }
 680
 681        // Recurse into subfolders
 0682        foreach (var folder in children.ChildFolders)
 683        {
 0684            var childPath = string.IsNullOrEmpty(path) ? folder.Slug : $"{path}/{folder.Slug}";
 0685            await CollectLeafCategoriesAsync(childPath, leaves, depth + 1, ct);
 686        }
 0687    }
 688
 689    // ==================================================================================
 690    // PRIVATE METHODS - Index Building & Filtering
 691    // ==================================================================================
 692
 693    private string BuildCacheKey(string type, string? key = null)
 694    {
 0695        var cacheKey = $"ExternalLinks:{_options.Key}:{type}";
 0696        if (!string.IsNullOrEmpty(key))
 697        {
 0698            cacheKey += $":{key}";
 699        }
 0700        return cacheKey;
 701    }
 702
 703    /// <summary>
 704    /// Builds and caches the index of files for a specific category path.
 705    /// Delegates to GetChildrenAtPathAsync to ensure consistent blob path resolution
 706    /// (handling mixed-case folder names in blob storage).
 707    /// Returns only the files (not subfolders) at the given path.
 708    /// </summary>
 709    private async Task<List<CategoryItem>> GetCategoryIndexAsync(string category, CancellationToken ct)
 710    {
 0711        var cacheKey = BuildCacheKey("CategoryIndex", category.ToLowerInvariant());
 712
 0713        if (_cache.TryGetValue<List<CategoryItem>>(cacheKey, out var cached) && cached != null)
 714        {
 0715            return cached;
 716        }
 717
 718        // Delegate to GetChildrenAtPathAsync which handles blob path resolution
 0719        var children = await GetChildrenAtPathAsync(category, ct);
 0720        if (children == null)
 721        {
 0722            _logger.LogDebugSanitized(
 0723                "Category index empty (path not found) - Provider={Key}, Category={Category}",
 0724                _options.Key, category);
 0725            return new List<CategoryItem>();
 726        }
 727
 728        // The ChildFiles are already sorted and have correct lowercase IDs
 0729        var items = children.ChildFiles;
 730
 0731        _logger.LogDebugSanitized(
 0732            "Category index built (via children) - Provider={Key}, Category={Category}, Count={Count}",
 0733            _options.Key, category, items.Count);
 734
 0735        _cache.Set(cacheKey, items, new MemoryCacheEntryOptions
 0736        {
 0737            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CategoryIndexCacheTtl)
 0738        });
 739
 0740        return items;
 0741    }
 742
 743    /// <summary>
 744    /// Filters child files by search term and converts to suggestions.
 745    /// Supports multi-token AND search (space-separated tokens all must match).
 746    /// Empty search term returns all files.
 747    /// </summary>
 748    private IEnumerable<ExternalLinkSuggestion> FilterChildFiles(
 749        List<CategoryItem> files,
 750        string searchTerm,
 751        string categoryPath)
 752    {
 0753        if (string.IsNullOrWhiteSpace(searchTerm))
 754        {
 0755            return files.Select(item => new ExternalLinkSuggestion
 0756            {
 0757                Source = _options.Key,
 0758                Id = item.Id,
 0759                Title = item.Title,
 0760                Subtitle = BlobFilenameParser.PrettifySlug(categoryPath),
 0761                Category = categoryPath,
 0762                Icon = null,
 0763                Href = null
 0764            });
 765        }
 766
 0767        var tokens = searchTerm
 0768            .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 0769            .Where(t => !string.IsNullOrWhiteSpace(t))
 0770            .ToList();
 771
 0772        if (tokens.Count == 0)
 0773            return Enumerable.Empty<ExternalLinkSuggestion>();
 774
 0775        return files
 0776            .Where(item => tokens.All(token =>
 0777                item.Title.Contains(token, StringComparison.OrdinalIgnoreCase)))
 0778            .Select(item => new ExternalLinkSuggestion
 0779            {
 0780                Source = _options.Key,
 0781                Id = item.Id,
 0782                Title = item.Title,
 0783                Subtitle = BlobFilenameParser.PrettifySlug(categoryPath),
 0784                Category = categoryPath,
 0785                Icon = null,
 0786                Href = null
 0787            });
 788    }
 789}