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

namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;

public sealed class LegacyComparisonWorkflowTests
{
    private static readonly LegacyComparisonTarget Samsung =
        LegacyComparisonTarget.CreateKrxStock(
            LegacyComparisonDomesticMarket.Kospi,
            "삼성전자",
            "005930");

    private static readonly LegacyComparisonTarget SkHynix =
        LegacyComparisonTarget.CreateKrxStock(
            LegacyComparisonDomesticMarket.Kospi,
            "SK하이닉스",
            "000660");

    private static readonly LegacyComparisonTarget NxtSamsung =
        LegacyComparisonTarget.CreateNxtStock(
            LegacyComparisonDomesticMarket.Kospi,
            "삼성전자",
            "005930");

    private static readonly LegacyComparisonTarget Apple =
        LegacyComparisonTarget.CreateWorldStock("APPLE", "AAPL", "US");

    [Fact]
    public void InitialSnapshot_ClosesFixedTargetsAndRuntimeActions()
    {
        var controller = CreateController();

        var snapshot = controller.Current;

        Assert.Equal(24, snapshot.FixedTargets.Count);
        Assert.Equal(
            new[]
            {
                "kospi", "kosdaq", "kospi200", "futures", "krx100",
                "won-dollar", "won-yen", "won-yuan", "won-euro", "dow",
                "nasdaq", "sp500", "germany-dax", "united-kingdom-ftse",
                "france-cac", "nikkei", "hang-seng", "taiwan-weighted",
                "singapore-straits-times", "thailand-set",
                "philippines-composite", "malaysia-klse",
                "indonesia-composite", "shanghai-composite"
            },
            snapshot.FixedTargets.Select(target => target.SelectionId));
        Assert.Equal(
            new[]
            {
                "two-column-expected",
                "two-column-current",
                "comparison-candle",
                "comparison-line",
                "return-5d",
                "return-1m",
                "return-3m",
                "return-6m",
                "return-12m"
            },
            snapshot.Actions.Select(action => action.ActionId));
        Assert.All(snapshot.Actions, action => Assert.False(action.IsAvailable));
    }

    [Fact]
    public async Task SearchesAndSelection_ReproduceUc3RotateThenSwapRule()
    {
        var data = new FakeDataService
        {
            DomesticResults = [Samsung, NxtSamsung],
            WorldResults = [Apple]
        };
        var controller = CreateController(data);

        var domestic = await controller.SearchDomesticAsync("  삼성  ");

        Assert.Equal("삼성", data.DomesticQuery);
        Assert.Equal(LegacyStockSearchService.MaximumResults, data.DomesticMaximumResults);
        Assert.Equal(
            new[] { "삼성전자", "삼성전자(NXT)" },
            domestic.DomesticSearch.Results.Select(result => result.DisplayName));

        var selectedDomestic = controller.SelectDomesticResult(
            domestic.DomesticSearch.Results[0].SelectionId);
        Assert.Equal(
            domestic.DomesticSearch.Results[0].SelectionId,
            selectedDomestic.DomesticSearch.SelectedResultId);
        Assert.Null(selectedDomestic.FirstTarget);
        Assert.Null(selectedDomestic.SecondTarget);

        var first = controller.ChooseDomesticResult(
            domestic.DomesticSearch.Results[0].SelectionId);
        Assert.Equal("삼성전자", first.FirstTarget?.DisplayName);
        Assert.Null(first.SecondTarget);

        var second = controller.ChooseDomesticResult(
            domestic.DomesticSearch.Results[1].SelectionId);
        Assert.Equal("삼성전자(NXT)", second.FirstTarget?.DisplayName);
        Assert.Equal("삼성전자", second.SecondTarget?.DisplayName);

        var world = await controller.SearchWorldAsync(string.Empty);
        Assert.Equal(string.Empty, data.WorldQuery);
        Assert.Equal(LegacyWorldStockSearchService.MaximumResults, data.WorldMaximumResults);
        var selectedWorld = controller.SelectWorldResult(
            world.WorldSearch.Results[0].SelectionId);
        Assert.Equal(
            world.WorldSearch.Results[0].SelectionId,
            selectedWorld.WorldSearch.SelectedResultId);
        Assert.Equal(second.FirstTarget?.DisplayName, selectedWorld.FirstTarget?.DisplayName);
        var third = controller.ChooseWorldResult(world.WorldSearch.Results[0].SelectionId);
        Assert.Equal("APPLE", third.FirstTarget?.DisplayName);
        Assert.Equal("삼성전자(NXT)", third.SecondTarget?.DisplayName);

        var selectedFixed = controller.SelectFixedTarget("kospi");
        Assert.Equal("kospi", selectedFixed.SelectedFixedTargetId);
        Assert.Equal(third.FirstTarget?.DisplayName, selectedFixed.FirstTarget?.DisplayName);
        var fixedTarget = controller.ChooseFixedTarget("kospi");
        Assert.Equal("코스피 지수", fixedTarget.FirstTarget?.DisplayName);
        Assert.Equal("APPLE", fixedTarget.SecondTarget?.DisplayName);

        var swapped = controller.SwapDraftTargets();
        Assert.Equal("APPLE", swapped.FirstTarget?.DisplayName);
        Assert.Equal("코스피 지수", swapped.SecondTarget?.DisplayName);
    }

