using MBN_STOCK_WEBVIEW.LegacyApplication;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;

public sealed class LegacyOverseasSelectionWorkflowTests
{
    private static readonly DateTimeOffset RetrievedAt =
        new(2026, 7, 15, 9, 30, 0, TimeSpan.FromHours(9));

    [Fact]
    public void InitialSnapshotIsTypedImmutableAndKeepsFarPointFirstFixedRowActive()
    {
        var workflow = CreateWorkflow();

        var snapshot = workflow.Current;

        Assert.Equal(0, snapshot.Revision);
        Assert.Equal(10, snapshot.FixedIndexTargets.Count);
        Assert.Equal(12, snapshot.Actions.Count);
        Assert.Equal("dow-jones", snapshot.SelectedFixedIndex?.TargetId);
        Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Industry.Status);
        Assert.Equal(LegacyOverseasSearchStatus.Idle, snapshot.Stock.Status);
        Assert.Empty(snapshot.Industry.Results);
        Assert.Empty(snapshot.Stock.Results);
        Assert.Equal(default, snapshot.MovingAverages);

        var targets = Assert.IsAssignableFrom<IList<LegacyOverseasFixedIndexTarget>>(
            snapshot.FixedIndexTargets);
        var actions = Assert.IsAssignableFrom<IList<LegacyOverseasActionDefinition>>(
            snapshot.Actions);
        Assert.True(targets.IsReadOnly);
        Assert.True(actions.IsReadOnly);
        Assert.Throws<NotSupportedException>(() => targets.RemoveAt(0));
        Assert.Throws<NotSupportedException>(() => actions.RemoveAt(0));
    }

    [Fact]
    public async Task SearchesUseInjectedInterfacesNormalizeQueriesAndIssueGenerationBoundIds()
    {
        var industryItems = new List<OverseasIndustryIndexItem>
        {
            new("Alpha", "Alpha Industry", "NAS@ALPHA"),
            new("Beta", "Beta Industry", "NAS@BETA")
        };
        var stockItems = new List<WorldStockSearchItem>
        {
            new("Apple", "NAS@AAPL", "US"),
            new("Taiwan Semi", "TWS@2330", "TW")
        };
        var industry = new RecordingIndustryService(industryItems);
        var stock = new RecordingStockService(stockItems);
        var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);

        var industryState = await workflow.SearchIndustriesAsync("  alpha  ", 25);
        var stockState = await workflow.SearchStocksAsync("  semi  ", 30);
        industryItems.Clear();
        stockItems.Clear();

        Assert.Equal([("alpha", 25)], industry.Calls);
        Assert.Equal([("semi", 30)], stock.Calls);
        Assert.Equal(LegacyOverseasSearchStatus.Ready, industryState.Industry.Status);
        Assert.Equal("alpha", industryState.Industry.Query);
        Assert.Equal(RetrievedAt, industryState.Industry.RetrievedAt);
        Assert.Equal(
            ["overseas-industry-00000001-0001", "overseas-industry-00000001-0002"],
            industryState.Industry.Results.Select(row => row.SelectionId));
        Assert.Equal(
            ["overseas-stock-00000001-0001", "overseas-stock-00000001-0002"],
            stockState.Stock.Results.Select(row => row.SelectionId));
        Assert.Equal("Alpha", industryState.Industry.Selected?.KoreanName);
        Assert.Equal("Apple", stockState.Stock.Selected?.InputName);
        Assert.Equal(2, workflow.Current.Industry.ResultCount);
        Assert.Equal(2, workflow.Current.Stock.ResultCount);

        var industryRows = Assert.IsAssignableFrom<IList<LegacyOverseasIndustrySearchRow>>(
            workflow.Current.Industry.Results);
        var stockRows = Assert.IsAssignableFrom<IList<LegacyOverseasStockSearchRow>>(
            workflow.Current.Stock.Results);
        Assert.True(industryRows.IsReadOnly);
        Assert.True(stockRows.IsReadOnly);
        Assert.Throws<NotSupportedException>(() => industryRows.RemoveAt(0));
        Assert.Throws<NotSupportedException>(() => stockRows.RemoveAt(0));
    }

    [Fact]
    public async Task FirstInsertedSearchRowsBecomeTheActiveSourcesAtTheFullCompatibilityBound()
    {
        var industry = new RecordingIndustryService(
            [new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")]);
        var stock = new RecordingStockService(
            [new WorldStockSearchItem("Apple", "NAS@AAPL", "US")]);
        var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);

        var industries = await workflow.SearchIndustriesAsync("alpha");
        var stocks = await workflow.SearchStocksAsync("apple");

        Assert.Equal("Alpha", industries.Industry.Selected?.KoreanName);
        Assert.Equal("Apple", stocks.Stock.Selected?.InputName);
        Assert.Equal(
            [("alpha", LegacyOverseasSelectionWorkflow.DefaultMaximumResults)],
            industry.Calls);
        Assert.Equal(
            [("apple", LegacyOverseasStockSearchService.MaximumResults)],
            stock.Calls);
    }

    [Fact]
    public async Task SelectionUsesCurrentOpaqueIdsAndARefreshInvalidatesOldIds()
    {
        var industry = new QueueIndustryService(
            IndustryResult(
                "",
                new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA")),
            IndustryResult(
                "next",
                new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA")));
        var stock = new QueueStockService(
            StockResult("", new WorldStockSearchItem("Apple", "NAS@AAPL", "US")),
            StockResult("next", new WorldStockSearchItem("Taiwan Semi", "TWS@2330", "TW")));
        var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);

        var firstIndustry = await workflow.SearchIndustriesAsync("");
        var oldIndustryId = firstIndustry.Industry.Results[0].SelectionId;
        Assert.Equal(
            "Alpha",
            workflow.SelectIndustry(oldIndustryId).Industry.Selected?.KoreanName);
        var firstStock = await workflow.SearchStocksAsync("");
        var oldStockId = firstStock.Stock.Results[0].SelectionId;
        Assert.Equal("Apple", workflow.SelectStock(oldStockId).Stock.Selected?.InputName);

        await workflow.SearchIndustriesAsync("next");
        await workflow.SearchStocksAsync("next");

        Assert.Equal("Beta", workflow.Current.Industry.Selected?.KoreanName);
        Assert.Equal("Taiwan Semi", workflow.Current.Stock.Selected?.InputName);
        Assert.Throws<KeyNotFoundException>(() => workflow.SelectIndustry(oldIndustryId));
        Assert.Throws<KeyNotFoundException>(() => workflow.SelectStock(oldStockId));
        Assert.StartsWith(
            "overseas-industry-00000002-",
            workflow.Current.Industry.Results[0].SelectionId,
            StringComparison.Ordinal);
        Assert.StartsWith(
            "overseas-stock-00000002-",
            workflow.Current.Stock.Results[0].SelectionId,
            StringComparison.Ordinal);
    }

    [Fact]
    public async Task DuplicateIndustryDisplayNamesRemainDistinctOpaqueSelections()
    {
        var workflow = CreateWorkflow(industries:
        [
            new OverseasIndustryIndexItem("Shared", "First Input", "NAS@ONE"),
            new OverseasIndustryIndexItem("Shared", "Second Input", "NAS@TWO")
        ]);

        var searched = await workflow.SearchIndustriesAsync(string.Empty);

        Assert.Equal(2, searched.Industry.Results.Count);
        Assert.Equal("Shared", searched.Industry.Results[0].KoreanName);
        Assert.Equal("Shared", searched.Industry.Results[1].KoreanName);
        Assert.NotEqual(
            searched.Industry.Results[0].SelectionId,
            searched.Industry.Results[1].SelectionId);

        workflow.SelectIndustry(searched.Industry.Results[1].SelectionId);
        var draft = workflow.MaterializePlaylistDraft("industry-current");

        Assert.Equal("Second Input|NAS@TWO|US|0", draft.Selection.DataCode);
    }

    [Fact]
    public async Task IsolatedStockRowsAreReportedWithoutRejectingValidSelections()
    {
        var stockResult = new WorldStockSearchResult(
            string.Empty,
            RetrievedAt,
            [new WorldStockSearchItem("Apple", "NAS@AAPL", "US")],
            IsTruncated: true,
            IsolatedInvalidRowCount: 16);
        var workflow = new LegacyOverseasSelectionWorkflow(
            new RecordingIndustryService([]),
            new QueueStockService(stockResult));

        var snapshot = await workflow.SearchStocksAsync(string.Empty);

        Assert.Equal(LegacyOverseasSearchStatus.Ready, snapshot.Stock.Status);
        Assert.Single(snapshot.Stock.Results);
        Assert.Equal(16, snapshot.Stock.IsolatedInvalidRowCount);
        Assert.True(snapshot.Stock.IsTruncated);
    }

    [Fact]
    public void FixedIndexActionsMaterializeExactAliasIdentityPageAndMovingAverages()
    {
        var workflow = CreateWorkflow();
        workflow.SelectFixedIndex("germany-dax-30");
        workflow.SetMovingAverages(ma5: true, ma20: false);
        var expected = new Dictionary<string, (string Alias, int Days)>(StringComparer.Ordinal)
        {
            ["index-candle-5d"] = ("8061", 5),
            ["index-candle-20d"] = ("8040", 20),
            ["index-candle-60d"] = ("8046", 60),
            ["index-candle-120d"] = ("8051", 120),
            ["index-candle-240d"] = ("8056", 240)
        };

        foreach (var pair in expected)
        {
            var draft = workflow.MaterializePlaylistDraft(pair.Key);
            Assert.Equal("s8010", draft.BuilderKey);
            Assert.Equal(pair.Value.Alias, draft.SceneAlias);
            Assert.Equal([pair.Value.Alias], draft.Aliases);
            Assert.Equal(0, draft.PageSize);
            Assert.Equal("1/1", draft.InitialPageText);
            Assert.Equal("독일 DAX 30", draft.Title);
            Assert.Equal("chart", draft.Category);
            Assert.Equal("overseas", draft.Market);
            Assert.Equal(LegacyOverseasActionSource.FixedIndex, draft.Source);
            Assert.Equal(
                new LegacyOverseasPlaylistSelection(
                    "FOREIGN_INDEX",
                    "독일 DAX 30",
                    "MA5",
                    "PRICE",
                    "XTR@DAX30|DE|0"),
                draft.Selection);
            Assert.Equal(new LegacyOverseasMovingAverageSelection(true, false), draft.MovingAverages);
            Assert.True(draft.IsEnabled);
            Assert.Equal(6, draft.FadeDuration);
            Assert.Equal(
                new LegacyOverseasPlaylistVisibleFields(
                    "해외지수", "독일 DAX 30", "캔들그래프", $"{pair.Value.Days}일", "1/1"),
                draft.VisibleFields);
        }
    }

    [Fact]
    public async Task IndustryCurrentMaterializesRootWorkflowSelectionAndIgnoresMaOptions()
    {
        var workflow = CreateWorkflow(
            industries: [new("반도체", "US Semiconductor", "NAS@SOX")]);
        var searched = await workflow.SearchIndustriesAsync("");
        workflow.SelectIndustry(searched.Industry.Results[0].SelectionId);
        workflow.SetMovingAverages(ma5: true, ma20: true);

        var draft = workflow.MaterializePlaylistDraft("industry-current");

        Assert.Equal("s5001", draft.BuilderKey);
        Assert.Equal("5001", draft.SceneAlias);
        Assert.Equal(0, draft.PageSize);
        Assert.Equal("1/1", draft.InitialPageText);
        Assert.Equal("반도체", draft.Title);
        Assert.Equal("plate", draft.Category);
        Assert.Equal(
            new LegacyOverseasPlaylistSelection(
                "FOREIGN_INDUSTRY",
                "반도체",
                "1열판기본",
                "CURRENT",
                "US Semiconductor|NAS@SOX|US|0"),
            draft.Selection);
        Assert.Equal(default, draft.MovingAverages);
        Assert.Equal(
            new LegacyOverseasPlaylistVisibleFields(
                "해외업종", "반도체", "1열판기본", string.Empty, "1/1"),
            draft.VisibleFields);
    }

    [Fact]
    public async Task StockCurrentAndAllCandleActionsUseSelectedUsTwIdentity()
    {
        var workflow = CreateWorkflow(
            stocks: [new("Taiwan Semi", "TWS@2330", "TW")]);
        var searched = await workflow.SearchStocksAsync("semi");
        workflow.SelectStock(searched.Stock.Results[0].SelectionId);
        workflow.SetMovingAverages(ma5: true, ma20: true);

        var current = workflow.MaterializePlaylistDraft("stock-current");
        Assert.Equal("s5001", current.BuilderKey);
        Assert.Equal("5001", current.SceneAlias);
        Assert.Equal("stock", current.Category);
        Assert.Equal(
            new LegacyOverseasPlaylistSelection(
                "FOREIGN_STOCK",
                "Taiwan Semi",
                "1열판기본",
                "CURRENT",
                "TWS@2330|TW|1"),
            current.Selection);
        Assert.Equal(default, current.MovingAverages);
        Assert.Equal(
            new LegacyOverseasPlaylistVisibleFields(
                "해외종목", "Taiwan Semi", "1열판기본", string.Empty, "1/1"),
            current.VisibleFields);

        var expectedAliases = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["stock-candle-5d"] = "8061",
            ["stock-candle-20d"] = "8040",
            ["stock-candle-60d"] = "8046",
            ["stock-candle-120d"] = "8051",
            ["stock-candle-240d"] = "8056"
        };
        foreach (var pair in expectedAliases)
        {
            var candle = workflow.MaterializePlaylistDraft(pair.Key);
            Assert.Equal("s8010", candle.BuilderKey);
            Assert.Equal(pair.Value, candle.SceneAlias);
            Assert.Equal("chart", candle.Category);
            Assert.Equal("MA5,MA20", candle.Selection.GraphicType);
            Assert.Equal("PRICE", candle.Selection.Subtype);
            Assert.Equal("TWS@2330|TW|1", candle.Selection.DataCode);
            Assert.Equal(new LegacyOverseasMovingAverageSelection(true, true), candle.MovingAverages);
            Assert.Equal(
                new LegacyOverseasPlaylistVisibleFields(
                    "해외종목", "Taiwan Semi", "캔들그래프",
                    $"{LegacyOverseasActionCatalog.GetAction(pair.Key).PeriodDays}일", "1/1"),
                candle.VisibleFields);
        }
    }

    [Fact]
    public async Task CurrentProviderPunctuationRemainsAnExactBoundStockIdentity()
    {
        var workflow = CreateWorkflow(stocks:
        [
            new("Ampersand", "IDX@A&B", "US"),
            new("Parenthesized", "IDX@(A)", "US")
        ]);

        var searched = await workflow.SearchStocksAsync(string.Empty);

        Assert.Equal(LegacyOverseasSearchStatus.Ready, searched.Stock.Status);
        Assert.Equal(2, searched.Stock.Results.Count);
        workflow.SelectStock(searched.Stock.Results[0].SelectionId);
        Assert.Equal(
            "IDX@A&B|US|1",
            workflow.MaterializePlaylistDraft("stock-current").Selection.DataCode);
        workflow.SelectStock(searched.Stock.Results[1].SelectionId);
        Assert.Equal(
            "IDX@(A)|US|1",
            workflow.MaterializePlaylistDraft("stock-candle-5d").Selection.DataCode);
    }

    [Theory]
    [InlineData(false, false, "")]
    [InlineData(true, false, "MA5")]
    [InlineData(false, true, "MA20")]
    [InlineData(true, true, "MA5,MA20")]
    public async Task MovingAverageOptionsHaveExactClosedText(
        bool ma5,
        bool ma20,
        string expected)
    {
        var workflow = CreateWorkflow(stocks: [new("Apple", "NAS@AAPL", "US")]);
        var searched = await workflow.SearchStocksAsync("");
        workflow.SelectStock(searched.Stock.Results[0].SelectionId);
        workflow.SetMovingAverages(ma5, ma20);

        var draft = workflow.MaterializePlaylistDraft("stock-candle-20d");

        Assert.Equal(expected, draft.Selection.GraphicType);
        Assert.Equal(new LegacyOverseasMovingAverageSelection(ma5, ma20), draft.MovingAverages);
    }

    [Fact]
    public void ActionPrerequisitesUnknownIdsAndCallerOverridesFailClosed()
    {
        var workflow = CreateWorkflow();

        Assert.Equal(
            "DJI@DJI",
            workflow.MaterializePlaylistDraft("index-candle-5d").Selection.DataCode.Split('|')[0]);
        Assert.Throws<LegacyOverseasWorkflowException>(() =>
            workflow.MaterializePlaylistDraft("industry-current"));
        Assert.Throws<LegacyOverseasWorkflowException>(() =>
            workflow.MaterializePlaylistDraft("stock-current"));
        Assert.Throws<LegacyOverseasWorkflowException>(() =>
            workflow.MaterializePlaylistDraft("../../8010"));
        Assert.Throws<ArgumentNullException>(() =>
            workflow.MaterializePlaylistDraft(null!));
        Assert.Throws<KeyNotFoundException>(() => workflow.SelectFixedIndex("DOW-JONES"));

        var methods = typeof(LegacyOverseasSelectionWorkflow).GetMethods()
            .Where(method => method.Name == nameof(LegacyOverseasSelectionWorkflow.MaterializePlaylistDraft));
        var method = Assert.Single(methods);
        var parameter = Assert.Single(method.GetParameters());
        Assert.Equal(typeof(string), parameter.ParameterType);
    }

    [Fact]
    public async Task NonUsTwSearchHitMaterializesAnExactAllNationIdentity()
    {
        var workflow = CreateWorkflow(stocks: [new("London Stock", "LNS@LON", "GB")]);
        var searched = await workflow.SearchStocksAsync("london");
        workflow.SelectStock(Assert.Single(searched.Stock.Results).SelectionId);

        var availability = workflow.GetActionAvailability("stock-current");

        Assert.True(availability.IsAvailable);
        Assert.Null(availability.Reason);
        var current = workflow.MaterializePlaylistDraft("stock-current");
        var candle = workflow.MaterializePlaylistDraft("stock-candle-5d");
        Assert.Equal("LNS@LON|GB|1", current.Selection.DataCode);
        Assert.Equal("LNS@LON|GB|1", candle.Selection.DataCode);
    }

    [Fact]
    public async Task ThreeLetterNationRowsRemainVisibleButFailClosedBeforePlaylistMaterialization()
    {
        var workflow = CreateWorkflow(stocks: [new("Unsupported", "LNS@UNSUPPORTED", "GBR")]);

        var searched = await workflow.SearchStocksAsync("unsupported");

        Assert.Equal("Unsupported", searched.Stock.Selected?.InputName);
        var availability = workflow.GetActionAvailability("stock-current");
        Assert.False(availability.IsAvailable);
        Assert.Equal("선택한 해외종목의 국가 코드는 두 글자여야 합니다.", availability.Reason);
        Assert.Throws<LegacyOverseasWorkflowException>(() =>
            workflow.MaterializePlaylistDraft("stock-current"));
    }

    [Fact]
    public async Task SlowerIndustryResponseCannotReplaceOrInvalidateNewerState()
    {
        var industry = new ControlledIndustryService();
        var workflow = new LegacyOverseasSelectionWorkflow(industry, new RecordingStockService([]));

        var older = workflow.SearchIndustriesAsync("old");
        var newer = workflow.SearchIndustriesAsync("new");
        industry.Complete(
            "new",
            IndustryResult(
                "new",
                new OverseasIndustryIndexItem("New", "New Industry", "NAS@NEW")));
        var newerState = await newer;

        // The stale response is intentionally malformed. Generation gating must happen
        // before its envelope and identities are examined.
        industry.Complete(
            "old",
            new OverseasIndustryIndexSearchResult(
                "wrong",
                default,
                [new OverseasIndustryIndexItem(" bad ", "bad", "|")],
                false));
        var olderState = await older;

        Assert.Equal("new", newerState.Industry.Query);
        Assert.Equal("new", olderState.Industry.Query);
        Assert.Equal("New", workflow.Current.Industry.Results[0].KoreanName);
        Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Industry.Status);
        Assert.Equal(3, workflow.Current.Revision);
    }

    [Fact]
    public async Task SlowerStockFaultCannotReplaceOrFaultNewerState()
    {
        var stock = new ControlledStockService();
        var workflow = new LegacyOverseasSelectionWorkflow(new RecordingIndustryService([]), stock);

        var older = workflow.SearchStocksAsync("old");
        var newer = workflow.SearchStocksAsync("new");
        stock.Complete(
            "new",
            StockResult("new", new WorldStockSearchItem("New Stock", "NAS@NEW", "US")));
        await newer;
        stock.Fail("old", new InvalidOperationException("stale provider failure"));

        var olderState = await older;

        Assert.Equal("new", olderState.Stock.Query);
        Assert.Equal("New Stock", olderState.Stock.Results[0].InputName);
        Assert.Equal(LegacyOverseasSearchStatus.Ready, workflow.Current.Stock.Status);
        Assert.Equal(3, workflow.Current.Revision);
    }

    [Fact]
    public async Task InvalidCurrentIndustryResponseFailsAtomicallyIntoErrorState()
    {
        var invalid = IndustryResult(
            "query",
            new OverseasIndustryIndexItem("Beta", "Beta Industry", "NAS@BETA"),
            new OverseasIndustryIndexItem("Alpha", "Alpha Industry", "NAS@ALPHA"));
        var workflow = new LegacyOverseasSelectionWorkflow(
            new QueueIndustryService(invalid),
            new RecordingStockService([]));

        await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
            workflow.SearchIndustriesAsync("query"));

        Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Industry.Status);
        Assert.Empty(workflow.Current.Industry.Results);
        Assert.Null(workflow.Current.Industry.Selected);
        Assert.NotEmpty(workflow.Current.Industry.Error);
        Assert.Equal(2, workflow.Current.Revision);
    }

    [Fact]
    public async Task InvalidCurrentStockIdentityFailsAtomicallyIntoErrorState()
    {
        var invalid = StockResult(
            "query",
            new WorldStockSearchItem("London", "LNS@BAD", "gB"));
        var workflow = new LegacyOverseasSelectionWorkflow(
            new RecordingIndustryService([]),
            new QueueStockService(invalid));

        await Assert.ThrowsAsync<LegacyOverseasWorkflowException>(() =>
            workflow.SearchStocksAsync("query"));

        Assert.Equal(LegacyOverseasSearchStatus.Error, workflow.Current.Stock.Status);
        Assert.Empty(workflow.Current.Stock.Results);
        Assert.Null(workflow.Current.Stock.Selected);
        Assert.NotEmpty(workflow.Current.Stock.Error);
    }

    [Fact]
    public async Task SearchBoundsUnsafeQueriesAndNullServicesAreRejectedBeforeCalls()
    {
        var industry = new RecordingIndustryService([]);
        var stock = new RecordingStockService([]);
        var workflow = new LegacyOverseasSelectionWorkflow(industry, stock);

        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
            workflow.SearchIndustriesAsync("", 0));
        await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
            workflow.SearchStocksAsync("", LegacyOverseasSelectionWorkflow.MaximumResults + 1));
        await Assert.ThrowsAsync<ArgumentException>(() =>
            workflow.SearchIndustriesAsync(new string('x', 65)));
        await Assert.ThrowsAsync<ArgumentException>(() =>
            workflow.SearchStocksAsync("bad\u0001query"));
        await Assert.ThrowsAsync<ArgumentNullException>(() =>
            workflow.SearchStocksAsync(null!));
        Assert.Empty(industry.Calls);
        Assert.Empty(stock.Calls);
        Assert.Throws<ArgumentNullException>(() =>
            new LegacyOverseasSelectionWorkflow(null!, stock));
        Assert.Throws<ArgumentNullException>(() =>
            new LegacyOverseasSelectionWorkflow(industry, null!));
    }

    [Fact]
    public async Task ClearOperationsAndRepeatedMaChoiceHaveStableRevisionSemantics()
    {
        var workflow = CreateWorkflow(
            industries: [new("Alpha", "Alpha Industry", "NAS@ALPHA")],
            stocks: [new("Apple", "NAS@AAPL", "US")]);
        workflow.SelectFixedIndex("dow-jones");
        var industry = await workflow.SearchIndustriesAsync("");
        workflow.SelectIndustry(industry.Industry.Results[0].SelectionId);
        var stock = await workflow.SearchStocksAsync("");
        workflow.SelectStock(stock.Stock.Results[0].SelectionId);
        workflow.SetMovingAverages(true, false);

        var beforeClear = workflow.Current.Revision;
        workflow.ClearFixedIndexSelection();
        workflow.ClearIndustrySelection();
        workflow.ClearStockSelection();
        var cleared = workflow.Current;

        Assert.Equal(beforeClear + 3, cleared.Revision);
        Assert.Null(cleared.SelectedFixedIndex);
        Assert.Null(cleared.Industry.Selected);
        Assert.Null(cleared.Stock.Selected);
        var stable = workflow.SetMovingAverages(true, false);
        Assert.Equal(cleared.Revision, stable.Revision);
    }

    private static LegacyOverseasSelectionWorkflow CreateWorkflow(
        IReadOnlyList<OverseasIndustryIndexItem>? industries = null,
        IReadOnlyList<WorldStockSearchItem>? stocks = null) =>
        new(
            new RecordingIndustryService(industries ?? []),
            new RecordingStockService(stocks ?? []));

    private static OverseasIndustryIndexSearchResult IndustryResult(
        string query,
        params OverseasIndustryIndexItem[] items) =>
        new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);

    private static WorldStockSearchResult StockResult(
        string query,
        params WorldStockSearchItem[] items) =>
        new(query, RetrievedAt, Array.AsReadOnly(items), IsTruncated: false);

    private sealed class RecordingIndustryService(
        IReadOnlyList<OverseasIndustryIndexItem> items) : IOverseasIndustryIndexSearchService
    {
        public List<(string Query, int MaximumResults)> Calls { get; } = [];

        public Task<OverseasIndustryIndexSearchResult> SearchAsync(
            string query,
            int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            Calls.Add((query, maximumResults));
            return Task.FromResult(new OverseasIndustryIndexSearchResult(
                query,
                RetrievedAt,
                items,
                IsTruncated: false));
        }
    }

    private sealed class RecordingStockService(
        IReadOnlyList<WorldStockSearchItem> items) : IOverseasStockSearchService
    {
        public List<(string Query, int MaximumResults)> Calls { get; } = [];

        public Task<WorldStockSearchResult> SearchAsync(
            string query,
            int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            Calls.Add((query, maximumResults));
            return Task.FromResult(new WorldStockSearchResult(
                query,
                RetrievedAt,
                items,
                IsTruncated: false));
        }
    }

    private sealed class QueueIndustryService(
        params OverseasIndustryIndexSearchResult[] results) : IOverseasIndustryIndexSearchService
    {
        private readonly Queue<OverseasIndustryIndexSearchResult> _results = new(results);

        public Task<OverseasIndustryIndexSearchResult> SearchAsync(
            string query,
            int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            return Task.FromResult(_results.Dequeue());
        }
    }

    private sealed class QueueStockService(
        params WorldStockSearchResult[] results) : IOverseasStockSearchService
    {
        private readonly Queue<WorldStockSearchResult> _results = new(results);

        public Task<WorldStockSearchResult> SearchAsync(
            string query,
            int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            return Task.FromResult(_results.Dequeue());
        }
    }

    private sealed class ControlledIndustryService : IOverseasIndustryIndexSearchService
    {
        private readonly Dictionary<string, TaskCompletionSource<OverseasIndustryIndexSearchResult>>
            _pending = new(StringComparer.Ordinal);

        public Task<OverseasIndustryIndexSearchResult> SearchAsync(
            string query,
            int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            var source = new TaskCompletionSource<OverseasIndustryIndexSearchResult>(
                TaskCreationOptions.RunContinuationsAsynchronously);
            _pending.Add(query, source);
            return source.Task.WaitAsync(cancellationToken);
        }

        public void Complete(string query, OverseasIndustryIndexSearchResult result) =>
            _pending[query].SetResult(result);
    }

    private sealed class ControlledStockService : IOverseasStockSearchService
    {
        private readonly Dictionary<string, TaskCompletionSource<WorldStockSearchResult>> _pending =
            new(StringComparer.Ordinal);

        public Task<WorldStockSearchResult> SearchAsync(
            string query,
            int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            var source = new TaskCompletionSource<WorldStockSearchResult>(
                TaskCreationOptions.RunContinuationsAsynchronously);
            _pending.Add(query, source);
            return source.Task.WaitAsync(cancellationToken);
        }

        public void Complete(string query, WorldStockSearchResult result) =>
            _pending[query].SetResult(result);

        public void Fail(string query, Exception exception) =>
            _pending[query].SetException(exception);
    }
}
