using DeepDrftAPI.Middleware;
using DeepDrftAPI.Models;
using DeepDrftAPI.Services;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Mvc;

namespace DeepDrftAPI.Controllers;

[ApiController]
[Route("api/[controller]")]
public class TrackController : ControllerBase
{
    private readonly DeepDrftContent.TrackContentService _trackContentService;
    private readonly UnifiedTrackService _unifiedService;
    private readonly ITrackService _sqlTrackService;
    private readonly WaveformProfileService _waveformProfileService;
    private readonly UploadStagingDirectory _stagingDirectory;
    private readonly ILogger<TrackController> _logger;

    // FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
    // AudioBinaryDto over the wire, not a WAV file path. TrackContentService.AddTrackFromWavAsync is
    // file-path-oriented and not applicable here. If a file-upload flow is added in future,
    // route it through TrackContentService instead.
    private readonly DeepDrftContent.FileDatabase.Services.FileDatabase _fileDatabase;

    public TrackController(
        DeepDrftContent.TrackContentService trackContentService,
        DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
        UnifiedTrackService unifiedService,
        ITrackService sqlTrackService,
        WaveformProfileService waveformProfileService,
        UploadStagingDirectory stagingDirectory,
        ILogger<TrackController> logger)
    {
        _trackContentService = trackContentService;
        _fileDatabase = fileDatabase;
        _unifiedService = unifiedService;
        _sqlTrackService = sqlTrackService;
        _waveformProfileService = waveformProfileService;
        _stagingDirectory = stagingDirectory;
        _logger = logger;
    }