    [Fact]
    public async Task Search_projects_row_level_isolation_without_rejecting_safe_results()
    {
        var data = new FakeDataService
        {
            DomesticResults = [Samsung],
            DomesticIsTruncated = true,
            DomesticIsolatedInvalidRowCount = 2,
            WorldResults = [Apple],
            WorldIsTruncated = true,
            WorldIsolatedInvalidRowCount = 16
        };
        var controller = CreateController(data);

        var domestic = await controller.SearchDomesticAsync(string.Empty);
        var world = await controller.SearchWorldAsync(string.Empty);

        Assert.Single(domestic.DomesticSearch.Results);
        Assert.True(domestic.DomesticSearch.IsTruncated);
        Assert.Equal(2, domestic.DomesticSearch.IsolatedInvalidRowCount);
        Assert.Single(world.WorldSearch.Results);
        Assert.True(world.WorldSearch.IsTruncated);
        Assert.Equal(16, world.WorldSearch.IsolatedInvalidRowCount);
    }

    [Fact]
    public async Task Comma_world_name_stays_visible_but_native_selection_and_playout_are_blocked()
    {
        var deferred = LegacyComparisonTarget.CreateWorldStock(
            "ACME, INC",
            "ACME",
            "US");
        var data = new FakeDataService
        {
            WorldResults = [Apple, deferred],
            WorldDeferredUnsafeRowCount = 1
        };
        var controller = CreateController(data);

        var searched = await controller.SearchWorldAsync(string.Empty);
        var row = Assert.Single(searched.WorldSearch.Results, item => !item.CanSelect);
        Assert.Equal(2, searched.WorldSearch.Results.Count);
        Assert.Equal(1, searched.WorldSearch.DeferredUnsafeRowCount);
        Assert.Contains("쉼표", row.UnavailableReason, StringComparison.Ordinal);

        var selected = controller.SelectWorldResult(row.SelectionId);
        var chosen = controller.ChooseWorldResult(row.SelectionId);

        Assert.Null(selected.WorldSearch.SelectedResultId);
        Assert.Null(chosen.FirstTarget);
        Assert.Equal(LegacyOperatorStatusKind.Warning, chosen.StatusKind);
        Assert.Throws<InvalidOperationException>(() =>
            LegacyComparisonWorkflowCatalog.Materialize(
                "two-column-current",
                deferred,
                Apple,
                "comparison-draft-safe-id"));
    }

    [Fact]
    public async Task Search_RejectsDuplicateOrWrongUniverseResults()
    {
        var duplicateData = new FakeDataService
        {
            DomesticResults = [Samsung, Samsung]
        };
        var duplicateController = CreateController(duplicateData);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => duplicateController.SearchDomesticAsync("삼성"));
        Assert.Equal(
            LegacyComparisonSearchStatus.Error,
            duplicateController.Current.DomesticSearch.Status);
        Assert.Empty(duplicateController.Current.DomesticSearch.Results);

