using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Infrastructure.Search;
///
/// Structured + full-text search. Structured filters compose as SQL WHERE
/// clauses; free text uses PostgreSQL FTS via the generated SearchVector column
/// (websearch_to_tsquery / @@ / ts_rank_cd). Results are relevance-ranked when a
/// free-text query is present, date-ordered otherwise. See
/// docs/discovery/05-search-redesign.md for the full multi-layer search design.
///
public class SearchService : ISearchService
{
private readonly AppDbContext _db;
public SearchService(AppDbContext db) => _db = db;
public async Task> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
{
// NOTE: pagination is clamped at the user-facing trust boundary (SearchController),
// NOT here, so internal callers (e.g. CleanupService target resolution, which
// legitimately requests large pages) are unaffected. See V-10 fix.
var q = _db.Emails.AsNoTracking().Where(e => e.UserId == userId);
if (!string.IsNullOrWhiteSpace(r.Sender))
q = q.Where(e => e.Sender!.Address.Contains(r.Sender) || e.Sender.DisplayName!.Contains(r.Sender));
if (!string.IsNullOrWhiteSpace(r.Domain))
q = q.Where(e => e.Sender!.Domain!.Name == r.Domain);
if (r.From is { } from)
q = q.Where(e => e.SentAtUtc >= new DateTimeOffset(from.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero));
if (r.To is { } to)
q = q.Where(e => e.SentAtUtc <= new DateTimeOffset(to.ToDateTime(TimeOnly.MaxValue), TimeSpan.Zero));
if (r.IsUnread is { } unread)
q = q.Where(e => e.IsUnread == unread);
if (r.HasAttachments is { } att)
q = q.Where(e => e.HasAttachments == att);
if (r.IsInInbox is { } inbox)
q = q.Where(e => e.IsInInbox == inbox);
if (r.IsStarred is { } starred)
q = q.Where(e => e.IsStarred == starred);
if (r.IsTrashed is { } trashed)
q = q.Where(e => e.IsTrashed == trashed);
if (r.MinSizeBytes is { } minSize)
q = q.Where(e => e.SizeEstimateBytes >= minSize);
if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse(r.Category, true, out var cat))
q = q.Where(e => e.Category == cat);
if (!string.IsNullOrWhiteSpace(r.GmailLabel))
{
var labelId = await _db.Labels
.Where(l => l.UserId == userId && l.GmailLabelId == r.GmailLabel.ToUpperInvariant())
.Select(l => (Guid?)l.Id)
.FirstOrDefaultAsync(ct);
if (labelId.HasValue)
q = q.Where(e => e.EmailLabels.Any(el => el.LabelId == labelId.Value));
else
return new PagedResult { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 };
}
// websearch_to_tsquery (vs. plainto_tsquery) understands quotes ("exact phrase"),
// OR, and -exclusions — the syntax users already expect from web search boxes.
var hasFreeTextQuery = !string.IsNullOrWhiteSpace(r.Query);
var term = r.Query?.Trim() ?? string.Empty;
if (hasFreeTextQuery)
q = q.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term)));
var total = await q.CountAsync(ct);
// Relevance-ranked when there's a free-text query (ts_rank_cd via RankCoverDensity,
// recency as a tiebreaker); date-only otherwise — matches the existing browse
// behaviour when the user isn't searching for anything in particular.
var ranked = hasFreeTextQuery
? q.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
.ThenByDescending(e => e.SentAtUtc)
: q.OrderByDescending(e => e.SentAtUtc);
var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
// Two unconditional projections (no DB function inside a C# ternary → no doubt about
// EF translation). The browse path never touches ts_headline, so it's byte-for-byte
// unchanged AND safe under the InMemory test provider.
List items;
if (hasFreeTextQuery)
{
// "Why this matched": ts_headline body fragment with matched terms wrapped in
// U+E000/U+E001 sentinels (safe, non-HTML — the client renders them as escaped
// spans; see EmailSummaryDto).
var headlineOpts =
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
items = await paged
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
.ToListAsync(ct);
}
else
{
items = await paged
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
null))
.ToListAsync(ct);
}
return new PagedResult
{
Items = items,
Page = r.Page,
PageSize = r.PageSize,
TotalCount = total
};
}
}