using System.Globalization;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.LegacyApplication;

public enum LegacyOverseasSearchStatus
{
    Idle,
    Loading,
    Ready,
    Error
}

public readonly record struct LegacyOverseasMovingAverageSelection(bool Ma5, bool Ma20);

public sealed class LegacyOverseasIndustrySearchRow
{
    internal LegacyOverseasIndustrySearchRow(
        string selectionId,
        OverseasIndustryIndexItem identity)
    {
        SelectionId = selectionId;
        Identity = identity;
    }

    public string SelectionId { get; }

    public string KoreanName => Identity.KoreanName;

    public string InputName => Identity.InputName;

    public string Symbol => Identity.Symbol;

    public string NationCode => "US";

    public string ForeignDomesticCode => "0";

    internal OverseasIndustryIndexItem Identity { get; }
}

public sealed class LegacyOverseasStockSearchRow
{
    internal LegacyOverseasStockSearchRow(
        string selectionId,
        WorldStockSearchItem identity)
    {
        SelectionId = selectionId;
        Identity = identity;
    }

    public string SelectionId { get; }

    public string InputName => Identity.InputName;

    public string Symbol => Identity.Symbol;

    public string NationCode => Identity.NationCode;

    public string ForeignDomesticCode => "1";

    internal WorldStockSearchItem Identity { get; }
}

public sealed class LegacyOverseasIndustrySearchSnapshot
{
    internal LegacyOverseasIndustrySearchSnapshot(
        LegacyOverseasSearchStatus status,
        string query,
        DateTimeOffset? retrievedAt,
        IReadOnlyList<LegacyOverseasIndustrySearchRow> results,
        bool isTruncated,
        LegacyOverseasIndustrySearchRow? selected,
        string error)
    {
        Status = status;
        Query = query;
        RetrievedAt = retrievedAt;
        Results = results;
        IsTruncated = isTruncated;
        Selected = selected;
        Error = error;
    }

    public LegacyOverseasSearchStatus Status { get; }

    public string Query { get; }

    public DateTimeOffset? RetrievedAt { get; }

    public IReadOnlyList<LegacyOverseasIndustrySearchRow> Results { get; }

    public int ResultCount => Results.Count;

    public bool IsTruncated { get; }

    public LegacyOverseasIndustrySearchRow? Selected { get; }

    public string? SelectedId => Selected?.SelectionId;

    public string Error { get; }
}

public sealed class LegacyOverseasStockSearchSnapshot
{
    internal LegacyOverseasStockSearchSnapshot(
        LegacyOverseasSearchStatus status,
        string query,
        DateTimeOffset? retrievedAt,
        IReadOnlyList<LegacyOverseasStockSearchRow> results,
        bool isTruncated,
        int isolatedInvalidRowCount,
        LegacyOverseasStockSearchRow? selected,
        string error)
    {
        Status = status;
        Query = query;
        RetrievedAt = retrievedAt;
        Results = results;
        IsTruncated = isTruncated;
        IsolatedInvalidRowCount = isolatedInvalidRowCount;
        Selected = selected;
        Error = error;
    }

    public LegacyOverseasSearchStatus Status { get; }

    public string Query { get; }

    public DateTimeOffset? RetrievedAt { get; }

    public IReadOnlyList<LegacyOverseasStockSearchRow> Results { get; }

    public int ResultCount => Results.Count;

    public bool IsTruncated { get; }

    public int IsolatedInvalidRowCount { get; }

    public LegacyOverseasStockSearchRow? Selected { get; }

    public string? SelectedId => Selected?.SelectionId;

    public string Error { get; }
}

public sealed class LegacyOverseasWorkflowSnapshot
{
    internal LegacyOverseasWorkflowSnapshot(
        long revision,
        IReadOnlyList<LegacyOverseasFixedIndexTarget> fixedIndexTargets,
        LegacyOverseasFixedIndexTarget? selectedFixedIndex,
        LegacyOverseasIndustrySearchSnapshot industry,
        LegacyOverseasStockSearchSnapshot stock,
        LegacyOverseasMovingAverageSelection movingAverages,
        IReadOnlyList<LegacyOverseasActionDefinition> actions)
    {
        Revision = revision;
        FixedIndexTargets = fixedIndexTargets;
        SelectedFixedIndex = selectedFixedIndex;
        Industry = industry;
        Stock = stock;
        MovingAverages = movingAverages;
        Actions = actions;
    }