        var wrongUniverse = new FakeDataService
        {
            WorldResults = [Samsung]
        };
        var wrongController = CreateController(wrongUniverse);
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => wrongController.SearchWorldAsync("삼성"));
        Assert.Equal(
            LegacyComparisonSearchStatus.Error,
            wrongController.Current.WorldSearch.Status);
    }

    [Fact]
    public async Task LoadSavedPairs_ParsesStrictCp949AndDisplaysResolvedRows()
    {
        var data = new FakeDataService();
        data.Resolve(Samsung, "삼성전자", "코스피");
        data.Resolve(SkHynix, "SK하이닉스", "코스피");
        data.Resolve(Apple, "APPLE", string.Empty);
        var store = new FakePairStore
        {
            Bytes = EncodeCp949(
                ("삼성전자", "코스피", "SK하이닉스", "코스피"),
                ("코스피 지수", string.Empty, "APPLE", string.Empty))
        };
        var controller = CreateController(data, store);

        var snapshot = await controller.LoadSavedPairsAsync();

        Assert.False(snapshot.IsBusy);
        Assert.Equal(2, snapshot.SavedPairs.Count);
        Assert.Equal("삼성전자 ↔ SK하이닉스", snapshot.SavedPairs[0].DisplayLabel);
        Assert.Equal("코스피 지수 ↔ APPLE", snapshot.SavedPairs[1].DisplayLabel);
        Assert.True(snapshot.SavedPairs[0].IsSelected);
        Assert.Equal("comparison-pair-00000001", snapshot.SelectedPairId);
        Assert.Empty(store.Writes);
        Assert.Equal(3, data.ResolveRequests.Count);
    }

    [Fact]
    public async Task LoadSavedPairs_RejectsNonCp949NineFieldShapeWithoutChangingRows()
    {
        var store = new FakePairStore
        {
            Bytes = Encoding.UTF8.GetBytes("1^삼성전자,SK하이닉스^코스피^코스피^\n")
        };
        var controller = CreateController(pairStore: store);

        await Assert.ThrowsAnyAsync<Exception>(() => controller.LoadSavedPairsAsync());

        Assert.Empty(controller.Current.SavedPairs);
        Assert.False(controller.Current.IsBusy);
        Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
    }

    [Fact]
    public async Task AddDraftPair_WritesOnlyThroughTypedStoreBoundary()
    {
        var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
        var store = new FakePairStore();
        var controller = CreateController(data, store);
        var search = await controller.SearchDomesticAsync(string.Empty);
        controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
        controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);

        var snapshot = await controller.AddDraftPairAsync();

        var write = Assert.Single(store.Writes);
        var row = Assert.Single(write);
        Assert.Equal(
            new LegacyComparisonPairWriteRow(
                1,
                "SK하이닉스",
                "코스피",
                "삼성전자",
                "코스피"),
            row);
        Assert.Equal("SK하이닉스 ↔ 삼성전자", Assert.Single(snapshot.SavedPairs).DisplayLabel);
        Assert.False(snapshot.IsBusy);
    }

    [Fact]
    public async Task Uc3_AllowsExactDuplicatePairsAndMovesOrDeletesOnlyTheSelectedStableRow()
    {
        var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
        var store = new FakePairStore();
        var controller = CreateController(data, store);
        var search = await controller.SearchDomesticAsync(string.Empty);
        controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
        controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);

        var firstAdd = await controller.AddDraftPairAsync();
        var firstRowId = Assert.Single(firstAdd.SavedPairs).RowId;
        var secondAdd = await controller.AddDraftPairAsync();
        var secondRowId = secondAdd.SavedPairs[1].RowId;

        Assert.NotEqual(firstRowId, secondRowId);
        Assert.Equal(
            new[] { firstRowId, secondRowId },
            secondAdd.SavedPairs.Select(pair => pair.RowId));
        Assert.All(
            secondAdd.SavedPairs,
            pair => Assert.Equal("SK하이닉스 ↔ 삼성전자", pair.DisplayLabel));
        Assert.Collection(
            store.Writes[^1],
            row => Assert.Equal(
                new LegacyComparisonPairWriteRow(
                    1,
                    "SK하이닉스",
                    "코스피",
                    "삼성전자",
                    "코스피"),
                row),
            row => Assert.Equal(
                new LegacyComparisonPairWriteRow(
                    2,
                    "SK하이닉스",
                    "코스피",
                    "삼성전자",
                    "코스피"),
                row));

        controller.SelectSavedPair(secondRowId);
        var moved = await controller.MoveSelectedPairAsync(
            LegacyComparisonPairMoveDirection.Up);

        Assert.Equal(
            new[] { secondRowId, firstRowId },
            moved.SavedPairs.Select(pair => pair.RowId));
        Assert.Equal(secondRowId, moved.SelectedPairId);
        Assert.True(moved.SavedPairs[0].IsSelected);
        Assert.False(moved.SavedPairs[1].IsSelected);

        var deleted = await controller.DeleteSelectedPairAsync();

        var remaining = Assert.Single(deleted.SavedPairs);
        Assert.Equal(firstRowId, remaining.RowId);
        Assert.True(remaining.IsSelected);
        Assert.Equal(firstRowId, deleted.SelectedPairId);
        Assert.Single(store.Writes[^1]);
    }

    [Fact]
    public async Task Uc3_DuplicatePairSaveRoundTripPreservesPhysicalRowOrder()
    {
        var data = new FakeDataService();
        data.Resolve(Samsung, "삼성전자", "코스피");
        data.Resolve(SkHynix, "SK하이닉스", "코스피");
        data.Resolve(Apple, "APPLE", string.Empty);
        var store = new FakePairStore
        {
            Bytes = EncodeCp949(
                ("삼성전자", "코스피", "SK하이닉스", "코스피"),
                ("삼성전자", "코스피", "SK하이닉스", "코스피"),
                ("코스피 지수", string.Empty, "APPLE", string.Empty))
        };
        var controller = CreateController(data, store);
        var loaded = await controller.LoadSavedPairsAsync();
        var thirdRowId = loaded.SavedPairs[2].RowId;
        controller.SelectSavedPair(thirdRowId);

        var moved = await controller.MoveSelectedPairAsync(
            LegacyComparisonPairMoveDirection.Up);

        Assert.Equal(
            new[]
            {
                "삼성전자 ↔ SK하이닉스",
                "코스피 지수 ↔ APPLE",
                "삼성전자 ↔ SK하이닉스"
            },
            moved.SavedPairs.Select(pair => pair.DisplayLabel));
        Assert.Equal(new[] { 1, 2, 3 }, store.Writes[^1].Select(row => row.RowNumber));

        var reloadedStore = new FakePairStore
        {
            Bytes = EncodeCp949(store.Writes[^1])
        };
        var reloaded = await CreateController(data, reloadedStore).LoadSavedPairsAsync();

        Assert.Equal(
            moved.SavedPairs.Select(pair => pair.DisplayLabel),
            reloaded.SavedPairs.Select(pair => pair.DisplayLabel));
        Assert.Equal(3, reloaded.SavedPairs.Select(pair => pair.RowId).Distinct().Count());
    }

    [Fact]
    public async Task SavedPairMoveAndDelete_PreserveActiveRowAndPersistPhysicalOrder()
    {
        var data = new FakeDataService();
        data.Resolve(Samsung, "삼성전자", "코스피");
        data.Resolve(SkHynix, "SK하이닉스", "코스피");
        data.Resolve(NxtSamsung, "삼성전자(NXT)", "코스피_NXT");
        var store = new FakePairStore
        {
            Bytes = EncodeCp949(
                ("삼성전자", "코스피", "SK하이닉스", "코스피"),
                ("삼성전자(NXT)", "코스피_NXT", "삼성전자", "코스피"))
        };
        var controller = CreateController(data, store);
        var loaded = await controller.LoadSavedPairsAsync();
        controller.SelectSavedPair(loaded.SavedPairs[1].RowId);

        var moved = await controller.MoveSelectedPairAsync(
            LegacyComparisonPairMoveDirection.Up);

        Assert.Equal(
            new[] { "삼성전자(NXT) ↔ 삼성전자", "삼성전자 ↔ SK하이닉스" },
            moved.SavedPairs.Select(pair => pair.DisplayLabel));
        Assert.True(moved.SavedPairs[0].IsSelected);
        Assert.Equal("삼성전자(NXT)", store.Writes[^1][0].FirstName);

        var writesAtBoundary = store.Writes.Count;
        await controller.MoveSelectedPairAsync(LegacyComparisonPairMoveDirection.Up);
        Assert.Equal(writesAtBoundary, store.Writes.Count);

        var deleted = await controller.DeleteSelectedPairAsync();
        Assert.Equal("삼성전자 ↔ SK하이닉스", Assert.Single(deleted.SavedPairs).DisplayLabel);
        Assert.True(deleted.SavedPairs[0].IsSelected);

        var empty = await controller.DeleteAllPairsAsync();
        Assert.Empty(empty.SavedPairs);
        Assert.Null(empty.SelectedPairId);
        Assert.Empty(store.Writes[^1]);
    }

    [Fact]
    public async Task FailedWrite_RollsBackAuthoritativePairState()
    {
        var data = new FakeDataService { DomesticResults = [Samsung, SkHynix] };
        var store = new FakePairStore { ThrowOnWrite = true };
        var controller = CreateController(data, store);
        var search = await controller.SearchDomesticAsync("삼성");
        controller.ChooseDomesticResult(search.DomesticSearch.Results[0].SelectionId);
        controller.ChooseDomesticResult(search.DomesticSearch.Results[1].SelectionId);

        await Assert.ThrowsAsync<IOException>(() => controller.AddDraftPairAsync());

        Assert.Empty(controller.Current.SavedPairs);
        Assert.False(controller.Current.IsBusy);
        Assert.Equal(LegacyOperatorStatusKind.Error, controller.Current.StatusKind);
    }

    [Fact]
    public async Task MaterializeSelectedAction_MapsAllNineClosedActionsFromActionIdOnly()
    {
        var controller = await CreateLoadedKrxPairControllerAsync();

        Assert.Equal(9, controller.Current.Actions.Count);
        Assert.All(controller.Current.Actions, action => Assert.True(action.IsAvailable));
        var method = typeof(LegacyComparisonWorkflowController).GetMethod(
            nameof(LegacyComparisonWorkflowController.MaterializeSelectedAction));
        Assert.NotNull(method);
        Assert.Collection(
            method.GetParameters(),
            parameter => Assert.Equal("actionId", parameter.Name));

        var drafts = controller.Current.Actions.ToDictionary(
            action => action.ActionId,
            action => controller.MaterializeSelectedAction(action.ActionId),
            StringComparer.Ordinal);

        Assert.Equal(9, drafts.Count);
        Assert.Equal(("s8018", "8032", "2열판", "EXPECTED", ""), Fields(drafts["two-column-expected"]));
        Assert.Equal(("s8018", "8032", "2열판", "CURRENT", ""), Fields(drafts["two-column-current"]));
        Assert.Equal(("s5026", "5026", "COMPARISON_CANDLE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-candle"]));
        Assert.Equal(("s5087", "5087", "COMPARISON_LINE", "FiveDays", "KOSPI:005930|KOSPI:000660"), Fields(drafts["comparison-line"]));
        Assert.Equal("FiveDays", drafts["return-5d"].Selection.Subtype);
        Assert.Equal("OneMonth", drafts["return-1m"].Selection.Subtype);
        Assert.Equal("ThreeMonths", drafts["return-3m"].Selection.Subtype);
        Assert.Equal("SixMonths", drafts["return-6m"].Selection.Subtype);
        Assert.Equal("TwelveMonths", drafts["return-12m"].Selection.Subtype);
        Assert.All(drafts.Where(pair => pair.Key.StartsWith("return-", StringComparison.Ordinal)), pair =>
        {
            Assert.Equal("s5029", pair.Value.BuilderKey);
            Assert.Equal("5029", pair.Value.CutCode);
            Assert.Equal("RETURN_COMPARISON", pair.Value.Selection.GraphicType);
        });
        Assert.All(drafts.Values, draft =>
        {
            Assert.Equal("삼성전자,SK하이닉스", draft.Selection.Subject);
            Assert.Equal("legacy-comparison-workflow", draft.Source);
            Assert.Equal(6, draft.FadeDuration);
            Assert.Equal(new[] { draft.CutCode }, draft.Aliases);
            Assert.Equal(draft.DraftId, draft.ToLegacyPlaylistEntry().EntryId);
        });
        Assert.Throws<ArgumentOutOfRangeException>(
            () => controller.MaterializeSelectedAction("caller-builder-override"));
    }

    [Fact]
    public async Task FuturesExpectedAction_UsesClosedCurrentS5032CompatibilityBranch()
    {
        var store = new FakePairStore
        {
            Bytes = EncodeCp949(
                ("선물 지수", string.Empty, "코스피 지수", string.Empty))
        };
        var controller = CreateController(pairStore: store);
        await controller.LoadSavedPairsAsync();

        var expected = controller.MaterializeSelectedAction("two-column-expected");

        Assert.Equal("s5032", expected.BuilderKey);
        Assert.Equal("5032", expected.CutCode);
        Assert.Equal("EXPECTED", expected.Selection.Subtype);
        Assert.Equal("CURRENT", expected.EffectiveMode);
        Assert.NotNull(expected.Warning);
        Assert.False(controller.Current.Actions.Single(
            action => action.ActionId == "comparison-candle").IsAvailable);
        Assert.Throws<InvalidOperationException>(
            () => controller.MaterializeSelectedAction("comparison-candle"));
    }

    [Fact]
    public async Task Snapshots_RemainImmutableAfterLaterStateChanges()
    {
        var data = new FakeDataService { DomesticResults = [Samsung] };
        var controller = CreateController(data);
        var before = controller.Current;

        var after = await controller.SearchDomesticAsync("삼성");

        Assert.Empty(before.DomesticSearch.Results);
        Assert.Single(after.DomesticSearch.Results);
        var fixedCollection = Assert.IsAssignableFrom<ICollection<LegacyComparisonTargetView>>(
            before.FixedTargets);
        Assert.Throws<NotSupportedException>(() => fixedCollection.Add(before.FixedTargets[0]));
        var actionCollection = Assert.IsAssignableFrom<ICollection<LegacyComparisonActionView>>(
            before.Actions);
        Assert.Throws<NotSupportedException>(() => actionCollection.Clear());
    }

    [Fact]
    public async Task Import_preserves_current_order_appends_unseen_pairs_and_is_idempotent()
    {
        var data = new FakeDataService();
        var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung);
        var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix);
        data.Resolve(Samsung, samsungStored.Name, samsungStored.Market);
        data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market);
        var store = new FakePairStore
        {
            Bytes = EncodeCp949(
                (samsungStored.Name, samsungStored.Market,
                 skHynixStored.Name, skHynixStored.Market))
        };
        var importer = new FakeImporter(CreateImportResult(
            ImportKrx("kospi", Samsung.StockName!, Samsung.StockCode!),
            ImportKrx("kospi", SkHynix.StockName!, SkHynix.StockCode!),
            ImportWorld("APPLE", "AAPL", "US"),
            ImportMarket("Kospi"),
            ImportWorld("APPLE", "AAPL", "US"),
            ImportMarket("Kospi")));
        var controller = CreateController(data, store, importer);
        await controller.LoadSavedPairsAsync();
        var originalRowId = controller.Current.SavedPairs[0].RowId;

        var first = await controller.ImportLegacyPairsAsync();

        Assert.Equal(2, first.SavedPairs.Count);
        Assert.Equal(originalRowId, first.SavedPairs[0].RowId);
        Assert.Equal("APPLE", first.SavedPairs[1].First.DisplayName);
        Assert.Equal("Kospi", first.SavedPairs[1].Second.Metadata);
        var receipt = Assert.IsType<LegacyComparisonImportReceipt>(first.LastImport);
        Assert.Equal(3, receipt.SourceRowCount);
        Assert.Equal(1, receipt.AddedPairCount);
        Assert.Equal(2, receipt.SkippedDuplicateCount);
        Assert.Equal(2, receipt.TotalPairCount);
        var write = Assert.Single(store.Writes);
        Assert.Equal(2, write.Count);
        Assert.Equal(Samsung.StockName, write[0].FirstName);
        Assert.Equal("APPLE", write[1].FirstName);

        var second = await controller.ImportLegacyPairsAsync();

        Assert.Equal(2, second.SavedPairs.Count);
        Assert.Single(store.Writes);
        Assert.Equal(0, second.LastImport?.AddedPairCount);
        Assert.Equal(2, importer.Calls);
    }

    [Fact]
    public async Task Import_validates_every_resolved_target_before_writing()
    {
        var store = new FakePairStore();
        var invalid = new LegacyComparisonImportTarget(
            LegacyComparisonImportTargetKind.KrxStock,
            Market: "kospi",
            StockName: Samsung.StockName,
            StockCode: "005930",
            Symbol: "forged-extra-field");
        var importer = new FakeImporter(CreateImportResult(
            ImportWorld("APPLE", "AAPL", "US"),
            ImportMarket("Kospi"),
            invalid,
            ImportMarket("Kosdaq")));
        var controller = CreateController(pairStore: store, importer: importer);

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => controller.ImportLegacyPairsAsync());

        Assert.Empty(store.Writes);
        Assert.Empty(controller.Current.SavedPairs);
        Assert.Null(controller.Current.LastImport);
    }

    [Fact]
    public async Task Import_store_failure_rolls_back_controller_state()
    {
        var data = new FakeDataService();
        var samsungStored = LegacyComparisonWorkflowCatalog.ToStoredReference(Samsung);
        var skHynixStored = LegacyComparisonWorkflowCatalog.ToStoredReference(SkHynix);
        data.Resolve(Samsung, samsungStored.Name, samsungStored.Market);
        data.Resolve(SkHynix, skHynixStored.Name, skHynixStored.Market);
        var store = new FakePairStore
        {
            Bytes = EncodeCp949(
                (samsungStored.Name, samsungStored.Market,
                 skHynixStored.Name, skHynixStored.Market)),
            ThrowOnWrite = true
        };
        var importer = new FakeImporter(CreateImportResult(
            ImportWorld("APPLE", "AAPL", "US"),
            ImportMarket("Kospi")));
        var controller = CreateController(data, store, importer);
        await controller.LoadSavedPairsAsync();
        var before = controller.Current.SavedPairs.Select(row => row.RowId).ToArray();

        await Assert.ThrowsAsync<IOException>(() => controller.ImportLegacyPairsAsync());

        Assert.Equal(before, controller.Current.SavedPairs.Select(row => row.RowId));
        Assert.Null(controller.Current.LastImport);
        Assert.Empty(store.Writes);
    }

    private static LegacyComparisonWorkflowController CreateController(
        FakeDataService? dataService = null,
        FakePairStore? pairStore = null,
        ILegacyComparisonPairImportService? importer = null) =>
        new(
            dataService ?? new FakeDataService(),
            pairStore ?? new FakePairStore(),
            pairImporter: importer);

    private static LegacyComparisonImportResult CreateImportResult(
        params LegacyComparisonImportTarget[] targets)
    {
        Assert.True(targets.Length % 2 == 0);
        var pairs = targets.Chunk(2).Select((pair, index) =>
            new LegacyComparisonImportPair(index + 1, pair[0], pair[1])).ToArray();
        return new LegacyComparisonImportResult(
            new string('A', 64),
            pairs.Length,
            DateTimeOffset.Parse("2026-07-22T12:00:00+09:00"),
            Array.AsReadOnly(pairs));
    }

    private static LegacyComparisonImportTarget ImportKrx(
        string market,
        string name,
        string code) =>
        new(
            LegacyComparisonImportTargetKind.KrxStock,
            Market: market,
            StockName: name,
            StockCode: code);

    private static LegacyComparisonImportTarget ImportWorld(
        string name,
        string symbol,
        string nation) =>
        new(
            LegacyComparisonImportTargetKind.WorldStock,
            InputName: name,
            Symbol: symbol,
            Nation: nation);

    private static LegacyComparisonImportTarget ImportMarket(string target) =>
        new(LegacyComparisonImportTargetKind.MarketTarget, MarketTarget: target);

    private static async Task<LegacyComparisonWorkflowController>
        CreateLoadedKrxPairControllerAsync()
    {
        var data = new FakeDataService();
        data.Resolve(Samsung, "삼성전자", "코스피");
        data.Resolve(SkHynix, "SK하이닉스", "코스피");
        var store = new FakePairStore
        {
            Bytes = EncodeCp949(
                ("삼성전자", "코스피", "SK하이닉스", "코스피"))
        };
        var controller = CreateController(data, store);
        await controller.LoadSavedPairsAsync();
        return controller;
    }

    private static (
        string BuilderKey,
        string CutCode,
        string GraphicType,
        string Subtype,
        string DataCode) Fields(LegacyGenericPlaylistDraft draft) =>
        (
            draft.BuilderKey,
            draft.CutCode,
            draft.Selection.GraphicType,
            draft.Selection.Subtype,
            draft.Selection.DataCode);

    private static byte[] EncodeCp949(
        params (string First, string FirstMarket, string Second, string SecondMarket)[] rows)
    {
        Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
        var encoding = Encoding.GetEncoding(
            949,
            EncoderFallback.ExceptionFallback,
            DecoderFallback.ExceptionFallback);
        var lines = rows.Select((row, index) => string.Join(
            '^',
            (index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture),
            $"{row.First},{row.Second}",
            row.FirstMarket,
            row.SecondMarket,
            string.Empty,
            string.Empty,
            string.Empty,
            string.Empty,
            string.Empty));
        return encoding.GetBytes(string.Join("\r\n", lines) + "\r\n");
    }

    private static byte[] EncodeCp949(IReadOnlyList<LegacyComparisonPairWriteRow> rows) =>
        EncodeCp949(rows.Select(row => (
            row.FirstName,
            row.FirstMarket,
            row.SecondName,
            row.SecondMarket)).ToArray());

    private sealed class FakeDataService :
        ILegacyComparisonDataService,
        ILegacyComparisonDataDiagnosticsService
    {
        private readonly Dictionary<LegacyComparisonStoredTargetReference, LegacyComparisonTarget>
            _resolved = [];

        public IReadOnlyList<LegacyComparisonTarget> DomesticResults { get; init; } = [];

        public IReadOnlyList<LegacyComparisonTarget> WorldResults { get; init; } = [];

        public bool DomesticIsTruncated { get; init; }

        public bool WorldIsTruncated { get; init; }

        public int DomesticIsolatedInvalidRowCount { get; init; }

        public int WorldIsolatedInvalidRowCount { get; init; }

        public int DomesticDeferredUnsafeRowCount { get; init; }

        public int WorldDeferredUnsafeRowCount { get; init; }

        public string? DomesticQuery { get; private set; }

        public string? WorldQuery { get; private set; }

        public int DomesticMaximumResults { get; private set; }

        public int WorldMaximumResults { get; private set; }

        public List<LegacyComparisonStoredTargetReference> ResolveRequests { get; } = [];

        public void Resolve(LegacyComparisonTarget target, string name, string market) =>
            _resolved[new LegacyComparisonStoredTargetReference(name, market)] = target;

        public Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
            string query,
            int maximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            DomesticQuery = query;
            DomesticMaximumResults = maximumResults;
            return Task.FromResult(DomesticResults);
        }

        public Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
            string query,
            int maximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            WorldQuery = query;
            WorldMaximumResults = maximumResults;
            return Task.FromResult(WorldResults);
        }

        public Task<LegacyComparisonSearchData> SearchDomesticWithDiagnosticsAsync(
            string query,
            int maximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            DomesticQuery = query;
            DomesticMaximumResults = maximumResults;
            return Task.FromResult(new LegacyComparisonSearchData(
                DomesticResults,
                DomesticIsTruncated,
                DomesticIsolatedInvalidRowCount)
            {
                DeferredUnsafeRowCount = DomesticDeferredUnsafeRowCount
            });
        }

        public Task<LegacyComparisonSearchData> SearchWorldWithDiagnosticsAsync(
            string query,
            int maximumResults,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            WorldQuery = query;
            WorldMaximumResults = maximumResults;
            return Task.FromResult(new LegacyComparisonSearchData(
                WorldResults,
                WorldIsTruncated,
                WorldIsolatedInvalidRowCount)
            {
                DeferredUnsafeRowCount = WorldDeferredUnsafeRowCount
            });
        }

        public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
            LegacyComparisonStoredTargetReference target,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ResolveRequests.Add(target);
            _resolved.TryGetValue(target, out var resolved);
            return Task.FromResult(resolved);
        }
    }

    private sealed class FakePairStore : ILegacyComparisonPairStore
    {
        public byte[]? Bytes { get; init; }

        public bool ThrowOnWrite { get; init; }

        public List<IReadOnlyList<LegacyComparisonPairWriteRow>> Writes { get; } = [];

        public Task<byte[]?> ReadCp949Async(CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            return Task.FromResult(Bytes?.ToArray());
        }

        public Task WriteCp949Async(
            IReadOnlyList<LegacyComparisonPairWriteRow> rows,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (ThrowOnWrite)
            {
                throw new IOException("Synthetic comparison store failure.");
            }

            Writes.Add(Array.AsReadOnly(rows.ToArray()));
            return Task.CompletedTask;
        }
    }

    private sealed class FakeImporter(LegacyComparisonImportResult result)
        : ILegacyComparisonPairImportService
    {
        public int Calls { get; private set; }

        public Task<LegacyComparisonImportResult> ImportAsync(
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            Calls++;
            return Task.FromResult(result);
        }
    }
}
