using System.Data;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Core.Tests;

public sealed class LegacyStockSearchServiceTests
{
    [Fact]
    public async Task SearchAsync_UsesFourParameterizedProviderQueriesInLegacyMarketOrder()
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyStockSearchService(executor);
        using var cancellation = new CancellationTokenSource();

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

        Assert.Equal("삼성", result.Query);
        Assert.False(result.IsTruncated);
        Assert.Empty(result.Items);
        Assert.Equal(
            [
                "STOCK_SEARCH_KOSPI",
                "STOCK_SEARCH_KOSDAQ",
                "STOCK_SEARCH_NXT_KOSPI",
                "STOCK_SEARCH_NXT_KOSDAQ"
            ],
            executor.Calls.Select(static call => call.TableName));
        Assert.Equal(
            [
                DataSourceKind.Oracle,
                DataSourceKind.Oracle,
                DataSourceKind.MariaDb,
                DataSourceKind.MariaDb
            ],
            executor.Calls.Select(static call => call.Source));

        Assert.All(executor.Calls, call =>
        {
            call.Spec.ValidateFor(call.Source);
            Assert.Equal(cancellation.Token, call.CancellationToken);
            Assert.Equal(2, call.Spec.Parameters.Count);
            Assert.Equal("search_term", call.Spec.Parameters[0].Name);
            Assert.Equal("삼성", call.Spec.Parameters[0].Value);
            Assert.Equal(DbType.String, call.Spec.Parameters[0].DbType);
            Assert.Equal("row_limit", call.Spec.Parameters[1].Name);
            Assert.Equal(26, call.Spec.Parameters[1].Value);
            Assert.Equal(DbType.Int32, call.Spec.Parameters[1].DbType);
            Assert.Contains("ORDER BY", call.Spec.Sql, StringComparison.Ordinal);
        });
        Assert.Equal(0, executor.StringSqlCallCount);
    }

    [Fact]
    public async Task SearchAsync_EscapesLikeMetacharactersInsideBoundValue()
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyStockSearchService(executor);

        await service.SearchAsync(" 50%!_ ");

        Assert.All(
            executor.Calls,
            call => Assert.Equal("50!%!!!_", call.Spec.Parameters[0].Value));
        Assert.All(
            executor.Calls,
            call => Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal));
    }

    [Fact]
    public async Task SearchAsync_AcceptsFiniteAllMarketCompatibilityCeiling()
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyStockSearchService(executor);

        var result = await service.SearchAsync(
            "삼성",
            LegacyStockSearchService.MaximumResults);

        Assert.False(result.IsTruncated);
        Assert.Equal(10_000, LegacyStockSearchService.MaximumResults);
        Assert.All(
            executor.Calls,
            call => Assert.Equal(
                LegacyStockSearchService.MaximumResults + 1,
                call.Spec.Parameters[1].Value));
    }

    [Fact]
    public async Task InitialConsonantSearch_UsesFullEmptyBoundCandidateWindowsThenFiltersNames()
    {
        var tables = new Queue<DataTable>(
        [
            StockTable(
                ("카카오", "035720"),
                ("삼성전자", "005930")),
            StockTable(("신성델타테크", "065350")),
            StockTable(("삼성전자(NXT)", "005930")),
            StockTable(("알테오젠(NXT)", "196170"))
        ]);
        var executor = new RecordingExecutor(_ => tables.Dequeue());
        var service = new LegacyStockSearchService(executor);

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

        Assert.Equal("ㅅㅅ", result.Query);
        Assert.True(result.IsTruncated);
        Assert.Collection(
            result.Items,
            item => Assert.Equal(
                new StockSearchItem(
                    StockMarket.Kospi,
                    DataSourceKind.Oracle,
                    "삼성전자",
                    "005930"),
                item),
            item => Assert.Equal(
                new StockSearchItem(
                    StockMarket.Kosdaq,
                    DataSourceKind.Oracle,
                    "신성델타테크",
                    "065350"),
                item));
        Assert.All(executor.Calls, call =>
        {
            Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
            Assert.Equal(
                LegacyStockSearchService.MaximumResults + 1,
                call.Spec.Parameters[1].Value);
        });
    }

    [Fact]
    public async Task InitialConsonantSearch_ValidatesNonmatchingCandidatesBeforeFiltering()
    {
        var malformed = StockTable(("카\n카오", "035720"));
        var executor = new RecordingExecutor(_ => malformed);
        var service = new LegacyStockSearchService(executor);

        await Assert.ThrowsAsync<StockSearchDataException>(
            () => service.SearchAsync("ㅅㅅ"));

        Assert.Single(executor.Calls);
        Assert.Equal(string.Empty, executor.Calls[0].Spec.Parameters[0].Value);
    }

    [Fact]
    public async Task InitialConsonantSearch_ReportsSaturatedCandidateWindowConservatively()
    {
        var callIndex = 0;
        var executor = new RecordingExecutor(_ =>
            callIndex++ == 0
                ? StockTable(LegacyStockSearchService.MaximumResults + 1)
                : StockTable());
        var service = new LegacyStockSearchService(executor);

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

        Assert.Empty(result.Items);
        Assert.True(result.IsTruncated);
        Assert.All(executor.Calls, call =>
        {
            Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
            Assert.Equal(
                LegacyStockSearchService.MaximumResults + 1,
                call.Spec.Parameters[1].Value);
        });
    }

    [Fact]
    public async Task OperatorCatalogSearchAllAsync_UsesBoundEmptyTermAndFiniteProviderLimits()
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyOperatorCatalogStockSearchService(executor);
        using var cancellation = new CancellationTokenSource();

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

        Assert.Equal(string.Empty, result.Query);
        Assert.False(result.IsTruncated);
        Assert.Equal(4, executor.Calls.Count);
        Assert.All(executor.Calls, call =>
        {
            call.Spec.ValidateFor(call.Source);
            Assert.Equal(string.Empty, call.Spec.Parameters[0].Value);
            Assert.Equal(26, call.Spec.Parameters[1].Value);
            Assert.Contains("ESCAPE '!'", call.Spec.Sql, StringComparison.Ordinal);
            Assert.Equal(cancellation.Token, call.CancellationToken);
        });
        Assert.Equal(0, executor.StringSqlCallCount);
    }

    [Fact]
    public async Task OperatorCatalogSearchAllAsync_TruncatesAtTenThousandValidatedRows()
    {
        var callIndex = 0;
        var executor = new RecordingExecutor(_ =>
            callIndex++ == 0
                ? StockTable(LegacyStockSearchService.MaximumResults + 1)
                : StockTable());
        var service = new LegacyOperatorCatalogStockSearchService(executor);

        var result = await service.SearchAllAsync(LegacyStockSearchService.MaximumResults);

        Assert.Equal(LegacyStockSearchService.MaximumResults, result.Items.Count);
        Assert.True(result.IsTruncated);
        Assert.All(
            executor.Calls,
            call => Assert.Equal(
                LegacyStockSearchService.MaximumResults + 1,
                call.Spec.Parameters[1].Value));
    }

    [Fact]
    public async Task OperatorCatalogRegularSearchStillRejectsBlankInput()
    {
        var executor = new RecordingExecutor(_ => StockTable());
        IOperatorCatalogStockSearchService service =
            new LegacyOperatorCatalogStockSearchService(executor);

        await Assert.ThrowsAsync<ArgumentException>(() => service.SearchAsync(string.Empty));

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task SearchAsync_ValidatesSchemaAndReturnsDeterministicCappedOrder()
    {
        var tables = new Queue<DataTable>(
        [
            StockTable(("가나다", "000002"), ("가나다", "000001")),
            StockTable(("코스닥", "100001")),
            StockTable(("NXT가(NXT)", "000002")),
            StockTable(("NXT나(NXT)", "100001"))
        ]);
        var executor = new RecordingExecutor(_ => tables.Dequeue());
        var service = new LegacyStockSearchService(executor);

        var result = await service.SearchAsync("가", maximumResults: 3);

        Assert.True(result.IsTruncated);
        Assert.Collection(
            result.Items,
            item => Assert.Equal(
                new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "가나다", "000001"),
                item),
            item => Assert.Equal(
                new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "가나다", "000002"),
                item),
            item => Assert.Equal(
                new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "코스닥", "100001"),
                item));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(LegacyStockSearchService.MaximumResults + 1)]
    [InlineData(int.MaxValue)]
    public async Task SearchAsync_RejectsInvalidMaximumBeforeDatabaseAccess(int maximumResults)
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyStockSearchService(executor);

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

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

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("bad\u0000value")]
    [InlineData("\n삼성")]
    [InlineData("bad\u200Bvalue")]
    [InlineData("bad\uD800value")]
    public async Task SearchAsync_RejectsUnsafeQueryBeforeDatabaseAccess(string query)
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyStockSearchService(executor);

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

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

    [Fact]
    public async Task SearchAsync_RejectsOverlongQueryBeforeDatabaseAccess()
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyStockSearchService(executor);

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

        Assert.Empty(executor.Calls);
    }

    [Fact]
    public async Task SearchAsync_RejectsNullQueryBeforeDatabaseAccess()
    {
        var executor = new RecordingExecutor(_ => StockTable());
        var service = new LegacyStockSearchService(executor);

        var exception = await Assert.ThrowsAsync<ArgumentNullException>(
            () => service.SearchAsync(null!));

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

    [Fact]
    public async Task SearchAsync_RejectsAnythingOtherThanExactStringSchema()
    {
        var wrongCase = new DataTable();
        wrongCase.Columns.Add("stock_name", typeof(string));
        wrongCase.Columns.Add("STOCK_CODE", typeof(string));
        var executor = new RecordingExecutor(_ => wrongCase);
        var service = new LegacyStockSearchService(executor);

        var exception = await Assert.ThrowsAsync<StockSearchDataException>(
            () => service.SearchAsync("삼성"));

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

    [Fact]
    public async Task SearchAsync_RejectsNullOrControlCharacterDatabaseValues()
    {
        var malformed = StockTable();
        malformed.Rows.Add("삼성\n전자", "005930");
        var executor = new RecordingExecutor(_ => malformed);
        var service = new LegacyStockSearchService(executor);

        await Assert.ThrowsAsync<StockSearchDataException>(
            () => service.SearchAsync("삼성"));

        Assert.Single(executor.Calls);
    }

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

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

        Assert.Empty(executor.Calls);
    }

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

        return table;
    }

    private static DataTable StockTable(int rowCount)
    {
        var table = StockTable();
        for (var index = 0; index < rowCount; index++)
        {
            table.Rows.Add($"Stock {index:D5}", index.ToString("D6"));
        }

        return table;
    }

    private sealed record RecordedCall(
        DataSourceKind Source,
        string TableName,
        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());
        }
    }
}
