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

namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;

public sealed class LegacyThemeWorkflowTests
{
    [Fact]
    public void Actions_AreClosedImmutableAndMatchCoreSceneCatalog()
    {
        Assert.Equal(
            ["five-row-current", "five-row-expected", "six-row-current", "twelve-row-current"],
            LegacyThemeWorkflow.Actions.Select(action => action.Id));
        Assert.Equal([5, 5, 6, 12], LegacyThemeWorkflow.Actions.Select(action => action.PageSize));
        Assert.Equal(
            [
                LegacyThemeValueKind.Current,
                LegacyThemeValueKind.Expected,
                LegacyThemeValueKind.Current,
                LegacyThemeValueKind.Current
            ],
            LegacyThemeWorkflow.Actions.Select(action => action.ValueKind));

        foreach (var action in LegacyThemeWorkflow.Actions)
        {
            var scene = Assert.Single(
                LegacySceneCatalog.Definitions,
                definition => definition.BuilderKey == action.BuilderKey);
            Assert.Single(action.Aliases);
            Assert.Equal(action.Code, action.Aliases[0]);
            Assert.Contains(action.Code, scene.CutAliases);
            Assert.Equal(action.PageSize, (int)scene.PageSize);
        }

        var mutable = Assert.IsAssignableFrom<IList<LegacyThemeActionDefinition>>(
            LegacyThemeWorkflow.Actions);
        Assert.True(mutable.IsReadOnly);
        Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
        Assert.Null(LegacyThemeWorkflow.FindAction(null));
        Assert.Null(LegacyThemeWorkflow.FindAction("unknown"));
        Assert.Equal(
            ["INPUT_ORDER", "GAIN_DESC", "GAIN_ASC"],
            LegacyThemeWorkflow.Sorts.Select(sort => sort.Id));
        Assert.Equal(
            ["입력순", "상승률순", "하락률순"],
            LegacyThemeWorkflow.Sorts.Select(sort => sort.Label));
        var mutableSorts = Assert.IsAssignableFrom<IList<LegacyThemeSortDefinition>>(
            LegacyThemeWorkflow.Sorts);
        Assert.True(mutableSorts.IsReadOnly);
        Assert.Throws<NotSupportedException>(() => mutableSorts.RemoveAt(0));
    }

    [Fact]
    public void InitialSnapshot_IsImmutableAndUsesOriginalInputOrderDefault()
    {
        var workflow = new LegacyThemeWorkflow(new FakeThemeSelectionService());

        var snapshot = workflow.CreateInitial(ThemeSession.AfterMarket);

        Assert.Equal(0, snapshot.Revision);
        Assert.Equal("", snapshot.Query);
        Assert.Equal(ThemeSession.AfterMarket, snapshot.NxtSession);
        Assert.Equal(LegacyThemeSort.InputOrder, snapshot.Sort);
        Assert.Equal("INPUT_ORDER", snapshot.SortId);
        Assert.Empty(snapshot.SearchResults);
        Assert.Null(snapshot.SelectedResultId);
        Assert.Null(snapshot.SelectedTheme);
        Assert.Empty(snapshot.PreviewItems);
        Assert.All(snapshot.ActionStates, state =>
        {
            Assert.False(state.IsAvailable);
            Assert.Equal("theme-not-selected", state.UnavailableReason);
        });
        Assert.All(
            typeof(LegacyThemeWorkflowSnapshot).GetProperties(),
            property => Assert.False(property.CanWrite));
        Assert.Throws<ArgumentOutOfRangeException>(() =>
            workflow.CreateInitial(ThemeSession.NotApplicable));
        Assert.Equal(5_000, LegacyThemeWorkflow.MaximumSearchResults);
        Assert.Equal(
            LegacyThemeSelectionService.MaximumResults,
            LegacyThemeWorkflow.MaximumSearchResults);
    }

    [Theory]
    [InlineData(0, ThemeSession.PreMarket)]
    [InlineData(12, ThemeSession.PreMarket)]
    [InlineData(13, ThemeSession.AfterMarket)]
    [InlineData(23, ThemeSession.AfterMarket)]
    public void CurrentNxtSession_UsesTheOriginalNativeLocalHourBoundary(
        int hour,
        ThemeSession expected)
    {
        var workflow = new LegacyThemeWorkflow(
            new FakeThemeSelectionService(),
            () => new DateTimeOffset(2026, 7, 18, hour, 0, 0, TimeSpan.FromHours(9)));

        Assert.Equal(expected, workflow.CurrentNxtSession);
    }