    public long Revision { get; }

    public IReadOnlyList<LegacyOverseasFixedIndexTarget> FixedIndexTargets { get; }

    public LegacyOverseasFixedIndexTarget? SelectedFixedIndex { get; }

    public LegacyOverseasIndustrySearchSnapshot Industry { get; }

    public LegacyOverseasStockSearchSnapshot Stock { get; }

    public LegacyOverseasMovingAverageSelection MovingAverages { get; }

    public IReadOnlyList<LegacyOverseasActionDefinition> Actions { get; }
}

public sealed record LegacyOverseasPlaylistSelection(
    string GroupCode,
    string Subject,
    string GraphicType,
    string Subtype,
    string DataCode);

/// <summary>Exactly the five visible legacy playlist columns for a UC5 row.</summary>
public sealed record LegacyOverseasPlaylistVisibleFields(
    string Market,
    string StockName,
    string GraphicType,
    string Subtype,
    string PageText);

public sealed record LegacyOverseasActionAvailability(bool IsAvailable, string? Reason);

public sealed record LegacyOverseasPlaylistDraft(
    string ActionId,
    string BuilderKey,
    string SceneAlias,
    IReadOnlyList<string> Aliases,
    int PageSize,
    string InitialPageText,
    string Title,
    string Detail,
    string Category,
    string Market,
    LegacyOverseasActionSource Source,
    LegacyOverseasPlaylistSelection Selection,
    LegacyOverseasPlaylistVisibleFields VisibleFields,
    LegacyOverseasMovingAverageSelection MovingAverages,
    bool IsEnabled,
    int FadeDuration);

public sealed class LegacyOverseasWorkflowException : Exception
{
    public LegacyOverseasWorkflowException(string message)
        : base(message)
    {
    }
}

/// <summary>
/// C# authority for the UC5 overseas selector. Search services return identities, while Web
/// receives generation-bound selection ids and closed action ids only. A slower response can
/// never replace the state established by a newer request of the same source.
/// </summary>
public sealed class LegacyOverseasSelectionWorkflow
{
    // UC5 itself did not impose a UI result cap. The database boundary remains
    // bounded, but use that boundary's full capacity and expose truncation.
    public const int DefaultMaximumResults = LegacyOverseasStockSearchService.MaximumResults;
    public const int MaximumResults = LegacyOverseasStockSearchService.MaximumResults;
    public const int MaximumQueryLength = 64;

    private const int DefaultFadeDuration = 6;
    private const string InitialPageText = "1/1";
    private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다.";
    private const string StockError = "해외종목 검색 결과를 안전하게 확인하지 못했습니다.";

    private static readonly IReadOnlyList<LegacyOverseasIndustrySearchRow> NoIndustryResults =
        Array.AsReadOnly(Array.Empty<LegacyOverseasIndustrySearchRow>());

    private static readonly IReadOnlyList<LegacyOverseasStockSearchRow> NoStockResults =
        Array.AsReadOnly(Array.Empty<LegacyOverseasStockSearchRow>());

    private readonly IOverseasIndustryIndexSearchService _industrySearchService;
    private readonly IOverseasStockSearchService _stockSearchService;
    private readonly object _sync = new();

    // FarPoint starts with ActiveRowIndex=-1, but its first AddRows call makes
    // row zero active. UC5_Load inserts the fixed indices one by one, so the
    // first index is immediately the active source for the period buttons.
    private LegacyOverseasFixedIndexTarget? _selectedFixedIndex =
        LegacyOverseasActionCatalog.FixedIndexTargets[0];
    private LegacyOverseasSearchStatus _industryStatus;
    private string _industryQuery = string.Empty;
    private DateTimeOffset? _industryRetrievedAt;
    private IReadOnlyList<LegacyOverseasIndustrySearchRow> _industryResults = NoIndustryResults;
    private bool _industryIsTruncated;
    private LegacyOverseasIndustrySearchRow? _selectedIndustry;
    private string _industryError = string.Empty;
    private long _industryGeneration;
    private LegacyOverseasSearchStatus _stockStatus;
    private string _stockQuery = string.Empty;
    private DateTimeOffset? _stockRetrievedAt;
    private IReadOnlyList<LegacyOverseasStockSearchRow> _stockResults = NoStockResults;
    private bool _stockIsTruncated;
    private int _stockIsolatedInvalidRowCount;
    private LegacyOverseasStockSearchRow? _selectedStock;
    private string _stockError = string.Empty;
    private long _stockGeneration;
    private LegacyOverseasMovingAverageSelection _movingAverages;
    private long _revision;

