#nullable enable

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

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class ComparisonAndYieldLegacyRequestResolverTests
{
    [Theory]
    [InlineData("WonDollar", "FiveDays", YieldGraphInstrumentKind.WonDollar, YieldGraphPeriod.FiveDays)]
    [InlineData("WonYen", "TwentyDays", YieldGraphInstrumentKind.WonYen, YieldGraphPeriod.TwentyDays)]
    [InlineData("WonYuan", "SixtyDays", YieldGraphInstrumentKind.WonYuan, YieldGraphPeriod.SixtyDays)]
    [InlineData("WonEuro", "OneHundredTwentyDays", YieldGraphInstrumentKind.WonEuro, YieldGraphPeriod.OneHundredTwentyDays)]
    [InlineData("WonDollar", "TwoHundredFortyDays", YieldGraphInstrumentKind.WonDollar, YieldGraphPeriod.TwoHundredFortyDays)]
    public async Task Fixed_operator_catalog_accepts_closed_FX_and_period_enum_names(
        string subject,
        string subtype,
        YieldGraphInstrumentKind expectedKind,
        YieldGraphPeriod expectedPeriod)
    {
        var resolver = new ComparisonAndYieldLegacyRequestResolver(new RecordingExecutor());

        var result = Assert.IsType<LegacyS5086SceneLoadRequest>(await resolver.ResolveAsync(
            Entry("5086", "EXCHANGE_RATE", subject, subtype)));

        Assert.Equal(expectedKind, result.Request.Instrument.Kind);
        Assert.Equal(expectedPeriod, result.Request.Period);
    }

    [Theory]
    [InlineData("KOSPI", YieldGraphInstrumentKind.KospiIndex)]
    [InlineData("KOSDAQ", YieldGraphInstrumentKind.KosdaqIndex)]
    [InlineData("KOSPI200", YieldGraphInstrumentKind.Kospi200Index)]
    [InlineData("KRX100", YieldGraphInstrumentKind.Krx100Index)]
    public async Task Fixed_operator_catalog_accepts_closed_index_selectors(
        string groupCode,
        YieldGraphInstrumentKind expectedKind)
    {
        var resolver = new ComparisonAndYieldLegacyRequestResolver(new RecordingExecutor());

        var result = Assert.IsType<LegacyS5086SceneLoadRequest>(await resolver.ResolveAsync(
            Entry("5086", groupCode, "INDEX", "TwentyDays")));

        Assert.Equal(expectedKind, result.Request.Instrument.Kind);
        Assert.Equal(YieldGraphPeriod.TwentyDays, result.Request.Period);
    }

    [Theory]
    [InlineData("5026", "s5026")]
    [InlineData("5087", "s5087")]
    public async Task Fixed_pair_scenes_preserve_subject_order_and_use_five_days(
        string cutCode,
        string builderKey)
    {
        var executor = new RecordingExecutor(
            StockIdentity("첫번째", "111111"),
            StockIdentity("두번째", "222222"));
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = await resolver.ResolveAsync(Entry(
            cutCode,
            groupCode: "ignored-code",
            subject: " 첫번째 , 두번째 ",
            subtype: "ignored-sub",
            graphicType: "ignored-forCutInfo",
            dataCode: "KOSPI:111111|KOSDAQ:222222"));

        Assert.Equal(builderKey, result.BuilderKey);
        var request = result switch
        {
            LegacyS5026SceneLoadRequest routed => routed.Request,
            LegacyS5087SceneLoadRequest routed => routed.Request,
            _ => throw new Xunit.Sdk.XunitException("Unexpected routed request type.")
        };
        Assert.Equal(ComparisonGraphPeriod.FiveDays, request.Period);
        Assert.Equal(
            new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째", "111111"),
            request.First);
        Assert.Equal(
            new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째", "222222"),
            request.Second);
        Assert.Equal(2, executor.Calls.Count);
    }

    [Fact]
    public async Task Exact_pair_identity_preserves_market_and_code_without_cross_market_name_fallback()
    {
        var executor = new RecordingExecutor(
            StockIdentity("Duplicate", "111111"),
            StockIdentity("Second", "222222"));
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS5026SceneLoadRequest>(await resolver.ResolveAsync(Entry(
            "5026",
            "STOCK",
            "Duplicate,Second",
            "",
            dataCode: "KOSPI:111111|KOSDAQ:222222")));

        Assert.Equal(
            new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "Duplicate", "111111"),
            result.Request.First);
        Assert.Equal(
            new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "222222"),
            result.Request.Second);
        Assert.Equal(2, executor.Calls.Count);
        Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSPI_STOCK", executor.Calls[0].TableName);
        Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSDAQ_STOCK", executor.Calls[1].TableName);
        Assert.Equal(
            new object?[] { "Duplicate", "111111" },
            executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
        Assert.DoesNotContain(
            executor.Calls,
            call => call.TableName == "SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK");
    }

    [Theory]
    [InlineData("")]
    [InlineData("KOSPI:111111")]
    [InlineData("KOSPI:111111|KOSDAQ:")]
    [InlineData("KOSPI:111111|KOSDAQ:222222|KOSPI:333333")]
    [InlineData("KOSPI:111111|KOSDAQ:bad/code")]
    public async Task Malformed_exact_pair_identity_fails_before_database_access(string dataCode)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
            Entry("5026", "STOCK", "First,Second", "", dataCode: dataCode)));

        Assert.Empty(executor.Calls);
    }

    [Theory]
    [InlineData("\uC77C\uBD09", ComparisonGraphPeriod.Daily)]
    [InlineData("5\uC77C", ComparisonGraphPeriod.FiveDays)]
    [InlineData("1\uAC1C\uC6D4", ComparisonGraphPeriod.OneMonth)]
    [InlineData("3\uAC1C\uC6D4", ComparisonGraphPeriod.ThreeMonths)]
    [InlineData("6\uAC1C\uC6D4", ComparisonGraphPeriod.SixMonths)]
    [InlineData("12\uAC1C\uC6D4", ComparisonGraphPeriod.TwelveMonths)]
    public async Task S5029_maps_every_original_period(
        string subtype,
        ComparisonGraphPeriod expected)
    {
        var executor = new RecordingExecutor(
            StockIdentity("A", "111111"),
            StockIdentity("B", "222222"));
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS5029SceneLoadRequest>(
            await resolver.ResolveAsync(Entry(
                "5029",
                "STOCK",
                "A,B",
                subtype,
                dataCode: "KOSPI:111111|KOSDAQ:222222")));

        Assert.Equal(expected, result.Request.Period);
        Assert.Equal("A", result.Request.First.StockName);
        Assert.Equal("111111", result.Request.First.StockCode);
        Assert.Equal("B", result.Request.Second.StockName);
    }

    [Theory]
    [InlineData("5일", YieldGraphPeriod.FiveDays)]
    [InlineData("20일", YieldGraphPeriod.TwentyDays)]
    [InlineData("60일", YieldGraphPeriod.SixtyDays)]
    [InlineData("120일", YieldGraphPeriod.OneHundredTwentyDays)]
    [InlineData("240일", YieldGraphPeriod.TwoHundredFortyDays)]
    public async Task S5086_maps_every_original_yield_period(
        string subtype,
        YieldGraphPeriod expected)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
            await resolver.ResolveAsync(Entry("5086", "환율", "원달러", subtype)));

        Assert.Equal(expected, result.Request.Period);
        Assert.Equal(YieldGraphInstrumentKind.WonDollar, result.Request.Instrument.Kind);
        Assert.Empty(executor.Calls);
    }

    [Theory]
    [InlineData("오늘 원달러", YieldGraphInstrumentKind.WonDollar)]
    [InlineData("오늘 원엔", YieldGraphInstrumentKind.WonYen)]
    [InlineData("오늘 원위엔", YieldGraphInstrumentKind.WonYuan)]
    [InlineData("오늘 원유로", YieldGraphInstrumentKind.WonEuro)]
    public async Task S5086_preserves_original_contains_based_fx_routing(
        string subject,
        YieldGraphInstrumentKind expected)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
            await resolver.ResolveAsync(Entry("5086", "unused", subject, "5일")));

        Assert.Equal(expected, result.Request.Instrument.Kind);
        Assert.Null(result.Request.Instrument.Name);
        Assert.Empty(executor.Calls);
    }

    [Theory]
    [InlineData("코스피", YieldGraphInstrumentKind.KospiIndex)]
    [InlineData("코스닥", YieldGraphInstrumentKind.KosdaqIndex)]
    [InlineData("코스피200", YieldGraphInstrumentKind.Kospi200Index)]
    [InlineData("KRX100", YieldGraphInstrumentKind.Krx100Index)]
    public async Task S5086_uses_group_code_only_for_the_original_index_branch(
        string groupCode,
        YieldGraphInstrumentKind expected)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
            await resolver.ResolveAsync(Entry(
                "5086",
                groupCode,
                "지수",
                "20일",
                graphicType: "must-not-route",
                dataCode: "must-not-route")));

        Assert.Equal(expected, result.Request.Instrument.Kind);
        Assert.Null(result.Request.Instrument.Name);
        Assert.Empty(executor.Calls);
    }

    [Theory]
    [InlineData("코스피업종", YieldGraphInstrumentKind.KospiIndustry)]
    [InlineData("코스닥업종", YieldGraphInstrumentKind.KosdaqIndustry)]
    [InlineData("기타업종", YieldGraphInstrumentKind.KosdaqIndustry)]
    public async Task S5086_preserves_original_industry_branch_and_kosdaq_fallback(
        string groupCode,
        YieldGraphInstrumentKind expected)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
            await resolver.ResolveAsync(Entry("5086", groupCode, "반도체", "60일")));

        Assert.Equal(expected, result.Request.Instrument.Kind);
        Assert.Equal("반도체", result.Request.Instrument.Name);
        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task S5086_stock_uses_kosdaq_later_table_precedence()
    {
        var executor = new RecordingExecutor(Count(1), Count(1));
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS5086SceneLoadRequest>(
            await resolver.ResolveAsync(Entry("5086", "종목", "중복이름", "120일")));

        Assert.Equal(YieldGraphInstrumentKind.KosdaqStock, result.Request.Instrument.Kind);
        Assert.Equal("중복이름", result.Request.Instrument.Name);
        AssertStockLookupOrder(executor, "중복이름");
    }

    [Theory]
    [InlineData("원달러", YieldGraphInstrumentKind.WonDollar)]
    [InlineData("원엔", YieldGraphInstrumentKind.WonYen)]
    [InlineData("원위엔", YieldGraphInstrumentKind.WonYuan)]
    [InlineData("원유로", YieldGraphInstrumentKind.WonEuro)]
    public async Task S50860_accepts_only_the_quote_capable_fx_branches(
        string subject,
        YieldGraphInstrumentKind expected)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        var result = Assert.IsType<LegacyS50860SceneLoadRequest>(
            await resolver.ResolveAsync(Entry("50860", "ignored", subject, "240일")));

        Assert.Equal(YieldGraphPeriod.TwoHundredFortyDays, result.Request.Period);
        Assert.Equal(expected, result.Request.Instrument.Kind);
        Assert.Empty(executor.Calls);
    }

    [Theory]
    [InlineData("코스피", "지수")]
    [InlineData("코스피업종", "반도체")]
    [InlineData("종목", "삼성전자")]
    public async Task S50860_rejects_non_fx_before_any_database_lookup(
        string groupCode,
        string subject)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            resolver.ResolveAsync(Entry("50860", groupCode, subject, "5일")));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task Stock_lookups_are_read_only_parameterized_oracle_queries()
    {
        const string firstName = "A' OR '1'='1";
        const string secondName = "B; DELETE FROM T_STOCK";
        var executor = new RecordingExecutor(
            StockIdentity(firstName, "111111"),
            StockIdentity(secondName, "222222"));
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        _ = await resolver.ResolveAsync(Entry(
            "5026",
            "STOCK",
            firstName + "," + secondName,
            "",
            dataCode: "KOSPI:111111|KOSDAQ:222222"));

        Assert.Equal(2, executor.Calls.Count);
        Assert.All(executor.Calls, call =>
        {
            Assert.Equal(DataSourceKind.Oracle, call.Source);
            Assert.Contains("SELECT STOCK_NAME, STOCK_CODE", call.Spec.Sql, StringComparison.Ordinal);
            Assert.DoesNotContain("INSERT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain("UPDATE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain("DELETE FROM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            Assert.Equal(new[] { "stockName", "stockCode" },
                call.Spec.Parameters.Select(parameter => parameter.Name));
            Assert.All(call.Spec.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType));
            Assert.All(call.Spec.Parameters, parameter => Assert.DoesNotContain(
                Assert.IsType<string>(parameter.Value), call.Spec.Sql, StringComparison.Ordinal));
        });
        Assert.Contains("FROM T_STOCK", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("FROM T_KOSDAQ_STOCK", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
    }

    [Theory]
    [InlineData("9999", "", "A,B", "5일")]
    [InlineData("5029", "", "A,B", "주봉")]
    [InlineData("5029", "", "A", "5일")]
    [InlineData("5029", "", "A,B,C", "5일")]
    [InlineData("5086", "UNKNOWN", "지수", "5일")]
    [InlineData("5086", "환율", "원달러", "1일")]
    public async Task Unsupported_closed_selections_fail_before_database_access(
        string cutCode,
        string groupCode,
        string subject,
        string subtype)
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            resolver.ResolveAsync(Entry(cutCode, groupCode, subject, subtype)));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task Missing_selection_fails_before_database_access()
    {
        var executor = new RecordingExecutor();
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            resolver.ResolveAsync(new LegacyPlaylistEntry("entry", "5026")));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task Unknown_stock_fails_after_both_original_market_lookups()
    {
        var executor = new RecordingExecutor(Count(0), Count(0));
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            resolver.ResolveAsync(Entry("5086", "종목", "없는종목", "5일")));

        AssertStockLookupOrder(executor, "없는종목");
    }

    public static IEnumerable<object[]> InvalidStockMasterResults()
    {
        var wrongColumn = new DataTable();
        wrongColumn.Columns.Add("COUNT", typeof(int));
        wrongColumn.Rows.Add(1);
        yield return [wrongColumn];

        var noRows = new DataTable();
        noRows.Columns.Add("MATCH_COUNT", typeof(int));
        yield return [noRows];

        yield return [Count(2)];
        yield return [Count(0.5m)];

        var nullCount = new DataTable();
        nullCount.Columns.Add("MATCH_COUNT", typeof(object));
        nullCount.Rows.Add(DBNull.Value);
        yield return [nullCount];
    }

    [Theory]
    [MemberData(nameof(InvalidStockMasterResults))]
    public async Task Invalid_stock_master_results_fail_closed(DataTable result)
    {
        var executor = new RecordingExecutor(result);
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            resolver.ResolveAsync(Entry(
                "5026",
                "STOCK",
                "A,B",
                "",
                dataCode: "KOSPI:111111|KOSDAQ:222222")));

        Assert.Single(executor.Calls);
    }

    [Fact]
    public async Task Exact_identity_duplicate_rows_are_ambiguous_even_when_name_and_code_match()
    {
        var duplicate = StockIdentity("A", "111111");
        duplicate.ImportRow(duplicate.Rows[0]);
        var executor = new RecordingExecutor(duplicate);
        var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);

        await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(Entry(
            "5026",
            "STOCK",
            "A,B",
            "",
            dataCode: "KOSPI:111111|KOSDAQ:222222")));

        Assert.Single(executor.Calls);
        Assert.DoesNotContain("DISTINCT", executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase);
    }

    private static LegacyPlaylistEntry Entry(
        string cutCode,
        string groupCode,
        string subject,
        string subtype,
        string graphicType = "",
        string dataCode = "") => new(
        "entry",
        cutCode,
        Selection: new LegacySceneSelection(
            groupCode,
            subject,
            graphicType,
            subtype,
            dataCode));

    private static DataTable Count(object value)
    {
        var table = new DataTable();
        table.Columns.Add("MATCH_COUNT", value.GetType());
        table.Rows.Add(value);
        return table;
    }

    private static DataTable StockIdentity(string name, string code)
    {
        var table = new DataTable();
        table.Columns.Add("STOCK_NAME", typeof(string));
        table.Columns.Add("STOCK_CODE", typeof(string));
        table.Rows.Add(name, code);
        return table;
    }

    private static void AssertStockLookupOrder(
        RecordingExecutor executor,
        params string[] stockNames)
    {
        Assert.Equal(stockNames.Length * 2, executor.Calls.Count);
        for (var index = 0; index < stockNames.Length; index++)
        {
            var kospi = executor.Calls[index * 2];
            var kosdaq = executor.Calls[(index * 2) + 1];
            Assert.Equal("SCENE_COMPARISON_RESOLVE_KOSPI_STOCK", kospi.TableName);
            Assert.Equal("SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK", kosdaq.TableName);
            Assert.Equal(stockNames[index], Assert.Single(kospi.Spec.Parameters).Value);
            Assert.Equal(stockNames[index], Assert.Single(kosdaq.Spec.Parameters).Value);
        }
    }

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

    private sealed class RecordingExecutor(params DataTable[] results) : IDataQueryExecutor
    {
        private readonly Queue<DataTable> _results = new(results);

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

        public DataTable Execute(DataSourceKind source, string tableName, string query) =>
            throw new InvalidOperationException("String SQL fallback is forbidden.");

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            string query,
            CancellationToken cancellationToken = default) =>
            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);
            Calls.Add(new RecordedCall(source, tableName, query));
            if (_results.Count == 0)
            {
                throw new InvalidOperationException("No fake query result remains.");
            }

            return Task.FromResult(_results.Dequeue().Copy());
        }
    }
}
