using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;

namespace DeepDrftAPI.Services;

/// <summary>
/// Host-internal orchestrator that makes DeepDrftAPI the single authority over both the
/// vault (FileDatabase) and SQL metadata (DeepDrftData). Owns the two-database write/delete
/// flow so the controller stays a thin HTTP boundary and no caller coordinates the two stores.
/// </summary>
public class UnifiedTrackService
{
    internal const string TrackNotFoundMessage = "Track not found.";

    /// <summary>
    /// Stable marker prefixed onto a cardinality-rejection message so the controller can map this
    /// specific failure to 409 Conflict (a well-formed request that violates a domain rule),
    /// distinct from the 400 (malformed) and 500 (processing) paths. The human-readable detail
    /// follows the marker and is what the CMS surfaces to the admin.
    /// </summary>
    internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: ";
    private readonly TrackContentService _contentTrackContentService;
    private readonly ITrackService _sqlTrackService;
    private readonly FileDb _fileDatabase;
    private readonly WaveformProfileService _waveformProfileService;
    private readonly ILogger<UnifiedTrackService> _logger;

    public UnifiedTrackService(
        TrackContentService contentTrackContentService,
        ITrackService sqlTrackService,
        FileDb fileDatabase,
        WaveformProfileService waveformProfileService,
        ILogger<UnifiedTrackService> logger)
    {
        _contentTrackContentService = contentTrackContentService;
        _sqlTrackService = sqlTrackService;
        _fileDatabase = fileDatabase;
        _waveformProfileService = waveformProfileService;
        _logger = logger;
    }

    /// <summary>
    /// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata
    /// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds
    /// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is
    /// recoverable manually.
    /// </summary>
    public async Task<ResultContainer<TrackDto>> UploadAsync(
        string tempFilePath,
        string trackName,
        string artist,
        string? album,
        string? genre,
        string? description,
        DateOnly? releaseDate,
        long createdByUserId,
        string? originalFileName,
        ReleaseType releaseType,
        ReleaseMedium medium,
        int trackNumber,
        CancellationToken ct)
    {
        // Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans
        // audio in the tracks vault. This is a READ-only peek (no release is created for an upload we
        // may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the
        // find path can violate: a release that does not yet exist has zero tracks and admits its
        // first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so
        // a future bounded medium is covered by the same line.
        if (!string.IsNullOrWhiteSpace(album))
        {
            var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
            if (!peek.Success)
            {
                var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
                _logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
                return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
            }

            if (peek.Value is { } existing)
            {
                var cardinality = MediumRules.CardinalityOf(existing.Medium);
                if (existing.TrackCount + 1 > cardinality.Max)
                {
                    return ResultContainer<TrackDto>.CreateFailResult(
                        $"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " +
                        $"'{existing.Title}' already has one — edit the existing track or choose a different release.");
                }
            }
        }

        var unpersisted = await _contentTrackContentService.AddTrackAsync(
            tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);

        if (unpersisted is null)
        {
            _logger.LogWarning("UploadAsync: content TrackContentService returned null for {TrackName}", trackName);
            return ResultContainer<TrackDto>.CreateFailResult("Failed to process and store WAV.");
        }

        unpersisted.TrackNumber = trackNumber;

        // Resolve the release FK before persisting the track. An upload with an album lands on the
        // shared release (created on first sighting); an upload without one stays a loose track with
        // a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
        // rides on the release, not the track.
        long? releaseId = null;
        if (!string.IsNullOrWhiteSpace(album))
        {
            var releaseData = new ReleaseDto
            {
                Title = album,
                Artist = artist,
                Genre = genre,
                Description = description,
                ReleaseDate = releaseDate,
                ReleaseType = releaseType,
                Medium = medium,
                CreatedByUserId = createdByUserId,
            };

            // Medium (like every other field in releaseData) applies only when this upload CREATES the
            // release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
            // upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
            // subsequent track add: medium is a release-level property, changed only via the edit path
            // (PUT api/track/meta), never silently flipped by adding a track to an existing release.
            var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
            if (!releaseResult.Success || releaseResult.Value is null)
            {
                var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
                _logger.LogError(
                    "Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}",
                    unpersisted.EntryKey, error);
                return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
            }

            releaseId = releaseResult.Value.Id;
        }

        var trackDto = TrackConverter.Convert(unpersisted);
        trackDto.ReleaseId = releaseId;
        trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.

        var saveResult = await _sqlTrackService.Create(trackDto);
        if (!saveResult.Success || saveResult.Value is null)
        {
            // Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
            // under EntryKey. Log loudly (include EntryKey) so it is recoverable manually.
            var error = saveResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError(
                "Track persisted to vault but SQL save failed. Orphaned entry: {EntryKey}. Error: {Error}",
                unpersisted.EntryKey, error);
            return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
        }

        // Best-effort waveform profile: both stores succeeded, so the upload is a success
        // regardless of the profile outcome. A missing profile renders as a flat seekbar on the
        // frontend, so a failure here is logged and swallowed — never fails the upload.
        await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);