    public LegacyOverseasSelectionWorkflow(
        IOverseasIndustryIndexSearchService industrySearchService,
        IOverseasStockSearchService stockSearchService)
    {
        _industrySearchService = industrySearchService ??
            throw new ArgumentNullException(nameof(industrySearchService));
        _stockSearchService = stockSearchService ??
            throw new ArgumentNullException(nameof(stockSearchService));
    }

    public LegacyOverseasWorkflowSnapshot Current
    {
        get
        {
            lock (_sync)
            {
                return CreateSnapshot();
            }
        }
    }

    /// <summary>
    /// UC5 retains the selected symbol/nation identity when materializing a
    /// stock cut. Search hits are therefore actionable for every canonical
    /// two-letter foreign nation, not only the initial-list US/TW scope.
    /// </summary>
    public LegacyOverseasActionAvailability GetActionAvailability(string actionId)
    {
        var action = LegacyOverseasActionCatalog.FindAction(actionId)
            ?? throw new LegacyOverseasWorkflowException("The overseas action is not registered.");
        lock (_sync)
        {
            return action.Source switch
            {
                LegacyOverseasActionSource.FixedIndex when _selectedFixedIndex is null =>
                    new(false, "해외지수를 선택하십시오."),
                LegacyOverseasActionSource.Industry when _selectedIndustry is null =>
                    new(false, "해외업종을 선택하십시오."),
                LegacyOverseasActionSource.Stock when _selectedStock is null =>
                    new(false, "해외종목을 선택하십시오."),
                LegacyOverseasActionSource.Stock when _selectedStock!.NationCode.Length != 2 =>
                    new(false, "선택한 해외종목의 국가 코드는 두 글자여야 합니다."),
                _ => new(true, null)
            };
        }
    }

    /// <summary>
    /// Recreates UC5's per-control search and selection state while retaining the
    /// MainForm-owned moving-average checkboxes. Incrementing both generations makes
    /// results from the discarded control stale before they can commit.
    /// </summary>
    public LegacyOverseasWorkflowSnapshot ResetForTabActivation()
    {
        lock (_sync)
        {
            checked
            {
                _industryGeneration++;
                _stockGeneration++;
            }

            // Resetting the Web projection recreates UC5's populated fixed
            // index spread; retain FarPoint's first-row activation behavior.
            _selectedFixedIndex = LegacyOverseasActionCatalog.FixedIndexTargets[0];
            _industryStatus = LegacyOverseasSearchStatus.Idle;
            _industryQuery = string.Empty;
            _industryRetrievedAt = null;
            _industryResults = NoIndustryResults;
            _industryIsTruncated = false;
            _selectedIndustry = null;
            _industryError = string.Empty;
            _stockStatus = LegacyOverseasSearchStatus.Idle;
            _stockQuery = string.Empty;
            _stockRetrievedAt = null;
            _stockResults = NoStockResults;
            _stockIsTruncated = false;
            _stockIsolatedInvalidRowCount = 0;
            _selectedStock = null;
            _stockError = string.Empty;
            Touch();
            return CreateSnapshot();
        }
    }

