#nullable enable

using System.Data;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class LegacyForeignIndexCandleRestoreServiceTests
{
    public static TheoryData<string, string, string, string, int, string> SupportedProfiles =>
        new()
        {
            { "다우존스", "5일", "Dow", "DJI@DJI", 5, "8061" },
            { "다우존스", "20일", "Dow", "DJI@DJI", 20, "8040" },
            { "다우존스", "60일", "Dow", "DJI@DJI", 60, "8046" },
            { "다우존스", "120일", "Dow", "DJI@DJI", 120, "8051" },
            { "다우존스", "240일", "Dow", "DJI@DJI", 240, "8056" },
            { "나스닥", "5일", "Nasdaq", "NAS@IXIC", 5, "8061" },
            { "나스닥", "20일", "Nasdaq", "NAS@IXIC", 20, "8040" },
            { "나스닥", "60일", "Nasdaq", "NAS@IXIC", 60, "8046" },
            { "나스닥", "120일", "Nasdaq", "NAS@IXIC", 120, "8051" },
            { "나스닥", "240일", "Nasdaq", "NAS@IXIC", 240, "8056" },
            { "S&P500", "5일", "Sp500", "SPI@SPX", 5, "8061" },
            { "S&P500", "20일", "Sp500", "SPI@SPX", 20, "8040" },
            { "S&P500", "60일", "Sp500", "SPI@SPX", 60, "8046" },
            { "S&P500", "120일", "Sp500", "SPI@SPX", 120, "8051" },
            { "S&P500", "240일", "Sp500", "SPI@SPX", 240, "8056" }
        };

    [Theory]
    [MemberData(nameof(SupportedProfiles))]
    public async Task ResolveAsync_VerifiesEveryClosedTargetAndPeriodExactly(
        string subject,
        string period,
        string targetKey,
        string symbol,
        int periodDays,
        string cutCode)
    {
        var executor = new RecordingExecutor(call =>
        {
            Assert.Equal(DataSourceKind.Oracle, call.Source);
            Assert.Equal(LegacyForeignIndexCandleRestoreService.QueryName, call.QueryName);
            call.Spec.ValidateFor(DataSourceKind.Oracle);
            Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("F_SYMB = :symbol", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("F_NATC = 'US'", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("F_FDTC = '0'", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
            Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain(subject, call.Spec.Sql, StringComparison.Ordinal);
            Assert.DoesNotContain(symbol, call.Spec.Sql, StringComparison.Ordinal);
            Assert.Collection(
                call.Spec.Parameters,
                parameter =>
                {
                    Assert.Equal("input_name", parameter.Name);
                    Assert.Equal(subject, parameter.Value);
                    Assert.Equal(DbType.String, parameter.DbType);
                },
                parameter =>
                {
                    Assert.Equal("symbol", parameter.Name);
                    Assert.Equal(symbol, parameter.Value);
                    Assert.Equal(DbType.String, parameter.DbType);
                });
            return IdentityTable((subject, symbol, "US", "0"));
        });
        var service = new LegacyForeignIndexCandleRestoreService(executor);
        var row = Row(17, subject, period);

        Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
        var result = await service.ResolveAsync([row]);

        var outcome = Assert.Single(result.Rows);
        Assert.Equal(17, outcome.ItemIndex);
        Assert.True(outcome.IsResolved);
        Assert.Null(outcome.Failure);
        Assert.Equal(
            new LegacyForeignIndexCandleRestoreIdentity(
                targetKey,
                subject,
                symbol,
                "US",
                "0",
                "s8010",
                "PRICE",
                periodDays,
                cutCode),
            outcome.Identity);
        Assert.Single(executor.Calls);
        Assert.Equal(0, executor.StringSqlCallCount);
    }

    [Fact]
    public async Task ResolveAsync_PreservesBatchOrderAndCachesEachTargetIdentity()
    {
        var executor = new RecordingExecutor(call =>
        {
            var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
            var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
            return IdentityTable((inputName, symbol, "US", "0"));
        });
        var service = new LegacyForeignIndexCandleRestoreService(executor);
        var rows = new[]
        {
            Row(900, "S&P500", "240일"),
            Row(2, "다우존스", "20일"),
            Row(701, "나스닥", "5일"),
            Row(3, "다우존스", "5일")
        };

        var result = await service.ResolveAsync(rows);

        Assert.Equal([900, 2, 701, 3], result.Rows.Select(row => row.ItemIndex));
        Assert.Equal(
            ["Sp500", "Dow", "Nasdaq", "Dow"],
            result.Rows.Select(row => row.Identity!.TargetKey));
        Assert.Equal([240, 20, 5, 5], result.Rows.Select(row => row.Identity!.PeriodDays));
        Assert.Equal(3, executor.Calls.Count);
        Assert.Equal(
            ["S&P500", "다우존스", "나스닥"],
            executor.Calls.Select(call => call.Spec.Parameters[0].Value));
    }

    [Fact]
    public async Task ResolveAsync_ReturnsNotFoundAmbiguousAndInvalidPerRow()
    {
        var executor = new RecordingExecutor(call =>
        {
            var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
            var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
            return inputName switch
            {
                "다우존스" => IdentityTable(),
                "나스닥" => IdentityTable(
                    (inputName, symbol, "US", "0"),
                    (inputName, symbol, "US", "0")),
                "S&P500" => WrongSchemaTable(),
                _ => throw new InvalidOperationException(inputName)
            };
        });
        var service = new LegacyForeignIndexCandleRestoreService(executor);

        var result = await service.ResolveAsync(
        [
            Row(10, "다우존스", "5일"),
            Row(11, "나스닥", "20일"),
            Row(12, "S&P500", "60일")
        ]);

        Assert.Equal(
            new LegacyForeignIndexCandleRestoreFailure?[]
            {
                LegacyForeignIndexCandleRestoreFailure.IdentityNotFound,
                LegacyForeignIndexCandleRestoreFailure.IdentityAmbiguous,
                LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid
            },
            result.Rows.Select(row => row.Failure));
        Assert.All(result.Rows, row =>
        {
            Assert.False(row.IsResolved);
            Assert.Null(row.Identity);
        });
    }

    [Theory]
    [InlineData("다우", "5일")]
    [InlineData("DOW", "5일")]
    [InlineData(" 다우존스", "5일")]
    [InlineData("다우존스", "5")]
    [InlineData("다우존스", "240")]
    [InlineData("다우존스", " 240일")]
    public async Task ResolveAsync_RejectsNonExactRawRowsWithoutDatabaseAccess(
        string subject,
        string period)
    {
        var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
        var service = new LegacyForeignIndexCandleRestoreService(executor);
        var row = Row(0, subject, period);

        Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
        var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);

        Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
        Assert.Null(outcome.Identity);
        Assert.Empty(executor.Calls);
    }

    [Theory]
    [InlineData("FOREIGN_INDEX", "캔들그래프", "1/1", "")]
    [InlineData(" 해외지수", "캔들그래프", "1/1", "")]
    [InlineData("해외지수", "캔들 그래프", "1/1", "")]
    [InlineData("해외지수", "CANDLE", "1/1", "")]
    [InlineData("해외지수", "캔들그래프", "01/01", "")]
    [InlineData("해외지수", "캔들그래프", "1/2", "")]
    [InlineData("해외지수", "캔들그래프", "1/1", "DJI@DJI")]
    public async Task ResolveAsync_RevalidatesFullRawPlaylistBoundary(
        string groupCode,
        string graphicType,
        string pageText,
        string dataCode)
    {
        var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
        var service = new LegacyForeignIndexCandleRestoreService(executor);
        var row = Row(
            groupCode: groupCode,
            graphicType: graphicType,
            pageText: pageText,
            dataCode: dataCode);

        Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
        var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);

        Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task ResolveAsync_AllowsDisabledRowSoCallerCanPreserveEnabledState()
    {
        var executor = new RecordingExecutor(call => IdentityTable((
            Assert.IsType<string>(call.Spec.Parameters[0].Value),
            Assert.IsType<string>(call.Spec.Parameters[1].Value),
            "US",
            "0")));
        var service = new LegacyForeignIndexCandleRestoreService(executor);
        var row = Row(isEnabled: false);

        Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
        var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);

        Assert.True(outcome.IsResolved);
        Assert.Equal("Dow", outcome.Identity!.TargetKey);
        Assert.Single(executor.Calls);
    }

    [Fact]
    public async Task ResolveAsync_RejectsMalformedBatchSchemaAndDuplicateIndexes()
    {
        var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
        var service = new LegacyForeignIndexCandleRestoreService(executor);
        var tooMany = Enumerable.Range(
                0,
                LegacyForeignIndexCandleRestoreService.MaximumRows + 1)
            .Select(index => Row(index))
            .ToArray();

        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync(tooMany));
        await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
        [
            Row(4, "다우존스", "5일"),
            Row(4, "나스닥", "20일")
        ]));
        await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
        [
            null!
        ]));
        await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
        [
            Row(-1, "다우존스", "5일")
        ]));
        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task ResolveAsync_PropagatesDatabaseFailureWithoutRetry()
    {
        var executor = new RecordingExecutor(_ =>
            throw new DataException("simulated read failure"));
        var service = new LegacyForeignIndexCandleRestoreService(executor);

        await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync(
            [Row(0, "다우존스", "5일")]));

        Assert.Single(executor.Calls);
    }

    [Theory]
    [InlineData("extra-column")]
    [InlineData("wrong-case")]
    [InlineData("wrong-type")]
    [InlineData("unexpected-value")]
    [InlineData("too-many")]
    public async Task ResolveAsync_FailsClosedOnExpandedOrTamperedDatabaseData(string mode)
    {
        var executor = new RecordingExecutor(_ => mode switch
        {
            "extra-column" => TableWithExtraColumn(),
            "wrong-case" => WrongSchemaTable(),
            "wrong-type" => WrongTypeTable(),
            "unexpected-value" => IdentityTable(("다우존스", "WRONG", "US", "0")),
            "too-many" => IdentityTable(
                ("다우존스", "DJI@DJI", "US", "0"),
                ("다우존스", "DJI@DJI", "US", "0"),
                ("다우존스", "DJI@DJI", "US", "0")),
            _ => throw new InvalidOperationException(mode)
        });
        var service = new LegacyForeignIndexCandleRestoreService(executor);

        var outcome = Assert.Single((await service.ResolveAsync(
            [Row(0, "다우존스", "5일")])).Rows);

        Assert.Equal(
            LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid,
            outcome.Failure);
        Assert.False(outcome.IsResolved);
    }

    [Fact]
    public async Task ResolveAsync_HonorsPreCancellationWithoutQueryOrRetry()
    {
        var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
        var service = new LegacyForeignIndexCandleRestoreService(executor);
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
            [Row(0, "다우존스", "5일")],
            cancellation.Token));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task ResolveAsync_StopsAfterCancellationObservedByFirstQuery()
    {
        using var cancellation = new CancellationTokenSource();
        var executor = new RecordingExecutor(call =>
        {
            cancellation.Cancel();
            return IdentityTable((
                Assert.IsType<string>(call.Spec.Parameters[0].Value),
                Assert.IsType<string>(call.Spec.Parameters[1].Value),
                "US",
                "0"));
        }, throwBeforeHandlerWhenCanceled: false);
        var service = new LegacyForeignIndexCandleRestoreService(executor);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
        [
            Row(0, "다우존스", "5일"),
            Row(1, "나스닥", "5일")
        ], cancellation.Token));

        Assert.Single(executor.Calls);
    }

    private static DataTable IdentityTable(
        params (string InputName, string Symbol, string Nation, string ForeignDomestic)[] rows)
    {
        var table = new DataTable();
        table.Columns.Add("F_INPUT_NAME", typeof(string));
        table.Columns.Add("F_SYMB", typeof(string));
        table.Columns.Add("F_NATC", typeof(string));
        table.Columns.Add("F_FDTC", typeof(string));
        foreach (var row in rows)
        {
            table.Rows.Add(row.InputName, row.Symbol, row.Nation, row.ForeignDomestic);
        }

        return table;
    }

    private static LegacyForeignIndexCandleRestoreRow Row(
        int itemIndex = 0,
        string subject = "다우존스",
        string subtype = "5일",
        bool isEnabled = true,
        string groupCode = "해외지수",
        string graphicType = "캔들그래프",
        string pageText = "1/1",
        string dataCode = "") =>
        new(
            itemIndex,
            isEnabled,
            groupCode,
            subject,
            graphicType,
            subtype,
            pageText,
            dataCode);

    private static DataTable WrongSchemaTable()
    {
        var table = IdentityTable();
        table.Columns[0].ColumnName = "f_input_name";
        return table;
    }

    private static DataTable WrongTypeTable()
    {
        var table = new DataTable();
        table.Columns.Add("F_INPUT_NAME", typeof(string));
        table.Columns.Add("F_SYMB", typeof(string));
        table.Columns.Add("F_NATC", typeof(string));
        table.Columns.Add("F_FDTC", typeof(int));
        return table;
    }

    private static DataTable TableWithExtraColumn()
    {
        var table = IdentityTable(("다우존스", "DJI@DJI", "US", "0"));
        table.Columns.Add("UNEXPECTED", typeof(string));
        return table;
    }

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

    private sealed class RecordingExecutor(
        Func<RecordedCall, DataTable> handler,
        bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor
    {
        private int _stringSqlCallCount;

        public List<RecordedCall> 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();
        }

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

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            DataQuerySpec query,
            CancellationToken cancellationToken = default)
        {
            if (throwBeforeHandlerWhenCanceled)
            {
                cancellationToken.ThrowIfCancellationRequested();
            }

            query.ValidateFor(source);
            var call = new RecordedCall(source, tableName, query, cancellationToken);
            Calls.Add(call);
            return Task.FromResult(handler(call).Copy());
        }
    }
}
