using Data.Errors; using Data.Managers; using DeepDrftData.Repositories; using DeepDrftModels.DTOs; using DeepDrftModels.Entities; using Microsoft.Extensions.Logging; using Models.Common; using NetBlocks.Models; namespace DeepDrftData; /// /// SQL-side track service built on the BlazorBlocks Manager base. The layer boundary: /// TrackRepository outputs entities; this service outputs DTOs via TrackConverter — the /// single authoritative entity↔DTO conversion path. The ITrackService surface is DTO-typed /// throughout; the entity never escapes the service layer. /// /// The base Manager<> surface does not line up with ITrackService by signature (base /// Add vs Create, base Update→Result vs Update→DTO, base Get/GetPage vs GetAll/GetPaged, /// base GetById→TDto vs GetById→TDto?), so the query and mutation methods are implemented /// here over Repository + TrackConverter. Only Delete(long)→Result is inherited unchanged. /// public class TrackManager : Manager, ITrackService { public TrackManager( TrackRepository repository, ILogger> logger) : base(repository, logger) { } // Explicit impl: base GetById returns ResultContainer (fails on miss); the // service contract is ResultContainer (pass with null on miss). Return types // differ, so this cannot be a public overload of the inherited member. async Task> ITrackService.GetById(long id) { try { var entity = await Repository.GetByIdAsync(id); return ResultContainer.CreatePassResult( entity is null ? null : TrackConverter.Convert(entity)); } catch (Exception e) { return ResultContainer.CreateFailResult(e.Message); } } // Lookup by vault entry key. No base-name conflict (unlike GetById), so this is a plain // public method. Mirrors the nullable-on-miss shape of ITrackService.GetById. public async Task> GetByEntryKey(string entryKey) { try { var entity = await Repository.GetByEntryKeyAsync(entryKey); return ResultContainer.CreatePassResult( entity is null ? null : TrackConverter.Convert(entity)); } catch (Exception e) { return ResultContainer.CreateFailResult(e.Message); } } // No base-name conflict, so this is a plain public method. Mirrors the nullable-on-empty // shape of GetById: pass with null when the library has no tracks. public async Task> GetRandom(CancellationToken cancellationToken = default) { try { var entity = await Repository.GetRandomAsync(cancellationToken); return ResultContainer.CreatePassResult( entity is null ? null : TrackConverter.Convert(entity)); } catch (Exception e) { return ResultContainer.CreateFailResult(e.Message); } } public async Task>> GetAll() { try { var entities = await Repository.GetAllAsync(); return ResultContainer>.CreatePassResult( entities.Select(TrackConverter.Convert).ToList()); } catch (Exception e) { return ResultContainer>.CreateFailResult(e.Message); } } public async Task>> GetPaged( int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default) { try { var parameters = new PagingParameters { Page = pageNumber, PageSize = pageSize, IsDescending = sortDescending, // Sorts navigate through the nullable Release relation; the null-coalescing // sentinels push loose tracks (no release) to the end, matching the prior // nulls-last behaviour on the flat columns. OrderBy = sortColumn switch { "TrackName" => e => e.TrackName, "Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist), "Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title), "Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)), "ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)), "TrackNumber" => e => e.TrackNumber, _ => e => e.Id } }; // Always route through GetPagedFilteredAsync — it handles a null filter by skipping // all Where predicates, and it always includes Release. This removes the base-class // GetPagedAsync path, which has no .Include and would return entities with null Release. var effectiveFilter = filter is null || filter.IsEmpty ? null : filter; var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken); var dtoPage = PagedResult.From(page, page.Items.Select(TrackConverter.Convert)); return ResultContainer>.CreatePassResult(dtoPage); } catch (Exception e) { return ResultContainer>.CreateFailResult(e.Message); } } public async Task>> GetReleases(CancellationToken cancellationToken = default) { try { var releases = await Repository.GetReleasesAsync(cancellationToken); var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken); var dtos = releases .Select(r => { var dto = TrackConverter.Convert(r); dto.TrackCount = counts.GetValueOrDefault(r.Id); return dto; }) .ToList(); return ResultContainer>.CreatePassResult(dtos); } catch (Exception e) { return ResultContainer>.CreateFailResult(e.Message); } } public async Task> FindOrCreateRelease( string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default) { try { var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken); if (existing is not null) return ResultContainer.CreatePassResult(TrackConverter.Convert(existing)); // The natural key (title + artist) is authoritative — override whatever the caller put // in releaseData so a typo upstream cannot create a release that won't be found again. var entity = TrackConverter.Convert(releaseData); entity.Id = 0; entity.Title = title; entity.Artist = artist; try { var added = await Repository.AddReleaseAsync(entity, cancellationToken); return ResultContainer.CreatePassResult(TrackConverter.Convert(added)); } catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation) { // Concurrent upload inserted the same (title, artist) between our read and write. // Re-query and return the winning row. Should not return null here since the // constraint just fired, but re-throw if it does so the caller sees an error. var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken); if (race is null) throw; return ResultContainer.CreatePassResult(TrackConverter.Convert(race)); } } catch (Exception e) { return ResultContainer.CreateFailResult(e.Message); } } public async Task>> GetDistinctGenres(CancellationToken cancellationToken = default) { try { var genres = await Repository.GetDistinctGenresAsync(cancellationToken); return ResultContainer>.CreatePassResult(genres); } catch (Exception e) { return ResultContainer>.CreateFailResult(e.Message); } } public async Task> Create(TrackDto newTrack) { try { // A track with release context resolves (or creates) the shared release first so the FK // is set before insert. A standalone track (Release null) stays a loose track, ReleaseId // null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and // a populated ReleaseId, which falls straight through. if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title)) { var resolved = await FindOrCreateRelease(release.Title, release.Artist, release); if (!resolved.Success || resolved.Value is null) { var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release."; return ResultContainer.CreateFailResult(error); } newTrack.ReleaseId = resolved.Value.Id; } var added = await Repository.AddAsync(TrackConverter.Convert(newTrack)); return ResultContainer.CreatePassResult(TrackConverter.Convert(added)); } catch (Exception e) { return ResultContainer.CreateFailResult(e.Message); } } // Explicit impl: base Update returns Result; the service contract returns the persisted // DTO so the CMS edit flow reads back DB-authoritative values. async Task> ITrackService.Update(TrackDto track) { try { await Repository.UpdateAsync(TrackConverter.Convert(track)); // Release-cardinal edits flow through the linked release row, not the track. When the // track carries a Release payload and a resolved FK, load the tracked release, apply the // edited fields, and save. EntryKey/track fields are already persisted above. if (track.Release is { } release && track.ReleaseId is { } releaseId) { var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId); if (releaseEntity is not null) { releaseEntity.Title = release.Title; releaseEntity.Artist = release.Artist; releaseEntity.Genre = release.Genre; releaseEntity.ReleaseDate = release.ReleaseDate; releaseEntity.ImagePath = release.ImagePath; releaseEntity.ReleaseType = release.ReleaseType; releaseEntity.CreatedByUserId = release.CreatedByUserId; await Repository.UpdateReleaseAsync(releaseEntity); } } var updated = await Repository.GetByIdAsync(track.Id); return updated is not null ? ResultContainer.CreatePassResult(TrackConverter.Convert(updated)) : ResultContainer.CreateFailResult("Track not found after update."); } catch (Exception e) { return ResultContainer.CreateFailResult(e.Message); } } // Delete(long) → Result is inherited from Manager<> and satisfies ITrackService.Delete // by signature. No override. public async Task DeleteRelease(long id, CancellationToken cancellationToken = default) { try { await Repository.SoftDeleteReleaseAsync(id, cancellationToken); return Result.CreatePassResult(); } catch (Exception e) { return Result.CreateFailResult(e.Message); } } public async Task> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default) { try { var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken); return ResultContainer.CreatePassResult(count); } catch (Exception e) { return ResultContainer.CreateFailResult(e.Message); } } }