    public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId)
    {
        var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId);
        lock (_sync)
        {
            if (!ReferenceEquals(_selectedFixedIndex, target))
            {
                _selectedFixedIndex = target;
                Touch();
            }

            return CreateSnapshot();
        }
    }

    public async Task<LegacyOverseasWorkflowSnapshot> SearchIndustriesAsync(
        string query,
        int maximumResults = DefaultMaximumResults,
        CancellationToken cancellationToken = default)
    {
        var normalizedQuery = NormalizeQuery(query);
        ValidateMaximumResults(maximumResults);
        cancellationToken.ThrowIfCancellationRequested();

        long generation;
        lock (_sync)
        {
            generation = checked(++_industryGeneration);
            _industryStatus = LegacyOverseasSearchStatus.Loading;
            _industryQuery = normalizedQuery;
            _industryRetrievedAt = null;
            _industryResults = NoIndustryResults;
            _industryIsTruncated = false;
            _selectedIndustry = null;
            _industryError = string.Empty;
            Touch();
        }

        try
        {
            var result = await _industrySearchService.SearchAsync(
                normalizedQuery,
                maximumResults,
                cancellationToken).ConfigureAwait(false);
            cancellationToken.ThrowIfCancellationRequested();

            lock (_sync)
            {
                if (generation != _industryGeneration)
                {
                    return CreateSnapshot();
                }
            }

            var validated = ValidateIndustryResult(
                result,
                normalizedQuery,
                maximumResults,
                generation);
            lock (_sync)
            {
                if (generation != _industryGeneration)
                {
                    return CreateSnapshot();
                }

                _industryStatus = LegacyOverseasSearchStatus.Ready;
                _industryRetrievedAt = result.RetrievedAt;
                _industryResults = validated;
                _industryIsTruncated = result.IsTruncated;
                // A first AddRows call in the original RowMode spread activates
                // row zero. The action buttons therefore use its hidden identity
                // without requiring a preliminary click.
                _selectedIndustry = validated.Count == 0 ? null : validated[0];
                _industryError = string.Empty;
                Touch();
                return CreateSnapshot();
            }
        }
        catch (OperationCanceledException)
        {
            lock (_sync)
            {
                if (generation == _industryGeneration)
                {
                    _industryStatus = LegacyOverseasSearchStatus.Idle;
                    _industryError = string.Empty;
                    Touch();
                }
            }

            throw;
        }
        catch
        {
            lock (_sync)
            {
                if (generation != _industryGeneration)
                {
                    return CreateSnapshot();
                }

                _industryStatus = LegacyOverseasSearchStatus.Error;
                _industryRetrievedAt = null;
                _industryResults = NoIndustryResults;
                _industryIsTruncated = false;
                _selectedIndustry = null;
                _industryError = IndustryError;
                Touch();
            }

            throw;
        }
    }

    public async Task<LegacyOverseasWorkflowSnapshot> SearchStocksAsync(
        string query,
        int maximumResults = DefaultMaximumResults,
        CancellationToken cancellationToken = default)
    {
        var normalizedQuery = NormalizeQuery(query);
        ValidateMaximumResults(maximumResults);
        cancellationToken.ThrowIfCancellationRequested();

        long generation;
        lock (_sync)
        {
            generation = checked(++_stockGeneration);
            _stockStatus = LegacyOverseasSearchStatus.Loading;
            _stockQuery = normalizedQuery;
            _stockRetrievedAt = null;
            _stockResults = NoStockResults;
            _stockIsTruncated = false;
            _stockIsolatedInvalidRowCount = 0;
            _selectedStock = null;
            _stockError = string.Empty;
            Touch();
        }

        try
        {
            var result = await _stockSearchService.SearchAsync(
                normalizedQuery,
                maximumResults,
                cancellationToken).ConfigureAwait(false);
            cancellationToken.ThrowIfCancellationRequested();

            lock (_sync)
            {
                if (generation != _stockGeneration)
                {
                    return CreateSnapshot();
                }
            }

            var validated = ValidateStockResult(
                result,
                normalizedQuery,
                maximumResults,
                generation);
            lock (_sync)
            {
                if (generation != _stockGeneration)
                {
                    return CreateSnapshot();
                }

                _stockStatus = LegacyOverseasSearchStatus.Ready;
                _stockRetrievedAt = result.RetrievedAt;
                _stockResults = validated;
                _stockIsTruncated = result.IsTruncated;
                _stockIsolatedInvalidRowCount = result.IsolatedInvalidRowCount;
                // Match UC5's FarPoint RowMode active-row transition after the
                // first result row is inserted.
                _selectedStock = validated.Count == 0 ? null : validated[0];
                _stockError = string.Empty;
                Touch();
                return CreateSnapshot();
            }
        }
        catch (OperationCanceledException)
        {
            lock (_sync)
            {
                if (generation == _stockGeneration)
                {
                    _stockStatus = LegacyOverseasSearchStatus.Idle;
                    _stockError = string.Empty;
                    Touch();
                }
            }

            throw;
        }
        catch
        {
            lock (_sync)
            {
                if (generation != _stockGeneration)
                {
                    return CreateSnapshot();
                }

                _stockStatus = LegacyOverseasSearchStatus.Error;
                _stockRetrievedAt = null;
                _stockResults = NoStockResults;
                _stockIsTruncated = false;
                _stockIsolatedInvalidRowCount = 0;
                _selectedStock = null;
                _stockError = StockError;
                Touch();
            }

            throw;
        }
    }

    public LegacyOverseasWorkflowSnapshot SelectIndustry(string selectionId)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
        lock (_sync)
        {
            var selected = _industryResults.FirstOrDefault(row =>
                string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
                ?? throw new KeyNotFoundException("The overseas-industry result is no longer current.");
            if (!ReferenceEquals(_selectedIndustry, selected))
            {
                _selectedIndustry = selected;
                Touch();
            }

            return CreateSnapshot();
        }
    }

    public LegacyOverseasWorkflowSnapshot SelectStock(string selectionId)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(selectionId);
        lock (_sync)
        {
            var selected = _stockResults.FirstOrDefault(row =>
                string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal))
                ?? throw new KeyNotFoundException("The overseas-stock result is no longer current.");
            if (!ReferenceEquals(_selectedStock, selected))
            {
                _selectedStock = selected;
                Touch();
            }

            return CreateSnapshot();
        }
    }

    public LegacyOverseasWorkflowSnapshot ClearFixedIndexSelection()
    {
        lock (_sync)
        {
            if (_selectedFixedIndex is not null)
            {
                _selectedFixedIndex = null;
                Touch();
            }

            return CreateSnapshot();
        }
    }

    public LegacyOverseasWorkflowSnapshot ClearIndustrySelection()
    {
        lock (_sync)
        {
            if (_selectedIndustry is not null)
            {
                _selectedIndustry = null;
                Touch();
            }

            return CreateSnapshot();
        }
    }

    public LegacyOverseasWorkflowSnapshot ClearStockSelection()
    {
        lock (_sync)
        {
            if (_selectedStock is not null)
            {
                _selectedStock = null;
                Touch();
            }

            return CreateSnapshot();
        }
    }

    public LegacyOverseasWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20)
    {
        var value = new LegacyOverseasMovingAverageSelection(ma5, ma20);
        lock (_sync)
        {
            if (_movingAverages != value)
            {
                _movingAverages = value;
                Touch();
            }

            return CreateSnapshot();
        }
    }

    /// <summary>
    /// Builds all playlist and scene-selection fields from current native state. The caller can
    /// select only a closed action id; it cannot supply aliases, data-code identities, paging,
    /// moving-average text, or fade values.
    /// </summary>
    public LegacyOverseasPlaylistDraft MaterializePlaylistDraft(string actionId)
    {
        ArgumentNullException.ThrowIfNull(actionId);
        var action = LegacyOverseasActionCatalog.FindAction(actionId)
            ?? throw new LegacyOverseasWorkflowException("The overseas action is not registered.");

        lock (_sync)
        {
            return action.Source switch
            {
                LegacyOverseasActionSource.FixedIndex => MaterializeFixedIndex(action),
                LegacyOverseasActionSource.Industry => MaterializeIndustry(action),
                LegacyOverseasActionSource.Stock => MaterializeStock(action),
                _ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.")
            };
        }
    }

    private LegacyOverseasPlaylistDraft MaterializeFixedIndex(
        LegacyOverseasActionDefinition action)
    {
        var target = _selectedFixedIndex ??
            throw new LegacyOverseasWorkflowException("Select a fixed overseas index first.");
        var movingAverages = MovingAveragesFor(action);
        return CreateDraft(
            action,
            target.DisplayName,
            "chart",
            new LegacyOverseasPlaylistSelection(
                "FOREIGN_INDEX",
                target.InputName,
                MovingAverageText(movingAverages),
                "PRICE",
                $"{target.Symbol}|{target.NationCode}|{target.ForeignDomesticCode}"),
            movingAverages);
    }

    private LegacyOverseasPlaylistDraft MaterializeIndustry(
        LegacyOverseasActionDefinition action)
    {
        var industry = _selectedIndustry ??
            throw new LegacyOverseasWorkflowException("Select a US overseas industry first.");
        return CreateDraft(
            action,
            industry.KoreanName,
            "plate",
            new LegacyOverseasPlaylistSelection(
                "FOREIGN_INDUSTRY",
                industry.KoreanName,
                "1열판기본",
                "CURRENT",
                $"{industry.InputName}|{industry.Symbol}|{industry.NationCode}|{industry.ForeignDomesticCode}"),
            default);
    }

    private LegacyOverseasPlaylistDraft MaterializeStock(
        LegacyOverseasActionDefinition action)
    {
        var stock = _selectedStock ??
            throw new LegacyOverseasWorkflowException("Select an overseas stock first.");
        var availability = GetActionAvailability(action.ActionId);
        if (!availability.IsAvailable)
        {
            throw new LegacyOverseasWorkflowException(availability.Reason!);
        }
        var movingAverages = MovingAveragesFor(action);
        var current = action.Kind == LegacyOverseasActionKind.Current;
        return CreateDraft(
            action,
            stock.InputName,
            current ? "stock" : "chart",
            new LegacyOverseasPlaylistSelection(
                "FOREIGN_STOCK",
                stock.InputName,
                current ? "1열판기본" : MovingAverageText(movingAverages),
                current ? "CURRENT" : "PRICE",
                $"{stock.Symbol}|{stock.NationCode}|{stock.ForeignDomesticCode}"),
            movingAverages);
    }

    private static LegacyOverseasPlaylistDraft CreateDraft(
        LegacyOverseasActionDefinition action,
        string title,
        string category,
        LegacyOverseasPlaylistSelection selection,
        LegacyOverseasMovingAverageSelection movingAverages) =>
        new(
            action.ActionId,
            action.BuilderKey,
            action.SceneAlias,
            action.Aliases,
            action.PageSize,
            InitialPageText,
            title,
            action.Label,
            category,
            "overseas",
            action.Source,
            selection,
            new LegacyOverseasPlaylistVisibleFields(
                action.Source switch
                {
                    LegacyOverseasActionSource.FixedIndex => "해외지수",
                    LegacyOverseasActionSource.Industry => "해외업종",
                    LegacyOverseasActionSource.Stock => "해외종목",
                    _ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.")
                },
                title,
                action.Kind == LegacyOverseasActionKind.Candle ? "캔들그래프" : "1열판기본",
                action.Kind == LegacyOverseasActionKind.Candle ? $"{action.PeriodDays}일" : string.Empty,
                InitialPageText),
            movingAverages,
            IsEnabled: true,
            FadeDuration: DefaultFadeDuration);

    private LegacyOverseasMovingAverageSelection MovingAveragesFor(
        LegacyOverseasActionDefinition action) =>
        action.SupportsMovingAverages ? _movingAverages : default;

    private static string MovingAverageText(LegacyOverseasMovingAverageSelection selection) =>
        selection switch
        {
            { Ma5: true, Ma20: true } => "MA5,MA20",
            { Ma5: true } => "MA5",
            { Ma20: true } => "MA20",
            _ => string.Empty
        };

    private LegacyOverseasWorkflowSnapshot CreateSnapshot() =>
        new(
            _revision,
            LegacyOverseasActionCatalog.FixedIndexTargets,
            _selectedFixedIndex,
            new LegacyOverseasIndustrySearchSnapshot(
                _industryStatus,
                _industryQuery,
                _industryRetrievedAt,
                _industryResults,
                _industryIsTruncated,
                _selectedIndustry,
                _industryError),
            new LegacyOverseasStockSearchSnapshot(
                _stockStatus,
                _stockQuery,
                _stockRetrievedAt,
                _stockResults,
                _stockIsTruncated,
                _stockIsolatedInvalidRowCount,
                _selectedStock,
                _stockError),
            _movingAverages,
            LegacyOverseasActionCatalog.Actions);

    private static IReadOnlyList<LegacyOverseasIndustrySearchRow> ValidateIndustryResult(
        OverseasIndustryIndexSearchResult? result,
        string expectedQuery,
        int maximumResults,
        long generation)
    {
        if (result is null || result.Items is null || result.RetrievedAt == default ||
            !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
        {
            throw InvalidData("industry search envelope");
        }

        var items = result.Items.ToArray();
        if (items.Length > maximumResults)
        {
            throw InvalidData("industry result bound");
        }

        var identities = new HashSet<OverseasIndustryIndexItem>();
        for (var index = 0; index < items.Length; index++)
        {
            var item = items[index] ?? throw InvalidData("industry identity");
            if (!IsSafeName(item.KoreanName) ||
                !IsSafeName(item.InputName) ||
                !IsSafeSymbol(item.Symbol) ||
                !identities.Add(item) ||
                index > 0 && CompareIndustry(items[index - 1], item) > 0)
            {
                throw InvalidData("industry identity or ordering");
            }
        }

        var rows = items.Select((item, index) =>
            new LegacyOverseasIndustrySearchRow(
                $"overseas-industry-{generation:D8}-{index + 1:D4}",
                item));
        return Array.AsReadOnly(rows.ToArray());
    }

    private static IReadOnlyList<LegacyOverseasStockSearchRow> ValidateStockResult(
        WorldStockSearchResult? result,
        string expectedQuery,
        int maximumResults,
        long generation)
    {
        if (result is null || result.Items is null || result.RetrievedAt == default ||
            !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
            result.IsolatedInvalidRowCount < 0 ||
            result.IsolatedInvalidRowCount >
                LegacyOverseasStockSearchService.MaximumIsolatedRows + 1 ||
            result.IsolatedInvalidRowCount > 0 && !result.IsTruncated)
        {
            throw InvalidData("stock search envelope");
        }

        var items = result.Items.ToArray();
        if (items.Length > maximumResults)
        {
            throw InvalidData("stock result bound");
        }

        var identities = new HashSet<WorldStockSearchItem>();
        for (var index = 0; index < items.Length; index++)
        {
            var item = items[index] ?? throw InvalidData("stock identity");
            if (!IsSafeName(item.InputName) ||
                !IsSafeSymbol(item.Symbol) ||
                !IsSafeNationCode(item.NationCode) ||
                !identities.Add(item))
            {
                throw InvalidData("stock identity or ordering");
            }
        }

        var rows = items.Select((item, index) =>
            new LegacyOverseasStockSearchRow(
                $"overseas-stock-{generation:D8}-{index + 1:D4}",
                item));
        return Array.AsReadOnly(rows.ToArray());
    }

    private static int CompareIndustry(
        OverseasIndustryIndexItem left,
        OverseasIndustryIndexItem right)
    {
        var comparison = CompareUpper(left.KoreanName, right.KoreanName);
        if (comparison != 0)
        {
            return comparison;
        }

        comparison = string.Compare(left.InputName, right.InputName, StringComparison.Ordinal);
        return comparison != 0
            ? comparison
            : string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal);
    }

    private static int CompareUpper(string left, string right) =>
        string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal);

    private static bool IsSafeNationCode(string value) =>
        value.Length is 2 or 3 &&
        value.All(character => character is >= 'A' and <= 'Z');

    private static string NormalizeQuery(string query)
    {
        ArgumentNullException.ThrowIfNull(query);
        if (!IsSafeText(query))
        {
            throw new ArgumentException("The overseas search query is invalid.", nameof(query));
        }

        var normalized = query.Trim();
        if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized))
        {
            throw new ArgumentException(
                $"The overseas search query must contain at most {MaximumQueryLength} safe characters.",
                nameof(query));
        }

        return normalized;
    }

    private static void ValidateMaximumResults(int maximumResults)
    {
        if (maximumResults is < 1 or > MaximumResults)
        {
            throw new ArgumentOutOfRangeException(
                nameof(maximumResults),
                $"Overseas results must be limited from 1 through {MaximumResults}.");
        }
    }

    private static bool IsSafeName(string value) =>
        value.Length is > 0 and <= 200 &&
        string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
        !value.Contains('|', StringComparison.Ordinal) &&
        IsSafeText(value);

    private static bool IsSafeSymbol(string value) =>
        LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(value);

    private static bool IsSafeText(string value)
    {
        foreach (var character in value)
        {
            var category = CharUnicodeInfo.GetUnicodeCategory(character);
            if (char.IsControl(character) || char.IsSurrogate(character) ||
                character == '\uFFFD' ||
                category is UnicodeCategory.Format or
                    UnicodeCategory.LineSeparator or
                    UnicodeCategory.ParagraphSeparator)
            {
                return false;
            }
        }

        return true;
    }

    private static LegacyOverseasWorkflowException InvalidData(string detail) =>
        new($"The overseas selector returned an invalid {detail}.");

    private void Touch() => _revision = checked(_revision + 1);
}