    // Builds a unique staging file path on the data disk with the validated extension. The caller MUST
    // assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
    // way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
    // partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
    // on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
    private string BuildStagingPath(string uploadExtension) =>
        Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);

    // Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
    // must delete it in a finally block; separating path generation from the copy ensures the finally
    // guard fires even when CopyToAsync throws before returning.
    private async Task StageUploadAsync(
        IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
    {
        await using var stagingStream = new FileStream(
            stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
            bufferSize: 81920, useAsync: true);
        await using var uploadStream = audioFile.OpenReadStream();
        await uploadStream.CopyToAsync(stagingStream, cancellationToken);
    }

    // Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
    // disk-hygiene concern, not a request failure.
    private void DeleteStagingFile(string stagingPath)
    {
        try
        {
            if (System.IO.File.Exists(stagingPath))
            {
                System.IO.File.Delete(stagingPath);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
        }
    }

    // --- Literal-segment routes first ---
    // These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
    // resolution never treats "page", "upload", or "meta" as a trackId.

    // GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId=
    // Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
    // q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering).
    // releaseId is the authoritative release→tracks join (exact match), preferred over album title.
    [HttpGet("page")]
    public async Task<ActionResult> GetPage(
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20,
        [FromQuery] string? sortColumn = null,
        [FromQuery] bool sortDescending = false,
        [FromQuery] string? q = null,
        [FromQuery] string? album = null,
        [FromQuery] string? genre = null,
        [FromQuery] long? releaseId = null,
        CancellationToken cancellationToken = default)
    {
        var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId };
        var effectiveFilter = filter.IsEmpty ? null : filter;

        var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
        if (!result.Success || result.Value is null)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("GetPage failed: {Error}", error);
            return StatusCode(500, "Failed to load tracks");
        }

        return Ok(result.Value);
    }

    // GET api/track/albums (unauthenticated)
    // All releases with per-release track counts. Public browse data, same posture as GET
    // api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
    // Route name kept as "albums" for client/proxy compatibility; the payload is List<ReleaseDto>.
    [HttpGet("albums")]
    public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
    {
        var result = await _sqlTrackService.GetReleases(ct);
        if (!result.Success || result.Value is null)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("GetAlbums failed: {Error}", error);
            return StatusCode(500, "Failed to load albums");
        }

        return Ok(result.Value);
    }

    // GET api/track/release/exists?title=...&artist=... ([ApiKeyAuthorize])
    // Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the
    // matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses
    // the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the
    // pre-flight and the server backstop agree on the match by construction (exact ordinal comparison,
    // soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the
    // parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape).
    [ApiKeyAuthorize]
    [HttpGet("release/exists")]
    public async Task<ActionResult> ReleaseExists(
        [FromQuery] string? title,
        [FromQuery] string? artist,
        CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
            return BadRequest("title and artist are both required");

        var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct);
        if (!result.Success)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error);
            return StatusCode(500, "Failed to check release");
        }

        if (result.Value is null)
            return NotFound();

        return Ok(result.Value);
    }

    // GET api/track/genres (unauthenticated)
    // Distinct non-null genres with track counts. Public browse data, same posture as GET
    // api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
    [HttpGet("genres")]
    public async Task<ActionResult> GetGenres(CancellationToken ct = default)
    {
        var result = await _sqlTrackService.GetDistinctGenres(ct);
        if (!result.Success || result.Value is null)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("GetGenres failed: {Error}", error);
            return StatusCode(500, "Failed to load genres");
        }

        return Ok(result.Value);
    }

    // GET api/track/random (unauthenticated)
    // Picks one track at random from the full library and returns its metadata. Public, same auth
    // posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
    // 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 +
    // TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there.
    [HttpGet("random")]
    public async Task<ActionResult> GetRandom(CancellationToken cancellationToken = default)
    {
        var result = await _sqlTrackService.GetRandom(cancellationToken);
        if (!result.Success)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("GetRandom failed: {Error}", error);
            return StatusCode(500, "Failed to load track");
        }

        if (result.Value is null)
        {
            return NotFound();
        }

        return Ok(result.Value);
    }

    // GET api/track/waveform-status ([ApiKeyAuthorize])
    // Admin backfill view: returns every track with flags for whether each waveform datum is stored —
    // the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
    // datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
    // the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
    // segment is never treated as a trackId.
    [ApiKeyAuthorize]
    [HttpGet("waveform-status")]
    public async Task<ActionResult> GetWaveformStatus()
    {
        var tracks = await _sqlTrackService.GetAll();
        if (!tracks.Success || tracks.Value is null)
        {
            var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error);
            return StatusCode(500, "Failed to load tracks");
        }

        var status = new List<WaveformStatusDto>(tracks.Value.Count);
        foreach (var track in tracks.Value)
        {
            var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
            var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
            status.Add(new WaveformStatusDto
            {
                TrackId = track.Id,
                EntryKey = track.EntryKey,
                TrackName = track.TrackName,
                HasProfile = profile is not null,
                HasHighRes = highRes is not null,
            });
        }

        return Ok(status);
    }

    // POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
    // One-time admin backfill: for every track whose SQL duration is still null, read the duration from
    // the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
    // only touches still-missing rows. Returns { updated, skipped }. Declared in the literal-route block
    // (before "{trackId}") so the segment is never treated as a trackId.
    [ApiKeyAuthorize]
    [HttpPost("duration/backfill")]
    public async Task<ActionResult> BackfillDurations(CancellationToken cancellationToken)
    {
        var result = await _unifiedService.BackfillDurationsAsync(cancellationToken);
        if (!result.Success)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("BackfillDurations failed: {Error}", error);
            return StatusCode(500, error);
        }

        return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped });
    }

    // POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
    // Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
    // proxies the upload here so it never touches the vault disk path or SQL directly.
    // UnifiedTrackService owns the two-database write.
    //
    // RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to
    // hundreds of MB (or over a GB for high-res WAVs); the framework defaults (~28 MB) reject them
    // outright. The IFormFile path streams the body to a temp file once Kestrel surfaces it, so the
    // limit is the per-request ceiling, not a buffered allocation. 2_000_000_000 stays below
    // int.MaxValue (2,147,483,647) so it is safe where limits are int-typed.
    [ApiKeyAuthorize]
    [HttpPost("upload")]
    [RequestSizeLimit(2_000_000_000)]
    [RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
    public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
        [FromForm] IFormFile? audioFile,
        [FromForm] string? trackName,
        [FromForm] string? artist,
        [FromForm] string? album,
        [FromForm] string? genre,
        [FromForm] string? description,
        [FromForm] string? releaseDate,
        [FromForm] string? originalFileName,
        [FromForm] long createdByUserId,
        [FromForm] string? releaseType,
        [FromForm] string? medium,
        [FromForm] int? trackNumber,
        [FromForm] long? releaseId,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
            trackName, artist, originalFileName, audioFile?.Length);

        if (audioFile is null || audioFile.Length == 0)
        {
            return BadRequest("Audio file is required");
        }

        if (string.IsNullOrWhiteSpace(trackName))
        {
            return BadRequest("trackName is required");
        }

        if (string.IsNullOrWhiteSpace(artist))
        {
            return BadRequest("artist is required");
        }

        var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
        if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
        {
            return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
        }

        DateOnly? parsedReleaseDate = null;
        if (!string.IsNullOrWhiteSpace(releaseDate))
        {
            if (!DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out var parsed))
            {
                return BadRequest("releaseDate must be in YYYY-MM-DD format");
            }
            parsedReleaseDate = parsed;
        }

        // Default to Single for null/unparseable release type; default track number to a valid 1-based value.
        ReleaseType parsedReleaseType;
        if (!string.IsNullOrWhiteSpace(releaseType)
            && Enum.TryParse<ReleaseType>(releaseType, ignoreCase: true, out var rt)
            && Enum.IsDefined(rt))
        {
            parsedReleaseType = rt;
        }
        else
        {
            parsedReleaseType = ReleaseType.Single;
            if (!string.IsNullOrWhiteSpace(releaseType))
                _logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
        }
        // Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above.
        ReleaseMedium parsedMedium;
        if (!string.IsNullOrWhiteSpace(medium)
            && Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var rm)
            && Enum.IsDefined(rm))
        {
            parsedMedium = rm;
        }
        else
        {
            parsedMedium = ReleaseMedium.Cut;
            if (!string.IsNullOrWhiteSpace(medium))
                _logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium);
        }

        var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;

        // Build the staging path before the copy so the finally block can delete the partial file
        // even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
        var stagingPath = BuildStagingPath(uploadExtension);
        try
        {
            await StageUploadAsync(audioFile, stagingPath, cancellationToken);

            var result = await _unifiedService.UploadAsync(
                stagingPath,
                trackName,
                artist,
                string.IsNullOrWhiteSpace(album) ? null : album,
                string.IsNullOrWhiteSpace(genre) ? null : genre,
                string.IsNullOrWhiteSpace(description) ? null : description,
                parsedReleaseDate,
                createdByUserId,
                string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
                parsedReleaseType,
                parsedMedium,
                resolvedTrackNumber,
                releaseId,
                cancellationToken);

            if (!result.Success || result.Value is null)
            {
                var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
                _logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);

                // A cardinality or duplicate-release rejection is a well-formed request that violates a
                // domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure.
                // The marker is stripped so the client sees only the human-readable detail.
                if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
                {
                    return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
                }

                if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
                {
                    return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
                }

                return StatusCode(500, error);
            }

            _logger.LogInformation("UploadTrack succeeded: id={Id}, entryKey={EntryKey}", result.Value.Id, result.Value.EntryKey);
            return Ok(result.Value);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "UploadTrack failed for {TrackName}", trackName);
            return StatusCode(500, "Internal server error");
        }
        finally
        {
            DeleteStagingFile(stagingPath);
        }
    }

    // GET api/track/meta/{id}: single track metadata from SQL.
    [ApiKeyAuthorize]
    [HttpGet("meta/{id:long}")]
    public async Task<ActionResult> GetMeta(long id)
    {
        var result = await _sqlTrackService.GetById(id);
        if (!result.Success)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("GetMeta failed for {TrackId}: {Error}", id, error);
            return StatusCode(500, "Failed to load track");
        }

        if (result.Value is null)
        {
            return NotFound();
        }

        return Ok(result.Value);
    }

    // GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key.
    // Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the
    // public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}.
    [HttpGet("meta/by-key/{entryKey}")]
    public async Task<ActionResult> GetMetaByKey(string entryKey)
    {
        var result = await _sqlTrackService.GetByEntryKey(entryKey);
        if (!result.Success)
        {
            var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error);
            return StatusCode(500, "Failed to load track");
        }

        if (result.Value is null)
        {
            return NotFound();
        }

        return Ok(result.Value);
    }

    // PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
    [ApiKeyAuthorize]
    [HttpPut("meta/{id:long}")]
    public async Task<ActionResult> UpdateMeta(long id, [FromBody] UpdateTrackMetadataRequest request)
    {
        var lookup = await _sqlTrackService.GetById(id);
        if (!lookup.Success)
        {
            var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("UpdateMeta lookup failed for {TrackId}: {Error}", id, error);
            return StatusCode(500, "Failed to load track");
        }

        if (lookup.Value is null)
        {
            return NotFound();
        }

        if (request.TrackNumber is <= 0)
            return BadRequest("trackNumber must be a positive integer when provided.");

        var track = lookup.Value;

        // Track-cardinal fields update the track row directly.
        track.TrackName = request.TrackName;
        if (request.TrackNumber is > 0)
            track.TrackNumber = request.TrackNumber.Value;

        // Release-cardinal fields update the linked release (handled in TrackManager.Update, which
        // persists track.Release when the track carries a resolved ReleaseId). The loaded track has
        // its Release populated via the Include; mutate it in place so the edited values flow through.
        // A loose track (no release) cannot take release-cardinal edits — there is no release row to
        // write to — so these fields are simply not persisted in that case.
        if (track.Release is { } release)
        {
            release.Artist = request.Artist;
            release.Title = request.Album ?? string.Empty;
            release.Genre = request.Genre;
            release.Description = request.Description;
            release.ReleaseDate = request.ReleaseDate;

            // ImagePath is tri-state: null = no change, "" = clear, value = set.
            if (request.ImagePath is not null)
                release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;

            // ReleaseType is non-null on the release; null in the request means "no change".
            if (request.ReleaseType is not null)
                release.ReleaseType = request.ReleaseType.Value;

            // Medium is non-null on the release; null in the request means "no change".
            if (request.Medium is not null)
            {
                release.Medium = request.Medium.Value;

                // ReleaseType is meaningful only for Cut. When the medium is anything else, reset
                // ReleaseType to the DB-level default rather than leaving a stale studio-format value —
                // mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This
                // runs after the ReleaseType apply above, so it correctly overrides a contradictory
                // ReleaseType sent in the same request alongside a non-Cut medium.
                if (request.Medium.Value != ReleaseMedium.Cut)
                    release.ReleaseType = ReleaseType.Single;
            }
        }

        var update = await _sqlTrackService.Update(track);
        if (!update.Success)
        {
            var error = update.Messages.FirstOrDefault()?.Message ?? "Unknown error";
            _logger.LogError("UpdateMeta failed for {TrackId}: {Error}", id, error);
            return StatusCode(500, "Failed to update track");
        }

        return Ok();
    }

    // DELETE api/track/{id}: removes the SQL row then the vault entry. UnifiedTrackService owns
    // the ordering and orphan handling. Declared (with the long route constraint) before the
    // string "{trackId}" GET so a numeric id routes here.
    [ApiKeyAuthorize]
    [HttpDelete("{id:long}")]
    public async Task<ActionResult> DeleteTrack(long id, CancellationToken cancellationToken)
    {
        _logger.LogInformation("DeleteTrack called with id: {Id}", id);

        var result = await _unifiedService.DeleteAsync(id, cancellationToken);
        if (result.Success)
        {
            return Ok();
        }

        var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
        if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
        {
            return NotFound();
        }

        _logger.LogError("DeleteTrack failed for id {Id}: {Error}", id, error);
        return StatusCode(500, error);
    }

    // POST api/track/{id}/replace-audio ([ApiKeyAuthorize])
    // Swap an existing track's audio bytes from a raw upload, preserving the track's id, EntryKey,
    // release membership, position, and metadata. UnifiedTrackService.ReplaceAudioAsync owns the
    // vault swap + waveform regen; nothing in SQL is written. Mirrors the upload endpoint's temp-file
    // streaming and ~1.86 GB ceiling (a WAV replace is a large-body upload like the original). The
    // literal "{id:long}/replace-audio" segment is declared in the literal-route block so it never
    // resolves to the parameterized "{trackId}" GET.
    [ApiKeyAuthorize]
    [HttpPost("{id:long}/replace-audio")]
    [RequestSizeLimit(2_000_000_000)]
    [RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
    public async Task<ActionResult> ReplaceAudio(
        long id,
        [FromForm] IFormFile? audioFile,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("ReplaceAudio called: id={Id}, size={Size}", id, audioFile?.Length);

        if (audioFile is null || audioFile.Length == 0)
        {
            return BadRequest("Audio file is required");
        }

        var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
        if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
        {
            return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
        }

        // Build the staging path before the copy so the finally block can delete the partial file
        // even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
        var stagingPath = BuildStagingPath(uploadExtension);
        try
        {
            await StageUploadAsync(audioFile, stagingPath, cancellationToken);

            var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
            if (result.Success)
            {
                _logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
                return Ok();
            }

            var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to replace audio";
            if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
            {
                return NotFound();
            }

            _logger.LogError("ReplaceAudio failed for id {Id}: {Error}", id, error);
            return StatusCode(500, error);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "ReplaceAudio failed for id {Id}", id);
            return StatusCode(500, "Internal server error");
        }
        finally
        {
            DeleteStagingFile(stagingPath);
        }
    }

    // DELETE api/track/release/{id} ([ApiKeyAuthorize])
    // Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
    // (one with no live tracks). "release" is a literal segment, declared here in the literal-route
    // block so it never resolves to the parameterized "{trackId}" GET.
    [ApiKeyAuthorize]
    [HttpDelete("release/{id:long}")]
    public async Task<ActionResult> DeleteRelease(long id, CancellationToken cancellationToken)
    {
        var result = await _sqlTrackService.DeleteRelease(id, cancellationToken);
        if (result.Success) return Ok();

        var error = result.Messages.FirstOrDefault()?.Message ?? "unknown error";
        _logger.LogError("DeleteRelease failed for id {Id}: {Error}", id, error);
        return StatusCode(500, error);
    }

    // --- Parameterized routes ---

    [HttpGet("{trackId}")]
    public async Task<ActionResult> GetTrack(string trackId)
    {
        _logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);

        try
        {
            var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
            if (vault == null)
            {
                _logger.LogWarning("Tracks vault not found");
                return NotFound();
            }

            var mediaStream = await vault.GetEntryStreamAsync(trackId);
            if (mediaStream == null)
            {
                _logger.LogWarning("Track not found: {TrackId}", trackId);
                return NotFound();
            }

            // Resolve MIME and log before handing the stream to File().
            // If anything here throws, the finally block disposes the wrapper
            // (and its inner FileStream) so neither leaks. On the success path
            // File() takes ownership of the inner stream; ASP.NET Core disposes
            // it after the response body is sent. The wrapper is a thin struct
            // with no extra resources, so disposing it after extracting the
            // inner stream is a no-op — we only call Dispose() in the catch path.
            string streamMimeType;
            long streamLength;
            Stream innerStream;
            try
            {
                streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
                streamLength = mediaStream.Stream.Length;
                innerStream = mediaStream.Stream;
            }
            catch
            {
                await mediaStream.DisposeAsync();
                throw;
            }

            _logger.LogInformation(
                "Streaming track from disk: {TrackId}, Size: {Size} bytes",
                trackId, streamLength);
            // enableRangeProcessing: true — seek is served by HTTP Range requests.
            // The FileStream is seekable, so ASP.NET Core honours an incoming
            // Range header by slicing the file and responding 206 Partial Content.
            return File(innerStream, streamMimeType, enableRangeProcessing: true);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving track: {TrackId}", trackId);
            return StatusCode(500, "Internal server error");
        }
    }

    // GET api/track/{trackId}/waveform (unauthenticated)
    // Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
    // data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
    // (existing tracks predate profiling, or computation failed at upload — the frontend falls back
    // to a flat seekbar). The "waveform" literal suffix keeps this distinct from the audio route.
    [HttpGet("{trackId}/waveform")]
    public async Task<ActionResult> GetWaveform(string trackId)
    {
        var bytes = await _waveformProfileService.GetProfileAsync(trackId);
        if (bytes is null)
        {
            _logger.LogInformation("No waveform profile for track: {TrackId}", trackId);
            return NotFound();
        }

        return Ok(new WaveformProfileDto
        {
            BucketCount = bytes.Length,
            Data = Convert.ToBase64String(bytes),
        });
    }

    // GET api/track/{trackId}/waveform/high-res (unauthenticated)
    // Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
    // from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
    // fetches for whatever track is currently playing/selected — the release is only addressing context.
    // Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
    // "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
    // stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
    // parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
    [HttpGet("{trackId}/waveform/high-res")]
    public async Task<ActionResult> GetHighResWaveform(string trackId)
    {
        var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
        if (bytes is null)
        {
            _logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
            return NotFound();
        }

        return Ok(new WaveformProfileDto
        {
            BucketCount = bytes.Length,
            Data = Convert.ToBase64String(bytes),
        });
    }

    // POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
    // Admin backfill: compute and store a waveform profile for an existing track from its vault
    // audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
    // WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for
    // tracks that predate the WaveformSeeker feature.
    [ApiKeyAuthorize]
    [HttpPost("{trackId}/waveform")]
    public async Task<ActionResult> GenerateWaveform(string trackId)
    {
        var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
        if (audio is null)
        {
            _logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
            return NotFound();
        }

        var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
        if (!stored)
        {
            _logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
            return StatusCode(500, "Failed to generate waveform profile.");
        }

        return Ok();
    }

    // POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
    // Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
    // the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
    // track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
    // Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
    // is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
    // before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
    [ApiKeyAuthorize]
    [HttpPost("{trackId}/waveform/high-res")]
    public async Task<ActionResult> GenerateHighResWaveform(string trackId)
    {
        var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
        if (audio is null)
        {
            _logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
            return NotFound();
        }

        var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
            audio.Buffer, trackId, audio.Duration);
        if (!stored)
        {
            _logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
            return StatusCode(500, "Failed to generate high-res waveform datum.");
        }

        return Ok();
    }

    [ApiKeyAuthorize]
    [HttpPut("{trackId}")]
    public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
    {
        _logger.LogInformation("PutTrack called with trackId: {TrackId}", trackId);

        // Reject unknown MIME types up front rather than silently storing the binary
        // with a ".bin" extension. GetExtension returns ".bin" for any unrecognised
        // MIME, so treat that as the sentinel for an unsupported type.
        if (MimeTypeExtensions.GetExtension(track.Mime) == ".bin")
        {
            _logger.LogWarning("PutTrack rejected: unsupported MIME type '{Mime}' for track {TrackId}", track.Mime, trackId);
            return BadRequest($"Unsupported MIME type: {track.Mime}");
        }

        var audioBinary = AudioBinary.From(track);
        // Direct FileDatabase write: this endpoint receives an already-processed AudioBinaryDto,
        // not a WAV file, so TrackContentService.AddTrackFromWavAsync does not apply. See constructor comment.
        var success = await _fileDatabase.RegisterResourceAsync(
            DeepDrftContent.Constants.VaultConstants.Tracks, trackId, audioBinary);
        return success ? Ok() : BadRequest("Failed to store audio track");
    }
}