        return saveResult;
    }

    private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
    {
        try
        {
            var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
            await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
        }
    }

    /// <summary>
    /// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
    /// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
    /// failure is logged as an orphan and swallowed — it is a maintenance concern, not user-facing.
    /// </summary>
    public async Task<Result> DeleteAsync(long id, CancellationToken ct)
    {
        var lookup = await _sqlTrackService.GetById(id);
        if (!lookup.Success)
        {
            var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error";
            _logger.LogError("DeleteAsync: GetById failed for track {TrackId}: {Error}", id, error);
            return Result.CreateFailResult("Failed to load track.");
        }

        if (lookup.Value is null)
        {
            return Result.CreateFailResult(TrackNotFoundMessage);
        }

        var entryKey = lookup.Value.EntryKey;
        var releaseId = lookup.Value.ReleaseId;

        var sqlDelete = await _sqlTrackService.Delete(id);
        if (!sqlDelete.Success)
        {
            var error = sqlDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
            _logger.LogError("DeleteAsync: SQL delete failed for track {TrackId}: {Error}", id, error);
            return Result.CreateFailResult("Failed to delete track.");
        }

        // Cascade: if this was the last live track on its release, soft-delete the release too so it
        // does not linger as a 0-track orphan in the albums browser. Non-fatal — the track delete
        // already succeeded, so any failure here is logged and swallowed, not surfaced to the caller.
        if (releaseId is { } rid)
        {
            await TrySoftDeleteEmptyReleaseAsync(rid, ct);
        }

        // Tri-state per FileDatabase's error-swallow contract: null = vault missing/error,
        // false = entry not present, true = removed. Anything but a clean removal is an orphan.
        var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
        if (removed is not true)
        {
            _logger.LogWarning(
                "Vault delete did not remove entry after SQL delete. {TrackId} {EntryKey} outcome={Outcome}",
                id, entryKey, removed);
        }

        return Result.CreatePassResult();
    }

    // Soft-delete the release only when no live tracks remain on it. Best-effort: a count or delete
    // failure here never fails the track delete that triggered it — it is logged so an orphaned
    // release can be cleaned up later (the migration backfill also catches pre-existing orphans).
    private async Task TrySoftDeleteEmptyReleaseAsync(long releaseId, CancellationToken ct)
    {
        var countResult = await _sqlTrackService.CountLiveTracksByRelease(releaseId, ct);
        if (!countResult.Success)
        {
            var error = countResult.Messages.FirstOrDefault()?.Message ?? "unknown error";
            _logger.LogWarning("DeleteAsync: live-track count failed for release {ReleaseId}: {Error}", releaseId, error);
            return;
        }

        if (countResult.Value > 0)
        {
            return;
        }

        var releaseDelete = await _sqlTrackService.DeleteRelease(releaseId, ct);
        if (!releaseDelete.Success)
        {
            var error = releaseDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
            _logger.LogWarning("DeleteAsync: release soft-delete failed for {ReleaseId}: {Error}", releaseId, error);
        }
    }
}
