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

namespace DeepDrftTests;

/// <summary>
/// Phase 9.5 medium write-path coverage. Exercises the SQL layer that carries the medium through the
/// upload and edit flows (TrackManager + TrackRepository), plus the releaseId track-resolution filter
/// (9.5.C). Runs on the EF in-memory provider, which executes every predicate here in process —
/// release creation, the no-mutation-on-find rule, the medium update + ReleaseType reset, and exact
/// releaseId equality.
///
/// The controller-level form/JSON parse and the ReleaseType-reset conditional (9.5.B) live in
/// TrackController; this fixture asserts the persisted outcome of that logic by driving the same
/// service surface the controller calls (FindOrCreateRelease for upload, ITrackService.Update for
/// the meta edit), so a regression in the data layer that backs the medium write path is caught.
/// </summary>
[TestFixture]
public class MediumWritePathTests
{
    private DeepDrftContext _context = null!;

    [SetUp]
    public void SetUp()
    {
        var options = new DbContextOptionsBuilder<DeepDrftContext>()
            .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
            .Options;
        _context = new DeepDrftContext(options);
    }

    [TearDown]
    public void TearDown() => _context.Dispose();

    private TrackRepository CreateRepository()
        => new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);

    private TrackManager CreateManager(TrackRepository repository)
        => new(repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);

    private static ReleaseDto ReleaseData(string title, string artist, ReleaseMedium medium)
        => new() { Title = title, Artist = artist, Medium = medium };

    // 9.5.A — a Session upload creates a release carrying Medium == Session.
    [Test]
    public async Task FindOrCreateRelease_NewSessionRelease_PersistsMediumSession()
    {
        var manager = CreateManager(CreateRepository());

        var result = await manager.FindOrCreateRelease(
            "Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));

        Assert.That(result.Success, Is.True);
        Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session));

        var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
        Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
    }

    // 9.5.A — a Mix upload creates a release carrying Medium == Mix.
    [Test]
    public async Task FindOrCreateRelease_NewMixRelease_PersistsMediumMix()
    {
        var manager = CreateManager(CreateRepository());

        var result = await manager.FindOrCreateRelease(
            "Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix));

        Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Mix));
    }

    // 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
    [Test]
    public async Task FindOrCreateRelease_NewCutRelease_PersistsMediumCut()
    {
        var manager = CreateManager(CreateRepository());

        var result = await manager.FindOrCreateRelease(
            "Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut));

        Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Cut));
    }

    // 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
    // upload's medium is authoritative; a Cut-typed follow-up upload must not flip a Session release.
    [Test]
    public async Task FindOrCreateRelease_ExistingRelease_DoesNotMutateMedium()
    {
        var repo = CreateRepository();
        var manager = CreateManager(repo);

        var created = await manager.FindOrCreateRelease(
            "Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));

        // Second add to the same (title, artist) arrives carrying Cut — the find path must ignore it.
        var found = await manager.FindOrCreateRelease(
            "Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut));

        Assert.That(found.Value.Release.Id, Is.EqualTo(created.Value.Release.Id), "same release row is returned");
        Assert.That(found.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");

        var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Release.Id);
        Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
    }

    // 9.5.B — updating a track's release to a non-Cut medium persists the new medium. Mirrors the
    // PUT api/track/meta apply: the controller sets release.Medium, the manager saves the linked release.
    [Test]
    public async Task Update_FlipsCutReleaseToSession_PersistsMedium()
    {
        var repo = CreateRepository();
        ITrackService manager = CreateManager(repo);

        var release = new ReleaseEntity
        {
            EntryKey = Guid.NewGuid().ToString("N"), Title = "Originally a Cut", Artist = "Artist A",
            Medium = ReleaseMedium.Cut, ReleaseType = ReleaseType.EP,
        };
        var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release };
        _context.Tracks.Add(track);
        await _context.SaveChangesAsync();

        var loaded = (await manager.GetById(track.Id)).Value!;
        loaded.Release!.Medium = ReleaseMedium.Session;
        // The controller resets ReleaseType to the default when medium goes non-Cut; replicate so the
        // edited DTO matches what the controller would persist.
        loaded.Release.ReleaseType = ReleaseType.Single;

        var result = await manager.Update(loaded);
        Assert.That(result.Success, Is.True);

        var stored = await CreateRepository().GetReleaseByIdAsync(release.Id);
        Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
        Assert.That(stored.ReleaseType, Is.EqualTo(ReleaseType.Single), "ReleaseType reset to default for a non-Cut medium");
    }

    // 9.5.B — the read-path converter already enforces the ReleaseType-only-for-Cut invariant: a
    // non-Cut release surfaces a null ReleaseType regardless of the stale column value. This is the
    // invariant the write-path reset mirrors, asserted at the single mapping point.
    [Test]
    public void Convert_NonCutRelease_NullsReleaseTypeOnRead()
    {
        var sessionWithStaleType = new ReleaseEntity
        {
            EntryKey = Guid.NewGuid().ToString("N"), Title = "Session", Artist = "A",
            Medium = ReleaseMedium.Session, ReleaseType = ReleaseType.Album,
        };

        var dto = TrackConverter.Convert(sessionWithStaleType);

        Assert.That(dto.Medium, Is.EqualTo(ReleaseMedium.Session));
        Assert.That(dto.ReleaseType, Is.Null);
    }

    // 11.G — Description round-trips through both converter directions verbatim (no medium dance,
    // unlike ReleaseType): entity → DTO preserves the prose, and DTO → entity carries it back.
    [Test]
    public void Convert_Description_RoundTripsBothDirections()
    {
        const string prose = "A late-night set\nrecorded at the Vault.";
        var entity = new ReleaseEntity
        {
            EntryKey = "rk-desc", Title = "Live at the Vault", Artist = "Artist A",
            Medium = ReleaseMedium.Session, Description = prose,
        };

        var dto = TrackConverter.Convert(entity);
        Assert.That(dto.Description, Is.EqualTo(prose), "entity → DTO preserves Description");

        var back = TrackConverter.Convert(dto);
        Assert.That(back.Description, Is.EqualTo(prose), "DTO → entity preserves Description");
    }

    // 11.G — a null Description round-trips as null in both directions (existing rows migrate as NULL).
    [Test]
    public void Convert_NullDescription_RoundTripsAsNull()
    {
        var entity = new ReleaseEntity { EntryKey = "rk-nulldesc", Title = "Studio Album", Artist = "Artist C", Description = null };

        var dto = TrackConverter.Convert(entity);
        Assert.That(dto.Description, Is.Null);

        var back = TrackConverter.Convert(dto);
        Assert.That(back.Description, Is.Null);
    }

    // 11.G — Description rides the release-cardinal write channel onto the persisted release row,
    // exactly as Genre does. FindOrCreateRelease is the upload-path projection point.
    [Test]
    public async Task FindOrCreateRelease_NewRelease_PersistsDescription()
    {
        const string prose = "Three cuts pressed for the summer.";
        var manager = CreateManager(CreateRepository());

        var data = ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut);
        data.Description = prose;

        var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);

        Assert.That(result.Success, Is.True);
        Assert.That(result.Value.Release.Description, Is.EqualTo(prose));

        var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
        Assert.That(stored!.Description, Is.EqualTo(prose));
    }

    // 11.C — editing a track's linked release sets the Description on the persisted release row,
    // mirroring the PUT api/track/meta apply (release.Description = request.Description).
    [Test]
    public async Task Update_SetsReleaseDescription_PersistsDescription()
    {
        const string prose = "Now with a proper blurb.";
        var repo = CreateRepository();
        ITrackService manager = CreateManager(repo);

        var release = new ReleaseEntity { EntryKey = "rk-editdesc", Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut };
        var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release };
        _context.Tracks.Add(track);
        await _context.SaveChangesAsync();

        var loaded = (await manager.GetById(track.Id)).Value!;
        loaded.Release!.Description = prose;

        var result = await manager.Update(loaded);
        Assert.That(result.Success, Is.True);

        var stored = await CreateRepository().GetReleaseByIdAsync(release.Id);
        Assert.That(stored!.Description, Is.EqualTo(prose));
    }

    // 9.5.C — releaseId filter returns only the tracks of the given release. Built on the repository
    // directly to assert the WHERE release_id predicate in isolation.
    [Test]
    public async Task GetPagedFilteredAsync_WithReleaseId_ReturnsOnlyThatReleasesTracks()
    {
        var first = new ReleaseEntity { EntryKey = "rk-first", Title = "Untitled", Artist = "Artist A" };
        var second = new ReleaseEntity { EntryKey = "rk-second", Title = "Untitled", Artist = "Artist B" };
        _context.Tracks.AddRange(
            new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
            new TrackEntity { EntryKey = "a2", TrackName = "A-Two", Release = first },
            new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second });
        await _context.SaveChangesAsync();

        var repo = CreateRepository();
        var paging = new PagingParameters<TrackEntity> { Page = 1, PageSize = 20, OrderBy = t => t.Id };

        var result = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = first.Id });

        Assert.That(result.TotalCount, Is.EqualTo(2));
        Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "A-One", "A-Two" }));
    }

    // 9.5.C — two same-titled releases resolve distinctly by id, the exact failure album-title join
    // could not survive. Each releaseId returns only its own track.
    [Test]
    public async Task GetPagedFilteredAsync_SameTitledReleases_ResolveDistinctlyById()
    {
        var first = new ReleaseEntity { EntryKey = "rk-first2", Title = "Untitled", Artist = "Artist A" };
        var second = new ReleaseEntity { EntryKey = "rk-second2", Title = "Untitled", Artist = "Artist B" };
        _context.Tracks.AddRange(
            new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
            new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second });
        await _context.SaveChangesAsync();

        var repo = CreateRepository();
        var paging = new PagingParameters<TrackEntity> { Page = 1, PageSize = 20, OrderBy = t => t.Id };

        var firstResult = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = first.Id });
        var secondResult = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = second.Id });

        Assert.That(firstResult.Items.Single().TrackName, Is.EqualTo("A-One"));
        Assert.That(secondResult.Items.Single().TrackName, Is.EqualTo("B-One"));
    }

    // 9.5.C — TrackFilter.IsEmpty accounts for ReleaseId, so a releaseId-only filter is not collapsed
    // to a null passthrough by the manager's effectiveFilter guard.
    [Test]
    public void TrackFilter_WithOnlyReleaseId_IsNotEmpty()
    {
        Assert.That(new TrackFilter { ReleaseId = 5 }.IsEmpty, Is.False);
        Assert.That(new TrackFilter().IsEmpty, Is.True);
    }

    // 9.7 — the cardinality declaration is the single source of truth read by both the upload service
    // and the CMS form collapse. Guard the declared ranges so a drift in MediumRules is caught here.
    [Test]
    public void MediumRules_CardinalityOf_DeclaresExpectedRanges()
    {
        var cut = MediumRules.CardinalityOf(ReleaseMedium.Cut);
        Assert.That(cut.Min, Is.EqualTo(1));
        Assert.That(cut.Max, Is.EqualTo(int.MaxValue));
        Assert.That(cut.IsSingleTrack, Is.False);

        var session = MediumRules.CardinalityOf(ReleaseMedium.Session);
        Assert.That(session.Min, Is.EqualTo(1));
        Assert.That(session.Max, Is.EqualTo(1));
        Assert.That(session.IsSingleTrack, Is.True);

        var mix = MediumRules.CardinalityOf(ReleaseMedium.Mix);
        Assert.That(mix.Min, Is.EqualTo(1));
        Assert.That(mix.Max, Is.EqualTo(1));
        Assert.That(mix.IsSingleTrack, Is.True);
    }

    // 9.7 — Allows() bands. The first track always fits; a single-track medium rejects the second; an
    // unbounded medium accepts any positive count.
    [Test]
    public void MediumCardinality_Allows_HonoursTheBand()
    {
        var single = MediumRules.CardinalityOf(ReleaseMedium.Session);
        Assert.That(single.Allows(1), Is.True);
        Assert.That(single.Allows(2), Is.False);

        var many = MediumRules.CardinalityOf(ReleaseMedium.Cut);
        Assert.That(many.Allows(1), Is.True);
        Assert.That(many.Allows(50), Is.True);
    }

    // 9.7 — GetReleaseByTitleAndArtist is the read-only peek the upload pre-check reads. It surfaces
    // the stored medium and the live-track count without creating a release. Null on miss.
    [Test]
    public async Task GetReleaseByTitleAndArtist_ExistingRelease_ReturnsMediumAndLiveCount()
    {
        var repo = CreateRepository();
        ITrackService manager = CreateManager(repo);

        var release = new ReleaseEntity { EntryKey = "rk-peek", Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session };
        _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release });
        await _context.SaveChangesAsync();

        var peek = await manager.GetReleaseByTitleAndArtist("Live at the Vault", "Artist A");

        Assert.That(peek.Success, Is.True);
        Assert.That(peek.Value, Is.Not.Null);
        Assert.That(peek.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
        Assert.That(peek.Value.TrackCount, Is.EqualTo(1));
    }

    [Test]
    public async Task GetReleaseByTitleAndArtist_NoSuchRelease_ReturnsNullWithoutCreating()
    {
        ITrackService manager = CreateManager(CreateRepository());

        var peek = await manager.GetReleaseByTitleAndArtist("Nothing Here", "Nobody");

        Assert.That(peek.Success, Is.True);
        Assert.That(peek.Value, Is.Null);

        // The peek must not have created a release for a non-existent (title, artist).
        var releases = (await manager.GetReleases()).Value!;
        Assert.That(releases, Is.Empty);
    }

    // 9.7 — the cardinality decision the orchestrator makes, asserted over the SQL-layer seam it reads.
    // A Session release that already holds its single track REJECTS a second track-add: the peek's
    // live count + 1 exceeds the medium's Max. (The orchestrator's vault write spans the FileDatabase,
    // not reachable from this in-memory fixture — see the orphan-avoidance note in the handoff.)
    [Test]
    public async Task CardinalityDecision_SessionWithOneTrack_RejectsSecondAdd()
    {
        var repo = CreateRepository();
        ITrackService manager = CreateManager(repo);

        var release = new ReleaseEntity { EntryKey = "rk-cardses", Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session };
        _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release });
        await _context.SaveChangesAsync();

        var peek = (await manager.GetReleaseByTitleAndArtist("Live at the Vault", "Artist A")).Value!;
        var max = MediumRules.CardinalityOf(peek.Medium).Max;

        Assert.That(peek.TrackCount + 1 > max, Is.True, "second track-add to a single-track Session is over-limit");
    }

    // 9.7 — Mix mirrors Session: a Mix holding its one track rejects a second add.
    [Test]
    public async Task CardinalityDecision_MixWithOneTrack_RejectsSecondAdd()
    {
        var repo = CreateRepository();
        ITrackService manager = CreateManager(repo);

        var release = new ReleaseEntity { EntryKey = "rk-cardmix", Title = "Sunset Set", Artist = "DJ B", Medium = ReleaseMedium.Mix };
        _context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "The Set", Release = release });
        await _context.SaveChangesAsync();

        var peek = (await manager.GetReleaseByTitleAndArtist("Sunset Set", "DJ B")).Value!;
        var max = MediumRules.CardinalityOf(peek.Medium).Max;

        Assert.That(peek.TrackCount + 1 > max, Is.True);
    }

    // 9.7 — a Cut release accepts the 2nd and Nth track-add: the unbounded Max is never exceeded.
    [Test]
    public async Task CardinalityDecision_CutWithManyTracks_AcceptsFurtherAdds()
    {
        var repo = CreateRepository();
        ITrackService manager = CreateManager(repo);

        var release = new ReleaseEntity { EntryKey = "rk-cardcut", Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut };
        _context.Tracks.AddRange(
            new TrackEntity { EntryKey = "c1", TrackName = "One", Release = release },
            new TrackEntity { EntryKey = "c2", TrackName = "Two", Release = release },
            new TrackEntity { EntryKey = "c3", TrackName = "Three", Release = release });
        await _context.SaveChangesAsync();

        var peek = (await manager.GetReleaseByTitleAndArtist("Studio Album", "Artist C")).Value!;
        var max = MediumRules.CardinalityOf(peek.Medium).Max;

        Assert.That(peek.TrackCount, Is.EqualTo(3));
        Assert.That(peek.TrackCount + 1 > max, Is.False, "Cut is unbounded — a 4th track is admitted");
    }

    // 9.7 — the first track on a new Session/Mix succeeds: with no existing release the peek returns
    // null, so the pre-check never fires and the create path admits the 0→1 add (within 1..1).
    [Test]
    public async Task CardinalityDecision_FirstTrackOnNewSession_IsAdmitted()
    {
        ITrackService manager = CreateManager(CreateRepository());

        // No release exists yet for this (title, artist).
        var peek = await manager.GetReleaseByTitleAndArtist("Brand New Session", "Artist D");
        Assert.That(peek.Value, Is.Null, "no release means the cardinality pre-check is skipped — the create path admits the first track");
    }
}