    [Fact]
    public async Task ResetForTabActivation_RestoresTheNewUc4Defaults()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var workflow = new LegacyThemeWorkflow(
            ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]));
        var selected = workflow.SelectSort(
            await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket),
            "GAIN_DESC");

        var reset = workflow.ResetForTabActivation(selected);

        Assert.Equal(selected.Revision + 1, reset.Revision);
        Assert.Equal(string.Empty, reset.Query);
        Assert.Equal(ThemeSession.PreMarket, reset.NxtSession);
        Assert.Equal(LegacyThemeSort.InputOrder, reset.Sort);
        Assert.Empty(reset.SearchResults);
        Assert.Null(reset.SelectedResultId);
        Assert.Null(reset.SelectedTheme);
        Assert.Empty(reset.PreviewItems);
    }

    [Fact]
    public async Task SearchAsync_NormalizesQueryAndPreservesProviderOrderWithinKrxThenNxt()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (query, session) => SearchResult(
                query,
                session,
                Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Zulu", "00000002"),
                Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "Alpha", "00000001"),
                Selector(ThemeMarket.Nxt, session, "Robotics", "00000003"))
        };
        var workflow = new LegacyThemeWorkflow(service);
        var initial = workflow.CreateInitial();

        var result = await workflow.SearchAsync(
            initial,
            " ai ",
            ThemeSession.PreMarket);

        var call = Assert.Single(service.SearchCalls);
        Assert.Equal("ai", call.Query);
        Assert.Equal(ThemeSession.PreMarket, call.Session);
        Assert.Equal(LegacyThemeWorkflow.MaximumSearchResults, call.MaximumResults);
        Assert.Equal(1, result.Revision);
        Assert.Equal("ai", result.Query);
        Assert.Collection(
            result.SearchResults,
            row =>
            {
                Assert.Equal("theme-result-001", row.ResultId);
                Assert.Equal("Zulu", row.DisplayTitle);
                Assert.Equal(ThemeMarket.Krx, row.Market);
                Assert.Equal(ThemeSession.NotApplicable, row.Session);
                Assert.Equal(DataSourceKind.Oracle, row.Source);
            },
            row => Assert.Equal("Alpha", row.DisplayTitle),
            row =>
            {
                Assert.Equal("theme-result-003", row.ResultId);
                Assert.Equal("Robotics(NXT)", row.DisplayTitle);
                Assert.Equal(ThemeMarket.Nxt, row.Market);
                Assert.Equal(ThemeSession.PreMarket, row.Session);
                Assert.Equal(DataSourceKind.MariaDb, row.Source);
            });
        Assert.Empty(initial.SearchResults);
        var mutable = Assert.IsAssignableFrom<IList<LegacyThemeSearchRow>>(result.SearchResults);
        Assert.True(mutable.IsReadOnly);
        Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
    }

    [Fact]
    public async Task SearchAsync_RejectsUnsafeInputBeforeCallingService()
    {
        var service = new FakeThemeSelectionService();
        var workflow = new LegacyThemeWorkflow(service);
        var snapshot = workflow.CreateInitial();

        await Assert.ThrowsAsync<ArgumentNullException>(() =>
            workflow.SearchAsync(snapshot, null!, ThemeSession.PreMarket));
        await Assert.ThrowsAsync<ArgumentException>(() =>
            workflow.SearchAsync(snapshot, "bad\u0000query", ThemeSession.PreMarket));
        await Assert.ThrowsAsync<ArgumentException>(() =>
            workflow.SearchAsync(
                snapshot,
                new string('A', LegacyThemeSelectionService.MaximumQueryLength + 1),
                ThemeSession.PreMarket));
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
            workflow.SearchAsync(snapshot, "AI", ThemeSession.NotApplicable));

        Assert.Empty(service.SearchCalls);
    }

    [Fact]
    public async Task SearchAsync_RejectsUntrustedServiceEnvelopeProviderAndIdentity()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (_, session) => SearchResult(
                "wrong-query",
                session,
                Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"))
        };
        var workflow = new LegacyThemeWorkflow(service);
        var snapshot = workflow.CreateInitial();
        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));

        service.SearchHandler = (query, session) => SearchResult(
            query,
            session,
            new ThemeSelectorItem(
                Identity(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
                DataSourceKind.MariaDb));
        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));

        service.SearchHandler = (query, session) => SearchResult(
            query,
            session,
            Selector(ThemeMarket.Nxt, ThemeSession.AfterMarket, "AI", "00000001"));
        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SearchAsync(snapshot, "AI", ThemeSession.PreMarket));

        service.SearchHandler = (query, session) => SearchResult(
            query,
            session,
            Selector(ThemeMarket.Nxt, session, "AI(NXT)", "00000001"));
        var storedSuffix = await workflow.SearchAsync(
            snapshot,
            "AI",
            ThemeSession.PreMarket);
        Assert.Equal("AI(NXT)", Assert.Single(storedSuffix.SearchResults).DisplayTitle);

        service.SearchHandler = (query, session) => SearchResult(
            query,
            session,
            Selector(ThemeMarket.Nxt, session, "AI(NXT)(NXT)", "00000001"));
        var repeatedNxtSuffix = await workflow.SearchAsync(
            snapshot,
            "AI",
            ThemeSession.PreMarket);
        Assert.Equal(
            "AI(NXT)(NXT)",
            Assert.Single(repeatedNxtSuffix.SearchResults).Identity.ThemeTitle);

        service.SearchHandler = (query, session) => SearchResult(
            query,
            session,
            Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI(NXT)", "00000001"));
        var krxStoredSuffix = await workflow.SearchAsync(
            snapshot,
            "AI",
            ThemeSession.PreMarket);
        Assert.Equal("AI(NXT)", Assert.Single(krxStoredSuffix.SearchResults).DisplayTitle);

        service.SearchHandler = (query, session) => SearchResult(
            query,
            session,
            Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI(NXT)(NXT)", "00000001"));
        var repeatedKrxSuffix = await workflow.SearchAsync(
            snapshot,
            "AI",
            ThemeSession.PreMarket);
        Assert.Equal(ThemeMarket.Krx, Assert.Single(repeatedKrxSuffix.SearchResults).Market);

        service.SearchHandler = (query, session) => SearchResult(
            query,
            session,
            Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"),
            Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000002"));
        var duplicateTitles = await workflow.SearchAsync(
            snapshot,
            "AI",
            ThemeSession.PreMarket);
        Assert.Equal(2, duplicateTitles.SearchResults.Count);
    }

    [Fact]
    public async Task SearchAsync_RejectsProviderRowsThatWouldRequireReordering()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (query, session) => SearchResult(
                query,
                session,
                Selector(ThemeMarket.Nxt, session, "NXT first", "00000002"),
                Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "KRX later", "00000001"))
        };
        var workflow = new LegacyThemeWorkflow(service);

        var exception = await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SearchAsync(workflow.CreateInitial(), "", ThemeSession.PreMarket));

        Assert.Contains("search provider order", exception.Message, StringComparison.Ordinal);
    }

    [Fact]
    public async Task SearchAsync_PreservesExactLegacyStoredTitlesWithEdgeSpaces()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (query, session) => SearchResult(
                query,
                session,
                Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, " KRX Theme ", "00000001"),
                Selector(ThemeMarket.Nxt, session, " NXT Theme ", "00000002"))
        };
        var workflow = new LegacyThemeWorkflow(service);

        var result = await workflow.SearchAsync(
            workflow.CreateInitial(),
            string.Empty,
            ThemeSession.PreMarket);

        Assert.Collection(
            result.SearchResults,
            row =>
            {
                Assert.Equal(" KRX Theme ", row.Identity.ThemeTitle);
                Assert.Equal(" KRX Theme ", row.DisplayTitle);
                Assert.Equal(ThemeMarket.Krx, row.Market);
            },
            row =>
            {
                Assert.Equal(" NXT Theme ", row.Identity.ThemeTitle);
                Assert.Equal(" NXT Theme (NXT)", row.DisplayTitle);
                Assert.Equal(ThemeMarket.Nxt, row.Market);
            });
    }

    [Fact]
    public async Task SearchAsync_AcceptsFiniteFiveThousandRowTruncationBoundary()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (query, session) => new ThemeSearchResult(
                query,
                session,
                DateTimeOffset.UtcNow,
                Enumerable.Range(0, LegacyThemeWorkflow.MaximumSearchResults)
                    .Select(index => Selector(
                        ThemeMarket.Krx,
                        ThemeSession.NotApplicable,
                        $"Theme {index}",
                        index.ToString("D8", System.Globalization.CultureInfo.InvariantCulture)))
                    .ToArray(),
                IsTruncated: true)
        };
        var workflow = new LegacyThemeWorkflow(service);

        var snapshot = await workflow.SearchAsync(
            workflow.CreateInitial(),
            string.Empty,
            ThemeSession.PreMarket);

        Assert.Equal(5_000, snapshot.SearchResults.Count);
        Assert.True(snapshot.SearchIsTruncated);
        Assert.Equal("theme-result-5000", snapshot.SearchResults[^1].ResultId);
    }

    [Fact]
    public async Task SearchAsync_AcceptsConservativeTruncationAfterInitialFiltering()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (query, session) => new ThemeSearchResult(
                query,
                session,
                DateTimeOffset.UtcNow,
                [
                    Selector(
                        ThemeMarket.Krx,
                        ThemeSession.NotApplicable,
                        "삼성 테마",
                        "00000001")
                ],
                IsTruncated: true)
        };
        var workflow = new LegacyThemeWorkflow(service);

        var snapshot = await workflow.SearchAsync(
            workflow.CreateInitial(),
            "ㅅㅅ",
            ThemeSession.PreMarket);

        Assert.Equal("삼성 테마", Assert.Single(snapshot.SearchResults).DisplayTitle);
        Assert.True(snapshot.SearchIsTruncated);
    }

    [Fact]
    public async Task SearchAsync_StillRejectsImpossiblePartialTruncationForOrdinaryText()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (query, session) => new ThemeSearchResult(
                query,
                session,
                DateTimeOffset.UtcNow,
                [
                    Selector(
                        ThemeMarket.Krx,
                        ThemeSession.NotApplicable,
                        "AI",
                        "00000001")
                ],
                IsTruncated: true)
        };
        var workflow = new LegacyThemeWorkflow(service);

        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SearchAsync(
                workflow.CreateInitial(),
                "AI",
                ThemeSession.PreMarket));
    }

    [Fact]
    public async Task SelectAsync_UsesOpaqueResultIdAndCopiesValidatedPreview()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var previewItems = new List<ThemeItemPreview>
        {
            new(0, "P005930", "삼성전자"),
            new(2, "D035720", "카카오")
        };
        var service = ServiceWithTheme(identity, previewItems);
        var workflow = new LegacyThemeWorkflow(service);
        var search = await workflow.SearchAsync(
            workflow.CreateInitial(),
            "AI",
            ThemeSession.PreMarket);

        var selected = await workflow.SelectAsync(search, "theme-result-001");

        var call = Assert.Single(service.PreviewCalls);
        Assert.Equal(identity, call.Identity);
        Assert.Equal(LegacyThemeWorkflow.MaximumPreviewItems, call.MaximumItems);
        Assert.Equal(2, selected.Revision);
        Assert.Equal("theme-result-001", selected.SelectedResultId);
        Assert.Equal(
            new LegacySelectedThemeSummary(
                ThemeMarket.Krx,
                ThemeSession.NotApplicable,
                "AI",
                2,
                false),
            selected.SelectedTheme);
        Assert.Equal(["삼성전자", "카카오"], selected.PreviewItems.Select(row => row.ItemName));
        Assert.Null(search.SelectedTheme);
        Assert.All(selected.ActionStates, state =>
        {
            Assert.True(state.IsAvailable);
            Assert.Null(state.UnavailableReason);
        });

        previewItems.Add(new ThemeItemPreview(3, "P000001", "Later mutation"));
        Assert.Equal(2, selected.PreviewItems.Count);
        var mutable = Assert.IsAssignableFrom<IList<LegacyThemePreviewRow>>(selected.PreviewItems);
        Assert.True(mutable.IsReadOnly);
        Assert.Throws<NotSupportedException>(() => mutable.RemoveAt(0));
    }

    [Fact]
    public async Task SearchAsync_ReplacesResultsAndClearsOnlyTheNewSnapshotSelection()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
        var workflow = new LegacyThemeWorkflow(service);
        var selected = workflow.SelectSort(
            await SearchAndSelectAsync(workflow),
            "GAIN_ASC");
        service.SearchHandler = (query, session) => SearchResult(query, session);

        var replaced = await workflow.SearchAsync(
            selected,
            "new",
            ThemeSession.AfterMarket);

        Assert.Equal("new", replaced.Query);
        Assert.Equal(ThemeSession.AfterMarket, replaced.NxtSession);
        Assert.Equal(LegacyThemeSort.GainRateAscending, replaced.Sort);
        Assert.Empty(replaced.SearchResults);
        Assert.Null(replaced.SelectedTheme);
        Assert.Empty(replaced.PreviewItems);
        Assert.NotNull(selected.SelectedTheme);
        Assert.Single(selected.PreviewItems);
    }

    [Fact]
    public async Task SearchAsync_RetainsOnlyACorrelatedSelectionAndPreview()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
        var workflow = new LegacyThemeWorkflow(service);
        var selected = workflow.SelectSort(await SearchAndSelectAsync(workflow), "GAIN_DESC");

        var refreshed = await workflow.SearchAsync(
            selected,
            "AI",
            ThemeSession.AfterMarket);

        Assert.Equal(
            new LegacySelectedThemeSummary(
                ThemeMarket.Krx,
                ThemeSession.NotApplicable,
                "AI",
                1,
                false),
            refreshed.SelectedTheme);
        Assert.NotNull(refreshed.SelectedResultId);
        Assert.Single(refreshed.PreviewItems);
        Assert.Equal("삼성전자", refreshed.PreviewItems[0].ItemName);
        Assert.Equal(LegacyThemeSort.GainRateDescending, refreshed.Sort);
        Assert.Single(service.PreviewCalls);
    }

    [Fact]
    public async Task SelectAsync_RejectsUnknownResultWithoutCallingPreview()
    {
        var service = new FakeThemeSelectionService
        {
            SearchHandler = (query, session) => SearchResult(
                query,
                session,
                Selector(ThemeMarket.Krx, ThemeSession.NotApplicable, "AI", "00000001"))
        };
        var workflow = new LegacyThemeWorkflow(service);
        var search = await workflow.SearchAsync(
            workflow.CreateInitial(),
            "",
            ThemeSession.PreMarket);

        await Assert.ThrowsAsync<KeyNotFoundException>(() =>
            workflow.SelectAsync(search, "theme-result-999"));

        Assert.Empty(service.PreviewCalls);
    }

    [Fact]
    public async Task SelectAsync_RejectsUntrustedPreviewEnvelopeAndItems()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
        var workflow = new LegacyThemeWorkflow(service);
        var search = await workflow.SearchAsync(
            workflow.CreateInitial(),
            "",
            ThemeSession.PreMarket);

        service.PreviewHandler = _ => new ThemePreviewResult(
            identity with { ThemeCode = "00000002" },
            DateTimeOffset.UtcNow,
            [new(0, "P005930", "삼성전자")],
            false);
        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SelectAsync(search, "theme-result-001"));

        service.PreviewHandler = _ => PreviewResult(
            identity,
            [new(0, "X005930", "잘못된 시장")]);
        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SelectAsync(search, "theme-result-001"));

        service.PreviewHandler = _ => PreviewResult(
            identity,
            [new(1, "P005930", "A"), new(1, "D035720", "B")]);
        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SelectAsync(search, "theme-result-001"));

        service.PreviewHandler = _ => PreviewResult(
            identity,
            CreateItems(239, ThemeMarket.Krx),
            isTruncated: true);
        await Assert.ThrowsAsync<LegacyThemeWorkflowDataException>(() =>
            workflow.SelectAsync(search, "theme-result-001"));
    }

    [Fact]
    public async Task SelectSort_UsesClosedIdsAndPreservesImmutableSelection()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
        var workflow = new LegacyThemeWorkflow(service);
        var selected = await SearchAndSelectAsync(workflow);

        var changed = workflow.SelectSort(selected, "GAIN_DESC");

        Assert.Equal(LegacyThemeSort.GainRateDescending, changed.Sort);
        Assert.Equal("GAIN_DESC", changed.SortId);
        Assert.Equal(selected.Revision + 1, changed.Revision);
        Assert.Equal(LegacyThemeSort.InputOrder, selected.Sort);
        Assert.Equal(selected.SelectedTheme, changed.SelectedTheme);
        Assert.Throws<ArgumentOutOfRangeException>(() =>
            workflow.SelectSort(selected, "gain_desc"));
        Assert.Throws<ArgumentOutOfRangeException>(() =>
            workflow.SelectSort(selected, " GAIN_DESC "));
        Assert.Equal("입력순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.InputOrder));
        Assert.Equal("상승률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateDescending));
        Assert.Equal("하락률순", LegacyThemeWorkflow.SortLabel(LegacyThemeSort.GainRateAscending));
    }

    [Theory]
    [InlineData("INPUT_ORDER", "입력순")]
    [InlineData("GAIN_DESC", "상승률순")]
    [InlineData("GAIN_ASC", "하락률순")]
    public async Task MaterializePlaylistDraft_KrxBuildsAllFourClosedActions(
        string sortId,
        string sortLabel)
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, CreateItems(13, ThemeMarket.Krx));
        var workflow = new LegacyThemeWorkflow(service);
        var selected = workflow.SelectSort(await SearchAndSelectAsync(workflow), sortId);

        foreach (var action in LegacyThemeWorkflow.Actions)
        {
            var draft = workflow.MaterializePlaylistDraft(selected, action.Id);

            Assert.Equal(action.Id, draft.ActionId);
            Assert.Equal(action.BuilderKey, draft.BuilderKey);
            Assert.Equal(action.Code, draft.Code);
            Assert.Equal(action.Aliases, draft.Aliases);
            Assert.Equal("AI", draft.Title);
            var expectedGraphicType = action.Id switch
            {
                "five-row-current" or "five-row-expected" => "5단 표그래프",
                "six-row-current" => "6종목 현재가",
                "twelve-row-current" => "12종목 현재가",
                _ => throw new InvalidOperationException()
            };
            var expectedSubtype =
                $"테마-{(action.ValueKind == LegacyThemeValueKind.Expected ? "예상체결가" : "현재가")}({sortLabel})";
            Assert.Equal(expectedGraphicType, draft.PlaylistGraphicType);
            Assert.Equal(expectedSubtype, draft.PlaylistSubtype);
            Assert.Equal(expectedSubtype, draft.Detail);
            Assert.Equal("plate", draft.Category);
            Assert.Equal("theme", draft.Market);
            Assert.Equal("PAGED_THEME", draft.Selection.GroupCode);
            Assert.Equal("AI", draft.Selection.Subject);
            Assert.Equal(sortId, draft.Selection.GraphicType);
            Assert.Equal(
                action.ValueKind == LegacyThemeValueKind.Expected ? "EXPECTED" : "CURRENT",
                draft.Selection.Subtype);
            Assert.Equal("00000001", draft.Selection.DataCode);
            Assert.Equal("AI", draft.Selection.ExactSubject);
            Assert.Equal(action.PageSize, draft.PageSize);
            Assert.Equal($"1/{draft.PagePlan.PageCount}", draft.PagePlan.InitialPageText);
            Assert.InRange(draft.PagePlan.PageCount, 1, 20);
        }
    }

    [Theory]
    [InlineData("INPUT_ORDER")]
    [InlineData("GAIN_DESC")]
    [InlineData("GAIN_ASC")]
    public async Task MaterializePlaylistDraft_NxtKeepsSessionAndExpectedClickShowsOriginalWarning(
        string sortId)
    {
        var identity = Identity(
            ThemeMarket.Nxt,
            ThemeSession.AfterMarket,
            "Robotics",
            "00000002");
        var service = ServiceWithTheme(identity, CreateItems(7, ThemeMarket.Nxt));
        var workflow = new LegacyThemeWorkflow(
            service,
            () => new DateTimeOffset(2026, 7, 18, 14, 0, 0, TimeSpan.FromHours(9)));
        var selected = workflow.SelectSort(
            await SearchAndSelectAsync(workflow, ThemeSession.AfterMarket),
            sortId);

        foreach (var actionId in new[]
                 {
                     "five-row-current", "six-row-current", "twelve-row-current"
                 })
        {
            var draft = workflow.MaterializePlaylistDraft(selected, actionId);
            Assert.Equal("Robotics(NXT)", draft.Title);
            Assert.Equal(
                new LegacyThemePlaylistSelection(
                    "PAGED_NXT_THEME",
                    "Robotics(NXT)",
                    "AFTER_MARKET",
                    sortId,
                    "00000002",
                    "Robotics"),
                draft.Selection);
            Assert.True(workflow.GetActionState(selected, actionId).IsAvailable);
        }

        var expectedState = workflow.GetActionState(selected, "five-row-expected");
        Assert.True(expectedState.IsAvailable);
        Assert.Null(expectedState.UnavailableReason);
        var exception = Assert.Throws<InvalidOperationException>(() =>
            workflow.MaterializePlaylistDraft(selected, "five-row-expected"));
        Assert.Equal("NXT는 데이터가 없습니다.", exception.Message);
    }

    [Fact]
    public async Task MaterializePlaylistDraft_NxtStoredSuffixIsNotDuplicated()
    {
        var identity = Identity(
            ThemeMarket.Nxt,
            ThemeSession.PreMarket,
            "Robotics(NXT)",
            "00000002");
        var workflow = new LegacyThemeWorkflow(
            ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Nxt)));
        var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);

        var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");

        Assert.Equal("Robotics(NXT)", draft.Title);
        Assert.Equal("Robotics(NXT)", draft.Selection.Subject);
        Assert.Equal("Robotics(NXT)", draft.Selection.ExactSubject);
    }

    [Fact]
    public async Task MaterializePlaylistDraft_KrxStoredNxtSuffixKeepsExplicitKrxRoute()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "Robotics(NXT)",
            "00000002");
        var workflow = new LegacyThemeWorkflow(
            ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Krx)));
        var selected = await SearchAndSelectAsync(workflow);

        var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");

        Assert.Equal("Robotics(NXT)", draft.Title);
        Assert.Equal("PAGED_THEME", draft.Selection.GroupCode);
        Assert.Equal("Robotics(NXT)", draft.Selection.Subject);
        Assert.Equal("Robotics(NXT)", draft.Selection.ExactSubject);
    }

    [Fact]
    public async Task SelectAsync_NxtEmptyActivePreviewRemainsSelectedButCannotAddOrPlayout()
    {
        var identity = Identity(
            ThemeMarket.Nxt,
            ThemeSession.PreMarket,
            " NXT Theme",
            "00000004");
        var workflow = new LegacyThemeWorkflow(ServiceWithTheme(identity, []));

        var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);

        Assert.Equal(ThemeMarket.Nxt, selected.SelectedTheme!.Market);
        Assert.Equal(" NXT Theme(NXT)", selected.SelectedTheme.DisplayTitle);
        Assert.Empty(selected.PreviewItems);
        Assert.All(selected.ActionStates, state =>
        {
            Assert.False(state.IsAvailable);
            Assert.Equal("theme-preview-is-empty", state.UnavailableReason);
        });
        Assert.Throws<InvalidOperationException>(() =>
            workflow.MaterializePlaylistDraft(selected, "five-row-current"));
    }

    [Fact]
    public async Task MaterializePlaylistDraft_NxtReevaluatesTheNativeHourAtActionTime()
    {
        var localNow = new DateTimeOffset(
            2026,
            7,
            18,
            10,
            0,
            0,
            TimeSpan.FromHours(9));
        var identity = Identity(
            ThemeMarket.Nxt,
            ThemeSession.PreMarket,
            "Robotics",
            "00000002");
        var workflow = new LegacyThemeWorkflow(
            ServiceWithTheme(identity, CreateItems(1, ThemeMarket.Nxt)),
            () => localNow);
        var selected = await SearchAndSelectAsync(workflow, ThemeSession.PreMarket);
        localNow = localNow.AddHours(4);

        var draft = workflow.MaterializePlaylistDraft(selected, "five-row-current");

        Assert.Equal("AFTER_MARKET", draft.Selection.GraphicType);
    }

    [Theory]
    [InlineData("five-row-current", 1, 1, 1, false)]
    [InlineData("five-row-current", 5, 5, 1, false)]
    [InlineData("five-row-current", 6, 6, 2, false)]
    [InlineData("five-row-current", 100, 100, 20, false)]
    [InlineData("five-row-current", 101, 100, 20, true)]
    [InlineData("five-row-expected", 101, 100, 20, true)]
    [InlineData("six-row-current", 120, 120, 20, false)]
    [InlineData("six-row-current", 121, 120, 20, true)]
    [InlineData("twelve-row-current", 239, 239, 20, false)]
    [InlineData("twelve-row-current", 240, 240, 20, false)]
    public async Task MaterializePlaylistDraft_CalculatesPageNWithTwentyPageMaximum(
        string actionId,
        int itemCount,
        int accessibleCount,
        int pageCount,
        bool truncated)
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, CreateItems(itemCount, ThemeMarket.Krx));
        var workflow = new LegacyThemeWorkflow(service);
        var selected = await SearchAndSelectAsync(workflow);

        var page = workflow.MaterializePlaylistDraft(selected, actionId).PagePlan;

        Assert.Equal(itemCount, page.PreviewItemCount);
        Assert.Equal(accessibleCount, page.AccessibleItemCount);
        Assert.Equal(pageCount, page.PageCount);
        Assert.Equal(20, page.MaximumPageCount);
        Assert.Equal(truncated, page.IsTruncated);
        Assert.Equal($"1/{pageCount}", page.InitialPageText);
    }

    [Fact]
    public async Task MaterializePlaylistDraft_PropagatesServiceTruncationAtPageTwenty()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(
            identity,
            CreateItems(240, ThemeMarket.Krx),
            previewIsTruncated: true);
        var workflow = new LegacyThemeWorkflow(service);
        var selected = await SearchAndSelectAsync(workflow);

        var page = workflow.MaterializePlaylistDraft(
            selected,
            "twelve-row-current").PagePlan;

        Assert.Equal(20, page.PageCount);
        Assert.True(page.IsTruncated);
        Assert.True(selected.PreviewIsTruncated);
    }

    [Fact]
    public async Task MaterializePlaylistDraft_RejectsMissingEmptyUnknownAndForeignState()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, []);
        var workflow = new LegacyThemeWorkflow(service);
        var initial = workflow.CreateInitial();
        Assert.Throws<InvalidOperationException>(() =>
            workflow.MaterializePlaylistDraft(initial, "five-row-current"));

        var selected = await SearchAndSelectAsync(workflow);
        Assert.All(selected.ActionStates, state =>
        {
            Assert.False(state.IsAvailable);
            Assert.Equal("theme-preview-is-empty", state.UnavailableReason);
        });
        Assert.Throws<InvalidOperationException>(() =>
            workflow.MaterializePlaylistDraft(selected, "five-row-current"));
        Assert.Throws<KeyNotFoundException>(() =>
            workflow.MaterializePlaylistDraft(selected, "s5074"));

        var other = new LegacyThemeWorkflow(new FakeThemeSelectionService());
        Assert.Throws<ArgumentException>(() =>
            other.MaterializePlaylistDraft(selected, "five-row-current"));
    }

    [Fact]
    public async Task SearchAndSelect_RespectPreCanceledTokensWithoutServiceCalls()
    {
        var identity = Identity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "AI",
            "00000001");
        var service = ServiceWithTheme(identity, [new(0, "P005930", "삼성전자")]);
        var workflow = new LegacyThemeWorkflow(service);
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();

        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            workflow.SearchAsync(
                workflow.CreateInitial(),
                "",
                ThemeSession.PreMarket,
                cancellation.Token));
        Assert.Empty(service.SearchCalls);

        var search = await workflow.SearchAsync(
            workflow.CreateInitial(),
            "",
            ThemeSession.PreMarket);
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            workflow.SelectAsync(search, "theme-result-001", cancellation.Token));
        Assert.Empty(service.PreviewCalls);
    }

    private static async Task<LegacyThemeWorkflowSnapshot> SearchAndSelectAsync(
        LegacyThemeWorkflow workflow,
        ThemeSession session = ThemeSession.PreMarket)
    {
        var search = await workflow.SearchAsync(
            workflow.CreateInitial(session),
            "",
            session);
        return await workflow.SelectAsync(search, "theme-result-001");
    }

    private static FakeThemeSelectionService ServiceWithTheme(
        ThemeSelectionIdentity identity,
        IReadOnlyList<ThemeItemPreview> items,
        bool previewIsTruncated = false) =>
        new()
        {
            SearchHandler = (query, session) => SearchResult(
                query,
                session,
                new ThemeSelectorItem(
                    identity,
                    identity.Market == ThemeMarket.Krx
                        ? DataSourceKind.Oracle
                        : DataSourceKind.MariaDb)),
            PreviewHandler = _ => PreviewResult(identity, items, previewIsTruncated)
        };

    private static ThemeSelectionIdentity Identity(
        ThemeMarket market,
        ThemeSession session,
        string title,
        string code) =>
        new(market, session, code, title);

    private static ThemeSelectorItem Selector(
        ThemeMarket market,
        ThemeSession session,
        string title,
        string code) =>
        new(
            Identity(market, session, title, code),
            market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb);

    private static ThemeSearchResult SearchResult(
        string query,
        ThemeSession session,
        params ThemeSelectorItem[] items) =>
        new(query, session, DateTimeOffset.UtcNow, items, false);

    private static ThemePreviewResult PreviewResult(
        ThemeSelectionIdentity identity,
        IReadOnlyList<ThemeItemPreview> items,
        bool isTruncated = false) =>
        new(identity, DateTimeOffset.UtcNow, items, isTruncated);

    private static IReadOnlyList<ThemeItemPreview> CreateItems(
        int count,
        ThemeMarket market)
    {
        var prefix = market == ThemeMarket.Krx ? 'P' : 'X';
        return Enumerable.Range(0, count)
            .Select(index => new ThemeItemPreview(
                index,
                $"{prefix}{index + 1:000000}",
                $"Item {index + 1}"))
            .ToArray();
    }

    private sealed record SearchCall(
        string Query,
        ThemeSession Session,
        int MaximumResults,
        CancellationToken CancellationToken);

    private sealed record PreviewCall(
        ThemeSelectionIdentity Identity,
        int MaximumItems,
        CancellationToken CancellationToken);

    private sealed class FakeThemeSelectionService : IThemeSelectionService
    {
        public Func<string, ThemeSession, ThemeSearchResult> SearchHandler { get; set; } =
            (query, session) => SearchResult(query, session);

        public Func<ThemeSelectionIdentity, ThemePreviewResult> PreviewHandler { get; set; } =
            identity => PreviewResult(identity, []);

        public List<SearchCall> SearchCalls { get; } = [];

        public List<PreviewCall> PreviewCalls { get; } = [];

        public Task<ThemeSearchResult> SearchAsync(
            string query,
            ThemeSession nxtSession,
            int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            SearchCalls.Add(new SearchCall(query, nxtSession, maximumResults, cancellationToken));
            return Task.FromResult(SearchHandler(query, nxtSession));
        }

        public Task<ThemePreviewResult> PreviewAsync(
            ThemeSelectionIdentity identity,
            int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
            CancellationToken cancellationToken = default)
        {
            PreviewCalls.Add(new PreviewCall(identity, maximumItems, cancellationToken));
            return Task.FromResult(PreviewHandler(identity));
        }
    }
}
