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

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class ChartSceneDataLoadersTests
{
    [Theory]
    [InlineData(SectorIndexMarket.Dow, "DJI@DJINET")]
    [InlineData(SectorIndexMarket.Nasdaq, "NAS@CXBT")]
    [InlineData(SectorIndexMarket.StandardAndPoor, "SPI@S5ENRS")]
    [InlineData(SectorIndexMarket.Kospi, "FROM t_index")]
    [InlineData(SectorIndexMarket.Kosdaq, "FROM t_kosdaq_index")]
    public async Task S5078_CoversEveryClosedLegacyQueryBranch(
        SectorIndexMarket market,
        string queryMarker)
    {
        var executor = new RecordingExecutor(
            new Dictionary<string, DataTable>
            {
                ["S5078_SECTOR_INDEX"] = SectorTable()
            });

        var data = await new S5078SceneDataLoader(executor).LoadAsync(
            new S5078SceneDataRequest(market),
            CancellationToken.None);

        Assert.Equal(market, data.Market);
        Assert.Equal(10, data.Rows.Count);
        Assert.Equal(SectorBarVariant.Primary, data.Rows[0].Variant);
        Assert.Equal(SectorBarVariant.Secondary, data.Rows[1].Variant);
        var call = Assert.Single(executor.Calls);
        Assert.Equal(DataSourceKind.Oracle, call.Source);
        Assert.Empty(call.Spec.Parameters);
        Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
        Assert.NotEmpty(new S5078SceneMutationBuilder().Build(data));
    }

    [Fact]
    public async Task S5078_RequiresExactlyTenRowsAndTheExactSchema()
    {
        var tooMany = SectorTable(11);
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5078SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["S5078_SECTOR_INDEX"] = tooMany }))
                .LoadAsync(new S5078SceneDataRequest(SectorIndexMarket.Kospi)));

        var wrongSchema = SectorTable();
        wrongSchema.Columns[4].ColumnName = "UNEXPECTED";
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5078SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["S5078_SECTOR_INDEX"] = wrongSchema }))
                .LoadAsync(new S5078SceneDataRequest(SectorIndexMarket.Kospi)));
    }

    [Fact]
    public async Task S5079_UsesOneOracleParameterAndMapsAllFourSeriesAndDates()
    {
        var table = Table(
            ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"],
            ["삼성전자", "1_2_3_4", "-1_-2_-3_-4", "_0_1_2", "5.5_6.5_7.5_8.5", "2025Q1_2025Q2_2025Q3_2025Q4"]);
        var executor = new RecordingExecutor(
            new Dictionary<string, DataTable> { ["INPUT_GROW"] = table });

        var data = await new S5079SceneDataLoader(executor).LoadAsync(
            new S5079SceneDataRequest("삼성전자"));

        Assert.Equal("삼성전자", data.StockName);
        Assert.Equal(new double?[] { 1, 2, 3, 4 }, data.Series[0].Values);
        Assert.Null(data.Series[2].Values[0]);
        Assert.Equal(new[] { "2025Q1", "2025Q2", "2025Q3", "2025Q4" }, data.Dates);
        var call = Assert.Single(executor.Calls);
        Assert.Equal("INPUT_GROW", call.TableName);
        Assert.Contains("WHERE STOCK_NAME = :stockName", call.Spec.Sql, StringComparison.Ordinal);
        var parameter = Assert.Single(call.Spec.Parameters);
        Assert.Equal("stockName", parameter.Name);
        Assert.Equal("삼성전자", parameter.Value);
        Assert.Equal(DbType.String, parameter.DbType);
        Assert.NotEmpty(new S5079SceneMutationBuilder().Build(data));
    }

    [Theory]
    [InlineData("1_2_3", "2025Q1_2025Q2_2025Q3_2025Q4")]
    [InlineData("1_2_bad_4", "2025Q1_2025Q2_2025Q3_2025Q4")]
    [InlineData("1_2_3_4", "2025Q1_2025Q2_2025Q3")]
    public async Task S5079_FailsClosedForMalformedLegacyTokens(
        string series,
        string dates)
    {
        var table = Table(
            ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"],
            ["A", series, "1_2_3_4", "1_2_3_4", "1_2_3_4", dates]);

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5079SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_GROW"] = table }))
                .LoadAsync(new S5079SceneDataRequest("A")));
    }

    [Fact]
    public async Task S5080_UsesOneOracleParameterAndMapsSixTypedQuarters()
    {
        var table = Table(
            ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
            ["A", "1Q_100", "2Q_-50", "3Q_", "4Q_1000", "5Q_25", "6Q_-2"]);
        var executor = new RecordingExecutor(
            new Dictionary<string, DataTable> { ["INPUT_SELL"] = table });

        var data = await new S5080SceneDataLoader(executor).LoadAsync(
            new S5080SceneDataRequest("A"));

        Assert.Equal(new[] { "1Q", "2Q", "3Q", "4Q", "5Q", "6Q" },
            data.Quarters.Select(item => item.Quarter));
        Assert.Equal(new[] { 100, -50, 0, 1000, 25, -2 },
            data.Quarters.Select(item => item.Value));
        var call = Assert.Single(executor.Calls);
        Assert.Equal("INPUT_SELL", call.TableName);
        Assert.Single(call.Spec.Parameters);
        Assert.NotEmpty(new S5080SceneMutationBuilder().Build(data));
    }

    [Fact]
    public async Task S5080_RejectsMalformedQuarterAndConflictingDuplicateRows()
    {
        var malformed = Table(
            ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
            ["A", "missing-delimiter", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]);
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5080SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_SELL"] = malformed }))
                .LoadAsync(new S5080SceneDataRequest("A")));

        var ambiguous = malformed.Clone();
        ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6");
        ambiguous.Rows.Add("A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_7");
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5080SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_SELL"] = ambiguous }))
                .LoadAsync(new S5080SceneDataRequest("A")));
    }

    [Fact]
    public async Task S5080_AcceptsEquivalentDuplicateRows()
    {
        var table = Table(
            ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
            ["A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"],
            ["A", "1Q_1", "2Q_2", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]);

        var data = await new S5080SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_SELL"] = table }))
            .LoadAsync(new S5080SceneDataRequest("A"));

        Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, data.Quarters.Select(item => item.Value));
    }

    [Fact]
    public async Task S5080_NormalizesOnlyTrailingHorizontalTabsInQuarterLabels()
    {
        var accepted = Table(
            ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
            ["A", "1Q_1", "2023/12\t_159", "3Q_\t3\t", "4Q_4", "5Q_5", "6Q_6"]);
        var data = await new S5080SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_SELL"] = accepted }))
            .LoadAsync(new S5080SceneDataRequest("A"));

        Assert.Equal("2023/12", data.Quarters[1].Quarter);
        Assert.Equal(159, data.Quarters[1].Value);
        Assert.Equal(3, data.Quarters[2].Value);
        var setValues = new S5080SceneMutationBuilder().Build(data)
            .OfType<PlayoutSetValue>()
            .ToArray();
        Assert.Contains(setValues, mutation =>
            mutation.ObjectName == "value3_1" && mutation.Value == "3");
        Assert.DoesNotContain(setValues, mutation => mutation.Value.Contains('\t'));

        foreach (var invalid in new[] { "2023\t/12_159", "2023/12_\t", "2023/12_15\t9" })
        {
            var rejected = accepted.Copy();
            rejected.Rows[0]["GUSUNG_2"] = invalid;
            await Assert.ThrowsAsync<LegacySceneDataException>(() =>
                new S5080SceneDataLoader(new RecordingExecutor(
                    new Dictionary<string, DataTable> { ["INPUT_SELL"] = rejected }))
                    .LoadAsync(new S5080SceneDataRequest("A")));
        }
    }

    [Fact]
    public async Task S5079_AcceptsEquivalentDuplicatesAndRejectsConflictingDuplicates()
    {
        var columns = new[]
        {
            "STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE"
        };
        object?[] row = ["A", "1_2_3_4", "5_6_7_8", "9_10_11_12", "13_14_15_16", "1Q_2Q_3Q_4Q"];
        var equivalent = Table(columns, row, row.ToArray());

        var data = await new S5079SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_GROW"] = equivalent }))
            .LoadAsync(new S5079SceneDataRequest("A"));
        Assert.Equal(new double?[] { 1, 2, 3, 4 }, data.Series[0].Values);

        var conflicting = equivalent.Copy();
        conflicting.Rows[1]["GUSUNG_4"] = "13_14_15_17";
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5079SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_GROW"] = conflicting }))
                .LoadAsync(new S5079SceneDataRequest("A")));
    }

    [Fact]
    public async Task S5080_KeepsLegacyEmptyQuarterLabelsAndIgnoresExtraTokens()
    {
        var table = Table(
            ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"],
            ["A", "_", "_20_legacy-extra", "3Q_3", "4Q_4", "5Q_5", "6Q_6"]);

        var data = await new S5080SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_SELL"] = table }))
            .LoadAsync(new S5080SceneDataRequest("A"));

        Assert.Equal(string.Empty, data.Quarters[0].Quarter);
        Assert.Equal(0, data.Quarters[0].Value);
        Assert.Equal(string.Empty, data.Quarters[1].Quarter);
        Assert.Equal(20, data.Quarters[1].Value);
        _ = new S5080SceneMutationBuilder().Build(data);
    }

    [Theory]
    [InlineData(InvestorFlowParticipant.Individual, "F_INVEST_CODE = '8000'")]
    [InlineData(InvestorFlowParticipant.Institution, "'7000'")]
    [InlineData(InvestorFlowParticipant.Foreign, "'9000', '9001'")]
    public async Task S5083_CoversEveryParticipantSummaryAndTrendBranch(
        InvestorFlowParticipant participant,
        string queryMarker)
    {
        var executor = new RecordingExecutor(new Dictionary<string, DataTable>
        {
            ["S5083_SUMMARY"] = ParticipantSummaryTable(),
            ["S5083_TREND"] = ParticipantTrendTable()
        });

        var data = await new S5083SceneDataLoader(executor).LoadAsync(
            new S5083SceneDataRequest(participant));

        Assert.Equal(participant, data.Participant);
        Assert.Equal(
            new[] { InvestorFlowMarket.Kospi, InvestorFlowMarket.Kosdaq, InvestorFlowMarket.Kospi200 },
            data.Summary.Select(item => item.Market));
        Assert.Equal(new TimeOnly(15, 30), data.TrendPoints[^1].DataTime);
        Assert.Equal(2, executor.Calls.Count);
        Assert.All(executor.Calls, call =>
        {
            Assert.Equal(DataSourceKind.Oracle, call.Source);
            Assert.Empty(call.Spec.Parameters);
            Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
        });
        Assert.NotEmpty(new S5083SceneMutationBuilder().Build(data));
    }

    [Fact]
    public async Task S5083_RejectsDuplicateOrUnknownMarketsAndInvalidTimes()
    {
        var summary = ParticipantSummaryTable();
        summary.Rows[1]["MARKET"] = "코스피";
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5083SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
            {
                ["S5083_SUMMARY"] = summary,
                ["S5083_TREND"] = ParticipantTrendTable()
            })).LoadAsync(new S5083SceneDataRequest(InvestorFlowParticipant.Individual)));

        var trend = ParticipantTrendTable();
        trend.Rows[2]["DATA_TIME"] = "202607109999";
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5083SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
            {
                ["S5083_SUMMARY"] = ParticipantSummaryTable(),
                ["S5083_TREND"] = trend
            })).LoadAsync(new S5083SceneDataRequest(InvestorFlowParticipant.Foreign)));
    }

    [Theory]
    [InlineData(InvestorFlowIndex.Kospi, "FROM t_invest")]
    [InlineData(InvestorFlowIndex.Kosdaq, "FROM t_kosdaq_invest")]
    public async Task S5084_CoversBothIndexSummaryAndTrendBranches(
        InvestorFlowIndex index,
        string queryMarker)
    {
        var executor = new RecordingExecutor(new Dictionary<string, DataTable>
        {
            ["S5084_SUMMARY"] = IndexSummaryTable(),
            ["S5084_TREND"] = IndexTrendTable()
        });

        var data = await new S5084SceneDataLoader(executor).LoadAsync(
            new S5084SceneDataRequest(index));

        Assert.Equal(index, data.Index);
        Assert.Equal(new InvestorFlowTotalsData(100, -50, 25), data.Totals);
        Assert.Equal(2, data.TrendPoints.Count);
        Assert.Equal(new TimeOnly(15, 30), data.TrendPoints[^1].DataTime);
        Assert.Equal(2, executor.Calls.Count);
        Assert.All(executor.Calls, call =>
        {
            Assert.Empty(call.Spec.Parameters);
            Assert.Contains(queryMarker, call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
        });
        Assert.NotEmpty(new S5084SceneMutationBuilder().Build(data));
    }

    [Fact]
    public async Task S5084_RejectsEmptyTrendAndFractionalIntegerValues()
    {
        var emptyTrend = IndexTrendTable();
        emptyTrend.Rows.Clear();
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5084SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
            {
                ["S5084_SUMMARY"] = IndexSummaryTable(),
                ["S5084_TREND"] = emptyTrend
            })).LoadAsync(new S5084SceneDataRequest(InvestorFlowIndex.Kospi)));

        var fractional = IndexSummaryTable();
        fractional.Rows[0]["INDIVIDUAL"] = 1.5m;
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5084SceneDataLoader(new RecordingExecutor(new Dictionary<string, DataTable>
            {
                ["S5084_SUMMARY"] = fractional,
                ["S5084_TREND"] = IndexTrendTable()
            })).LoadAsync(new S5084SceneDataRequest(InvestorFlowIndex.Kosdaq)));
    }

    [Fact]
    public async Task ClosedRequestsFailBeforeAnyDatabaseCall()
    {
        var executor = new RecordingExecutor(new Dictionary<string, DataTable>());

        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5078SceneDataLoader(executor).LoadAsync(
                new S5078SceneDataRequest((SectorIndexMarket)99)));
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5083SceneDataLoader(executor).LoadAsync(
                new S5083SceneDataRequest((InvestorFlowParticipant)99)));
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5084SceneDataLoader(executor).LoadAsync(
                new S5084SceneDataRequest((InvestorFlowIndex)99)));
        await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5079SceneDataLoader(executor).LoadAsync(
                new S5079SceneDataRequest("\r\n")));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task ParameterizedLoaderErrorsDoNotExposeTheSelection()
    {
        const string selection = "SENSITIVE-STOCK-SELECTION";
        var exception = await Assert.ThrowsAsync<LegacySceneDataException>(() =>
            new S5079SceneDataLoader(new RecordingExecutor(
                new Dictionary<string, DataTable> { ["INPUT_GROW"] = new DataTable() }))
                .LoadAsync(new S5079SceneDataRequest(selection)));

        Assert.DoesNotContain(selection, exception.Message, StringComparison.Ordinal);
    }

    private static DataTable SectorTable(int count = 10)
    {
        var table = Table(["CATEGORY", "PART_INDEX", "CHG_TYPE", "PART_CHANGE", "RATE"]);
        for (var index = 0; index < count; index++)
        {
            table.Rows.Add(
                "Sector" + index,
                1000 + index,
                index == 0 ? "-" : "+",
                index,
                index - 5d);
        }

        return table;
    }

    private static DataTable ParticipantSummaryTable() => Table(
        ["MARKET", "AMOUNT"],
        ["코스피", 100],
        ["코스닥", -50],
        ["코스피200", 25]);

    private static DataTable ParticipantTrendTable() => Table(
        ["MARKET", "DATA_TIME", "PRICE"],
        ["코스피", "202607100900", -10],
        ["코스닥", "202607101000", 0],
        ["코스피200", "202607101530", 10]);

    private static DataTable IndexSummaryTable() => Table(
        ["INDIVIDUAL", "FOREIGN", "INSTITUTION"],
        [100, -50, 25]);

    private static DataTable IndexTrendTable() => Table(
        ["DATA_TIME", "INDIVIDUAL", "FOREIGN", "INSTITUTION"],
        ["202607100900", -10, 0, 10],
        ["202607101530", 10, -5, 5]);

    private static DataTable Table(string[] columns, params object?[][] rows)
    {
        var table = new DataTable();
        foreach (var column in columns)
        {
            table.Columns.Add(column, typeof(object));
        }

        foreach (var row in rows)
        {
            table.Rows.Add(row);
        }

        return table;
    }

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

    private sealed class RecordingExecutor(
        IReadOnlyDictionary<string, DataTable> results) : IDataQueryExecutor
    {
        public List<QueryCall> Calls { get; } = [];

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

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            string query,
            CancellationToken cancellationToken = default) =>
            throw new InvalidOperationException("String query fallback is forbidden in this test.");

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            DataQuerySpec query,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            query.ValidateFor(source);
            Calls.Add(new QueryCall(source, tableName, query));
            if (!results.TryGetValue(tableName, out var table))
            {
                throw new InvalidOperationException("The test did not configure this table.");
            }

            return Task.FromResult(table.Copy());
        }
    }
}
