using System.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class LegacyNamedPlaylistPersistenceServiceTests
{
    [Fact]
    public async Task ListAsync_UsesBoundOracleLimitAndReportsTruncation()
    {
        var query = new RecordingQueryExecutor(call =>
        {
            Assert.Equal(LegacyNamedPlaylistPersistenceService.ListQueryName, call.QueryName);
            Assert.Equal(DataSourceKind.Oracle, call.Source);
            Assert.Equal(LegacyNamedPlaylistPersistenceService.ListSql, call.Spec.Sql);
            var parameter = Assert.Single(call.Spec.Parameters);
            Assert.Equal("row_limit", parameter.Name);
            Assert.Equal(3, parameter.Value);
            Assert.Equal(DbType.Int32, parameter.DbType);
            return ListTable(
                ("00000001", "Alpha"),
                ("00000002", "Beta"),
                ("00000003", "Gamma"));
        });
        var service = new LegacyNamedPlaylistPersistenceService(query);

        var result = await service.ListAsync(2);

        Assert.True(result.IsTruncated);
        Assert.Equal(
            ["00000001", "00000002"],
            result.Items.Select(static item => item.ProgramCode));
        Assert.Equal(["Alpha", "Beta"], result.Items.Select(static item => item.Title));
        Assert.Single(query.Calls);
        Assert.Equal(0, query.StringSqlCallCount);
    }

    [Fact]
    public async Task ListAsync_AllowsDuplicateTitlesButRejectsDuplicateCodes()
    {
        var duplicateTitles = new RecordingQueryExecutor(_ =>
            ListTable(("00000001", "Morning"), ("00000002", "Morning")));
        var validService = new LegacyNamedPlaylistPersistenceService(duplicateTitles);

        var valid = await validService.ListAsync();

        Assert.Equal(2, valid.Items.Count);

        var duplicateCodes = new RecordingQueryExecutor(_ =>
            ListTable(("00000001", "Morning"), ("00000001", "Evening")));
        var invalidService = new LegacyNamedPlaylistPersistenceService(duplicateCodes);

        await Assert.ThrowsAsync<NamedPlaylistDataException>(() => invalidService.ListAsync());
    }

    [Fact]
    public async Task ListAsync_RejectsSchemaDriftAndInvalidLimit()
    {
        var wrongSchema = new DataTable();
        wrongSchema.Columns.Add("DC_TITLE", typeof(string));
        wrongSchema.Columns.Add("DC_CODE", typeof(string));
        var query = new RecordingQueryExecutor(_ => wrongSchema);
        var service = new LegacyNamedPlaylistPersistenceService(query);

        await Assert.ThrowsAsync<NamedPlaylistDataException>(() => service.ListAsync());
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ListAsync(0));
    }

    [Fact]
    public async Task SuggestNextProgramCodeAsync_PreservesOriginalEightDigitContract()
    {
        var query = new RecordingQueryExecutor(call =>
        {
            Assert.Equal(LegacyNamedPlaylistPersistenceService.NextCodeQueryName, call.QueryName);
            Assert.Equal(LegacyNamedPlaylistPersistenceService.NextCodeSql, call.Spec.Sql);
            Assert.Empty(call.Spec.Parameters);
            return NextCodeTable("00000124");
        });
        var service = new LegacyNamedPlaylistPersistenceService(query);

        var code = await service.SuggestNextProgramCodeAsync();

        Assert.Equal("00000124", code);
    }

    [Theory]
    [InlineData("124")]
    [InlineData("0000000A")]
    [InlineData("100000000")]
    public async Task SuggestNextProgramCodeAsync_RejectsInvalidDatabaseCode(string code)
    {
        var query = new RecordingQueryExecutor(_ => NextCodeTable(code));
        var service = new LegacyNamedPlaylistPersistenceService(query);

        await Assert.ThrowsAsync<NamedPlaylistDataException>(
            () => service.SuggestNextProgramCodeAsync());
    }

    [Fact]
    public async Task LoadAsync_ParsesOriginalSevenFieldRowsAndPageWhitespace()
    {
        var query = new RecordingQueryExecutor(call =>
        {
            Assert.Equal(LegacyNamedPlaylistPersistenceService.LoadQueryName, call.QueryName);
            Assert.Equal(LegacyNamedPlaylistPersistenceService.LoadSql, call.Spec.Sql);
            Assert.Collection(
                call.Spec.Parameters,
                parameter =>
                {
                    Assert.Equal("program_code", parameter.Name);
                    Assert.Equal("00000042", parameter.Value);
                    Assert.Equal(DbType.String, parameter.DbType);
                },
                parameter =>
                {
                    Assert.Equal("row_limit", parameter.Name);
                    Assert.Equal(1_001, parameter.Value);
                    Assert.Equal(DbType.Int32, parameter.DbType);
                });
            return LoadTable(
                ("00000042", "Opening", "1^KOSPI^삼성전자^1열판기본^현재가^ 1 / 2 ^005930", "0"),
                ("00000042", "Opening", "0^INDEX^코스피 지수^2열판^현재가^1/1^", "1"));
        });
        var service = new LegacyNamedPlaylistPersistenceService(query);

        var document = await service.LoadAsync("00000042");

        Assert.Equal(new NamedPlaylistSummary("00000042", "Opening"), document.Definition);
        Assert.Collection(
            document.Items,
            item =>
            {
                Assert.Equal(0, item.ItemIndex);
                Assert.True(item.IsEnabled);
                Assert.Equal("KOSPI", item.Selection.GroupCode);
                Assert.Equal("삼성전자", item.Selection.Subject);
                Assert.Equal("1열판기본", item.Selection.GraphicType);
                Assert.Equal("현재가", item.Selection.Subtype);
                Assert.Equal("005930", item.Selection.DataCode);
                Assert.Equal(new NamedPlaylistPageState(1, 2), item.Page);
            },
            item =>
            {
                Assert.Equal(1, item.ItemIndex);
                Assert.False(item.IsEnabled);
                Assert.Equal("INDEX", item.Selection.GroupCode);
                Assert.Equal(string.Empty, item.Selection.DataCode);
                Assert.Equal(new NamedPlaylistPageState(1, 1), item.Page);
            });
    }

    [Fact]
    public async Task LoadAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
    {
        var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
        Assert.True(names.Length > 256);
        Assert.True($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^".Length <= 4_000);
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => LoadTable(
                ("00000001", "VI", $"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", "0"))));

        var document = await service.LoadAsync("00000001");

        Assert.Equal(names, Assert.Single(document.Items).Selection.Subject);
    }

    [Fact]
    public async Task LoadAsync_ReturnsEmptyDocumentFromLeftJoinSentinel()
    {
        var table = LoadTable();
        table.Rows.Add("00000007", "Empty", DBNull.Value, DBNull.Value);
        var query = new RecordingQueryExecutor(_ => table);
        var service = new LegacyNamedPlaylistPersistenceService(query);

        var document = await service.LoadAsync("00000007");

        Assert.Empty(document.Items);
        Assert.Equal("Empty", document.Definition.Title);
    }

    [Fact]
    public async Task LoadAsync_RejectsMissingDefinitionAndNonContiguousIndexes()
    {
        var notFound = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => LoadTable()));
        await Assert.ThrowsAsync<NamedPlaylistNotFoundException>(
            () => notFound.LoadAsync("00000001"));

        var gap = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => LoadTable(
                ("00000001", "Gap", "1^KOSPI^Alpha^Plate^Current^1/1^000001", "1"))));
        await Assert.ThrowsAsync<NamedPlaylistDataException>(
            () => gap.LoadAsync("00000001"));
    }

    [Theory]
    [InlineData("1^KOSPI^Alpha^Plate^Current^1/1")]
    [InlineData("yes^KOSPI^Alpha^Plate^Current^1/1^000001")]
    [InlineData("1^KOSPI^Alpha^Plate^Current^0/1^000001")]
    [InlineData("1^KOSPI^Alpha^Plate^Current^1/10000^000001")]
    [InlineData("1^KOSPI^Al\npha^Plate^Current^1/1^000001")]
    public async Task LoadAsync_RejectsMalformedLegacyListText(string listText)
    {
        var query = new RecordingQueryExecutor(_ =>
            LoadTable(("00000001", "Malformed", listText, "0")));
        var service = new LegacyNamedPlaylistPersistenceService(query);

        await Assert.ThrowsAsync<NamedPlaylistDataException>(
            () => service.LoadAsync("00000001"));
    }

    [Fact]
    public async Task LoadAsync_PreservesHistoricalPageDisplayOutsideCurrentPlayoutBound()
    {
        var query = new RecordingQueryExecutor(_ => LoadTable(
            ("00000001", "Historical", "1^KOSPI^Alpha^Plate^Current^1/0^000001", "0"),
            ("00000001", "Historical", "1^KOSPI^Beta^Plate^Current^1/24^000002", "1")));
        var service = new LegacyNamedPlaylistPersistenceService(query);

        var document = await service.LoadAsync("00000001");

        Assert.Collection(
            document.Items,
            item =>
            {
                Assert.Equal(new NamedPlaylistPageState(1, 0), item.Page);
                Assert.False(item.Page!.IsWithinPlayoutBounds);
            },
            item =>
            {
                Assert.Equal(new NamedPlaylistPageState(1, 24), item.Page);
                Assert.False(item.Page!.IsWithinPlayoutBounds);
            });
        Assert.True(new NamedPlaylistPageState(4, 20).IsWithinPlayoutBounds);
    }

    [Fact]
    public async Task CreateDefinitionAsync_BindsTitleAndNeverAddsItToSql()
    {
        var mutations = new RecordingMutationExecutor(SuccessResult);
        var query = new RecordingQueryExecutor(_ => throw new InvalidOperationException());
        var service = new LegacyNamedPlaylistPersistenceService(query, mutations);

        await service.CreateDefinitionAsync("00000009", "O'Brien 오전");

        var call = Assert.Single(mutations.Calls);
        Assert.Equal(DataSourceKind.Oracle, call.Source);
        Assert.Equal(NamedPlaylistMutationKind.CreateDefinition, call.Transaction.Kind);
        Assert.Equal("00000009", call.Transaction.ProgramCode);
        var command = Assert.Single(call.Transaction.Commands);
        Assert.Equal(NamedPlaylistDbCommandKind.InsertDefinition, command.Kind);
        Assert.Equal(LegacyNamedPlaylistPersistenceService.InsertDefinitionSql, command.Sql);
        Assert.DoesNotContain("O'Brien", command.Sql, StringComparison.Ordinal);
        Assert.Collection(
            command.Parameters,
            parameter => AssertParameter(parameter, "program_code", "00000009", DbType.String),
            parameter => AssertParameter(parameter, "program_title", "O'Brien 오전", DbType.String));
        Assert.Empty(query.Calls);
    }

    [Fact]
    public async Task ReplaceItemsAsync_AtomicallyDeletesThenInsertsInVisibleOrder()
    {
        var mutations = new RecordingMutationExecutor(call =>
            Result(call, [7, 1, 1]));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
            mutations);
        var items = new[]
        {
            Item(0, true, "KOSPI", "삼성전자", "1열판기본", "현재가", "005930", 1, 1),
            Item(1, false, "THEME", "반도체", "5단 표그래프", "상승률순", "00000012", 2, 3)
        };

        await service.ReplaceItemsAsync("00000011", items);

        var transaction = Assert.Single(mutations.Calls).Transaction;
        Assert.Equal(NamedPlaylistMutationKind.ReplaceItems, transaction.Kind);
        Assert.Collection(
            transaction.Commands,
            command =>
            {
                Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind);
                Assert.Equal(LegacyNamedPlaylistPersistenceService.DeleteItemsSql, command.Sql);
                AssertParameter(Assert.Single(command.Parameters), "program_code", "00000011", DbType.String);
            },
            command => AssertInsertItem(
                command,
                "1^KOSPI^삼성전자^1열판기본^현재가^1/1^005930",
                0),
            command => AssertInsertItem(
                command,
                "0^THEME^반도체^5단 표그래프^상승률순^2/3^00000012",
                1));
    }

    [Fact]
    public async Task ReplaceItemsAsync_PreservesClosedThemeEdgeSpacesAndOneNxtTransportSuffix()
    {
        var mutations = new RecordingMutationExecutor(call => Result(call, [0, 1]));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
            mutations);
        var item = Item(
            0,
            true,
            "테마_NXT",
            "  Robotics(NXT)(NXT)",
            "5단 표그래프",
            "테마-현재가(입력순)",
            "00000002",
            1,
            1);

        await service.ReplaceItemsAsync("00000011", [item]);

        var insert = Assert.Single(
            Assert.Single(mutations.Calls).Transaction.Commands,
            command => command.Kind == NamedPlaylistDbCommandKind.InsertItem);
        AssertParameter(
            insert.Parameters[1],
            "list_text",
            "1^테마_NXT^  Robotics(NXT)(NXT)^5단 표그래프^테마-현재가(입력순)^1/1^00000002",
            DbType.String);
    }

    [Fact]
    public async Task ReplaceItemsAsync_DoesNotRelaxEdgeSpacesForGenericRows()
    {
        var mutations = new RecordingMutationExecutor(SuccessResult);
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
            mutations);

        await Assert.ThrowsAsync<ArgumentException>(() => service.ReplaceItemsAsync(
            "00000011",
            [Item(0, true, "GENERIC", "  Free form  ", "G", "S", "123", 1, 1)]));

        Assert.Empty(mutations.Calls);
    }

    [Fact]
    public async Task ReplaceItemsAsync_AllowsAnEmptyAtomicReplacement()
    {
        var mutations = new RecordingMutationExecutor(call => Result(call, [3]));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
            mutations);

        await service.ReplaceItemsAsync("00000001", []);

        var command = Assert.Single(Assert.Single(mutations.Calls).Transaction.Commands);
        Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind);
    }

    [Fact]
    public async Task ReplaceItemsAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
    {
        var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
        var mutations = new RecordingMutationExecutor(call => Result(call, [0, 1]));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
            mutations);

        await service.ReplaceItemsAsync(
            "00000001",
            [Item(0, true, "", names, "5단 표그래프", "VI 발동(수동)", "", 1, 20)]);

        var insert = Assert.Single(
            Assert.Single(mutations.Calls).Transaction.Commands,
            command => command.Kind == NamedPlaylistDbCommandKind.InsertItem);
        var listText = Assert.Single(
            insert.Parameters,
            parameter => parameter.Name == "list_text");
        Assert.Equal($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", listText.Value);
    }

    [Fact]
    public async Task ReplaceItemsAsync_RejectsGapsAndCaretInjectionBeforeMutation()
    {
        var mutations = new RecordingMutationExecutor(SuccessResult);
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
            mutations);

        await Assert.ThrowsAsync<ArgumentException>(() => service.ReplaceItemsAsync(
            "00000001",
            [Item(1, true, "KOSPI", "Alpha", "Plate", "Current", "", 1, 1)]));
        await Assert.ThrowsAsync<ArgumentException>(() => service.ReplaceItemsAsync(
            "00000001",
            [Item(0, true, "KOSPI", "Alpha^Beta", "Plate", "Current", "", 1, 1)]));

        Assert.Empty(mutations.Calls);
    }

    [Fact]
    public async Task DeleteAsync_DeletesChildrenThenDefinitionInOneTransaction()
    {
        var mutations = new RecordingMutationExecutor(call => Result(call, [4, 1]));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
            mutations);

        await service.DeleteAsync("00000012");

        var transaction = Assert.Single(mutations.Calls).Transaction;
        Assert.Equal(NamedPlaylistMutationKind.DeletePlaylist, transaction.Kind);
        Assert.Collection(
            transaction.Commands,
            command => Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind),
            command => Assert.Equal(NamedPlaylistDbCommandKind.DeleteDefinition, command.Kind));
    }

    [Fact]
    public async Task Mutations_RequireConfiguredWriterAndHonorPreCancellation()
    {
        var readOnly = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => ListTable()));
        Assert.False(readOnly.CanMutate);
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => readOnly.DeleteAsync("00000001"));

        var mutations = new RecordingMutationExecutor(SuccessResult);
        var writable = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => ListTable()),
            mutations);
        Assert.True(writable.CanMutate);
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => writable.DeleteAsync("00000001", cancellation.Token));
        Assert.Empty(mutations.Calls);
    }

    [Fact]
    public async Task MutationExecutorUnknownFailure_IsMarkedOutcomeUnknownWithoutRetry()
    {
        var mutations = new RecordingMutationExecutor(_ =>
            throw new TimeoutException("simulated timeout"));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => ListTable()),
            mutations);

        var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
            () => service.DeleteAsync("00000001"));

        Assert.True(exception.OutcomeUnknown);
        Assert.Single(mutations.Calls);
        Assert.Contains("do not retry", exception.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task MutationExecutorKnownRollback_IsPreserved()
    {
        var expected = new NamedPlaylistMutationException(
            "Unique constraint; rolled back.",
            outcomeUnknown: false);
        var mutations = new RecordingMutationExecutor(_ => throw expected);
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => ListTable()),
            mutations);

        var actual = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
            () => service.CreateDefinitionAsync("00000001", "Duplicate"));

        Assert.Same(expected, actual);
        Assert.False(actual.OutcomeUnknown);
        Assert.Single(mutations.Calls);
    }

    [Fact]
    public async Task AmbiguousMutationResult_IsMarkedOutcomeUnknown()
    {
        var mutations = new RecordingMutationExecutor(call => Result(call, [0]));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => ListTable()),
            mutations);

        var exception = await Assert.ThrowsAsync<NamedPlaylistMutationException>(
            () => service.CreateDefinitionAsync("00000001", "No row"));

        Assert.True(exception.OutcomeUnknown);
    }

    [Fact]
    public async Task DeleteMissingDefinition_ReportsNotFoundAfterKnownCommit()
    {
        var mutations = new RecordingMutationExecutor(call => Result(call, [0, 0]));
        var service = new LegacyNamedPlaylistPersistenceService(
            new RecordingQueryExecutor(_ => ListTable()),
            mutations);

        await Assert.ThrowsAsync<NamedPlaylistNotFoundException>(
            () => service.DeleteAsync("00000001"));
    }

    private static NamedPlaylistStoredItem Item(
        int index,
        bool enabled,
        string groupCode,
        string subject,
        string graphicType,
        string subtype,
        string dataCode,
        int currentPage,
        int totalPages) =>
        new(
            index,
            enabled,
            new LegacySceneSelection(groupCode, subject, graphicType, subtype, dataCode),
            new NamedPlaylistPageState(currentPage, totalPages));

    private static void AssertInsertItem(
        NamedPlaylistDbCommand command,
        string expectedListText,
        int expectedIndex)
    {
        Assert.Equal(NamedPlaylistDbCommandKind.InsertItem, command.Kind);
        Assert.Equal(LegacyNamedPlaylistPersistenceService.InsertItemSql, command.Sql);
        Assert.Equal(11, command.Parameters.Count);
        AssertParameter(command.Parameters[0], "program_code", "00000011", DbType.String);
        AssertParameter(command.Parameters[1], "list_text", expectedListText, DbType.String);
        foreach (var parameter in command.Parameters.Skip(2).Take(8))
        {
            Assert.Null(parameter.Value);
            Assert.Equal(DbType.String, parameter.DbType);
        }

        AssertParameter(command.Parameters[10], "item_index", expectedIndex, DbType.Int32);
    }

    private static void AssertParameter(
        NamedPlaylistDbParameter parameter,
        string name,
        object? value,
        DbType dbType)
    {
        Assert.Equal(name, parameter.Name);
        Assert.Equal(value, parameter.Value);
        Assert.Equal(dbType, parameter.DbType);
    }

    private static DataTable ListTable(params (string Code, string Title)[] rows)
    {
        var table = new DataTable();
        table.Columns.Add("DC_CODE", typeof(string));
        table.Columns.Add("DC_TITLE", typeof(string));
        foreach (var row in rows)
        {
            table.Rows.Add(row.Code, row.Title);
        }

        return table;
    }

    private static DataTable NextCodeTable(string code)
    {
        var table = new DataTable();
        table.Columns.Add("DC_CODE", typeof(string));
        table.Rows.Add(code);
        return table;
    }

    private static DataTable LoadTable(
        params (string Code, string Title, string ListText, string ItemIndex)[] rows)
    {
        var table = new DataTable();
        table.Columns.Add("DC_CODE", typeof(string));
        table.Columns.Add("DC_TITLE", typeof(string));
        table.Columns.Add("LIST_TEXT", typeof(string));
        table.Columns.Add("ITEM_INDEX", typeof(string));
        foreach (var row in rows)
        {
            table.Rows.Add(row.Code, row.Title, row.ListText, row.ItemIndex);
        }

        return table;
    }

    private static NamedPlaylistDbTransactionResult SuccessResult(MutationCall call) =>
        Result(
            call,
            call.Transaction.Commands.Select(static command =>
                command.Kind is NamedPlaylistDbCommandKind.DeleteItems ? 0 : 1).ToArray());

    private static NamedPlaylistDbTransactionResult Result(
        MutationCall call,
        IReadOnlyList<int> affectedRows) =>
        new(call.Transaction.OperationId, affectedRows, DateTimeOffset.UtcNow);

    private sealed record QueryCall(
        DataSourceKind Source,
        string QueryName,
        DataQuerySpec Spec,
        CancellationToken CancellationToken);

    private sealed class RecordingQueryExecutor(Func<QueryCall, DataTable> handler)
        : IDataQueryExecutor
    {
        private int _stringSqlCallCount;

        public List<QueryCall> Calls { get; } = [];

        public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);

        public DataTable Execute(DataSourceKind source, string tableName, string query)
        {
            Interlocked.Increment(ref _stringSqlCallCount);
            throw new InvalidOperationException("String SQL fallback is forbidden.");
        }

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            string query,
            CancellationToken cancellationToken = default)
        {
            Interlocked.Increment(ref _stringSqlCallCount);
            throw new InvalidOperationException("String SQL fallback is forbidden.");
        }

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            DataQuerySpec query,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            query.ValidateFor(source);
            var call = new QueryCall(source, tableName, query, cancellationToken);
            Calls.Add(call);
            return Task.FromResult(handler(call).Copy());
        }
    }

    private sealed record MutationCall(
        DataSourceKind Source,
        NamedPlaylistDbTransaction Transaction,
        CancellationToken CancellationToken);

    private sealed class RecordingMutationExecutor(
        Func<MutationCall, NamedPlaylistDbTransactionResult> handler)
        : INamedPlaylistMutationExecutor
    {
        public List<MutationCall> Calls { get; } = [];

        public Task<NamedPlaylistDbTransactionResult> ExecuteTransactionAsync(
            DataSourceKind source,
            NamedPlaylistDbTransaction transaction,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var call = new MutationCall(source, transaction, cancellationToken);
            Calls.Add(call);
            return Task.FromResult(handler(call));
        }
    }
}
