#nullable enable

using System.Data;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class LegacyNamedForeignStockRestoreServiceTests
{
    public static TheoryData<string, string, string, string, int?> Actions =>
        new()
        {
            { "1열판기본", "", "stock-current", "5001", null },
            { "캔들그래프", "5일", "stock-candle-5d", "8061", 5 },
            { "캔들그래프", "20일", "stock-candle-20d", "8040", 20 },
            { "캔들그래프", "60일", "stock-candle-60d", "8046", 60 },
            { "캔들그래프", "120일", "stock-candle-120d", "8051", 120 },
            { "캔들그래프", "240일", "stock-candle-240d", "8056", 240 }
        };

    [Theory]
    [MemberData(nameof(Actions))]
    public async Task ResolveAsync_MapsEveryClosedUc5ActionAfterExactBoundIdentity(
        string graphicType,
        string subtype,
        string actionId,
        string cutCode,
        int? periodDays)
    {
        var executor = new RecordingExecutor(call =>
        {
            Assert.Equal(DataSourceKind.Oracle, call.Source);
            Assert.Equal(LegacyNamedForeignStockRestoreService.QueryName, call.QueryName);
            call.Spec.ValidateFor(DataSourceKind.Oracle);
            Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("F_FDTC = '1'", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("SELECT DISTINCT", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
            Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain("NVIDIA", call.Spec.Sql, StringComparison.Ordinal);
            var parameter = Assert.Single(call.Spec.Parameters);
            Assert.Equal("input_name", parameter.Name);
            Assert.Equal("NVIDIA", parameter.Value);
            Assert.Equal(DbType.String, parameter.DbType);
            return IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1"));
        });
        var row = Row(graphicType: graphicType, subtype: subtype);

        Assert.True(LegacyNamedForeignStockRestoreService.IsCandidate(row));
        var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
            .ResolveAsync([row])).Rows);

        Assert.True(outcome.IsResolved);
        Assert.Equal(actionId, outcome.Identity!.ActionId);
        Assert.Equal("NVIDIA", outcome.Identity.InputName);
        Assert.Equal("NAS@NVDA", outcome.Identity.Symbol);
        Assert.Equal("US", outcome.Identity.NationCode);
        Assert.Equal("1", outcome.Identity.ForeignDomesticCode);
        Assert.Equal(periodDays is null ? "s5001" : "s8010", outcome.Identity.BuilderKey);
        Assert.Equal(cutCode, outcome.Identity.CutCode);
        Assert.Equal(periodDays is null ? "CURRENT" : "PRICE", outcome.Identity.ValueType);
        Assert.Equal(periodDays, outcome.Identity.PeriodDays);
        Assert.Single(executor.Calls);
        Assert.Equal(0, executor.StringSqlCallCount);
    }

    [Fact]
    public async Task ResolveAsync_PreservesOrderAndCachesRepeatedExactInputName()
    {
        var executor = new RecordingExecutor(call =>
        {
            var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
            return name == "TSMC"
                ? IdentityTable((name, "TPE@2330", "TW", "1"))
                : IdentityTable((name, "NAS@NVDA", "US", "1"));
        });
        var rows = new[]
        {
            Row(itemIndex: 12, subject: "TSMC", graphicType: "캔들그래프", subtype: "240일"),
            Row(itemIndex: 2, graphicType: "1열판기본", subtype: ""),
            Row(itemIndex: 900, graphicType: "캔들그래프", subtype: "5일")
        };

        var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(rows);

        Assert.Equal([12, 2, 900], result.Rows.Select(value => value.ItemIndex));
        Assert.Equal(["TPE@2330", "NAS@NVDA", "NAS@NVDA"],
            result.Rows.Select(value => value.Identity!.Symbol));
        Assert.Equal(2, executor.Calls.Count);
    }

    [Fact]
    public async Task ResolveAsync_FailsClosedPerRowForMissingAmbiguousAndInvalidData()
    {
        var executor = new RecordingExecutor(call =>
        {
            var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
            return name switch
            {
                "MISSING" => IdentityTable(),
                "AMBIGUOUS" => IdentityTable(
                    (name, "NAS@ONE", "US", "1"),
                    (name, "TPE@TWO", "TW", "1")),
                "INVALID" => IdentityTable((name, "bad symbol!", "US", "1")),
                _ => throw new InvalidOperationException(name)
            };
        });

        var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
        [
            Row(1, "MISSING"),
            Row(2, "AMBIGUOUS"),
            Row(3, "INVALID")
        ]);

        Assert.Equal(
            new LegacyNamedForeignStockRestoreFailure?[]
            {
                LegacyNamedForeignStockRestoreFailure.IdentityNotFound,
                LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous,
                LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid
            },
            result.Rows.Select(value => value.Failure));
        Assert.All(result.Rows, value => Assert.False(value.IsResolved));
        Assert.Null(result.Rows[0].DatabaseIssue);
        Assert.Null(result.Rows[1].DatabaseIssue);
        Assert.Equal(
            LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation,
            result.Rows[2].DatabaseIssue);
    }

    [Theory]
    [InlineData("FOREIGN_STOCK", "1열판기본", "", "1/1", "")]
    [InlineData("해외종목", "CURRENT", "", "1/1", "")]
    [InlineData("해외종목", "캔들그래프", "1년", "1/1", "")]
    [InlineData("해외종목", "캔들그래프", "5일", "1/2", "")]
    [InlineData("해외종목", "캔들그래프", "5일", "1/1", "NAS@NVDA|US|1")]
    public async Task ResolveAsync_RejectsEverythingOutsideExactLegacyGrammarWithoutQuery(
        string groupCode,
        string graphicType,
        string subtype,
        string pageText,
        string dataCode)
    {
        var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
        var row = Row(
            groupCode: groupCode,
            graphicType: graphicType,
            subtype: subtype,
            pageText: pageText,
            dataCode: dataCode);

        Assert.False(LegacyNamedForeignStockRestoreService.IsCandidate(row));
        var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
            .ResolveAsync([row])).Rows);

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

    [Fact]
    public async Task ResolveAsync_PreservesDisabledRowAndTaiwanNation()
    {
        var executor = new RecordingExecutor(_ =>
            IdentityTable(("TSMC", "TPE@2330", "TW", "1")));
        var row = Row(subject: "TSMC", isEnabled: false);

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

        Assert.True(outcome.IsResolved);
        Assert.Equal("TW", outcome.Identity!.NationCode);
    }

    [Fact]
    public async Task ResolveAsync_AcceptsBoundProviderSymbolContainingHangulLetters()
    {
        const string symbol = "NAS@해외 종목1";
        var executor = new RecordingExecutor(_ =>
            IdentityTable(("NVIDIA", symbol, "US", "1")));

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

        Assert.True(outcome.IsResolved);
        Assert.Equal(symbol, outcome.Identity!.Symbol);
        Assert.Null(outcome.DatabaseIssue);
    }

    [Fact]
    public async Task ResolveAsync_AcceptsSafeSupplementaryScalarsConsistentlyWithWebContract()
    {
        const string inputName = "회사🚀";
        const string symbol = "NAS@𐐀1";
        var executor = new RecordingExecutor(_ =>
            IdentityTable((inputName, symbol, "US", "1")));

        var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
            .ResolveAsync([Row(subject: inputName)])).Rows);

        Assert.True(outcome.IsResolved);
        Assert.Equal(inputName, outcome.Identity!.InputName);
        Assert.Equal(symbol, outcome.Identity.Symbol);
        Assert.Null(outcome.DatabaseIssue);
    }

    [Theory]
    [InlineData("NAS|NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter)]
    [InlineData("NAS^NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolCaret)]
    [InlineData(" NAS@NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
    [InlineData("NAS@NVDA ", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
    [InlineData("NAS!NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation)]
    [InlineData("NAS\nNVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
    [InlineData("NAS😀NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther)]
    public async Task ResolveAsync_DiagnosticReasonNeverPrintsOrRelaxesUnsafeSymbol(
        string symbol,
        LegacyNamedForeignStockDatabaseIssue expectedIssue)
    {
        var executor = new RecordingExecutor(_ =>
            IdentityTable(("NVIDIA", symbol, "US", "1")));

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

        Assert.False(outcome.IsResolved);
        Assert.Equal(LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid, outcome.Failure);
        Assert.Equal(expectedIssue, outcome.DatabaseIssue);
        Assert.DoesNotContain(symbol, outcome.ToString(), StringComparison.Ordinal);
    }

    [Fact]
    public async Task ResolveAsync_UsesInjectedClockForCorrelatedBatchTimestamp()
    {
        var now = new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero);
        var executor = new RecordingExecutor(_ =>
            IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1")));
        var service = new LegacyNamedForeignStockRestoreService(
            executor,
            new FixedTimeProvider(now));

        var result = await service.ResolveAsync([Row()]);

        Assert.Equal(now, result.RetrievedAt);
    }

    [Fact]
    public async Task ResolveAsync_RejectsMalformedBatchAndNeverRetriesFailure()
    {
        var failed = new RecordingExecutor(_ => throw new DataException("one read failure"));
        var service = new LegacyNamedForeignStockRestoreService(failed);

        await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync([Row()]));
        Assert.Single(failed.Calls);

        var unused = new RecordingExecutor(_ => throw new InvalidOperationException());
        service = new LegacyNamedForeignStockRestoreService(unused);
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
        await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([Row(4), Row(4)]));
        await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([null!]));
        Assert.Empty(unused.Calls);
    }

    [Fact]
    public async Task ResolveAsync_HonorsCancellationWithoutASecondQuery()
    {
        using var cancellation = new CancellationTokenSource();
        var executor = new RecordingExecutor(call =>
        {
            cancellation.Cancel();
            var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
            return IdentityTable((name, "NAS@NVDA", "US", "1"));
        }, throwBeforeHandlerWhenCanceled: false);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
            [
                Row(0),
                Row(1, "TSMC")
            ],
            cancellation.Token));

        Assert.Single(executor.Calls);
    }

    private static LegacyNamedForeignStockRestoreRow Row(
        int itemIndex = 0,
        string subject = "NVIDIA",
        bool isEnabled = true,
        string groupCode = "해외종목",
        string graphicType = "1열판기본",
        string subtype = "",
        string pageText = "1/1",
        string dataCode = "") =>
        new(
            itemIndex,
            isEnabled,
            groupCode,
            subject,
            graphicType,
            subtype,
            pageText,
            dataCode);

    private static DataTable IdentityTable(
        params (string InputName, string Symbol, string Nation, string Fdtc)[] 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.Fdtc);
        }

        return table;
    }

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

    private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
    {
        public override DateTimeOffset GetUtcNow() => value;

        public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
    }

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