using System.Data;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class LegacyTradingHaltSelectionServiceTests
{
    [Fact]
    public async Task SearchAsync_EmptyQueryUsesBothBoundedUc7OracleQueries()
    {
        var results = new Queue<DataTable>(
        [
            TradingHaltTable(("000002", "Beta"), ("000001", "Alpha")),
            TradingHaltTable(("100002", "Delta"), ("100001", "Gamma"))
        ]);
        var executor = new RecordingExecutor(_ => results.Dequeue());
        var service = new LegacyTradingHaltSelectionService(executor);
        using var cancellation = new CancellationTokenSource();

        var result = await service.SearchAsync("", 25, cancellation.Token);

        Assert.Equal("", result.Query);
        Assert.False(result.IsTruncated);
        Assert.Collection(
            result.Items,
            item => Assert.Equal(
                new TradingHaltSelection(TradingHaltMarket.Kospi, "000001", "Alpha"),
                item),
            item => Assert.Equal(
                new TradingHaltSelection(TradingHaltMarket.Kospi, "000002", "Beta"),
                item),
            item => Assert.Equal(
                new TradingHaltSelection(TradingHaltMarket.Kosdaq, "100002", "Delta"),
                item),
            item => Assert.Equal(
                new TradingHaltSelection(TradingHaltMarket.Kosdaq, "100001", "Gamma"),
                item));

        Assert.Equal(
            ["TRADING_HALT_KOSPI", "TRADING_HALT_KOSDAQ"],
            executor.Calls.Select(static call => call.QueryName));
        Assert.All(executor.Calls, call =>
        {
            Assert.Equal(DataSourceKind.Oracle, call.Source);
            Assert.Equal(cancellation.Token, call.CancellationToken);
            call.Spec.ValidateFor(DataSourceKind.Oracle);
            Assert.Contains("F_MKT_HALT = 'Y'", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("ON a.F_STOCK_CODE = b.F_STOCK_CODE", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
            Assert.DoesNotContain("F_CURR_PRICE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            var parameter = Assert.Single(call.Spec.Parameters);
            Assert.Equal("row_limit", parameter.Name);
            Assert.Equal(26, parameter.Value);
            Assert.Equal(DbType.Int32, parameter.DbType);
        });
        Assert.Contains("FROM T_STOP_ONLINE1 a", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("INNER JOIN T_STOCK b", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("FROM T_STOP_KOSDAQ_ONLINE1 a", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
        Assert.Contains("INNER JOIN T_KOSDAQ_STOCK b", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
        Assert.Equal(0, executor.StringSqlCallCount);
    }

    [Fact]
    public async Task SearchAsync_FilteredQueryBindsAndEscapesTheUc7NameFilter()
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable());
        var service = new LegacyTradingHaltSelectionService(executor);

        var result = await service.SearchAsync(" 50%!_ ", maximumResults: 12);

        Assert.Equal("50%!_", result.Query);
        Assert.Equal(2, executor.Calls.Count);
        Assert.All(executor.Calls, call =>
        {
            Assert.Contains(
                "UPPER(b.F_STOCK_WANNAME) LIKE '%' || :search_term || '%' ESCAPE '!'",
                call.Spec.Sql,
                StringComparison.Ordinal);
            Assert.DoesNotContain("50", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Collection(
                call.Spec.Parameters,
                parameter =>
                {
                    Assert.Equal("search_term", parameter.Name);
                    Assert.Equal("50!%!!!_", parameter.Value);
                    Assert.Equal(DbType.String, parameter.DbType);
                },
                parameter =>
                {
                    Assert.Equal("row_limit", parameter.Name);
                    Assert.Equal(13, parameter.Value);
                    Assert.Equal(DbType.Int32, parameter.DbType);
                });
        });
        Assert.Equal(0, executor.StringSqlCallCount);
    }

    [Fact]
    public async Task SearchAsync_InitialConsonantsReadFullMarketsThenFilterBeforeExistingSort()
    {
        var results = new Queue<DataTable>(
        [
            TradingHaltTable(
                ("000001", "Alpha"),
                ("000002", "삼성전자")),
            TradingHaltTable(("100001", "삼성전기"))
        ]);
        var executor = new RecordingExecutor(_ => results.Dequeue());
        var service = new LegacyTradingHaltSelectionService(executor);

        var result = await service.SearchAsync(" ㅅㅅㅈ ", maximumResults: 1);

        Assert.Equal("ㅅㅅㅈ", result.Query);
        Assert.True(result.IsTruncated);
        Assert.Equal(
            new TradingHaltSelection(
                TradingHaltMarket.Kospi,
                "000002",
                "삼성전자"),
            Assert.Single(result.Items));
        Assert.All(executor.Calls, call =>
        {
            Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
            var parameter = Assert.Single(call.Spec.Parameters);
            Assert.Equal("row_limit", parameter.Name);
            Assert.Equal(
                LegacyTradingHaltSelectionService.MaximumResults + 1,
                parameter.Value);
        });
    }

    [Fact]
    public async Task SearchAsync_InitialConsonantsValidateNonMatchingCandidatesBeforeFiltering()
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable(
            ("000001", "Alpha"),
            ("000001", "Other")));
        var service = new LegacyTradingHaltSelectionService(executor);

        var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
            () => service.SearchAsync("ㅅㅅ"));

        Assert.Contains("duplicate market/code", exception.Message, StringComparison.Ordinal);
        Assert.Single(executor.Calls);
    }

    [Fact]
    public async Task SearchAsync_InitialConsonantsConservativelyReportProviderCandidateCap()
    {
        var cappedKospi = TradingHaltTable(
            Enumerable.Range(0, LegacyTradingHaltSelectionService.MaximumResults + 1)
                .Select(index => (
                    StockCode: index.ToString("D6", System.Globalization.CultureInfo.InvariantCulture),
                    DisplayName: "알파" + index.ToString(
                        System.Globalization.CultureInfo.InvariantCulture)))
                .ToArray());
        var results = new Queue<DataTable>(
        [
            cappedKospi,
            TradingHaltTable()
        ]);
        var executor = new RecordingExecutor(_ => results.Dequeue());
        var service = new LegacyTradingHaltSelectionService(executor);

        var result = await service.SearchAsync("ㅅㅅ", maximumResults: 1);

        Assert.Empty(result.Items);
        Assert.True(result.IsTruncated);
    }

    [Fact]
    public async Task SearchAsync_ReturnsDeterministicGlobalLimitAndTruncation()
    {
        var results = new Queue<DataTable>(
        [
            TradingHaltTable(("000003", "Gamma"), ("000001", "Alpha")),
            TradingHaltTable(("100001", "Beta"))
        ]);
        var executor = new RecordingExecutor(_ => results.Dequeue());
        var service = new LegacyTradingHaltSelectionService(executor);

        var result = await service.SearchAsync(maximumResults: 2);

        Assert.True(result.IsTruncated);
        Assert.Equal(
            [
                new TradingHaltSelection(TradingHaltMarket.Kospi, "000001", "Alpha"),
                new TradingHaltSelection(TradingHaltMarket.Kospi, "000003", "Gamma")
            ],
            result.Items);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(501)]
    [InlineData(int.MaxValue)]
    public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable());
        var service = new LegacyTradingHaltSelectionService(executor);

        var exception = await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
            () => service.SearchAsync("", maximumResults));

        Assert.Equal("maximumResults", exception.ParamName);
        Assert.Empty(executor.Calls);
    }

    [Theory]
    [InlineData("bad\u0000value")]
    [InlineData("bad\nvalue")]
    [InlineData("bad\u200Bvalue")]
    [InlineData("bad\uD800value")]
    public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable());
        var service = new LegacyTradingHaltSelectionService(executor);

        var exception = await Assert.ThrowsAsync<ArgumentException>(
            () => service.SearchAsync(query));

        Assert.Equal("query", exception.ParamName);
        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task SearchAsync_RejectsNullAndOverlongQueryBeforeDatabaseAccess()
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable());
        var service = new LegacyTradingHaltSelectionService(executor);

        await Assert.ThrowsAsync<ArgumentNullException>(() => service.SearchAsync(null!));
        await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(
            new string('A', LegacyTradingHaltSelectionService.MaximumQueryLength + 1)));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task SearchAsync_RejectsAnythingOtherThanExactTwoStringColumnSchema()
    {
        var wrongCase = new DataTable();
        wrongCase.Columns.Add("stock_code", typeof(string));
        wrongCase.Columns.Add("STOCK_NAME", typeof(string));
        var executor = new RecordingExecutor(_ => wrongCase);
        var service = new LegacyTradingHaltSelectionService(executor);

        var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
            () => service.SearchAsync());

        Assert.Contains("TRADING_HALT_KOSPI", exception.Message, StringComparison.Ordinal);
        Assert.Single(executor.Calls);
    }

    [Fact]
    public async Task SearchAsync_RejectsRowsBeyondTheBoundQueryContract()
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable(
            ("000001", "One"),
            ("000002", "Two"),
            ("000003", "Three")));
        var service = new LegacyTradingHaltSelectionService(executor);

        await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
            () => service.SearchAsync(maximumResults: 1));

        Assert.Single(executor.Calls);
    }

    [Theory]
    [InlineData(" 000001", "Name")]
    [InlineData("000001", "Name ")]
    [InlineData("000001", "Bad\nName")]
    [InlineData("00\u000001", "Name")]
    [InlineData("", "Name")]
    [InlineData("000001", "")]
    public async Task SearchAsync_RejectsNonCanonicalIdentityValues(
        string stockCode,
        string displayName)
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable((stockCode, displayName)));
        var service = new LegacyTradingHaltSelectionService(executor);

        await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
            () => service.SearchAsync());
    }

    [Fact]
    public async Task SearchAsync_RejectsDuplicateMarketCodeIdentity()
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable(
            ("000001", "First Name"),
            ("000001", "Second Name")));
        var service = new LegacyTradingHaltSelectionService(executor);

        var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
            () => service.SearchAsync());

        Assert.Contains("duplicate market/code", exception.Message, StringComparison.Ordinal);
    }

    [Fact]
    public async Task SearchAsync_RejectsAmbiguousMarketNameLookupKey()
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable(
            ("000001", "Shared Name"),
            ("000002", "Shared Name")));
        var service = new LegacyTradingHaltSelectionService(executor);

        var exception = await Assert.ThrowsAsync<TradingHaltSelectionDataException>(
            () => service.SearchAsync());

        Assert.Contains("ambiguous market/name", exception.Message, StringComparison.Ordinal);
    }

    [Fact]
    public async Task SearchAsync_AllowsSameCodeAndNameInDifferentTypedMarkets()
    {
        var results = new Queue<DataTable>(
        [
            TradingHaltTable(("000001", "Shared Name")),
            TradingHaltTable(("000001", "Shared Name"))
        ]);
        var executor = new RecordingExecutor(_ => results.Dequeue());
        var service = new LegacyTradingHaltSelectionService(executor);

        var result = await service.SearchAsync();

        Assert.Collection(
            result.Items,
            item => Assert.Equal(TradingHaltMarket.Kospi, item.Market),
            item => Assert.Equal(TradingHaltMarket.Kosdaq, item.Market));
    }

    [Fact]
    public async Task SearchAsync_PropagatesPreCanceledTokenWithoutDatabaseAccess()
    {
        var executor = new RecordingExecutor(_ => TradingHaltTable());
        var service = new LegacyTradingHaltSelectionService(executor);
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => service.SearchAsync(cancellationToken: cancellation.Token));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task SearchAsync_StopsBeforeKosdaqWhenCanceledAfterKospiQuery()
    {
        using var cancellation = new CancellationTokenSource();
        var executor = new RecordingExecutor(_ =>
        {
            cancellation.Cancel();
            return TradingHaltTable(("000001", "Alpha"));
        });
        var service = new LegacyTradingHaltSelectionService(executor);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => service.SearchAsync(cancellationToken: cancellation.Token));

        var call = Assert.Single(executor.Calls);
        Assert.Equal("TRADING_HALT_KOSPI", call.QueryName);
    }

    private static DataTable TradingHaltTable(params (string StockCode, string DisplayName)[] rows)
    {
        var table = new DataTable();
        table.Columns.Add("STOCK_CODE", typeof(string));
        table.Columns.Add("STOCK_NAME", typeof(string));
        foreach (var row in rows)
        {
            table.Rows.Add(row.StockCode, row.DisplayName);
        }

        return table;
    }

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

    private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler) : 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("String SQL fallback is forbidden.");
        }

        public Task<DataTable> ExecuteAsync(
            DataSourceKind source,
            string tableName,
            string query,
            CancellationToken cancellationToken = default)
        {
            Interlocked.Increment(ref _stringSqlCallCount);
            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);
            var call = new RecordedCall(source, tableName, query, cancellationToken);
            Calls.Add(call);
            return Task.FromResult(handler(call).Copy());
        }
    }
}
