#nullable enable

using System.Data;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class LegacyNamedKrxThemeRestoreServiceTests
{
    public static TheoryData<string, string, string, string, int, string> SupportedRows =>
        new()
        {
            { "5단 표그래프", "테마-현재가(입력순)", "five-row-current", "s5074", 5, "INPUT_ORDER" },
            { "5단 표그래프", "테마-현재가(상승률순)", "five-row-current", "s5074", 5, "GAIN_DESC" },
            { "5단 표그래프", "테마-현재가(하락률순)", "five-row-current", "s5074", 5, "GAIN_ASC" },
            { "5단 표그래프", "테마-현재가", "five-row-current", "s5074", 5, "GAIN_ASC" },
            { "5단 표그래프", "테마-예상체결가(입력순)", "five-row-expected", "s5074", 5, "INPUT_ORDER" },
            { "5단 표그래프", "테마-예상체결가(상승률순)", "five-row-expected", "s5074", 5, "GAIN_DESC" },
            { "5단 표그래프", "테마-예상체결가(하락률순)", "five-row-expected", "s5074", 5, "GAIN_ASC" },
            { "5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", 5, "GAIN_ASC" },
            { "6종목 현재가", "테마-현재가(입력순)", "six-row-current", "s5077", 6, "INPUT_ORDER" },
            { "6종목 현재가", "테마-현재가(상승률순)", "six-row-current", "s5077", 6, "GAIN_DESC" },
            { "6종목 현재가", "테마-현재가(하락률순)", "six-row-current", "s5077", 6, "GAIN_ASC" },
            { "6종목 현재가", "테마-현재가", "six-row-current", "s5077", 6, "GAIN_ASC" },
            { "12종목 현재가", "테마-현재가(입력순)", "twelve-row-current", "s5088", 12, "INPUT_ORDER" },
            { "12종목 현재가", "테마-현재가(상승률순)", "twelve-row-current", "s5088", 12, "GAIN_DESC" },
            { "12종목 현재가", "테마-현재가(하락률순)", "twelve-row-current", "s5088", 12, "GAIN_ASC" },
            { "12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", 12, "GAIN_ASC" }
        };

    [Theory]
    [MemberData(nameof(SupportedRows))]
    public async Task ResolveAsync_PreservesOriginalActionsAndFinalSortBranchWhileRemappingCode(
        string graphicType,
        string subtype,
        string actionId,
        string builderKey,
        int pageSize,
        string sort)
    {
        var executor = GoodExecutor("반도체", "87654321", previewCount: 2);
        var service = new LegacyNamedKrxThemeRestoreService(executor);

        var outcome = Assert.Single((await service.ResolveAsync(
            [Row(graphicType: graphicType, subtype: subtype)])).Rows);

        Assert.True(outcome.IsResolved);
        Assert.Null(outcome.Failure);
        Assert.NotNull(outcome.Identity);
        Assert.Equal(actionId, outcome.Identity.ActionId);
        Assert.Equal(builderKey, outcome.Identity.BuilderKey);
        Assert.Equal(builderKey[1..], outcome.Identity.CutCode);
        Assert.Equal(pageSize, outcome.Identity.PageSize);
        Assert.Equal(sort, outcome.Identity.Sort);
        Assert.Equal("반도체", outcome.Identity.ThemeTitle);
        Assert.Equal("0123", outcome.Identity.StoredThemeCode);
        Assert.Equal("87654321", outcome.Identity.CurrentThemeCode);
        Assert.True(outcome.Identity.CodeWasRemapped);
        Assert.Equal(2, outcome.Identity.PreviewItemCount);
        Assert.False(outcome.Identity.PreviewIsTruncated);
        Assert.Equal(2, executor.Calls.Count);

        var identityCall = executor.Calls[0];
        Assert.Equal(DataSourceKind.Oracle, identityCall.Source);
        Assert.Equal(LegacyNamedKrxThemeRestoreService.IdentityQueryName, identityCall.QueryName);
        identityCall.Spec.ValidateFor(DataSourceKind.Oracle);
        Assert.Contains("s.SB_TITLE = :theme_title", identityCall.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("d.SB_CODE = s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("ROWNUM <= 2", identityCall.Spec.Sql, StringComparison.Ordinal);
        Assert.DoesNotContain("반도체", identityCall.Spec.Sql, StringComparison.Ordinal);
        Assert.Collection(identityCall.Spec.Parameters, parameter =>
        {
            Assert.Equal("theme_title", parameter.Name);
            Assert.Equal("반도체", parameter.Value);
            Assert.Equal(DbType.String, parameter.DbType);
        });

        var previewCall = executor.Calls[1];
        Assert.Equal(DataSourceKind.Oracle, previewCall.Source);
        Assert.Equal("THEME_PREVIEW_KRX", previewCall.QueryName);
        Assert.Equal("87654321", previewCall.Spec.Parameters[0].Value);
        Assert.Equal("반도체", previewCall.Spec.Parameters[1].Value);
        Assert.Equal(241, previewCall.Spec.Parameters[2].Value);
        Assert.Equal(0, executor.StringSqlCallCount);
        Assert.All(executor.Calls, call =>
        {
            var sql = call.Spec.Sql.TrimStart().ToUpperInvariant();
            Assert.StartsWith("SELECT", sql, StringComparison.Ordinal);
            Assert.DoesNotContain("INSERT ", sql, StringComparison.Ordinal);
            Assert.DoesNotContain("UPDATE ", sql, StringComparison.Ordinal);
            Assert.DoesNotContain("DELETE ", sql, StringComparison.Ordinal);
            Assert.DoesNotContain("MERGE ", sql, StringComparison.Ordinal);
        });
    }

    [Fact]
    public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes()
    {
        var executor = GoodExecutor("AI", "90000001", previewCount: 1);
        var service = new LegacyNamedKrxThemeRestoreService(executor);
        var rows = new[]
        {
            Row(itemIndex: 7, subject: "AI", dataCode: "111"),
            Row(itemIndex: 2, subject: "AI", dataCode: "2222", graphicType: "6종목 현재가")
        };

        var result = await service.ResolveAsync(rows);

        Assert.Equal([7, 2], result.Rows.Select(row => row.ItemIndex));
        Assert.Equal(["111", "2222"], result.Rows.Select(row => row.Identity!.StoredThemeCode));
        Assert.All(result.Rows, row => Assert.Equal("90000001", row.Identity!.CurrentThemeCode));
        Assert.Equal(2, executor.Calls.Count);
    }

    [Fact]
    public async Task ResolveAsync_AcceptsSafeSupplementaryThemeTitleConsistentlyWithWebContract()
    {
        const string title = "우주🚀";
        var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
            GoodExecutor(title, "90000001", previewCount: 1))
            .ResolveAsync([Row(subject: title)])).Rows);

        Assert.True(outcome.IsResolved);
        Assert.Equal(title, outcome.Identity!.ThemeTitle);
    }

    [Theory]
    [InlineData("테마_NXT", "반도체", "1/1", "0123")]
    [InlineData("테마", "반도체(NXT)", "1/1", "0123")]
    [InlineData("테마", " 반도체", "1/1", "0123")]
    [InlineData("테마", "반도체", "01/1", "0123")]
    [InlineData("테마", "반도체", "1/1", "R0123")]
    public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess(
        string groupCode,
        string subject,
        string pageText,
        string dataCode)
    {
        var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
        var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
            .ResolveAsync([Row(
                groupCode: groupCode,
                subject: subject,
                pageText: pageText,
                dataCode: dataCode)])).Rows);

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

    [Theory]
    [InlineData("6종목 현재가", "테마-예상체결가(입력순)")]
    [InlineData("12종목 현재가", "테마-예상체결가")]
    [InlineData("네모그래프", "테마-현재가(입력순)")]
    [InlineData("5단 표그래프", "테마-현재가(거래량순)")]
    public async Task ResolveAsync_UnsupportedActionsFailClosedPerRow(
        string graphicType,
        string subtype)
    {
        var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
        var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
            .ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows);

        Assert.Equal(LegacyNamedKrxThemeRestoreFailure.UnsupportedAction, outcome.Failure);
        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerExactTitleWithoutPreview()
    {
        var executor = new RecordingExecutor(call =>
        {
            var title = Assert.IsType<string>(call.Spec.Parameters[0].Value);
            return title == "없음"
                ? IdentityTable()
                : IdentityTable(
                    (title, "111", "KRX", "1"),
                    (title, "222", "KRX", "1"));
        });
        var result = await new LegacyNamedKrxThemeRestoreService(executor).ResolveAsync(
        [
            Row(itemIndex: 0, subject: "없음"),
            Row(itemIndex: 1, subject: "중복")
        ]);

        Assert.Equal(
            [
                LegacyNamedKrxThemeRestoreFailure.IdentityNotFound,
                LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous
            ],
            result.Rows.Select(row => row.Failure));
        Assert.Equal(2, executor.Calls.Count);
    }

    [Fact]
    public async Task ResolveAsync_RequiresNonemptyValidatedKrxPreview()
    {
        var executor = new RecordingExecutor(call => call.QueryName switch
        {
            LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
                IdentityTable(("반도체", "00000001", "KRX", "1")),
            "THEME_PREVIEW_KRX" => EmptyPreviewTable("반도체", "00000001"),
            _ => throw new InvalidOperationException(call.QueryName)
        });

        var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
            .ResolveAsync([Row()])).Rows);

        Assert.Equal(LegacyNamedKrxThemeRestoreFailure.PreviewEmpty, outcome.Failure);
        Assert.False(outcome.IsResolved);
        Assert.Equal("00000001", outcome.ObservedCurrentThemeCode);
        Assert.Equal(2, executor.Calls.Count);
    }

    [Fact]
    public async Task ResolveAsync_PreservesValidatedPreviewTruncationEvidence()
    {
        var executor = GoodExecutor("반도체", "00000001", previewCount: 241);

        var identity = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
            .ResolveAsync([Row()])).Rows).Identity!;

        Assert.Equal(LegacyThemeSelectionService.MaximumPreviewItems, identity.PreviewItemCount);
        Assert.True(identity.PreviewIsTruncated);
    }

    [Fact]
    public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaCodeOrPreview()
    {
        var wrongSchema = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
            new RecordingExecutor(_ => new DataTable())).ResolveAsync([Row()])).Rows);
        Assert.Equal(LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure);

        var duplicateCode = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
            new RecordingExecutor(_ =>
                IdentityTable(("반도체", "00000001", "KRX", "2"))))
            .ResolveAsync([Row()])).Rows);
        Assert.Equal(LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure);

        var invalidPreviewExecutor = new RecordingExecutor(call => call.QueryName switch
        {
            LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
                IdentityTable(("반도체", "00000001", "KRX", "1")),
            "THEME_PREVIEW_KRX" => new DataTable(),
            _ => throw new InvalidOperationException(call.QueryName)
        });
        var invalidPreview = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
            invalidPreviewExecutor).ResolveAsync([Row()])).Rows);
        Assert.Equal(
            LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid,
            invalidPreview.Failure);
        Assert.Equal("00000001", invalidPreview.ObservedCurrentThemeCode);
    }

    [Fact]
    public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation()
    {
        var failing = new RecordingExecutor(_ => throw new DataException("simulated"));
        await Assert.ThrowsAsync<DataException>(() =>
            new LegacyNamedKrxThemeRestoreService(failing).ResolveAsync([Row()]));
        Assert.Single(failing.Calls);

        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();
        var canceled = new RecordingExecutor(_ => throw new InvalidOperationException());
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            new LegacyNamedKrxThemeRestoreService(canceled)
                .ResolveAsync([Row()], cancellation.Token));
        Assert.Empty(canceled.Calls);
    }

    [Fact]
    public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows()
    {
        var executor = GoodExecutor("반도체", "00000001", previewCount: 1);
        var service = new LegacyNamedKrxThemeRestoreService(executor);
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
        await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
            [Row(itemIndex: 3), Row(itemIndex: 3)]));

        var outcome = Assert.Single((await service.ResolveAsync([Row(isEnabled: false)])).Rows);
        Assert.True(outcome.IsResolved);
        Assert.True(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(isEnabled: false)));
        Assert.False(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(groupCode: "테마_NXT")));
    }

    private static RecordingExecutor GoodExecutor(
        string title,
        string code,
        int previewCount) =>
        new(call => call.QueryName switch
        {
            LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
                IdentityTable((title, code, "KRX", "1")),
            "THEME_PREVIEW_KRX" => PreviewTable(title, code, previewCount),
            _ => throw new InvalidOperationException(call.QueryName)
        });

    private static LegacyNamedKrxThemeRestoreRow Row(
        int itemIndex = 0,
        bool isEnabled = true,
        string groupCode = "테마",
        string subject = "반도체",
        string graphicType = "5단 표그래프",
        string subtype = "테마-현재가(입력순)",
        string pageText = "1/1",
        string dataCode = "0123") =>
        new(itemIndex, isEnabled, groupCode, subject, graphicType, subtype, pageText, dataCode);

    private static DataTable IdentityTable(
        params (string Title, string Code, string Market, string CodeCount)[] rows)
    {
        var table = new DataTable();
        table.Columns.Add("THEME_TITLE", typeof(string));
        table.Columns.Add("THEME_CODE", typeof(string));
        table.Columns.Add("THEME_MARKET", typeof(string));
        table.Columns.Add("CODE_IDENTITY_COUNT", typeof(string));
        foreach (var row in rows)
        {
            table.Rows.Add(row.Title, row.Code, row.Market, row.CodeCount);
        }

        return table;
    }

    private static DataTable PreviewTable(string title, string code, int count)
    {
        var table = CreatePreviewTable();
        for (var index = 0; index < count; index++)
        {
            table.Rows.Add(
                title,
                code,
                "KRX",
                $"종목{index + 1}",
                $"P{index + 1:D6}",
                index.ToString());
        }

        return table;
    }

    private static DataTable EmptyPreviewTable(string title, string code)
    {
        var table = CreatePreviewTable();
        table.Rows.Add(title, code, "KRX", DBNull.Value, DBNull.Value, DBNull.Value);
        return table;
    }

    private static DataTable CreatePreviewTable()
    {
        var table = new DataTable();
        table.Columns.Add("THEME_TITLE", typeof(string));
        table.Columns.Add("THEME_CODE", typeof(string));
        table.Columns.Add("THEME_MARKET", typeof(string));
        table.Columns.Add("ITEM_NAME", typeof(string));
        table.Columns.Add("ITEM_CODE", typeof(string));
        table.Columns.Add("ITEM_INDEX", typeof(string));
        return table;
    }

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

    private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
        : IDataQueryExecutor
    {
        public List<RecordedCall> Calls { get; } = [];

        public int StringSqlCallCount { get; private set; }

        public DataTable Execute(
            DataSourceKind source,
            string tableName,
            string query)
        {
            StringSqlCallCount++;
            throw new InvalidOperationException("String SQL must not be used.");
        }

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            string sql,
            CancellationToken cancellationToken = default)
        {
            StringSqlCallCount++;
            throw new InvalidOperationException("String SQL must not be used.");
        }

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