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;

/// <summary>
/// Medium-aware release queries. Deliberately does NOT extend <c>Repository&lt;DeepDrftContext, ReleaseEntity&gt;</c>:
/// that base is generic CRUD, while this repository's purpose is read-projection (paged, medium-filtered)
/// and satellite-row management (Session/Mix metadata find-or-create). Injects <see cref="DeepDrftContext"/>
/// directly so reads/writes stay in one unit of work.
/// </summary>
public class ReleaseRepository
{
    private readonly DeepDrftContext _context;
    private readonly ILogger<ReleaseRepository> _logger;

    public ReleaseRepository(DeepDrftContext context, ILogger<ReleaseRepository> logger)
    {
        _context = context;
        _logger = logger;
    }

    // Single location where the medium↔metadata correlation is determined on a list read: a satellite
    // is loaded only when the caller's medium filter matches it. TrackConverter.Convert(ReleaseEntity)
    // enforces the same rule at the DTO boundary (nulling non-matching satellites); this map ensures a
    // non-matching satellite is never even queried. Cut (or no filter) loads no satellite on list reads.
    private static IQueryable<ReleaseEntity> ApplyMediumInclude(IQueryable<ReleaseEntity> query, ReleaseMedium? medium)
        => medium switch
        {
            ReleaseMedium.Session => query.Include(r => r.SessionMetadata),
            ReleaseMedium.Mix => query.Include(r => r.MixMetadata),
            _ => query
        };

    // Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching
    // medium's satellite is Include'd; total count reflects every applied predicate (all before
    // Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release
    // browse path searches and filters identically to the track path.
    public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
        PagingParameters<ReleaseEntity> paging,
        ReleaseMedium? medium,
        ReleaseFilter? filter,
        CancellationToken ct)
    {
        IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
        if (medium.HasValue)
            query = query.Where(r => r.Medium == medium.Value);

        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. Title/Artist are non-null columns
                // on the release itself, so no navigation guard is needed (unlike the track path).
                var pattern = $"%{filter.SearchText}%";
                query = query.Where(r =>
                    EF.Functions.ILike(r.Title, pattern)
                    || EF.Functions.ILike(r.Artist, pattern));
            }

            if (!string.IsNullOrWhiteSpace(filter.Genre))
                query = query.Where(r => r.Genre == filter.Genre);
        }

        query = ApplyMediumInclude(query, medium);

        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<ReleaseEntity>
        {
            Items = items,
            TotalCount = totalCount,
            Page = paging.Page,
            PageSize = paging.PageSize,
        };
    }

    // Single release with both satellites Include'd: the medium is unknown until fetched, and both are
    // 1:1 FK-indexed joins. TrackConverter nulls the non-matching satellite at the DTO boundary.
    public async Task<ReleaseEntity?> GetByIdWithMetadataAsync(long id, CancellationToken ct)
        => await _context.Releases
            .Where(r => r.Id == id && !r.IsDeleted)
            .Include(r => r.SessionMetadata)
            .Include(r => r.MixMetadata)
            .FirstOrDefaultAsync(ct);

    // The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
    // GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
    // public surface. The resolved entity still carries its int Id for internal joins (track page).
    public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
        => await _context.Releases
            .Where(r => r.EntryKey == entryKey && !r.IsDeleted)
            .Include(r => r.SessionMetadata)
            .Include(r => r.MixMetadata)
            .FirstOrDefaultAsync(ct);

    // Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
    // list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
    public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
        IEnumerable<long> releaseIds,
        CancellationToken ct)
    {
        var ids = releaseIds.ToList();
        return await _context.Tracks
            .Where(t => !t.IsDeleted && t.ReleaseId != null && ids.Contains(t.ReleaseId.Value))
            .GroupBy(t => t.ReleaseId!.Value)
            .Select(g => new { ReleaseId = g.Key, Count = g.Count() })
            .ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
    }

    // Vault entry keys of the non-deleted tracks on a release, track-number ascending. Single-entry for
    // Session/Mix (enforced at upload); may be multiple for Cut.
    public async Task<List<string>> GetTrackEntryKeysByReleaseIdAsync(long releaseId, CancellationToken ct)
        => await _context.Tracks
            .Where(t => !t.IsDeleted && t.ReleaseId == releaseId)
            .OrderBy(t => t.TrackNumber)
            .Select(t => t.EntryKey)
            .ToListAsync(ct);

    // Find-or-create the Session satellite for a release and set its hero-image entry key. The 1:1 FK
    // makes (ReleaseId) the natural key; a repeat call updates the existing row in place.
    public async Task SetHeroImageEntryKeyAsync(long releaseId, string heroImageEntryKey, CancellationToken ct)
    {
        var existing = await _context.SessionMetadata.FirstOrDefaultAsync(s => s.ReleaseId == releaseId, ct);
        if (existing is not null)
        {
            existing.HeroImageEntryKey = heroImageEntryKey;
            existing.UpdatedAt = DateTime.UtcNow;
            await _context.SaveChangesAsync(ct);
            return;
        }

        _context.SessionMetadata.Add(new SessionMetadata
        {
            ReleaseId = releaseId,
            HeroImageEntryKey = heroImageEntryKey,
        });
        await _context.SaveChangesAsync(ct);
    }

    // Find-or-create the Mix satellite for a release and set its waveform entry key. Same 1:1 find-or-create
    // pattern as SetHeroImageEntryKeyAsync.
    public async Task SetWaveformEntryKeyAsync(long releaseId, string waveformEntryKey, CancellationToken ct)
    {
        var existing = await _context.MixMetadata.FirstOrDefaultAsync(m => m.ReleaseId == releaseId, ct);
        if (existing is not null)
        {
            existing.WaveformEntryKey = waveformEntryKey;
            existing.UpdatedAt = DateTime.UtcNow;
            await _context.SaveChangesAsync(ct);
            return;
        }

        _context.MixMetadata.Add(new MixMetadata
        {
            ReleaseId = releaseId,
            WaveformEntryKey = waveformEntryKey,
        });
        await _context.SaveChangesAsync(ct);
    }
}
