using Data.Errors;
using Data.Managers;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
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;
// Mint the public EntryKey app-side at creation — the identical call tracks make in
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
entity.EntryKey = Guid.NewGuid().ToString();
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> GetReleaseByTitleAndArtist(
string title, string artist, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is null)
return ResultContainer.CreatePassResult(null);
var dto = TrackConverter.Convert(existing);
dto.TrackCount = await Repository.CountLiveTracksByReleaseAsync(existing.Id, cancellationToken);
return ResultContainer.CreatePassResult(dto);
}
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.Description = release.Description;
releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath;
releaseEntity.Medium = release.Medium;
// DTO ReleaseType is nullable (meaningful only for Cut); the entity field is not.
// Default to Single when null, matching TrackConverter.Convert(ReleaseDto).
releaseEntity.ReleaseType = release.ReleaseType ?? ReleaseType.Single;
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);
}
}
}