using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;

public sealed class LegacyOperatorCatalogWorkflowTests
{
    [Theory]
    [InlineData(LegacyOperatorCatalogEntity.Theme)]
    [InlineData(LegacyOperatorCatalogEntity.Expert)]
    public async Task Blank_stock_picker_search_restores_bounded_ThemeA_and_UC6_show_all(
        LegacyOperatorCatalogEntity entity)
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();

        if (entity == LegacyOperatorCatalogEntity.Theme)
        {
            await workflow.OpenAsync(entity);
            await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        }
        else
        {
            var opened = await workflow.OpenAsync(entity);
            await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
        }

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

        Assert.Equal(string.Empty, searched.StockQuery);
        Assert.Single(searched.StockResults);
        Assert.Equal(1, fixture.StockSearch.SearchAllCalls);
        Assert.Equal(0, fixture.StockSearch.SearchCalls);
        Assert.Equal(
            LegacyStockSearchService.MaximumResults,
            fixture.StockSearch.LastMaximumResults);
    }

    [Theory]
    [InlineData("bad\0value")]
    [InlineData("bad\nvalue")]
    [InlineData("bad\u200Bvalue")]
    public async Task Stock_picker_rejects_unsafe_blank_boundary_input_before_search(string query)
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);

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

        Assert.Equal(0, fixture.StockSearch.SearchAllCalls);
        Assert.Equal(0, fixture.StockSearch.SearchCalls);
    }

    [Fact]
    public async Task Stock_picker_rejects_overlong_input_before_search()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
        await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);

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

        Assert.Equal(0, fixture.StockSearch.SearchAllCalls);
        Assert.Equal(0, fixture.StockSearch.SearchCalls);
    }

    [Fact]
    public async Task ThemeA_and_stock_picker_use_the_complete_finite_core_boundaries()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();

        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        await workflow.SearchStocksAsync("삼성");

        Assert.Equal(
            LegacyThemeSelectionService.MaximumResults,
            LegacyOperatorCatalogWorkflowController.MaximumCatalogResults);
        Assert.Equal(
            LegacyThemeSelectionService.MaximumResults,
            fixture.ThemeSelection.LastMaximumResults);
        Assert.Equal(
            LegacyStockSearchService.MaximumResults,
            LegacyOperatorCatalogWorkflowController.MaximumStockResults);
        Assert.Equal(
            LegacyStockSearchService.MaximumResults,
            fixture.StockSearch.LastMaximumResults);
    }

    [Fact]
    public async Task ThemeA_create_uses_opaque_stock_selection_and_writes_only_on_explicit_save()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();

        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        Assert.True(opened.SchemaValidated);
        Assert.Single(opened.Results);

        var creating = await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        Assert.Equal(LegacyOperatorCatalogMode.Create, creating.Mode);
        Assert.Equal("00000009", creating.CodeLabel);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);

        var stocks = await workflow.SearchStocksAsync("삼성");
        var stockId = Assert.Single(stocks.StockResults).ResultId;
        workflow.SelectStock(stockId);
        var draft = workflow.AddSelectedStock(null);
        Assert.Single(draft.DraftRows);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);

        var saved = await workflow.SaveAsync("반도체 대표주");

        Assert.Equal(1, fixture.ThemePersistence.CreateCalls);
        Assert.Equal(1, fixture.IdentityValidation.ThemeCalls);
        var definition = Assert.IsType<ThemeCatalogDefinition>(
            fixture.ThemePersistence.LastCreated);
        Assert.Equal("00000009", definition.ThemeCode);
        Assert.Equal("P005930", Assert.Single(definition.Items).ItemCode);
        Assert.Equal(LegacyOperatorCatalogMode.List, saved.Mode);
        Assert.False(saved.WritesQuarantined);
    }

    [Fact]
    public async Task ThemeA_create_market_change_preserves_name_and_search_but_removes_incompatible_rows()
    {
        var fixture = new Fixture();
        fixture.ThemePersistence.KrxNextCode = "00000009";
        fixture.ThemePersistence.NxtNextCode = "00000027";
        fixture.StockSearch.Items =
        [
            new StockSearchItem(
                StockMarket.Kospi,
                DataSourceKind.Oracle,
                "삼성전자",
                "005930"),
            new StockSearchItem(
                StockMarket.NxtKospi,
                DataSourceKind.MariaDb,
                "삼성전자(NXT)",
                "005930")
        ];
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        var stocks = await workflow.SearchStocksAsync("삼성");
        var krxStock = Assert.Single(stocks.StockResults, static row => row.IsCompatible);
        workflow.SelectStock(krxStock.ResultId);
        var withDraft = workflow.AddSelectedStock(null);
        var removedRowId = Assert.Single(withDraft.DraftRows).RowId;
        await workflow.CheckThemeNameAvailabilityAsync("사용자 입력 테마");

        var changed = await workflow.ChangeCreateThemeMarketAsync(
            ThemeMarket.Nxt,
            "사용자 입력 테마");

        Assert.Equal(LegacyOperatorCatalogMode.Create, changed.Mode);
        Assert.Equal(ThemeMarket.Nxt, changed.NewThemeMarket);
        Assert.Equal("00000027", changed.CodeLabel);
        Assert.Equal("사용자 입력 테마", changed.EditorName);
        Assert.Empty(changed.DraftRows);
        Assert.Null(changed.ActiveDraftRowId);
        Assert.DoesNotContain(changed.DraftRows, row => row.RowId == removedRowId);
        Assert.Equal("삼성", changed.StockQuery);
        Assert.Equal(2, changed.StockResults.Count);
        Assert.Single(changed.StockResults, static row => row.IsCompatible);
        Assert.Null(changed.SelectedStockResultId);
        Assert.Equal(LegacyThemeNameAvailability.NotChecked, changed.ThemeNameAvailability);
        Assert.Equal(string.Empty, changed.CheckedThemeName);
        Assert.Equal(LegacyOperatorCatalogStatusKind.Warning, changed.StatusKind);
        Assert.Contains("1개", changed.StatusMessage, StringComparison.Ordinal);
        Assert.Equal(ThemeMarket.Nxt, fixture.ThemePersistence.LastSuggestedMarket);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
    }

    [Fact]
    public async Task ThemeA_create_market_change_failure_preserves_the_correlated_editor_atomically()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        var stocks = await workflow.SearchStocksAsync("삼성");
        var stockId = Assert.Single(stocks.StockResults).ResultId;
        workflow.SelectStock(stockId);
        workflow.AddSelectedStock(null);
        await workflow.CheckThemeNameAvailabilityAsync("기존 초안명");
        var before = workflow.Current;
        fixture.ThemePersistence.SuggestNextCodeException =
            new InvalidOperationException("offline");

        var failed = await workflow.ChangeCreateThemeMarketAsync(
            ThemeMarket.Nxt,
            "기존 초안명");

        Assert.Equal(before.NewThemeMarket, failed.NewThemeMarket);
        Assert.Equal(before.CodeLabel, failed.CodeLabel);
        Assert.Equal(before.EditorName, failed.EditorName);
        Assert.Equal(
            before.DraftRows.Select(static row => row.RowId),
            failed.DraftRows.Select(static row => row.RowId));
        Assert.Equal(before.ActiveDraftRowId, failed.ActiveDraftRowId);
        Assert.Equal(before.StockQuery, failed.StockQuery);
        Assert.Equal(before.SelectedStockResultId, failed.SelectedStockResultId);
        Assert.Equal(before.ThemeNameAvailability, failed.ThemeNameAvailability);
        Assert.Equal(before.CheckedThemeName, failed.CheckedThemeName);
        Assert.Equal(LegacyOperatorCatalogStatusKind.Error, failed.StatusKind);
        Assert.Contains("변경하지 않았습니다", failed.StatusMessage, StringComparison.Ordinal);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
    }

    [Fact]
    public async Task ThemeA_market_change_is_rejected_in_edit_mode_without_reading_a_new_code()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
        var suggestCalls = fixture.ThemePersistence.SuggestNextCodeCalls;

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            workflow.ChangeCreateThemeMarketAsync(
                ThemeMarket.Nxt,
                editing.EditorName));

        var after = workflow.Current;
        Assert.Equal(suggestCalls, fixture.ThemePersistence.SuggestNextCodeCalls);
        Assert.Equal(editing.Revision, after.Revision);
        Assert.Equal(editing.Mode, after.Mode);
        Assert.Equal(editing.NewThemeMarket, after.NewThemeMarket);
        Assert.Equal(editing.CodeLabel, after.CodeLabel);
        Assert.Equal(editing.EditorName, after.EditorName);
        Assert.Equal(
            editing.DraftRows.Select(static row => row.RowId),
            after.DraftRows.Select(static row => row.RowId));
    }

    [Fact]
    public async Task ThemeA_existing_row_requires_fresh_complete_preview_for_edit_and_delete()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);

        var selected = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);

        Assert.Equal(LegacyOperatorCatalogMode.Edit, selected.Mode);
        Assert.Equal("AI 반도체", selected.EditorName);
        Assert.Single(selected.DraftRows);
        Assert.Equal(1, fixture.ThemePersistence.ReadStoredCalls);
        Assert.Equal(0, fixture.ThemeSelection.PreviewCalls);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);

        var deleted = await workflow.DeleteAsync();

        Assert.Equal(1, fixture.ThemePersistence.DeleteCalls);
        Assert.Equal(LegacyOperatorCatalogMode.List, deleted.Mode);
    }

    [Fact]
    public async Task ThemeA_edit_preserves_every_stored_row_without_revalidating_inactive_rows()
    {
        var fixture = new Fixture();
        fixture.ThemePersistence.StoredItems =
        [
            new ThemeCatalogItem(0, "P005930", "Active"),
            new ThemeCatalogItem(7, "X999998", "Cross X"),
            new ThemeCatalogItem(7, "T999999", "Cross T")
        ];
        var workflow = fixture.CreateWorkflow();
        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);

        var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
        Assert.Equal(
            ["Active", "Cross X", "Cross T"],
            editing.DraftRows.Select(static row => row.DisplayName));

        var saved = await workflow.SaveAsync(editing.EditorName);

        Assert.Equal(LegacyOperatorCatalogMode.List, saved.Mode);
        Assert.Equal(1, fixture.ThemePersistence.ReplaceCalls);
        Assert.Equal(
            ["P005930", "X999998", "T999999"],
            fixture.ThemePersistence.LastReplacedItems.Select(static item => item.ItemCode));
        Assert.Equal(
            [0, 1, 2],
            fixture.ThemePersistence.LastReplacedItems.Select(static item => item.InputIndex));
        Assert.Equal(0, fixture.IdentityValidation.ThemeCalls);
        Assert.Equal(0, fixture.ThemeSelection.PreviewCalls);
    }

    [Fact]
    public async Task ThemeA_edit_revalidates_only_rows_added_from_the_current_stock_picker()
    {
        var fixture = new Fixture();
        fixture.ThemePersistence.StoredItems =
        [
            new ThemeCatalogItem(0, "P999999", "Inactive legacy")
        ];
        var workflow = fixture.CreateWorkflow();
        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);
        var stocks = await workflow.SearchStocksAsync("stock");
        workflow.SelectStock(Assert.Single(stocks.StockResults).ResultId);
        workflow.AddSelectedStock(null);

        await workflow.SaveAsync(editing.EditorName);

        Assert.Equal(1, fixture.IdentityValidation.ThemeCalls);
        var validated = Assert.Single(fixture.IdentityValidation.LastThemeItems);
        Assert.Equal(0, validated.InputIndex);
        Assert.Equal("P005930", validated.ItemCode);
        Assert.Equal(
            ["P999999", "P005930"],
            fixture.ThemePersistence.LastReplacedItems.Select(static item => item.ItemCode));
    }

    [Fact]
    public async Task ThemeA_nxt_editor_uses_parent_market_for_display_but_saves_raw_cross_prefix_name()
    {
        var fixture = new Fixture();
        var nxt = ThemeItem(ThemeMarket.Nxt, "NXT theme");
        fixture.ThemeSelection.SearchItems = [nxt];
        fixture.ThemePersistence.StoredItems =
            [new ThemeCatalogItem(2, "P005930", "Legacy KRX")];
        var workflow = fixture.CreateWorkflow();

        var editing = await workflow.OpenThemeForEditAsync(nxt.Identity);

        Assert.Equal("Legacy KRX(NXT)", Assert.Single(editing.DraftRows).DisplayName);
        await workflow.SaveAsync(editing.EditorName);
        Assert.Equal("Legacy KRX", Assert.Single(fixture.ThemePersistence.LastReplacedItems).ItemName);
        Assert.Equal(0, fixture.IdentityValidation.ThemeCalls);
    }

    [Fact]
    public async Task ThemeA_edit_preserves_an_unchanged_exact_edge_space_title()
    {
        var fixture = new Fixture();
        var item = ThemeItem(ThemeMarket.Krx, " Edge title ");
        fixture.ThemeSelection.SearchItems = [item];
        var workflow = fixture.CreateWorkflow();

        var editing = await workflow.OpenThemeForEditAsync(item.Identity);
        await workflow.SaveAsync(editing.EditorName);

        Assert.Equal(" Edge title ", fixture.ThemePersistence.LastReplacedName);
    }

    [Theory]
    [InlineData(ThemeMarket.Krx)]
    [InlineData(ThemeMarket.Nxt)]
    public async Task ThemeA_name_check_is_read_only_and_scoped_to_the_selected_market(
        ThemeMarket market)
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        var creating = await workflow.BeginCreateThemeAsync(market);
        Assert.True(creating.CanCheckThemeName);

        fixture.ThemePersistence.NameExists = false;
        var available = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");

        Assert.Equal(LegacyThemeNameAvailability.Available, available.ThemeNameAvailability);
        Assert.Equal("AI 반도체", available.CheckedThemeName);
        Assert.Equal("사용 가능한 테마명입니다.", available.StatusMessage);
        Assert.Equal(market, fixture.ThemePersistence.LastNameCheckMarket);
        Assert.Equal("AI 반도체", fixture.ThemePersistence.LastCheckedName);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);

        fixture.ThemePersistence.NameExists = true;
        var duplicate = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");

        Assert.Equal(LegacyThemeNameAvailability.Duplicate, duplicate.ThemeNameAvailability);
        Assert.Equal("이미 사용중인 테마명입니다.", duplicate.StatusMessage);
        Assert.Equal(2, fixture.ThemePersistence.NameExistsCalls);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
    }

    [Theory]
    [InlineData(ThemeMarket.Krx)]
    [InlineData(ThemeMarket.Nxt)]
    public async Task ThemeA_edit_name_check_preserves_original_self_duplicate_behavior(
        ThemeMarket market)
    {
        var fixture = new Fixture();
        var item = ThemeItem(market, "AI 반도체");
        fixture.ThemeSelection.SearchItems = [item];
        fixture.ThemePersistence.NameExists = true;
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenThemeForEditAsync(item.Identity);

        var result = await workflow.CheckThemeNameAvailabilityAsync("AI 반도체");

        Assert.Equal(LegacyThemeNameAvailability.Duplicate, result.ThemeNameAvailability);
        Assert.Equal(market, fixture.ThemePersistence.LastNameCheckMarket);
        Assert.Equal("AI 반도체", fixture.ThemePersistence.LastCheckedName);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
    }

    [Fact]
    public async Task ThemeA_name_check_reports_indeterminate_when_exact_query_fails()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        fixture.ThemePersistence.NameExistsException = new InvalidOperationException("offline");

        var result = await workflow.CheckThemeNameAvailabilityAsync("새 테마");

        Assert.Equal(LegacyThemeNameAvailability.Indeterminate, result.ThemeNameAvailability);
        Assert.Equal("새 테마", result.CheckedThemeName);
        Assert.Equal(LegacyOperatorCatalogStatusKind.Error, result.StatusKind);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
    }

    [Fact]
    public async Task ThemeA_create_save_repeats_the_duplicate_check_and_does_not_write()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        fixture.ThemePersistence.NameExists = true;

        var before = workflow.Current;
        var result = await workflow.SaveAsync("중복 테마");

        Assert.Equal(before.Revision + 1, result.Revision);
        Assert.Equal(LegacyOperatorCatalogMode.Create, result.Mode);
        Assert.Equal(LegacyThemeNameAvailability.Duplicate, result.ThemeNameAvailability);
        Assert.Equal("이미 사용중인 테마명입니다.", result.StatusMessage);
        Assert.Equal(1, fixture.ThemePersistence.NameExistsCalls);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
        Assert.Equal(0, fixture.IdentityValidation.ThemeCalls);
    }

    [Fact]
    public async Task ThemeA_duplicate_preflight_stays_inside_the_correlation_gate()
    {
        var fixture = new Fixture();
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        fixture.ThemePersistence.NameExistsHandler = async () =>
        {
            entered.SetResult();
            return await release.Task;
        };
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);

        var save = workflow.SaveAsync("상관관계 변경 테마");
        await entered.Task;
        workflow.InvalidateBrowserCorrelation();
        release.SetResult(false);
        var result = await save;

        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
        Assert.Equal(1, fixture.IdentityValidation.ThemeCalls);
        Assert.Equal(LegacyOperatorCatalogMode.Create, result.Mode);
        Assert.Contains("변경되어", result.StatusMessage, StringComparison.Ordinal);
    }

    [Fact]
    public async Task EList_create_accepts_only_a_name_and_rejects_forged_recommendation_mutations()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);
        await workflow.BeginCreateExpertAsync();

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            workflow.SearchStocksAsync("주식"));
        Assert.Throws<InvalidOperationException>(() =>
            workflow.AddSelectedStock(null));
        Assert.Throws<InvalidOperationException>(() =>
            workflow.SetDraftBuyAmount("catalog-draft-forged", 70_000));
        Assert.Throws<InvalidOperationException>(() =>
            workflow.RemoveDraftRow("catalog-draft-forged"));

        Assert.Equal(0, fixture.ExpertPersistence.CreateCalls);
        await workflow.SaveAsync("김전문");

        Assert.Equal(1, fixture.ExpertPersistence.CreateCalls);
        Assert.Equal(0, fixture.IdentityValidation.ExpertCalls);
        var definition = Assert.IsType<ExpertCatalogDefinition>(
            fixture.ExpertPersistence.LastCreated);
        Assert.Empty(definition.Recommendations);
    }

    [Fact]
    public async Task EList_edit_adds_blank_duplicate_rows_then_reorders_by_opaque_row_id()
    {
        var fixture = new Fixture();
        fixture.ExpertSelection.PreviewItems =
        [
            new ExpertRecommendationPreview(0, "000001", "First", 10_000),
            new ExpertRecommendationPreview(1, "000002", "Second", 20_000),
            new ExpertRecommendationPreview(2, "000003", "Third", 30_000)
        ];
        fixture.StockSearch.Items =
        [
            new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "First", "000001")
        ];
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenExpertForEditAsync(new ExpertSelectionIdentity("0001", "이전문"));
        var stocks = await workflow.SearchStocksAsync("stock");
        workflow.SelectStock(Assert.Single(stocks.StockResults).ResultId);
        var withDuplicate = workflow.AddSelectedStock(null);
        Assert.Equal(4, withDuplicate.DraftRows.Count);
        Assert.Null(withDuplicate.DraftRows[3].BuyAmount);
        workflow.SetDraftBuyAmount(withDuplicate.DraftRows[3].RowId, 40_000);

        var initial = workflow.Current.DraftRows.ToArray();
        var moved = workflow.ReorderDraftRow(
            initial[0].RowId,
            initial[2].RowId,
            LegacyDragDropPosition.After);
        Assert.Equal(
            [initial[1].RowId, initial[2].RowId, initial[0].RowId, initial[3].RowId],
            moved.DraftRows.Select(row => row.RowId));
        Assert.Equal([0, 1, 2, 3], moved.DraftRows.Select(row => row.Position));

        var revision = moved.Revision;
        var duplicate = workflow.ReorderDraftRow(
            initial[0].RowId,
            initial[2].RowId,
            LegacyDragDropPosition.After);
        Assert.Equal(revision, duplicate.Revision);
        Assert.Equal(
            moved.DraftRows.Select(row => row.RowId),
            duplicate.DraftRows.Select(row => row.RowId));
    }

    [Fact]
    public async Task EList_edit_preserves_null_buy_amount_until_explicit_save_validation()
    {
        var fixture = new Fixture();
        fixture.ExpertSelection.PreviewItems =
        [
            new ExpertRecommendationPreview(0, "000001", "First", null),
            new ExpertRecommendationPreview(1, "000002", "Second", 20_000m)
        ];
        var workflow = fixture.CreateWorkflow();
        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Expert);

        var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);

        Assert.Equal(LegacyOperatorCatalogMode.Edit, editing.Mode);
        Assert.Equal(2, editing.DraftRows.Count);
        Assert.Null(editing.DraftRows[0].BuyAmount);
        Assert.Equal(20_000m, editing.DraftRows[1].BuyAmount);

        var blocked = await workflow.SaveAsync(string.Empty);

        Assert.Equal(LegacyOperatorCatalogMode.Edit, blocked.Mode);
        Assert.Equal(0, fixture.ExpertPersistence.ReplaceCalls);
    }

    [Fact]
    public async Task Uc6_edit_save_with_zero_recommendations_is_a_completely_silent_no_op()
    {
        var fixture = new Fixture();
        fixture.ExpertSelection.PreviewItems = [];
        var workflow = fixture.CreateWorkflow();
        var editing = await workflow.OpenExpertForEditAsync(
            new ExpertSelectionIdentity("0001", "이전문"));

        Assert.Equal(LegacyOperatorCatalogMode.Edit, editing.Mode);
        Assert.Empty(editing.DraftRows);
        var result = await workflow.SaveAsync(string.Empty);

        Assert.Equal(editing.Revision, result.Revision);
        Assert.Equal(editing.StatusMessage, result.StatusMessage);
        Assert.Equal(LegacyOperatorCatalogMode.Edit, result.Mode);
        Assert.Equal(0, fixture.ExpertPersistence.ReplaceCalls);
        Assert.Equal(0, fixture.IdentityValidation.ExpertCalls);
    }

    [Fact]
    public async Task ThemeA_delete_all_clears_only_the_local_draft_until_save()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        var opened = await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        var editing = await workflow.SelectAsync(Assert.Single(opened.Results).ResultId);

        var cleared = workflow.ClearThemeDraftRows();

        Assert.Empty(cleared.DraftRows);
        Assert.Equal(editing.Revision + 1, cleared.Revision);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
    }

    [Fact]
    public async Task Uc4ThemeAdd_OpensTheOriginalKrxDefaultAndLetsTheOperatorChooseNxt()
    {
        var fixture = new Fixture();
        var controller = fixture.CreateController();
        await controller.SelectTabAsync(LegacyOperatorTabId.Theme);

        var opened = await controller.BeginThemeCatalogFromUc4Async();

        var catalog = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            opened.OperatorCatalog);
        Assert.True(catalog.IsOpen);
        Assert.Equal(LegacyOperatorCatalogEntity.Theme, catalog.Entity);
        Assert.Equal(LegacyOperatorCatalogMode.List, catalog.Mode);
        Assert.Equal(ThemeMarket.Krx, catalog.NewThemeMarket);
        Assert.True(catalog.CanBeginCreate);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);

        var creating = await controller.BeginCreateThemeCatalogAsync(ThemeMarket.Nxt);

        catalog = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            creating.OperatorCatalog);
        Assert.Equal(LegacyOperatorCatalogMode.Create, catalog.Mode);
        Assert.Equal(ThemeMarket.Nxt, catalog.NewThemeMarket);
        Assert.Equal(0, fixture.ThemePersistence.CreateCalls);
    }

    [Fact]
    public async Task ThemeA_edit_projects_the_selected_nxt_market_into_the_original_market_choice()
    {
        var fixture = new Fixture();
        fixture.ThemeSelection.SearchItems =
        [
            ThemeItem(ThemeMarket.Nxt, "NXT 테마")
        ];
        var workflow = fixture.CreateWorkflow();
        var identity = Assert.Single(fixture.ThemeSelection.SearchItems).Identity;

        var editing = await workflow.OpenThemeForEditAsync(identity);

        Assert.Equal(LegacyOperatorCatalogEntity.Theme, editing.Entity);
        Assert.Equal(LegacyOperatorCatalogMode.Edit, editing.Mode);
        Assert.Equal(ThemeMarket.Nxt, editing.NewThemeMarket);
        Assert.Equal("NXT 테마", editing.EditorName);
        Assert.True(editing.CanSave);
    }

    [Fact]
    public async Task Uc4_theme_delete_requires_native_yes_and_consumes_yes_exactly_once()
    {
        var fixture = new Fixture();
        var controller = fixture.CreateController();
        await SelectThemeAsync(controller);

        var requested = await controller.DeleteSelectedThemeCatalogAsync();

        Assert.True(requested.Dialog?.IsConfirmation);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);

        var canceled = controller.CancelDialog();

        Assert.Null(canceled.Dialog);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);

        await controller.DeleteSelectedThemeCatalogAsync();
        var confirmed = await controller.ConfirmDialogAsync();

        Assert.Null(confirmed.Dialog);
        Assert.Equal(1, fixture.ThemePersistence.DeleteCalls);

        await controller.ConfirmDialogAsync();

        Assert.Equal(1, fixture.ThemePersistence.DeleteCalls);
    }

    [Fact]
    public async Task Uc6_expert_delete_requires_native_yes_and_consumes_yes_exactly_once()
    {
        var fixture = new Fixture();
        var controller = fixture.CreateController();
        await SelectExpertAsync(controller);

        var requested = await controller.DeleteSelectedExpertCatalogAsync();

        Assert.True(requested.Dialog?.IsConfirmation);
        Assert.Equal(0, fixture.ExpertPersistence.DeleteCalls);

        var canceled = controller.CancelDialog();

        Assert.Null(canceled.Dialog);
        Assert.Equal(0, fixture.ExpertPersistence.DeleteCalls);

        await controller.DeleteSelectedExpertCatalogAsync();
        var confirmed = await controller.ConfirmDialogAsync();

        Assert.Null(confirmed.Dialog);
        Assert.Equal(1, fixture.ExpertPersistence.DeleteCalls);

        await controller.ConfirmDialogAsync();

        Assert.Equal(1, fixture.ExpertPersistence.DeleteCalls);
    }

    [Fact]
    public async Task ThemeA_delete_all_requires_native_yes_and_never_writes_to_the_database()
    {
        var fixture = new Fixture();
        var controller = fixture.CreateController();
        await SelectThemeAsync(controller);
        var editing = await controller.EditSelectedThemeCatalogAsync();
        Assert.Single(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            editing.OperatorCatalog).DraftRows);

        var requested = controller.ClearOperatorCatalogDraftRows();

        Assert.True(requested.Dialog?.IsConfirmation);
        Assert.Single(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            requested.OperatorCatalog).DraftRows);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);

        var canceled = controller.CancelDialog();

        Assert.Null(canceled.Dialog);
        Assert.Single(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            canceled.OperatorCatalog).DraftRows);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);

        controller.ClearOperatorCatalogDraftRows();
        var confirmed = await controller.ConfirmDialogAsync();

        Assert.Empty(Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            confirmed.OperatorCatalog).DraftRows);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);

        await controller.ConfirmDialogAsync();

        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
    }

    [Fact]
    public async Task ThemeA_active_draft_row_delete_selects_the_native_successor()
    {
        var fixture = new Fixture();
        var controller = fixture.CreateController();
        await SelectThemeAsync(controller);
        await controller.EditSelectedThemeCatalogAsync();
        var stocks = await controller.SearchOperatorCatalogStocksAsync("stock");
        var stockId = Assert.Single(stocks.OperatorCatalog!.StockResults).ResultId;
        controller.SelectOperatorCatalogStock(stockId);
        controller.AddOperatorCatalogStock(null);
        controller.SelectOperatorCatalogStock(stockId);
        var editing = controller.AddOperatorCatalogStock(null);
        var rows = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            editing.OperatorCatalog).DraftRows.ToArray();

        var selected = controller.SelectOperatorCatalogDraftRow(rows[1].RowId);
        Assert.Equal(rows[1].RowId, selected.OperatorCatalog?.ActiveDraftRowId);

        var removed = controller.RemoveActiveOperatorCatalogDraftRow();

        Assert.Equal([rows[0].RowId, rows[2].RowId],
            removed.OperatorCatalog?.DraftRows.Select(row => row.RowId));
        Assert.Equal(rows[2].RowId, removed.OperatorCatalog?.ActiveDraftRowId);
        Assert.Equal(0, fixture.ThemePersistence.DeleteCalls);
    }

    [Fact]
    public async Task EList_active_draft_row_delete_selects_the_native_successor()
    {
        var fixture = new Fixture();
        fixture.ExpertSelection.PreviewItems =
        [
            new ExpertRecommendationPreview(0, "000001", "First", 10_000),
            new ExpertRecommendationPreview(1, "000002", "Second", 20_000),
            new ExpertRecommendationPreview(2, "000003", "Third", 30_000)
        ];
        var controller = fixture.CreateController();
        await SelectExpertAsync(controller);
        var editing = await controller.EditSelectedExpertCatalogAsync();
        var rows = Assert.IsType<LegacyOperatorCatalogWorkflowSnapshot>(
            editing.OperatorCatalog).DraftRows.ToArray();

        var selected = controller.SelectOperatorCatalogDraftRow(rows[1].RowId);
        Assert.Equal(rows[1].RowId, selected.OperatorCatalog?.ActiveDraftRowId);

        var removed = controller.RemoveActiveOperatorCatalogDraftRow();

        Assert.Equal([rows[0].RowId, rows[2].RowId],
            removed.OperatorCatalog?.DraftRows.Select(row => row.RowId));
        Assert.Equal(rows[2].RowId, removed.OperatorCatalog?.ActiveDraftRowId);
        Assert.Equal(0, fixture.ExpertPersistence.DeleteCalls);
    }

    [Fact]
    public async Task Uc4_and_Uc6_edit_entries_resolve_only_the_native_selected_identity()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();

        var theme = await workflow.OpenThemeForEditAsync(new ThemeSelectionIdentity(
            ThemeMarket.Krx,
            ThemeSession.NotApplicable,
            "00000001",
            "AI 반도체"));

        Assert.Equal(LegacyOperatorCatalogEntity.Theme, theme.Entity);
        Assert.Equal(LegacyOperatorCatalogMode.Edit, theme.Mode);
        Assert.Equal("AI 반도체", theme.EditorName);
        Assert.True(theme.CanDelete);

        workflow.Close();

        var expert = await workflow.OpenExpertForEditAsync(
            new ExpertSelectionIdentity("0001", "이전문"));

        Assert.Equal(LegacyOperatorCatalogEntity.Expert, expert.Entity);
        Assert.Equal(LegacyOperatorCatalogMode.Edit, expert.Mode);
        Assert.Equal("이전문", expert.EditorName);
        Assert.Equal(70_000, Assert.Single(expert.DraftRows).BuyAmount);
    }

    [Fact]
    public async Task OutcomeUnknown_latches_process_safety_instance_and_never_retries()
    {
        var fixture = new Fixture();
        fixture.ThemePersistence.CreateException = new OperatorCatalogMutationException(
            "lost commit acknowledgement",
            outcomeUnknown: true);
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);
        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        var stocks = await workflow.SearchStocksAsync("삼성");
        workflow.SelectStock(Assert.Single(stocks.StockResults).ResultId);
        workflow.AddSelectedStock(null);

        var unknown = await workflow.SaveAsync("확인 불명 테마");
        var blocked = await workflow.SaveAsync("확인 불명 테마");

        Assert.True(unknown.WritesQuarantined);
        Assert.True(blocked.WritesQuarantined);
        Assert.False(blocked.CanSave);
        Assert.Equal(1, fixture.ThemePersistence.CreateCalls);
    }

    [Fact]
    public async Task Fabricated_catalog_stock_and_draft_ids_fail_before_authority_is_used()
    {
        var fixture = new Fixture();
        var workflow = fixture.CreateWorkflow();
        await workflow.OpenAsync(LegacyOperatorCatalogEntity.Theme);

        await Assert.ThrowsAsync<ArgumentException>(() =>
            workflow.SelectAsync("catalog-result-fabricated"));

        await workflow.BeginCreateThemeAsync(ThemeMarket.Krx);
        await workflow.SearchStocksAsync("삼성");
        Assert.Throws<ArgumentException>(() => workflow.SelectStock("catalog-stock-fabricated"));
        Assert.Throws<ArgumentException>(() =>
            workflow.RemoveDraftRow("catalog-draft-fabricated"));
    }

    private sealed class Fixture
    {
        internal FakeThemeSelection ThemeSelection { get; } = new();

        internal FakeExpertSelection ExpertSelection { get; } = new();

        internal FakeThemePersistence ThemePersistence { get; } = new();

        internal FakeExpertPersistence ExpertPersistence { get; } = new();

        internal FakeStockSearch StockSearch { get; } = new();

        internal FakeIdentityValidation IdentityValidation { get; } = new();

        internal LegacyOperatorCatalogWorkflowController CreateWorkflow() =>
            new(
                ThemeSelection,
                ExpertSelection,
                ThemePersistence,
                ExpertPersistence,
                StockSearch,
                IdentityValidation,
                new FakeSchemaValidation(),
                new LegacyOperatorCatalogWriteSafetyState());

        internal LegacyOperatorController CreateController() =>
            new(
                new FakeLegacyStockLookup(),
                themeWorkflow: new LegacyThemeWorkflow(ThemeSelection),
                expertWorkflow: new LegacyExpertWorkflowController(ExpertSelection),
                operatorCatalogWorkflow: CreateWorkflow());
    }

    private sealed class FakeThemeSelection : IThemeSelectionService
    {
        internal int SearchCalls { get; private set; }

        internal int LastMaximumResults { get; private set; }

        internal int PreviewCalls { get; private set; }

        internal IReadOnlyList<ThemeSelectorItem> SearchItems { get; set; } =
        [
            ThemeItem(ThemeMarket.Krx, "AI 반도체")
        ];

        public Task<ThemeSearchResult> SearchAsync(
            string query,
            ThemeSession nxtSession,
            int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            SearchCalls++;
            LastMaximumResults = maximumResults;
            return Task.FromResult(new ThemeSearchResult(
                query,
                nxtSession,
                DateTimeOffset.UtcNow,
                SearchItems,
                false));
        }

        public Task<ThemePreviewResult> PreviewAsync(
            ThemeSelectionIdentity identity,
            int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems,
            CancellationToken cancellationToken = default)
        {
            PreviewCalls++;
            return Task.FromResult(new ThemePreviewResult(
                identity,
                DateTimeOffset.UtcNow,
                [new ThemeItemPreview(4, "P005930", "삼성전자")],
                false));
        }
    }

    private sealed class FakeExpertSelection : IExpertSelectionService
    {
        internal IReadOnlyList<ExpertRecommendationPreview> PreviewItems { get; set; } =
            [new ExpertRecommendationPreview(0, "005930", "삼성전자", 70_000)];

        public Task<ExpertSearchResult> SearchAsync(
            string query = "",
            int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults,
            CancellationToken cancellationToken = default) =>
            Task.FromResult(new ExpertSearchResult(
                query,
                DateTimeOffset.UtcNow,
                [
                    new ExpertSelectorItem(
                        new ExpertSelectionIdentity("0001", "이전문"),
                        DataSourceKind.Oracle)
                ],
                false));

        public Task<ExpertPreviewResult> PreviewAsync(
            ExpertSelectionIdentity identity,
            int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems,
            CancellationToken cancellationToken = default) =>
            Task.FromResult(new ExpertPreviewResult(
                identity,
                DateTimeOffset.UtcNow,
                PreviewItems,
                false));
    }

    private sealed class FakeThemePersistence : IThemeCatalogPersistenceService
    {
        internal int ReadStoredCalls { get; private set; }

        internal int CreateCalls { get; private set; }

        internal int DeleteCalls { get; private set; }

        internal ThemeCatalogDefinition? LastCreated { get; private set; }

        internal int ReplaceCalls { get; private set; }

        internal IReadOnlyList<ThemeCatalogItem> LastReplacedItems { get; private set; } = [];

        internal string? LastReplacedName { get; private set; }

        internal IReadOnlyList<ThemeCatalogItem> StoredItems { get; set; } =
            [new ThemeCatalogItem(4, "P005930", "삼성전자")];

        internal Exception? CreateException { get; set; }

        internal int NameExistsCalls { get; private set; }

        internal bool NameExists { get; set; }

        internal Exception? NameExistsException { get; set; }

        internal Func<Task<bool>>? NameExistsHandler { get; set; }

        internal ThemeMarket? LastNameCheckMarket { get; private set; }

        internal string? LastCheckedName { get; private set; }

        internal int SuggestNextCodeCalls { get; private set; }

        internal ThemeMarket? LastSuggestedMarket { get; private set; }

        internal string KrxNextCode { get; set; } = "00000009";

        internal string NxtNextCode { get; set; } = "00000009";

        internal Exception? SuggestNextCodeException { get; set; }

        public bool CanMutate => true;

        public Task<ThemeCatalogStoredResult> ReadStoredAsync(
            ThemeSelectionIdentity identity,
            CancellationToken cancellationToken = default)
        {
            ReadStoredCalls++;
            return Task.FromResult(new ThemeCatalogStoredResult(
                identity,
                DateTimeOffset.UtcNow,
                StoredItems,
                false));
        }

        public Task<bool> ThemeNameExistsAsync(
            ThemeMarket market,
            string themeTitle,
            CancellationToken cancellationToken = default)
        {
            NameExistsCalls++;
            LastNameCheckMarket = market;
            LastCheckedName = themeTitle;
            if (NameExistsHandler is not null)
            {
                return NameExistsHandler();
            }
            return NameExistsException is null
                ? Task.FromResult(NameExists)
                : Task.FromException<bool>(NameExistsException);
        }

        public Task<string> SuggestNextCodeAsync(
            ThemeMarket market,
            CancellationToken cancellationToken = default)
        {
            SuggestNextCodeCalls++;
            LastSuggestedMarket = market;
            return SuggestNextCodeException is null
                ? Task.FromResult(market == ThemeMarket.Krx ? KrxNextCode : NxtNextCode)
                : Task.FromException<string>(SuggestNextCodeException);
        }

        public Task<OperatorCatalogMutationReceipt> CreateAsync(
            ThemeCatalogDefinition definition,
            CancellationToken cancellationToken = default)
        {
            CreateCalls++;
            LastCreated = definition;
            return CreateException is null
                ? Receipt(OperatorCatalogMutationKind.CreateTheme)
                : Task.FromException<OperatorCatalogMutationReceipt>(CreateException);
        }

        public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
            ThemeSelectionIdentity expectedIdentity,
            string newTitle,
            IReadOnlyList<ThemeCatalogItem> items,
            CancellationToken cancellationToken = default)
        {
            ReplaceCalls++;
            LastReplacedItems = items;
            LastReplacedName = newTitle;
            return Receipt(OperatorCatalogMutationKind.ReplaceTheme);
        }

        public Task<OperatorCatalogMutationReceipt> DeleteAsync(
            ThemeSelectionIdentity expectedIdentity,
            CancellationToken cancellationToken = default)
        {
            DeleteCalls++;
            return Receipt(OperatorCatalogMutationKind.DeleteTheme);
        }
    }

    private sealed class FakeExpertPersistence : IExpertCatalogPersistenceService
    {
        internal int CreateCalls { get; private set; }

        internal ExpertCatalogDefinition? LastCreated { get; private set; }

        internal int ReplaceCalls { get; private set; }

        internal int DeleteCalls { get; private set; }

        public bool CanMutate => true;

        public Task<string> SuggestNextCodeAsync(CancellationToken cancellationToken = default) =>
            Task.FromResult("0009");

        public Task<OperatorCatalogMutationReceipt> CreateAsync(
            ExpertCatalogDefinition definition,
            CancellationToken cancellationToken = default)
        {
            CreateCalls++;
            LastCreated = definition;
            return Receipt(OperatorCatalogMutationKind.CreateExpert);
        }

        public Task<OperatorCatalogMutationReceipt> ReplaceAsync(
            ExpertSelectionIdentity expectedIdentity,
            string newName,
            IReadOnlyList<ExpertCatalogRecommendation> recommendations,
            CancellationToken cancellationToken = default)
        {
            ReplaceCalls++;
            return Receipt(OperatorCatalogMutationKind.ReplaceExpert);
        }

        public Task<OperatorCatalogMutationReceipt> DeleteAsync(
            ExpertSelectionIdentity expectedIdentity,
            CancellationToken cancellationToken = default)
        {
            DeleteCalls++;
            return Receipt(OperatorCatalogMutationKind.DeleteExpert);
        }
    }

    private sealed class FakeLegacyStockLookup : ILegacyStockLookup
    {
        public Task<LegacyStockSearchData> SearchAsync(
            string query,
            CancellationToken cancellationToken = default) =>
            Task.FromResult(new LegacyStockSearchData(query, [], []));
    }

    private sealed class FakeStockSearch : IOperatorCatalogStockSearchService
    {
        internal IReadOnlyList<StockSearchItem> Items { get; set; } =
        [new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "삼성전자", "005930")];

        internal int LastMaximumResults { get; private set; }

        internal int SearchCalls { get; private set; }

        internal int SearchAllCalls { get; private set; }

        public Task<StockSearchResult> SearchAsync(
            string query,
            int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            SearchCalls++;
            LastMaximumResults = maximumResults;
            return Task.FromResult(new StockSearchResult(
                query,
                DateTimeOffset.UtcNow,
                Items,
                false));
        }

        public Task<StockSearchResult> SearchAllAsync(
            int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
            CancellationToken cancellationToken = default)
        {
            SearchAllCalls++;
            LastMaximumResults = maximumResults;
            return Task.FromResult(new StockSearchResult(
                string.Empty,
                DateTimeOffset.UtcNow,
                Items,
                false));
        }
    }

    private sealed class FakeIdentityValidation : IStockMasterIdentityValidationService
    {
        internal int ThemeCalls { get; private set; }

        internal IReadOnlyList<ThemeCatalogItem> LastThemeItems { get; private set; } = [];

        internal int ExpertCalls { get; private set; }

        public Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
            IReadOnlyList<ThemeCatalogItem> items,
            CancellationToken cancellationToken = default)
        {
            ThemeCalls++;
            LastThemeItems = items;
            return Task.FromResult<IReadOnlyList<StockMasterIdentity>>([]);
        }

        public Task<IReadOnlyList<StockMasterIdentity>> ValidateExpertRecommendationsAsync(
            IReadOnlyList<ExpertCatalogRecommendation> recommendations,
            CancellationToken cancellationToken = default)
        {
            ExpertCalls++;
            return Task.FromResult<IReadOnlyList<StockMasterIdentity>>([]);
        }
    }

    private sealed class FakeSchemaValidation : IOperatorCatalogSchemaValidationService
    {
        public Task<OperatorCatalogSchemaValidationResult> ValidateAsync(
            CancellationToken cancellationToken = default) =>
            Task.FromResult(new OperatorCatalogSchemaValidationResult(
                DateTimeOffset.UtcNow,
                [DataSourceKind.Oracle, DataSourceKind.MariaDb]));
    }

    private static Task<OperatorCatalogMutationReceipt> Receipt(
        OperatorCatalogMutationKind kind) =>
        Task.FromResult(new OperatorCatalogMutationReceipt(
            Guid.NewGuid(),
            kind,
            DateTimeOffset.UtcNow));

    private static async Task SelectThemeAsync(LegacyOperatorController controller)
    {
        await controller.SelectTabAsync(LegacyOperatorTabId.Theme);
        var searched = await controller.SearchThemesAsync("AI", ThemeSession.PreMarket);
        var resultId = Assert.Single(searched.Theme!.SearchResults).ResultId;
        await controller.SelectThemeAsync(resultId);
    }

    private static async Task SelectExpertAsync(LegacyOperatorController controller)
    {
        await controller.SelectTabAsync(LegacyOperatorTabId.Expert);
        var searched = await controller.SearchExpertsAsync("expert");
        var selectionId = Assert.Single(searched.Expert!.Search.Results).SelectionId;
        await controller.SelectExpertAsync(selectionId);
    }

    private static ThemeSelectorItem ThemeItem(ThemeMarket market, string title) =>
        new(
            new ThemeSelectionIdentity(
                market,
                market == ThemeMarket.Nxt
                    ? ThemeSession.PreMarket
                    : ThemeSession.NotApplicable,
                market == ThemeMarket.Nxt ? "N0000001" : "00000001",
                title),
            market == ThemeMarket.Nxt ? DataSourceKind.MariaDb : DataSourceKind.Oracle);
}
