using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using System.Linq.Expressions; using System.Reflection; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation { public class VendorDataService : IVendorDataService { private readonly ApplicationDbContext _context; public VendorDataService(ApplicationDbContext context) { _context = context; } public async Task GetByIdAsync(int id) { return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id); } public async Task GetByIdWithDetailsAsync(int id) { return await ProjectWithTotalJobs( _context.Vendors .AsNoTracking() .Where(v => v.Id == id)) .FirstOrDefaultAsync(); } public async Task> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken) { var orderedIds = ids.Distinct().ToList(); if (orderedIds.Count == 0) return Array.Empty(); var loaded = await _context.Vendors .AsNoTracking() .Where(v => orderedIds.Contains(v.Id)) .ToListAsync(cancellationToken); var byId = loaded.ToDictionary(v => v.Id); var ordered = new List(orderedIds.Count); foreach (var id in orderedIds) { if (byId.TryGetValue(id, out var vendor)) ordered.Add(vendor); } return ordered; } public async Task> GetAllAsync() { return await _context.Vendors.ToListAsync(); } public async Task> GetActiveVendorsAsync(CancellationToken cancellationToken) { return await _context.Vendors .AsNoTracking() .Where(v => v.IsActive) .OrderBy(v => v.CompanyName) .ToListAsync(cancellationToken); } public async Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken) { var query = _context.Vendors .AsNoTracking() .Where(v => v.IsDeleted == null || v.IsDeleted == false); if (isActive.HasValue) query = query.Where(v => v.IsActive == isActive.Value); return await query .OrderByDescending(v => v.Id) .ToListAsync(cancellationToken); } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null) { var projectedQuery = BuildDirectoryQuery( search, isActive, companies, trades, locations, jobBuckets); var totalCount = await projectedQuery.CountAsync(); var items = await projectedQuery .OrderByDescending(v => v.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (items, totalCount); } internal IQueryable BuildDirectoryQuery( string? search = null, bool? isActive = true, IReadOnlyCollection? companies = null, IReadOnlyCollection? trades = null, IReadOnlyCollection? locations = null, IReadOnlyCollection? jobBuckets = null) { var query = _context.Vendors .AsNoTracking() .Where(v => v.IsDeleted == null || v.IsDeleted == false); if (isActive.HasValue) query = query.Where(v => v.IsActive == isActive.Value); if (!string.IsNullOrWhiteSpace(search)) { var normalizedSearch = search.ToLower(); query = query.Where(v => (v.CompanyName ?? "").ToLower().Contains(normalizedSearch) || (v.ContactName ?? "").ToLower().Contains(normalizedSearch) || (v.Email ?? "").ToLower().Contains(normalizedSearch) || (v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) || (v.City ?? "").ToLower().Contains(normalizedSearch) || (v.State ?? "").ToLower().Contains(normalizedSearch) || (v.Zip ?? "").ToLower().Contains(normalizedSearch)); } var selectedCompanies = companies? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedCompanies.Length > 0) query = query.Where(v => selectedCompanies.Contains((v.CompanyName ?? "").ToLower())); var selectedTrades = trades? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedTrades.Length > 0) query = query.Where(BuildTradePredicate(selectedTrades)); var selectedLocations = locations? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedLocations.Length > 0) query = query.Where(BuildLocationPredicate(selectedLocations)); var result = ProjectWithTotalJobs(query); var selectedJobBuckets = jobBuckets? .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => value.Trim().ToLower()) .Distinct() .ToArray() ?? Array.Empty(); if (selectedJobBuckets.Length == 0) return result; var under50 = selectedJobBuckets.Contains("under-50"); var from50To99 = selectedJobBuckets.Contains("50-99"); var from100To149 = selectedJobBuckets.Contains("100-149"); var from150 = selectedJobBuckets.Contains("150-plus"); return result.Where(v => (under50 && v.TotalJobs < 50) || (from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) || (from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) || (from150 && v.TotalJobs >= 150)); } private IQueryable ProjectWithTotalJobs(IQueryable query) => query.Select(v => new Vendor { Id = v.Id, CompanyName = v.CompanyName, CompanyId = v.CompanyId, ContactName = v.ContactName, Email = v.Email, Phone = v.Phone, CompanyPhone = v.CompanyPhone, PreferredContact = v.PreferredContact, Address = v.Address, City = v.City, State = v.State, Zip = v.Zip, TradeSpecialties = v.TradeSpecialties, GoogleMapsUrl = v.GoogleMapsUrl, Notes = v.Notes, IsActive = v.IsActive, IsDeleted = v.IsDeleted, TotalJobs = _context.Dispatches .Where(dispatch => dispatch.VendorId == v.Id && dispatch.WorkOrderId.HasValue) .Select(dispatch => dispatch.WorkOrderId!.Value) .Union( _context.DispatchWorkOrders .Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id) .Select(link => link.WorkOrderId)) .Count() }); private static readonly MethodInfo StringConcatMethod = typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!; private static Expression> BuildTradePredicate( IReadOnlyCollection selectedTrades) { var vendor = Expression.Parameter(typeof(Vendor), "vendor"); var specialties = Expression.Coalesce( Expression.Property(vendor, nameof(Vendor.TradeSpecialties)), Expression.Constant(string.Empty)); var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes); var delimiters = new[] { ", ", "," }; Expression? body = null; foreach (var trade in selectedTrades) { var token = Expression.Constant(trade); body = OrElse(body, Expression.Equal(lowered, token)); foreach (var delimiter in delimiters) { var delim = Expression.Constant(delimiter); var tokenWithTrailing = Concat(token, delim); var leadingToken = Concat(delim, token); var wrappedToken = Concat(leadingToken, delim); body = OrElse(body, StartsWith(lowered, tokenWithTrailing)); body = OrElse(body, EndsWith(lowered, leadingToken)); body = OrElse(body, Contains(lowered, wrappedToken)); } } return Expression.Lambda>(body ?? Expression.Constant(false), vendor); } private static Expression> BuildLocationPredicate( IReadOnlyCollection selectedLocations) { var vendor = Expression.Parameter(typeof(Vendor), "vendor"); var label = BuildLocationLabelBody(vendor); var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes); Expression? body = null; foreach (var location in selectedLocations) body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location))); return Expression.Lambda>(body ?? Expression.Constant(false), vendor); } private static Expression BuildLocationLabelBody(ParameterExpression vendor) { var city = Expression.Property(vendor, nameof(Vendor.City)); var state = Expression.Property(vendor, nameof(Vendor.State)); var cityBlank = IsBlank(city); var stateBlank = IsBlank(state); var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state); var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state); var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState); return Expression.Condition(cityBlank, onlyState, cityOrBoth); } private static Expression IsBlank(Expression value) { var isNull = Expression.Equal(value, Expression.Constant(null, value.Type)); var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes); var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty)); return Expression.Condition(isNull, Expression.Constant(true), isEmpty); } private static Expression Concat(Expression left, Expression right) => Expression.Call(StringConcatMethod, left, right); private static Expression StartsWith(Expression instance, Expression value) => Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value); private static Expression EndsWith(Expression instance, Expression value) => Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value); private static Expression Contains(Expression instance, Expression value) => Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value); private static Expression OrElse(Expression? accumulated, Expression next) => accumulated is null ? next : Expression.OrElse(accumulated, next); public async Task AddAsync(Vendor vendor) { await _context.Vendors.AddAsync(vendor); await _context.SaveChangesAsync(); return vendor; } public async Task UpdateAsync(Vendor vendor) { _context.Vendors.Update(vendor); await _context.SaveChangesAsync(); } public async Task DeleteAsync(int id) { var entity = await GetByIdAsync(id); if (entity != null) { _context.Vendors.Remove(entity); await _context.SaveChangesAsync(); } } public async Task ExistsAsync(int id) { return await _context.Vendors.AnyAsync(v => v.Id == id); } public async Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken) { var now = DateTime.UtcNow; return await _context.VendorAccessTokens .AsNoTracking() .Where(t => t.VendorId == vendorId && t.RevokedAt == null && t.ExpiresAt > now && (t.IsDeleted == null || t.IsDeleted == false)) .OrderByDescending(t => t.IssuedAt) .FirstOrDefaultAsync(cancellationToken); } public async Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken) { var now = DateTime.UtcNow; return await _context.VendorAccessTokens .Include(t => t.Vendor) .FirstOrDefaultAsync(t => t.Token == token && t.RevokedAt == null && t.ExpiresAt > now && (t.IsDeleted == null || t.IsDeleted == false), cancellationToken); } public async Task AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken) { await _context.VendorAccessTokens.AddAsync(token, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return token; } public async Task> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken) { return await _context.VendorAccessTokens .Where(t => t.VendorId == vendorId && t.RevokedAt == null) .ToListAsync(cancellationToken); } public async Task SaveChangesAsync(CancellationToken cancellationToken) { await _context.SaveChangesAsync(cancellationToken); } public async Task CountAsync() { return await _context.Vendors.CountAsync(); } public async Task GetCompanyByIdAsync(int id) { return await _context.VendorCompanies .AsNoTracking() .FirstOrDefaultAsync(c => c.Id == id); } public async Task GetCompanyByNormalizedNameAsync(string normalizedName) { return await _context.VendorCompanies .AsNoTracking() .FirstOrDefaultAsync(c => c.NormalizedName == normalizedName); } public async Task AddCompanyAsync(VendorCompany company) { await _context.VendorCompanies.AddAsync(company); try { await _context.SaveChangesAsync(); return company; } catch (DbUpdateException) { _context.Entry(company).State = EntityState.Detached; var existing = await _context.VendorCompanies .AsNoTracking() .SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName); if (existing != null) return existing; throw; } } public async Task UpdateCompanyAsync(VendorCompany company) { _context.VendorCompanies.Update(company); await _context.SaveChangesAsync(); } internal IQueryable BuildLinkedWorkOrdersQuery(int vendorId) { var directLinks = _context.Dispatches .Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue) .Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value }); var multiLinks = _context.DispatchWorkOrders .Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId) .Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId }); var linkedIds = directLinks.Concat(multiLinks); return from link in linkedIds join wo in _context.workOrders on link.WorkOrderId equals wo.Id orderby wo.Id, link.DispatchId select new LinkedWorkOrderInfo { WorkOrderId = wo.Id, WorkOrderNumber = wo.WorkerOrderNumber, WorkOrderTitle = wo.WorkerOrderTitle, Status = wo.Status, LifecycleStatus = wo.LifecycleStatus, ScheduledDate = wo.ScheduledDate, DispatchId = link.DispatchId }; } public async Task> GetLinkedWorkOrdersAsync(int vendorId) { var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync(); return raw.DistinctBy(x => x.WorkOrderId).ToList(); } public async Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId) { var hasDirect = await _context.Dispatches .AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId); if (hasDirect) return true; return await _context.DispatchWorkOrders .AnyAsync(link => link.WorkOrderId == workOrderId && link.Dispatch != null && link.Dispatch.VendorId == vendorId); } public async Task UpdateWithAuditLogsAsync( Vendor vendor, IReadOnlyCollection auditLogs) { _context.Vendors.Update(vendor); if (auditLogs.Count > 0) await _context.VendorAuditLogs.AddRangeAsync(auditLogs); await _context.SaveChangesAsync(); } } }