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: ";

    /// <summary>
    /// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409
    /// Conflict, the same way <see cref="CardinalityViolationMarker"/> is mapped. Fires when an upload
    /// with no explicit releaseId would create a release whose (title, artist) already exists in the
    /// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable
    /// detail follows the marker and is what the CMS surfaces to the admin.
    /// </summary>
    internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: ";

    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,
        long? releaseId,
        CancellationToken ct)
    {
        // Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
        // orphans audio. Two paths:
        //   - releaseId is null  → CREATE path: this is the first row of a submit. (title, artist) must
        //     NOT already exist — the upload form creates new releases only. A pre-existing match is a
        //     duplicate and is blocked (409).
        //   - releaseId is set   → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching
        //     to the release row 1 just created. No (title, artist) lookup — the release id is
        //     authoritative — so the within-batch build is never mistaken for a pre-existing duplicate.
        // Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a
        // future bounded medium is covered by the same line).
        ResolvedRelease? resolved = null;
        if (!string.IsNullOrWhiteSpace(album))
        {
            if (releaseId is { } attachId)
            {
                var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
                if (!attachPeek.Success)
                {
                    var error = attachPeek.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}");
                }

                // The attach target must be the same release the natural key resolves to — a guard against
                // a stale/forged releaseId pointing at a different (title, artist) than this row carries.
                if (attachPeek.Value is not { } target || target.Id != attachId)
                {
                    return ResultContainer<TrackDto>.CreateFailResult(
                        $"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
                        "Start the upload again.");
                }

                var cardinalityCheck = CheckCardinality(target);
                if (cardinalityCheck is { } violation)
                    return ResultContainer<TrackDto>.CreateFailResult(violation);

                resolved = new ResolvedRelease(target.Id);
            }
            else
            {
                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}");
                }

                // CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never
                // edits or appends to an existing release.
                if (peek.Value is { } existing)
                {
                    return ResultContainer<TrackDto>.CreateFailResult(
                        $"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " +
                        "exists. The upload form creates new releases only — use the edit tools to change an existing one.");
                }
                // resolved stays null → FindOrCreateRelease below creates the 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? resolvedReleaseId = resolved?.Id;
        if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null)
        {
            // CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this
            // mints the release. (The attach path already resolved the id from the pre-check above and
            // skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.)
            var releaseData = new ReleaseDto
            {
                Title = album,
                Artist = artist,
                Genre = genre,
                Description = description,
                ReleaseDate = releaseDate,
                ReleaseType = releaseType,
                Medium = medium,
                CreatedByUserId = createdByUserId,
            };

            // FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
            // row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
            // duplicate peek above already verified no pre-existing row exists — so WasCreated = false
            // means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
            // rather than silently attaching, keeping the DB unique index as the final safety net.
            var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
            if (!releaseResult.Success)
            {
                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}");
            }

            var (resolvedRelease, wasCreated) = releaseResult.Value;
            if (!wasCreated)
            {
                // The winning concurrent upload created this release between our peek and our insert.
                // Reject with the same marker the pre-flight peek uses so the controller maps it to 409.
                return ResultContainer<TrackDto>.CreateFailResult(
                    $"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " +
                    "exists. The upload form creates new releases only — use the edit tools to change an existing one.");
            }

            resolvedReleaseId = resolvedRelease.Id;
        }

        var trackDto = TrackConverter.Convert(unpersisted);
        trackDto.ReleaseId = resolvedReleaseId;
        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 datums: both stores succeeded, so the upload is a success regardless of
        // the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
        // frontend, so a failure here is logged and swallowed — never fails the upload.
        await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);

        return saveResult;
    }

    // The release a track resolved onto before the vault write. A null Id is the create path (mint
    // below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N).
    private readonly record struct ResolvedRelease(long Id);

    // The cardinality guard shared by the attach path and (historically) the create path: a release
    // already at its medium's Max rejects a further track. Returns the marker-prefixed rejection
    // message, or null when the add is within limits. The create path never trips this (a brand-new
    // release has zero tracks and admits its first), so only the attach path calls it today.
    private static string? CheckCardinality(ReleaseDto release)
    {
        var cardinality = MediumRules.CardinalityOf(release.Medium);
        if (release.TrackCount + 1 > cardinality.Max)
        {
            return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " +
                   $"'{release.Title}' already has one — edit the existing track or choose a different release.";
        }

        return null;
    }

    /// <summary>
    /// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
    /// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
    /// new duration to SQL. Track id, EntryKey, release membership, track number, and all other
    /// metadata are preserved. The waveform regen is best-effort (a missing datum renders as a flat
    /// seekbar / blank visualizer downstream), so a datum failure is logged and swallowed rather than
    /// failing the replace. The duration write is not best-effort — a failure is surfaced so derived
    /// aggregates (e.g. MixRuntimeSeconds) do not silently go stale. No release-cardinality cascade
    /// applies: the track count is unchanged, so the single-track-Mix case stays intact.
    /// </summary>
    public async Task<Result> ReplaceAudioAsync(long trackId, string tempFilePath, CancellationToken ct)
    {
        var lookup = await _sqlTrackService.GetById(trackId);
        if (!lookup.Success)
        {
            var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error";
            _logger.LogError("ReplaceAudioAsync: GetById failed for track {TrackId}: {Error}", trackId, error);
            return Result.CreateFailResult("Failed to load track.");
        }

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

        var entryKey = lookup.Value.EntryKey;

        var newAudio = await _contentTrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath);
        if (newAudio is null)
        {
            _logger.LogWarning("ReplaceAudioAsync: content swap returned null for track {TrackId} ({EntryKey})", trackId, entryKey);
            return Result.CreateFailResult("Failed to process and store the replacement audio.");
        }

        // The old waveform no longer matches the new bytes. Regenerate both datums in place; keyed
        // by the same EntryKey, the re-run overwrites the stale data (proven re-runnable). The
        // freshly stored buffer is the authoritative source — no re-read of the vault needed.
        try
        {
            await _waveformProfileService.ComputeAndStoreAsync(newAudio.Buffer, entryKey);
            await _waveformProfileService.ComputeAndStoreHighResAsync(newAudio.Buffer, entryKey, newAudio.Duration);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogError(ex, "ReplaceAudioAsync: waveform regen failed for {EntryKey}; replace unaffected.", entryKey);
        }

        // Write the new duration to SQL. The vault bytes are already swapped, so this is the
        // authoritative metadata update for the replace. A failure here is surfaced (unlike the
        // best-effort waveform regen above) because a stale DurationSeconds silently corrupts
        // derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint).
        var durationWrite = await _sqlTrackService.SetDuration(trackId, newAudio.Duration, ct);
        if (!durationWrite.Success)
        {
            var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError(
                "ReplaceAudioAsync: vault swap succeeded but SQL duration update failed for track {TrackId} ({EntryKey}): {Error}",
                trackId, entryKey, error);
            return Result.CreateFailResult("Audio replaced but duration metadata could not be updated.");
        }

        return Result.CreatePassResult();
    }

    // Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
    // the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
    // consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
    // audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
    // rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
    private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
    {
        try
        {
            var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
            if (audio is null)
            {
                _logger.LogWarning(
                    "Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
                    entryKey);
                return;
            }

            await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
            await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            _logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
        }
    }

    /// <summary>
    /// One-time backfill: for every non-deleted track whose SQL duration is still null, read the
    /// processor-extracted runtime from the vault audio (by EntryKey) and write it to SQL. The migration
    /// cannot read the vault, so this runs at runtime after deploy. Idempotent — a re-run only touches
    /// rows still missing a duration. Returns (updated, skipped) counts. A per-track vault miss or SQL
    /// failure is logged and skipped, never aborting the batch.
    /// </summary>
    public async Task<ResultContainer<(int Updated, int Skipped)>> BackfillDurationsAsync(CancellationToken ct)
    {
        var missing = await _sqlTrackService.GetTracksMissingDuration(ct);
        if (!missing.Success || missing.Value is null)
        {
            var error = missing.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("BackfillDurationsAsync: failed to load tracks missing duration: {Error}", error);
            return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
        }

        var updated = 0;
        var skipped = 0;
        foreach (var track in missing.Value)
        {
            ct.ThrowIfCancellationRequested();

            var audio = await _contentTrackContentService.GetAudioBinaryAsync(track.EntryKey);
            if (audio is null)
            {
                _logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.",
                    track.EntryKey, track.Id);
                skipped++;
                continue;
            }

            var write = await _sqlTrackService.UpdateDuration(track.Id, audio.Duration, ct);
            if (!write.Success)
            {
                var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
                _logger.LogWarning("BackfillDurationsAsync: SQL update failed for track {Id}: {Error}", track.Id, error);
                skipped++;
                continue;
            }

            updated++;
        }

        _logger.LogInformation("BackfillDurationsAsync complete: {Updated} updated, {Skipped} skipped.", updated, skipped);
        return ResultContainer<(int, int)>.CreatePassResult((updated, skipped));
    }

    /// <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);
        }
    }
}
