using Data.Data.Repositories;
using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;

namespace DeepDrftData.Repositories;

public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
{
    // The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) but no
    // DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference
    // to the injected context rather than reaching for a service locator — it is the same scoped
    // instance the base holds, so reads/writes stay in one unit of work.
    private readonly DeepDrftContext _context;

    public TrackRepository(
        DeepDrftContext context,
        ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
        IDbExceptionClassifier? classifier = null)
        : base(context, logger, classifier: classifier)
    {
        _context = context;
    }

    // Override base GetByIdAsync to include the Release navigation. Without this, the base
    // Query has no .Include, so Release is null on every entity (no lazy-loading proxies).
    public override async Task<TrackEntity?> GetByIdAsync(long id)
        => await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id);

    // Override base GetAllAsync for the same reason — include Release so callers (e.g.
    // TrackManager.GetAll) receive fully-populated entities without a separate query.
    public override async Task<IEnumerable<TrackEntity>> GetAllAsync()
        => await Query.Include(t => t.Release).ToListAsync();

    // Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
    // uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the
    // converter can project the release-cardinal fields.
    public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
        => await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey);

    // Picks one track uniformly at random. Two round-trips (count, then a single offset row)
    // rather than ORDER BY random() so the database never sorts the whole table — the catalogue
    // is small today but this keeps the cost flat as it grows. Returns null when empty so the
    // service surfaces a valid empty-library state, not an error. Uses Query (soft-delete
    // filtered) so deleted tracks are never candidates.
    public async Task<TrackEntity?> GetRandomAsync(CancellationToken cancellationToken = default)
    {
        var count = await Query.CountAsync(cancellationToken);
        if (count == 0)
            return null;

        var index = Random.Shared.Next(count);
        return await Query
            .Include(t => t.Release)
            .OrderBy(t => t.Id)
            .Skip(index)
            .Take(1)
            .FirstOrDefaultAsync(cancellationToken);
    }

    // Paged query with optional filter predicates. Built off Query (soft-delete filtered) rather than the
    // base GetPagedAsync(paging) overload, which takes no where-clause. The OrderBy expression and
    // direction ride in on the PagingParameters the manager already built, so sort + filter +
    // pagination compose. Filter predicates apply before sort and Skip/Take so TotalCount reflects
    // the filtered set.
    public async Task<PagedResult<TrackEntity>> GetPagedFilteredAsync(
        PagingParameters<TrackEntity> paging,
        TrackFilter? filter,
        CancellationToken ct = default)
    {
        // Include Release so both the filter predicates and the converter can read release-cardinal
        // fields through the navigation.
        IQueryable<TrackEntity> query = Query.Include(t => t.Release);

        if (filter is not null)
        {
            if (!string.IsNullOrWhiteSpace(filter.SearchText))
            {
                // Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
                // EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined
                // Release, which is null for loose tracks — guard the navigation before ILike.
                var pattern = $"%{filter.SearchText}%";
                query = query.Where(t =>
                    EF.Functions.ILike(t.TrackName, pattern)
                    || (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern))
                    || (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern)));
            }

            if (!string.IsNullOrWhiteSpace(filter.Album))
                query = query.Where(t => t.Release != null && t.Release.Title == filter.Album);

            if (!string.IsNullOrWhiteSpace(filter.Genre))
                query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);

            // Exact release-id join. ReleaseId is a column on the track itself, so this needs no
            // navigation guard — it is the authoritative alternative to the Album title match.
            if (filter.ReleaseId is { } releaseId)
                query = query.Where(t => t.ReleaseId == releaseId);
        }

        var totalCount = await query.CountAsync(ct);

        if (paging.OrderBy is not null)
        {
            query = paging.IsDescending
                ? query.OrderByDescending(paging.OrderBy)
                : query.OrderBy(paging.OrderBy);
        }

        var items = await query
            .Skip(paging.Skip)
            .Take(paging.PageSize)
            .ToListAsync(ct);

        return new PagedResult<TrackEntity>
        {
            Items = items,
            TotalCount = totalCount,
            Page = paging.Page,
            PageSize = paging.PageSize,
        };
    }

    // All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks.
    // The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto.
    public async Task<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
        => await _context.Set<ReleaseEntity>()
            .Where(r => !r.IsDeleted)
            .OrderBy(r => r.Title)
            .ToListAsync(ct);

    // Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks
    // (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks
    // (no release) carry no genre and are excluded.
    public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
        => await Query
            .Where(t => t.Release != null && t.Release.Genre != null)
            .GroupBy(t => t.Release!.Genre!)
            .Select(g => new GenreSummaryDto
            {
                Genre = g.Key,
                TrackCount = g.Count(),
            })
            .OrderBy(g => g.Genre)
            .ToListAsync(ct);

    // Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against
    // GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out.
    public async Task<Dictionary<long, int>> GetTrackCountsByReleaseAsync(CancellationToken ct = default)
        => await Query
            .Where(t => t.ReleaseId != null)
            .GroupBy(t => t.ReleaseId!.Value)
            .Select(g => new { ReleaseId = g.Key, Count = g.Count() })
            .ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);

    // Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean.
    // All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks
    // under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a
    // null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the
    // total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from
    // the result (no present-with-zero row).
    public async Task<HomeStatsDto> GetHomeStatsAsync(CancellationToken ct = default)
    {
        var releases = _context.Set<ReleaseEntity>().Where(r => !r.IsDeleted);

        var cutTrackCount = await Query
            .CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct);

        var cutReleaseTypeCounts = await releases
            .Where(r => r.Medium == ReleaseMedium.Cut)
            .GroupBy(r => r.ReleaseType)
            .Select(g => new CutReleaseTypeCount { ReleaseType = g.Key, Count = g.Count() })
            .ToListAsync(ct);

        var mixReleaseCount = await releases
            .CountAsync(r => r.Medium == ReleaseMedium.Mix, ct);

        var mixRuntimeSeconds = await Query
            .Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix)
            .SumAsync(t => t.DurationSeconds ?? 0d, ct);

        return new HomeStatsDto
        {
            CutTrackCount = cutTrackCount,
            CutReleaseTypeCounts = cutReleaseTypeCounts,
            MixReleaseCount = mixReleaseCount,
            MixRuntimeSeconds = mixRuntimeSeconds,
        };
    }

    // EntryKey + stored duration for non-deleted tracks whose SQL duration is still null — the work list
    // the one-time duration backfill iterates. The migration cannot read the vault, so duration is filled
    // at runtime: this lists which rows still need it, the backfill reads each from the vault and writes
    // it back via UpdateDurationAsync.
    public async Task<List<TrackEntity>> GetTracksMissingDurationAsync(CancellationToken ct = default)
        => await Query.Where(t => t.DurationSeconds == null).ToListAsync(ct);

    // Set-based duration write for one track (no load round-trip), used by the backfill. The
    // DurationSeconds == null guard keeps a re-run from re-stamping updated_at on an already-filled row
    // and from clobbering a value the upload path may have set in the meantime.
    public async Task<int> UpdateDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
        => await Query
            .Where(t => t.Id == id && t.DurationSeconds == null)
            .ExecuteUpdateAsync(s => s
                .SetProperty(t => t.DurationSeconds, durationSeconds)
                .SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);

    // Unconditional duration overwrite for one track (no load round-trip), used by the replace-audio
    // path. Unlike UpdateDurationAsync, there is no null guard — replace always overwrites the
    // existing value because a normally-uploaded track already has a non-null DurationSeconds and the
    // null-guarded backfill query would match zero rows and silently leave it stale. Returns the count
    // of rows affected; zero means the track was removed between the GetById lookup and this write.
    public async Task<int> SetDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
        => await Query
            .Where(t => t.Id == id)
            .ExecuteUpdateAsync(s => s
                .SetProperty(t => t.DurationSeconds, durationSeconds)
                .SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);

    // Resolve an existing release by its natural key (title + artist). Returns null when no match,
    // signalling the manager to create one. Soft-deleted releases never match.
    public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
        string title, string artist, CancellationToken ct = default)
        => await _context.Set<ReleaseEntity>()
            .FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct);

    // Persist a new release row and return it with its assigned Id. Lives here (not the manager)
    // because the repository owns the DbContext — the manager stays free of direct context access.
    public async Task<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
    {
        _context.Set<ReleaseEntity>().Add(release);
        await _context.SaveChangesAsync(ct);
        return release;
    }

    // Load a tracked release by id so the manager can edit its fields in place and save. Returns
    // null when the id does not resolve (or the release is soft-deleted).
    public async Task<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
        => await _context.Set<ReleaseEntity>()
            .FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct);

    // Persist edits to a release. Update marks the whole entity modified, so it works whether the
    // instance is the change-tracked one from GetReleaseByIdAsync or a detached graph.
    public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
    {
        _context.Set<ReleaseEntity>().Update(release);
        await _context.SaveChangesAsync(ct);
    }

    // Soft-delete a release row in a single set-based UPDATE (no load round-trip). The !IsDeleted
    // guard makes a repeat call a no-op rather than re-stamping updated_at on an already-deleted row.
    public async Task SoftDeleteReleaseAsync(long id, CancellationToken ct = default)
    {
        await _context.Set<ReleaseEntity>()
            .Where(r => r.Id == id && !r.IsDeleted)
            .ExecuteUpdateAsync(s => s
                .SetProperty(r => r.IsDeleted, true)
                .SetProperty(r => r.UpdatedAt, DateTime.UtcNow), ct);
    }

    // Count of non-deleted tracks on a single release. Backs the delete-cascade decision in
    // UnifiedTrackService: a release with zero live tracks after a delete is soft-deleted too.
    // Uses Query (soft-delete filtered) so just-deleted tracks are excluded from the count.
    public async Task<int> CountLiveTracksByReleaseAsync(long releaseId, CancellationToken ct = default)
        => await Query.CountAsync(t => t.ReleaseId == releaseId, ct);

    protected override void UpdateEntity(TrackEntity target, TrackEntity source)
    {
        base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
        target.EntryKey = source.EntryKey;
        target.TrackName = source.TrackName;
        target.TrackNumber = source.TrackNumber;
        target.OriginalFileName = source.OriginalFileName;
        target.DurationSeconds = source.DurationSeconds;
        target.ReleaseId = source.ReleaseId;
    }
}
