using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Infrastructure;
using Microsoft.UI.Windowing;
using Microsoft.Web.WebView2.Core;
using MMoneyCoderSharp.Data;
using Windows.Graphics;
using Windows.System;
using WinRT.Interop;

namespace MBN_STOCK_WEBVIEW;

public sealed partial class MainWindow : Window
{
    private const string AppHost = "app.mbn.local";
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
    private readonly CancellationTokenSource _lifetimeCancellation = new();
    private readonly SemaphoreSlim _databaseActivityGate = new(1, 1);
    private DatabaseRuntime? _databaseRuntime;
    private IMarketDataService? _marketDataService;
    private IStockSearchService? _stockSearchService;
    private IWorldStockSearchService? _worldStockSearchService;
    private IOverseasIndustryIndexSearchService? _overseasIndustryIndexSearchService;
    private IThemeSelectionService? _themeSelectionService;
    private IIndustrySelectionService? _industrySelectionService;
    private ITradingHaltSelectionService? _tradingHaltSelectionService;
    private IExpertSelectionService? _expertSelectionService;
    private INamedPlaylistPersistenceService? _namedPlaylistPersistenceService;
    private CancellationTokenSource? _marketDataCancellation;
    private CancellationTokenSource? _stockSearchCancellation;
    private CancellationTokenSource? _worldStockSearchCancellation;
    private CancellationTokenSource? _overseasIndustrySearchCancellation;
    private CancellationTokenSource? _themeSelectionCancellation;
    private CancellationTokenSource? _industrySelectionCancellation;
    private CancellationTokenSource? _tradingHaltSelectionCancellation;
    private CancellationTokenSource? _expertSelectionCancellation;
    private CancellationTokenSource? _namedPlaylistReadCancellation;
    private CancellationTokenSource? _namedPlaylistWriteCancellation;
    private CancellationTokenSource? _playlistPagePlanCancellation;
    private CancellationTokenSource? _databaseHealthCancellation;
    private readonly SemaphoreSlim _namedPlaylistWriteGate = new(1, 1);
    private int _namedPlaylistWriteInFlight;
    private int _namedPlaylistWriteQuarantined;
    private int _namedPlaylistBrowserGeneration;
    private int _playlistPagePlanBrowserGeneration;
    private string? _namedPlaylistWriteQuarantineMessage;
    private long _databaseStatusSequence;
    private string? _databaseInitializationError;
    private bool _webViewReady;

    public MainWindow()
    {
        InitializeComponent();
        LogNativeOperatorStartup();
        EnsureCloseConfirmationAttached();
        InitializeManualOperatorDataRuntime();
        InitializeDatabaseRuntime();
        InitializePlayoutRuntime();
        Root.Loaded += OnRootLoaded;
        Closed += OnClosed;
    }

    private void InitializeDatabaseRuntime()
    {
        try
        {
            _databaseRuntime = DatabaseRuntime.CreateDefault();
            DataQueryExecutor.Configure(_databaseRuntime.Executor);
            _marketDataService = new LegacyMarketDataService(_databaseRuntime.Executor);
            _stockSearchService = new LegacyStockSearchService(_databaseRuntime.Executor);
            _worldStockSearchService = new LegacyWorldStockSearchService(_databaseRuntime.Executor);
            _overseasIndustryIndexSearchService =
                new LegacyOverseasIndustryIndexSearchService(_databaseRuntime.Executor);
            _themeSelectionService = new LegacyThemeSelectionService(_databaseRuntime.Executor);
            _industrySelectionService = new LegacyIndustrySelectionService(_databaseRuntime.Executor);
            _tradingHaltSelectionService = new LegacyTradingHaltSelectionService(
                _databaseRuntime.Executor);
            _expertSelectionService = new LegacyExpertSelectionService(_databaseRuntime.Executor);
            _namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
                _databaseRuntime.Executor,
                new OracleNamedPlaylistMutationExecutor(
                    _databaseRuntime.ConnectionFactory,
                    _databaseRuntime.Options.Resilience));
            InitializeOperatorCatalogRuntime();
            InitializeManualFinancialRuntime();
        }
        catch (DatabaseOperationException exception)
        {
            _databaseInitializationError = exception.Message;
            DataQueryExecutor.Reset();
        }
        catch
        {
            _databaseInitializationError = "데이터베이스 설정을 초기화하지 못했습니다. 로컬 설정 파일을 확인하세요.";
            DataQueryExecutor.Reset();
        }
    }

    private async void OnRootLoaded(object sender, RoutedEventArgs e)
    {
        Root.Loaded -= OnRootLoaded;
        EnsureCloseConfirmationAttached();
        ResizeWindow();
        await InitializeWebViewAsync();

        if (_webViewReady)
        {
            _ = MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
            _ = ConnectPlayoutAsync(_lifetimeCancellation.Token);
        }
    }

    private void ResizeWindow()
    {
        try
        {
            var windowHandle = WindowNative.GetWindowHandle(this);
            var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
            AppWindow.GetFromWindowId(windowId).Resize(new SizeInt32(1600, 960));
        }
        catch
        {
            // The system-selected size is still usable if sizing is unavailable.
        }
    }

    private async Task InitializeWebViewAsync()
    {
        try
        {
            var webRoot = Path.Combine(AppContext.BaseDirectory, "Web");
            var startPage = Path.Combine(webRoot, "index.html");

            if (!File.Exists(startPage))
            {
                throw new FileNotFoundException("The packaged Web UI was not found.", startPage);
            }

            await Browser.EnsureCoreWebView2Async();

            Browser.CoreWebView2.SetVirtualHostNameToFolderMapping(
                AppHost,
                webRoot,
                CoreWebView2HostResourceAccessKind.DenyCors);

            Browser.CoreWebView2.Settings.AreDevToolsEnabled = Debugger.IsAttached;
            Browser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = Debugger.IsAttached;
            Browser.CoreWebView2.Settings.IsStatusBarEnabled = false;

            Browser.CoreWebView2.NavigationStarting += OnNavigationStarting;
            Browser.CoreWebView2.NavigationCompleted += OnNavigationCompleted;
            Browser.CoreWebView2.HistoryChanged += OnHistoryChanged;
            Browser.CoreWebView2.NewWindowRequested += OnNewWindowRequested;
            Browser.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
            Browser.CoreWebView2.ProcessFailed += OnProcessFailed;

            _webViewReady = true;
            Browser.Source = new Uri($"https://{AppHost}/index.html");
        }
        catch (Exception exception)
        {
            ShowError("WebView2 초기화에 실패했습니다.", exception.Message);
        }
    }

    private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args)
    {
        if (!Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri))
        {
            args.Cancel = true;
            return;
        }

        if (PlayoutBridgeProtocol.IsTrustedSource(uri.AbsoluteUri, AppHost) ||
            uri.Scheme == "about")
        {
            QuarantineIfBrowserCorrelationCanBeLost();
            LoadingOverlay.Visibility = Visibility.Visible;
            return;
        }

        args.Cancel = true;
        _ = OpenExternalUriAsync(uri);
    }

    private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args)
    {
        LoadingOverlay.Visibility = Visibility.Collapsed;

        if (!args.IsSuccess)
        {
            ShowError("운영 화면을 불러오지 못했습니다.", args.WebErrorStatus.ToString());
        }
    }

    private void OnHistoryChanged(object? sender, object args)
    {
        BackButton.IsEnabled = Browser.CanGoBack;
        ForwardButton.IsEnabled = Browser.CanGoForward;
    }

    private void OnNewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs args)
    {
        args.Handled = true;

        if (Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri))
        {
            _ = OpenExternalUriAsync(uri);
        }
    }

    private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args)
    {
        if (!PlayoutBridgeProtocol.IsTrustedSource(args.Source, AppHost))
        {
            return;
        }

        try
        {
            var request = JsonSerializer.Deserialize<WebRequest>(args.WebMessageAsJson, JsonOptions);

            if (request is not null &&
                TryHandleOperatorCatalogRequest(request.Type, request.Payload))
            {
                return;
            }
            if (request is not null &&
                TryHandleManualFinancialRequest(request.Type, request.Payload))
            {
                return;
            }
            if (request is not null &&
                TryHandleLegacyComparisonImportRequest(request.Type, request.Payload))
            {
                return;
            }

            switch (request?.Type)
            {
                case "ready":
                case "request-app-info":
                    PostAppInfo();
                    _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
                    PostPlayoutStatus();
                    break;
                case "request-database-status":
                    _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
                    break;
                case "request-playout-status":
                    HandlePlayoutStatusRequest(request.Payload);
                    break;
                case "playout-command":
                    _ = HandlePlayoutCommandAsync(request.Payload);
                    break;
                case "playout-timeout-quarantine":
                    _ = HandlePlayoutTimeoutQuarantineAsync(request.Payload);
                    break;
                case "request-playlist-page-plans" when
                    request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandlePlaylistPagePlansRequestAsync(request.Payload);
                    break;
                case "request-playlist-page-plans":
                    PostPlaylistPagePlansError(
                        string.Empty,
                        "INVALID_REQUEST",
                        "The playlist page-plan request payload is invalid.",
                        retryable: false);
                    break;
                case "request-background-status":
                    HandleBackgroundStatusRequest(request.Payload);
                    break;
                case "choose-background":
                    _ = HandleChooseBackgroundAsync(request.Payload);
                    break;
                case "toggle-background":
                    _ = HandleToggleBackgroundAsync(request.Payload);
                    break;
                case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleMarketDataRequestAsync(request.Payload);
                    break;
                case "request-market-data":
                    PostMessage("market-data-error", new
                    {
                        requestId = string.Empty,
                        view = string.Empty,
                        message = "The market-data request payload is invalid."
                    });
                    break;
                case "search-stocks" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleStockSearchRequestAsync(request.Payload);
                    break;
                case "search-stocks":
                    PostStockSearchError(
                        string.Empty,
                        string.Empty,
                        "The stock-search request payload is invalid.");
                    break;
                case "search-world-stocks" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleWorldStockSearchRequestAsync(request.Payload);
                    break;
                case "search-world-stocks":
                    PostWorldStockSearchError(
                        string.Empty,
                        string.Empty,
                        "올바르지 않은 해외종목 검색 요청입니다.");
                    break;
                case "search-overseas-industries" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleOverseasIndustrySearchRequestAsync(request.Payload);
                    break;
                case "search-overseas-industries":
                    PostOverseasIndustrySearchError(
                        string.Empty,
                        string.Empty,
                        "올바르지 않은 미국 해외업종 검색 요청입니다.");
                    break;
                case "search-themes" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleThemeSearchRequestAsync(request.Payload);
                    break;
                case "search-themes":
                    PostThemeSearchError(
                        string.Empty,
                        string.Empty,
                        string.Empty,
                        "The theme-search request payload is invalid.");
                    break;
                case "request-theme-preview" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleThemePreviewRequestAsync(request.Payload);
                    break;
                case "request-theme-preview":
                    PostThemePreviewError(
                        string.Empty,
                        string.Empty,
                        string.Empty,
                        string.Empty,
                        string.Empty,
                        "The theme-preview request payload is invalid.");
                    break;
                case "search-experts" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleExpertSearchRequestAsync(request.Payload);
                    break;
                case "search-experts":
                    PostExpertSearchError(
                        string.Empty,
                        string.Empty,
                        "올바르지 않은 전문가 검색 요청입니다.");
                    break;
                case "request-expert-preview" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleExpertPreviewRequestAsync(request.Payload);
                    break;
                case "request-expert-preview":
                    PostExpertPreviewError(
                        string.Empty,
                        string.Empty,
                        string.Empty,
                        "올바르지 않은 전문가 미리보기 요청입니다.");
                    break;
                case "request-industries" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleIndustrySelectionRequestAsync(request.Payload);
                    break;
                case "request-industries":
                    PostIndustrySelectionError(
                        string.Empty,
                        string.Empty,
                        "The industry request payload is invalid.");
                    break;
                case "search-trading-halts" when request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleTradingHaltSelectionRequestAsync(request.Payload);
                    break;
                case "search-trading-halts":
                    PostTradingHaltSelectionError(
                        string.Empty,
                        string.Empty,
                        "올바르지 않은 거래정지 종목 검색 요청입니다.");
                    break;
                case "request-manual-operator-data-status":
                    HandleManualOperatorStatusRequest(request.Payload);
                    break;
                case "import-legacy-manual-operator-data":
                    _ = HandleLegacyManualOperatorImportAsync(request.Payload);
                    break;
                case "request-manual-net-sell-data":
                    _ = HandleManualNetSellReadAsync(request.Payload);
                    break;
                case "save-manual-net-sell-data":
                    _ = HandleManualNetSellWriteAsync(request.Payload);
                    break;
                case "request-vi-manual-list":
                    _ = HandleViManualListReadAsync(request.Payload);
                    break;
                case "save-vi-manual-list":
                    _ = HandleViManualListWriteAsync(request.Payload);
                    break;
                case "request-named-playlist-list" when
                    request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleNamedPlaylistListRequestAsync(request.Payload);
                    break;
                case "request-named-playlist-load" when
                    request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleNamedPlaylistLoadRequestAsync(request.Payload);
                    break;
                case "create-named-playlist" when
                    request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleNamedPlaylistCreateRequestAsync(request.Payload);
                    break;
                case "replace-named-playlist" when
                    request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleNamedPlaylistReplaceRequestAsync(request.Payload);
                    break;
                case "delete-named-playlist" when
                    request.Payload.ValueKind == JsonValueKind.Object:
                    _ = HandleNamedPlaylistDeleteRequestAsync(request.Payload);
                    break;
                case "request-named-playlist-list":
                case "request-named-playlist-load":
                case "create-named-playlist":
                case "replace-named-playlist":
                case "delete-named-playlist":
                    PostNamedPlaylistError(
                        string.Empty,
                        "unknown",
                        "INVALID_REQUEST",
                        "The named-playlist request payload is invalid.",
                        retryable: false,
                        outcomeUnknown: false);
                    break;
            }
        }
        catch (JsonException exception)
        {
            PostMessage("bridge-error", new { message = exception.Message });
        }
        catch
        {
            PostMessage("bridge-error", new { message = "요청 메시지를 처리하지 못했습니다." });
        }
    }

    private async Task HandleMarketDataRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId");
        var viewName = GetString(payload, "view");

        if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 128 ||
            !TryParseMarketDataView(viewName, out var view))
        {
            PostMessage("market-data-error", new
            {
                requestId = requestId ?? string.Empty,
                view = viewName ?? string.Empty,
                message = "지원하지 않는 시장 데이터 요청입니다."
            });
            return;
        }

        var maximumRows = 250;
        if (payload.TryGetProperty("maximumRowsPerTable", out var rowLimit) &&
            rowLimit.ValueKind == JsonValueKind.Number &&
            rowLimit.TryGetInt32(out var requestedRows))
        {
            maximumRows = requestedRows;
        }

        if (maximumRows is < 1 or > 2_000)
        {
            PostMessage("market-data-error", new
            {
                requestId,
                view = viewName,
                message = "한 번에 조회할 수 있는 행 수는 1~2,000개입니다."
            });
            return;
        }

        if (_marketDataService is null)
        {
            PostMessage("market-data-error", new
            {
                requestId,
                view = viewName,
                message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."
            });
            return;
        }

        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(ref _marketDataCancellation, requestCancellation);
        previousCancellation?.Cancel();

        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();

            // Close the race where playout starts after this request is published
            // but before its market query begins.
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                return;
            }

            var snapshot = await _marketDataService
                .GetAsync(view, maximumRows, requestToken);
            requestToken.ThrowIfCancellationRequested();

            PostMessage("market-data", new
            {
                requestId,
                view = ToWireValue(snapshot.View),
                snapshot.RetrievedAt,
                tables = snapshot.Tables.Select(table => new
                {
                    table.Name,
                    source = ToWireValue(table.Source),
                    table.Columns,
                    table.Rows,
                    table.TotalRowCount
                })
            });
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityMarketDataCancellationIfCurrent(
                requestId,
                view,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            // Some providers surface a provider exception instead of cancellation after
            // a command has already taken priority. Never publish that stale failure.
            PostPlayoutPriorityMarketDataCancellationIfCurrent(
                requestId,
                view,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityMarketDataCancellationIfCurrent(
                requestId,
                view,
                requestCancellation);
        }
        catch (DatabaseOperationException exception)
        {
            PostMarketDataError(requestId, view, exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            PostMarketDataError(requestId, view, "시장 데이터를 조회하지 못했습니다. DB 연결 상태를 확인하세요.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _marketDataCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityMarketDataCancellationIfCurrent(
        string requestId,
        MarketDataView view,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _marketDataCancellation),
                requestCancellation))
        {
            // App shutdown or a newer navigation request superseded this response.
            return;
        }

        PostMarketDataError(
            requestId,
            view,
            "송출 명령과 자동 갱신을 우선 처리하기 위해 조회를 취소했습니다. 다시 조회하세요.");
    }

    private void PostMarketDataError(string requestId, MarketDataView view, string message)
    {
        PostMessage("market-data-error", new
        {
            requestId,
            view = ToWireValue(view),
            message
        });
    }

    private async Task HandleStockSearchRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var query = GetString(payload, "query") ?? string.Empty;
        if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") ||
            !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId))
        {
            PostStockSearchError(requestId, query, "올바르지 않은 종목 검색 요청입니다.");
            return;
        }

        var maximumResults = LegacyStockSearchService.DefaultMaximumResults;
        if (payload.TryGetProperty("maximumResults", out var requestedLimit))
        {
            if (requestedLimit.ValueKind != JsonValueKind.Number ||
                !requestedLimit.TryGetInt32(out maximumResults))
            {
                PostStockSearchError(requestId, query, "검색 결과 제한값이 올바르지 않습니다.");
                return;
            }
        }

        if (_stockSearchService is null)
        {
            PostStockSearchError(
                requestId,
                query,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(
            ref _stockSearchCancellation,
            requestCancellation);
        previousCancellation?.Cancel();

        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityStockSearchCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();

            // A playout command always wins the race for the shared DB executor.
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityStockSearchCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            var result = await _stockSearchService.SearchAsync(
                query,
                maximumResults,
                requestToken);
            requestToken.ThrowIfCancellationRequested();

            PostMessage("stock-search-results", new
            {
                requestId,
                query = result.Query,
                result.RetrievedAt,
                totalRowCount = result.Items.Count,
                truncated = result.IsTruncated,
                results = result.Items.Select(item => new
                {
                    market = ToWireValue(item.Market),
                    source = ToWireValue(item.Source),
                    name = item.Name,
                    code = item.Code
                })
            });
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityStockSearchCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            // A canceled provider may surface its native exception. Do not publish
            // stale provider details after playout or a newer search took priority.
            PostPlayoutPriorityStockSearchCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityStockSearchCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (ArgumentException)
        {
            PostStockSearchError(
                requestId,
                query,
                $"검색어는 공백이 아닌 {LegacyStockSearchService.MaximumQueryLength}자 이하여야 하며 결과 제한은 1~{LegacyStockSearchService.MaximumResults}개입니다.");
        }
        catch (StockSearchDataException)
        {
            PostStockSearchError(requestId, query, "종목 검색 결과 형식이 올바르지 않습니다.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch (DatabaseOperationException exception)
        {
            PostStockSearchError(requestId, query, exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            PostStockSearchError(
                requestId,
                query,
                "종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _stockSearchCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityStockSearchCancellationIfCurrent(
        string requestId,
        string query,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _stockSearchCancellation),
                requestCancellation))
        {
            // Shutdown or a newer stock search superseded this response.
            return;
        }

        PostStockSearchError(
            requestId,
            query,
            "송출 명령과 자동 갱신을 우선 처리하기 위해 검색을 취소했습니다. 다시 검색하세요.");
    }

    private void PostStockSearchError(string requestId, string query, string message)
    {
        PostMessage("stock-search-error", new
        {
            requestId,
            query,
            message
        });
    }

    private async Task HandleWorldStockSearchRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var query = GetString(payload, "query") ?? string.Empty;
        if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") ||
            !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
            !payload.TryGetProperty("query", out var queryElement) ||
            queryElement.ValueKind != JsonValueKind.String)
        {
            PostWorldStockSearchError(
                requestId,
                query,
                "올바르지 않은 해외종목 검색 요청입니다.");
            return;
        }

        var maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults;
        if (payload.TryGetProperty("maximumResults", out var requestedLimit))
        {
            if (requestedLimit.ValueKind != JsonValueKind.Number ||
                !requestedLimit.TryGetInt32(out maximumResults))
            {
                PostWorldStockSearchError(
                    requestId,
                    query,
                    "검색 결과 제한값이 올바르지 않습니다.");
                return;
            }
        }

        if (_worldStockSearchService is null)
        {
            PostWorldStockSearchError(
                requestId,
                query,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(
            ref _worldStockSearchCancellation,
            requestCancellation);
        previousCancellation?.Cancel();

        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityWorldStockSearchCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();

            // Playout commands always take priority over operator lookup reads.
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityWorldStockSearchCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            var result = await _worldStockSearchService.SearchAsync(
                query,
                maximumResults,
                requestToken);
            requestToken.ThrowIfCancellationRequested();

            PostMessage("world-stock-search-results", new
            {
                requestId,
                query = result.Query,
                result.RetrievedAt,
                totalRowCount = result.Items.Count,
                truncated = result.IsTruncated,
                results = result.Items.Select(item => new
                {
                    inputName = item.InputName,
                    symbol = item.Symbol,
                    nationCode = item.NationCode,
                    fdtc = "1"
                })
            });
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityWorldStockSearchCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            // Providers can surface a native error after cancellation. Suppress
            // stale details when playout or a newer lookup owns the DB slot.
            PostPlayoutPriorityWorldStockSearchCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityWorldStockSearchCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (ArgumentException)
        {
            PostWorldStockSearchError(
                requestId,
                query,
                $"검색어는 공백이 아닌 {LegacyWorldStockSearchService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyWorldStockSearchService.MaximumResults}개여야 합니다.");
        }
        catch (WorldStockSearchDataException)
        {
            PostWorldStockSearchError(
                requestId,
                query,
                "해외종목 검색 결과의 형식 또는 식별자가 올바르지 않습니다.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch (DatabaseOperationException exception)
        {
            PostWorldStockSearchError(requestId, query, exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            PostWorldStockSearchError(
                requestId,
                query,
                "해외종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _worldStockSearchCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityWorldStockSearchCancellationIfCurrent(
        string requestId,
        string query,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _worldStockSearchCancellation),
                requestCancellation))
        {
            // Shutdown or a newer world-stock search superseded this response.
            return;
        }

        PostWorldStockSearchError(
            requestId,
            query,
            "송출 명령과 자동 갱신을 우선 처리하기 위해 해외종목 검색을 취소했습니다. 다시 검색하세요.");
    }

    private void PostWorldStockSearchError(string requestId, string query, string message)
    {
        PostMessage("world-stock-search-error", new
        {
            requestId,
            query,
            message
        });
    }

    private async Task HandleOverseasIndustrySearchRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var query = GetString(payload, "query") ?? string.Empty;
        if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") ||
            !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
            !payload.TryGetProperty("query", out var queryElement) ||
            queryElement.ValueKind != JsonValueKind.String)
        {
            PostOverseasIndustrySearchError(
                requestId,
                query,
                "올바르지 않은 미국 해외업종 검색 요청입니다.");
            return;
        }

        var maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults;
        if (payload.TryGetProperty("maximumResults", out var requestedLimit) &&
            (requestedLimit.ValueKind != JsonValueKind.Number ||
             !requestedLimit.TryGetInt32(out maximumResults)))
        {
            PostOverseasIndustrySearchError(
                requestId,
                query,
                "해외업종 검색 결과 제한값이 올바르지 않습니다.");
            return;
        }

        var service = _overseasIndustryIndexSearchService;
        if (service is null)
        {
            PostOverseasIndustrySearchError(
                requestId,
                query,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(
            ref _overseasIndustrySearchCancellation,
            requestCancellation);
        previousCancellation?.Cancel();

        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityOverseasIndustryCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();

            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityOverseasIndustryCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            var result = await service.SearchAsync(query, maximumResults, requestToken);
            requestToken.ThrowIfCancellationRequested();
            PostMessage("overseas-industry-results", new
            {
                requestId,
                query = result.Query,
                result.RetrievedAt,
                totalRowCount = result.Items.Count,
                truncated = result.IsTruncated,
                results = result.Items.Select(item => new
                {
                    koreanName = item.KoreanName,
                    inputName = item.InputName,
                    symbol = item.Symbol,
                    nationCode = "US",
                    fdtc = "0"
                }).ToArray()
            });
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityOverseasIndustryCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityOverseasIndustryCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityOverseasIndustryCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (ArgumentException)
        {
            PostOverseasIndustrySearchError(
                requestId,
                query,
                $"검색어는 {LegacyOverseasIndustryIndexSearchService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyOverseasIndustryIndexSearchService.MaximumResults}개여야 합니다.");
        }
        catch (OverseasIndustryIndexSearchDataException)
        {
            PostOverseasIndustrySearchError(
                requestId,
                query,
                "미국 해외업종 검색 결과의 스키마 또는 식별자가 올바르지 않습니다.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch (DatabaseOperationException exception)
        {
            PostOverseasIndustrySearchError(requestId, query, exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            PostOverseasIndustrySearchError(
                requestId,
                query,
                "미국 해외업종을 검색하지 못했습니다. DB 연결 상태를 확인하세요.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _overseasIndustrySearchCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityOverseasIndustryCancellationIfCurrent(
        string requestId,
        string query,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _overseasIndustrySearchCancellation),
                requestCancellation))
        {
            // Shutdown or a newer overseas-industry lookup owns the response.
            return;
        }

        PostOverseasIndustrySearchError(
            requestId,
            query,
            "송출 명령과 자동 갱신을 우선 처리하기 위해 미국 해외업종 검색을 취소했습니다. 다시 검색하세요.");
    }

    private void PostOverseasIndustrySearchError(
        string requestId,
        string query,
        string message)
    {
        PostMessage("overseas-industry-error", new
        {
            requestId,
            query,
            message
        });
    }

    private async Task HandleThemeSearchRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var query = GetString(payload, "query") ?? string.Empty;
        var nxtSessionValue = GetString(payload, "nxtSession") ?? string.Empty;
        if (!HasOnlyProperties(
                payload,
                "requestId",
                "query",
                "nxtSession",
                "maximumResults") ||
            !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
            !payload.TryGetProperty("query", out var queryElement) ||
            queryElement.ValueKind != JsonValueKind.String ||
            !TryParseThemeNxtSession(nxtSessionValue, out var nxtSession))
        {
            PostThemeSearchError(
                requestId,
                query,
                nxtSessionValue,
                "올바르지 않은 테마 검색 요청입니다.");
            return;
        }

        var maximumResults = LegacyThemeSelectionService.DefaultMaximumResults;
        if (payload.TryGetProperty("maximumResults", out var requestedLimit) &&
            (requestedLimit.ValueKind != JsonValueKind.Number ||
             !requestedLimit.TryGetInt32(out maximumResults)))
        {
            PostThemeSearchError(
                requestId,
                query,
                nxtSessionValue,
                "테마 검색 결과 제한값이 올바르지 않습니다.");
            return;
        }

        var service = _themeSelectionService;
        if (service is null)
        {
            PostThemeSearchError(
                requestId,
                query,
                nxtSessionValue,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        await ExecuteThemeSelectionRequestAsync(
            async requestToken =>
            {
                var result = await service.SearchAsync(
                    query,
                    nxtSession,
                    maximumResults,
                    requestToken);
                requestToken.ThrowIfCancellationRequested();
                PostMessage("theme-search-results", new
                {
                    requestId,
                    query = result.Query,
                    nxtSession = ToWireValue(result.NxtSession),
                    result.RetrievedAt,
                    totalRowCount = result.Items.Count,
                    truncated = result.IsTruncated,
                    results = result.Items.Select(item => new
                    {
                        market = ToWireValue(item.Identity.Market),
                        session = ToWireValue(item.Identity.Session),
                        themeCode = item.Identity.ThemeCode,
                        title = item.Identity.ThemeTitle,
                        displayTitle = item.Identity.Market == ThemeMarket.Nxt
                            ? $"{item.Identity.ThemeTitle}(NXT)"
                            : item.Identity.ThemeTitle,
                        source = ToWireValue(item.Source)
                    }).ToArray()
                });
            },
            message => PostThemeSearchError(
                requestId,
                query,
                nxtSessionValue,
                message),
            $"검색어는 {LegacyThemeSelectionService.MaximumQueryLength}자 이하이고 결과 제한은 1~{LegacyThemeSelectionService.MaximumResults}개여야 합니다.",
            "테마 검색 결과의 스키마 또는 식별자가 올바르지 않습니다.",
            "테마를 검색하지 못했습니다. DB 연결 상태를 확인하세요.");
    }

    private void PostThemeSearchError(
        string requestId,
        string query,
        string nxtSession,
        string message)
    {
        PostMessage("theme-search-error", new
        {
            requestId,
            query,
            nxtSession,
            message
        });
    }

    private async Task HandleThemePreviewRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var marketValue = GetString(payload, "market") ?? string.Empty;
        var sessionValue = GetString(payload, "session") ?? string.Empty;
        var themeCode = GetString(payload, "themeCode") ?? string.Empty;
        var themeTitle = GetString(payload, "themeTitle") ?? string.Empty;
        if (!HasOnlyProperties(
                payload,
                "requestId",
                "market",
                "session",
                "themeCode",
                "themeTitle",
                "maximumItems") ||
            !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
            !TryParseThemeMarket(marketValue, out var market) ||
            !TryParseThemeSession(sessionValue, out var session) ||
            !payload.TryGetProperty("themeCode", out var codeElement) ||
            codeElement.ValueKind != JsonValueKind.String ||
            !payload.TryGetProperty("themeTitle", out var titleElement) ||
            titleElement.ValueKind != JsonValueKind.String)
        {
            PostThemePreviewError(
                requestId,
                marketValue,
                sessionValue,
                themeCode,
                themeTitle,
                "올바르지 않은 테마 미리보기 요청입니다.");
            return;
        }

        var maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems;
        if (payload.TryGetProperty("maximumItems", out var requestedLimit) &&
            (requestedLimit.ValueKind != JsonValueKind.Number ||
             !requestedLimit.TryGetInt32(out maximumItems)))
        {
            PostThemePreviewError(
                requestId,
                marketValue,
                sessionValue,
                themeCode,
                themeTitle,
                "테마 미리보기 종목 제한값이 올바르지 않습니다.");
            return;
        }

        var service = _themeSelectionService;
        if (service is null)
        {
            PostThemePreviewError(
                requestId,
                marketValue,
                sessionValue,
                themeCode,
                themeTitle,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        var identity = new ThemeSelectionIdentity(market, session, themeCode, themeTitle);
        await ExecuteThemeSelectionRequestAsync(
            async requestToken =>
            {
                var result = await service.PreviewAsync(
                    identity,
                    maximumItems,
                    requestToken);
                requestToken.ThrowIfCancellationRequested();
                PostMessage("theme-preview-results", new
                {
                    requestId,
                    selection = new
                    {
                        market = ToWireValue(result.Identity.Market),
                        session = ToWireValue(result.Identity.Session),
                        themeCode = result.Identity.ThemeCode,
                        title = result.Identity.ThemeTitle
                    },
                    result.RetrievedAt,
                    totalRowCount = result.Items.Count,
                    truncated = result.IsTruncated,
                    items = result.Items.Select(item => new
                    {
                        inputIndex = item.InputIndex,
                        itemCode = item.ItemCode,
                        itemName = item.ItemName
                    }).ToArray()
                });
            },
            message => PostThemePreviewError(
                requestId,
                marketValue,
                sessionValue,
                themeCode,
                themeTitle,
                message),
            $"테마 시장·세션·코드·제목을 확인하고 종목 제한은 1~{LegacyThemeSelectionService.MaximumPreviewItems}개로 지정하세요.",
            "테마 미리보기 결과의 스키마 또는 식별자가 올바르지 않습니다.",
            "테마 종목을 불러오지 못했습니다. DB 연결 상태를 확인하세요.");
    }

    private void PostThemePreviewError(
        string requestId,
        string market,
        string session,
        string themeCode,
        string themeTitle,
        string message)
    {
        PostMessage("theme-preview-error", new
        {
            requestId,
            selection = new
            {
                market,
                session,
                themeCode,
                title = themeTitle
            },
            message
        });
    }

    private async Task ExecuteThemeSelectionRequestAsync(
        Func<CancellationToken, Task> operation,
        Action<string> postError,
        string validationMessage,
        string invalidDataMessage,
        string unexpectedMessage)
    {
        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(
            ref _themeSelectionCancellation,
            requestCancellation);
        previousCancellation?.Cancel();

        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityThemeCancellationIfCurrent(
                    postError,
                    requestCancellation);
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();

            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityThemeCancellationIfCurrent(
                    postError,
                    requestCancellation);
                return;
            }

            await operation(requestToken);
            requestToken.ThrowIfCancellationRequested();
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityThemeCancellationIfCurrent(
                postError,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityThemeCancellationIfCurrent(
                postError,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityThemeCancellationIfCurrent(
                postError,
                requestCancellation);
        }
        catch (ArgumentException)
        {
            postError(validationMessage);
        }
        catch (ThemeSelectionDataException)
        {
            postError(invalidDataMessage);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch (DatabaseOperationException exception)
        {
            postError(exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            postError(unexpectedMessage);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _themeSelectionCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityThemeCancellationIfCurrent(
        Action<string> postError,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _themeSelectionCancellation),
                requestCancellation))
        {
            // Shutdown or a newer theme selector request owns the response.
            return;
        }

        postError(
            "송출 명령과 자동 갱신을 우선 처리하기 위해 테마 조회를 취소했습니다. 다시 조회하세요.");
    }

    private async Task HandleExpertSearchRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var query = GetString(payload, "query") ?? string.Empty;
        if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") ||
            !TryGetSafeToken(
                payload,
                "requestId",
                MaximumPlayoutRequestIdLength,
                out requestId) ||
            !payload.TryGetProperty("query", out var queryElement) ||
            queryElement.ValueKind != JsonValueKind.String)
        {
            PostExpertSearchError(
                requestId,
                query,
                "올바르지 않은 전문가 검색 요청입니다.");
            return;
        }

        var maximumResults = LegacyExpertSelectionService.DefaultMaximumResults;
        if (payload.TryGetProperty("maximumResults", out var requestedLimit) &&
            (requestedLimit.ValueKind != JsonValueKind.Number ||
             !requestedLimit.TryGetInt32(out maximumResults)))
        {
            PostExpertSearchError(
                requestId,
                query,
                "전문가 검색 결과 제한값이 올바르지 않습니다.");
            return;
        }

        var service = _expertSelectionService;
        if (service is null)
        {
            PostExpertSearchError(
                requestId,
                query,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        await ExecuteExpertSelectionRequestAsync(
            async requestToken =>
            {
                var result = await service.SearchAsync(
                    query,
                    maximumResults,
                    requestToken);
                requestToken.ThrowIfCancellationRequested();
                PostMessage("expert-search-results", new
                {
                    requestId,
                    query = result.Query,
                    result.RetrievedAt,
                    totalRowCount = result.Items.Count,
                    truncated = result.IsTruncated,
                    results = result.Items.Select(item => new
                    {
                        expertCode = item.Identity.ExpertCode,
                        expertName = item.Identity.ExpertName,
                        source = ToWireValue(item.Source)
                    }).ToArray()
                });
            },
            message => PostExpertSearchError(requestId, query, message),
            $"검색어는 {LegacyExpertSelectionService.MaximumQueryLength}자 이하여야 하며 " +
                $"결과 제한은 1~{LegacyExpertSelectionService.MaximumResults}개입니다.",
            "전문가 검색 결과의 형식 또는 식별자가 올바르지 않습니다.",
            "전문가를 검색하지 못했습니다. DB 연결 상태를 확인하세요.");
    }

    private void PostExpertSearchError(string requestId, string query, string message)
    {
        PostMessage("expert-search-error", new
        {
            requestId,
            query,
            message
        });
    }

    private async Task HandleExpertPreviewRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var expertCode = GetString(payload, "expertCode") ?? string.Empty;
        var expertName = GetString(payload, "expertName") ?? string.Empty;
        if (!HasOnlyProperties(
                payload,
                "requestId",
                "expertCode",
                "expertName",
                "maximumItems") ||
            !TryGetSafeToken(
                payload,
                "requestId",
                MaximumPlayoutRequestIdLength,
                out requestId) ||
            !payload.TryGetProperty("expertCode", out var codeElement) ||
            codeElement.ValueKind != JsonValueKind.String ||
            !payload.TryGetProperty("expertName", out var nameElement) ||
            nameElement.ValueKind != JsonValueKind.String)
        {
            PostExpertPreviewError(
                requestId,
                expertCode,
                expertName,
                "올바르지 않은 전문가 미리보기 요청입니다.");
            return;
        }

        var maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems;
        if (payload.TryGetProperty("maximumItems", out var requestedLimit) &&
            (requestedLimit.ValueKind != JsonValueKind.Number ||
             !requestedLimit.TryGetInt32(out maximumItems)))
        {
            PostExpertPreviewError(
                requestId,
                expertCode,
                expertName,
                "전문가 추천종목 제한값이 올바르지 않습니다.");
            return;
        }

        var service = _expertSelectionService;
        if (service is null)
        {
            PostExpertPreviewError(
                requestId,
                expertCode,
                expertName,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        var identity = new ExpertSelectionIdentity(expertCode, expertName);
        await ExecuteExpertSelectionRequestAsync(
            async requestToken =>
            {
                var result = await service.PreviewAsync(
                    identity,
                    maximumItems,
                    requestToken);
                requestToken.ThrowIfCancellationRequested();
                PostMessage("expert-preview-results", new
                {
                    requestId,
                    selection = new
                    {
                        expertCode = result.Identity.ExpertCode,
                        expertName = result.Identity.ExpertName
                    },
                    result.RetrievedAt,
                    totalRowCount = result.Items.Count,
                    truncated = result.IsTruncated,
                    items = result.Items.Select(item => new
                    {
                        playIndex = item.PlayIndex,
                        stockCode = item.StockCode,
                        stockName = item.StockName,
                        buyAmount = item.BuyAmount
                    }).ToArray()
                });
            },
            message => PostExpertPreviewError(
                requestId,
                expertCode,
                expertName,
                message),
            $"전문가 코드·이름을 확인하고 종목 제한은 " +
                $"1~{LegacyExpertSelectionService.MaximumPreviewItems}개로 지정하세요.",
            "전문가 추천종목의 형식, 순서 또는 식별자가 올바르지 않습니다.",
            "전문가 추천종목을 불러오지 못했습니다. DB 연결 상태를 확인하세요.");
    }

    private void PostExpertPreviewError(
        string requestId,
        string expertCode,
        string expertName,
        string message)
    {
        PostMessage("expert-preview-error", new
        {
            requestId,
            selection = new
            {
                expertCode,
                expertName
            },
            message
        });
    }

    private async Task ExecuteExpertSelectionRequestAsync(
        Func<CancellationToken, Task> operation,
        Action<string> postError,
        string validationMessage,
        string invalidDataMessage,
        string unexpectedMessage)
    {
        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(
            ref _expertSelectionCancellation,
            requestCancellation);
        CancelRequest(previousCancellation);

        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityExpertCancellationIfCurrent(
                    postError,
                    requestCancellation);
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();

            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityExpertCancellationIfCurrent(
                    postError,
                    requestCancellation);
                return;
            }

            await operation(requestToken);
            requestToken.ThrowIfCancellationRequested();
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityExpertCancellationIfCurrent(
                postError,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityExpertCancellationIfCurrent(
                postError,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityExpertCancellationIfCurrent(
                postError,
                requestCancellation);
        }
        catch (ArgumentException)
        {
            postError(validationMessage);
        }
        catch (ExpertSelectionDataException)
        {
            postError(invalidDataMessage);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch (DatabaseOperationException exception)
        {
            postError(exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            postError(unexpectedMessage);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _expertSelectionCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityExpertCancellationIfCurrent(
        Action<string> postError,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _expertSelectionCancellation),
                requestCancellation))
        {
            // Shutdown or a newer expert selector request owns the response.
            return;
        }

        postError(
            "송출 명령을 우선 처리하기 위해 전문가 조회를 취소했습니다. 다시 조회하세요.");
    }

    private async Task HandleIndustrySelectionRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var marketValue = GetString(payload, "market") ?? string.Empty;
        if (!HasOnlyProperties(payload, "requestId", "market") ||
            !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId) ||
            !TryParseIndustryMarket(marketValue, out var market))
        {
            PostIndustrySelectionError(requestId, marketValue, "올바르지 않은 업종 목록 요청입니다.");
            return;
        }

        if (_industrySelectionService is null)
        {
            PostIndustrySelectionError(
                requestId,
                marketValue,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(
            ref _industrySelectionCancellation,
            requestCancellation);
        previousCancellation?.Cancel();
        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityIndustryCancellationIfCurrent(
                    requestId,
                    marketValue,
                    requestCancellation);
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityIndustryCancellationIfCurrent(
                    requestId,
                    marketValue,
                    requestCancellation);
                return;
            }

            var result = await _industrySelectionService.GetAsync(market, requestToken);
            requestToken.ThrowIfCancellationRequested();
            PostMessage("industry-results", new
            {
                requestId,
                market = ToWireValue(market),
                totalRowCount = result.Count,
                results = result.Select(item => new
                {
                    market = ToWireValue(item.Market),
                    name = item.IndustryName,
                    code = item.IndustryCode
                })
            });
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityIndustryCancellationIfCurrent(
                requestId,
                marketValue,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityIndustryCancellationIfCurrent(
                requestId,
                marketValue,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityIndustryCancellationIfCurrent(
                requestId,
                marketValue,
                requestCancellation);
        }
        catch (IndustrySelectionDataException)
        {
            PostIndustrySelectionError(requestId, marketValue, "업종 목록 결과 형식이 올바르지 않습니다.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch (DatabaseOperationException exception)
        {
            PostIndustrySelectionError(requestId, marketValue, exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            PostIndustrySelectionError(
                requestId,
                marketValue,
                "업종 목록을 불러오지 못했습니다. DB 연결 상태를 확인하세요.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _industrySelectionCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityIndustryCancellationIfCurrent(
        string requestId,
        string market,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _industrySelectionCancellation),
                requestCancellation))
        {
            return;
        }

        PostIndustrySelectionError(
            requestId,
            market,
            "송출 명령과 자동 갱신을 우선 처리하기 위해 업종 조회를 취소했습니다. 다시 조회하세요.");
    }

    private void PostIndustrySelectionError(string requestId, string market, string message)
    {
        PostMessage("industry-error", new
        {
            requestId,
            market,
            message
        });
    }

    private async Task HandleTradingHaltSelectionRequestAsync(JsonElement payload)
    {
        var requestId = GetString(payload, "requestId") ?? string.Empty;
        var query = GetString(payload, "query") ?? string.Empty;
        if (!HasOnlyProperties(payload, "requestId", "query", "maximumResults") ||
            !TryGetSafeToken(
                payload,
                "requestId",
                MaximumPlayoutRequestIdLength,
                out requestId) ||
            !payload.TryGetProperty("query", out var queryElement) ||
            queryElement.ValueKind != JsonValueKind.String)
        {
            PostTradingHaltSelectionError(
                requestId,
                query,
                "올바르지 않은 거래정지 종목 검색 요청입니다.");
            return;
        }

        var maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults;
        if (payload.TryGetProperty("maximumResults", out var requestedLimit) &&
            (requestedLimit.ValueKind != JsonValueKind.Number ||
             !requestedLimit.TryGetInt32(out maximumResults)))
        {
            PostTradingHaltSelectionError(
                requestId,
                query,
                "거래정지 검색 결과 제한값이 올바르지 않습니다.");
            return;
        }

        var service = _tradingHaltSelectionService;
        if (service is null)
        {
            PostTradingHaltSelectionError(
                requestId,
                query,
                _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.");
            return;
        }

        var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(
            _lifetimeCancellation.Token);
        var requestToken = requestCancellation.Token;
        var previousCancellation = Interlocked.Exchange(
            ref _tradingHaltSelectionCancellation,
            requestCancellation);
        CancelRequest(previousCancellation);

        var databaseActivityEntered = false;
        try
        {
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityTradingHaltCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            await _databaseActivityGate.WaitAsync(requestToken);
            databaseActivityEntered = true;
            requestToken.ThrowIfCancellationRequested();

            // Playout always owns the shared executor before an operator lookup.
            if (Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                requestCancellation.Cancel();
                PostPlayoutPriorityTradingHaltCancellationIfCurrent(
                    requestId,
                    query,
                    requestCancellation);
                return;
            }

            var result = await service.SearchAsync(query, maximumResults, requestToken);
            requestToken.ThrowIfCancellationRequested();

            PostMessage("trading-halt-results", new
            {
                requestId,
                query = result.Query,
                result.RetrievedAt,
                totalRowCount = result.Items.Count,
                truncated = result.IsTruncated,
                results = result.Items.Select(item => new
                {
                    market = ToWireValue(item.Market),
                    stockCode = item.StockCode,
                    displayName = item.DisplayName
                }).ToArray()
            });
        }
        catch (OperationCanceledException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityTradingHaltCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (DatabaseOperationException) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityTradingHaltCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (Exception) when (requestToken.IsCancellationRequested)
        {
            PostPlayoutPriorityTradingHaltCancellationIfCurrent(
                requestId,
                query,
                requestCancellation);
        }
        catch (ArgumentException)
        {
            PostTradingHaltSelectionError(
                requestId,
                query,
                $"검색어는 {LegacyTradingHaltSelectionService.MaximumQueryLength}자 이하여야 하며 " +
                $"결과 제한은 1~{LegacyTradingHaltSelectionService.MaximumResults}개입니다.");
        }
        catch (TradingHaltSelectionDataException)
        {
            PostTradingHaltSelectionError(
                requestId,
                query,
                "거래정지 종목 검색 결과의 형식 또는 식별자가 올바르지 않습니다.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch (DatabaseOperationException exception)
        {
            PostTradingHaltSelectionError(requestId, query, exception.Message);
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        catch
        {
            PostTradingHaltSelectionError(
                requestId,
                query,
                "거래정지 종목을 검색하지 못했습니다. DB 연결 상태를 확인하세요.");
            _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
        }
        finally
        {
            if (databaseActivityEntered)
            {
                _databaseActivityGate.Release();
            }

            Interlocked.CompareExchange(
                ref _tradingHaltSelectionCancellation,
                null,
                requestCancellation);
            requestCancellation.Dispose();
        }
    }

    private void PostPlayoutPriorityTradingHaltCancellationIfCurrent(
        string requestId,
        string query,
        CancellationTokenSource requestCancellation)
    {
        if (_lifetimeCancellation.IsCancellationRequested ||
            !ReferenceEquals(
                Volatile.Read(ref _tradingHaltSelectionCancellation),
                requestCancellation))
        {
            // Shutdown or a newer trading-halt request owns the response.
            return;
        }

        PostTradingHaltSelectionError(
            requestId,
            query,
            "송출 명령을 우선 처리하기 위해 거래정지 검색을 취소했습니다. 다시 검색하세요.");
    }

    private void PostTradingHaltSelectionError(
        string requestId,
        string query,
        string message)
    {
        PostMessage("trading-halt-error", new
        {
            requestId,
            query,
            message
        });
    }

    private async Task PostDatabaseStatusAsync(
        CancellationToken cancellationToken,
        bool periodic = false)
    {
        if (!_webViewReady)
        {
            return;
        }

        while (!cancellationToken.IsCancellationRequested)
        {
            if (periodic && Volatile.Read(ref _playoutCommandInFlight) != 0)
            {
                return;
            }

            bool entered;
            if (periodic)
            {
                entered = await _databaseActivityGate.WaitAsync(0, cancellationToken);
            }
            else
            {
                await _databaseActivityGate.WaitAsync(cancellationToken);
                entered = true;
            }

            if (!entered)
            {
                return;
            }

            var retryAfterPlayout = false;
            CancellationTokenSource? healthCancellation = null;
            try
            {
                if (Volatile.Read(ref _playoutCommandInFlight) != 0)
                {
                    retryAfterPlayout = !periodic;
                }
                else
                {
                    healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                    Volatile.Write(ref _databaseHealthCancellation, healthCancellation);

                    // Close the race where playout starts after this health request
                    // entered the gate but before its diagnostic query begins.
                    if (Volatile.Read(ref _playoutCommandInFlight) != 0)
                    {
                        healthCancellation.Cancel();
                        retryAfterPlayout = !periodic;
                    }
                    else
                    {
                        await QueryAndPostDatabaseStatusAsync(healthCancellation.Token);
                        return;
                    }
                }
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                return;
            }
            catch (OperationCanceledException) when (healthCancellation?.IsCancellationRequested == true)
            {
                // Playout has priority over diagnostics. Interactive requests resume
                // after the command; the periodic monitor keeps the last Web snapshot.
                retryAfterPlayout = !periodic;
            }
            finally
            {
                if (healthCancellation is not null)
                {
                    Interlocked.CompareExchange(
                        ref _databaseHealthCancellation,
                        null,
                        healthCancellation);
                    healthCancellation.Dispose();
                }

                _databaseActivityGate.Release();
            }

            if (!retryAfterPlayout)
            {
                return;
            }

            // Let the already-active command enter the activity gate before an
            // interactive health request retries.
            await Task.Delay(TimeSpan.FromMilliseconds(25), cancellationToken);
        }
    }

    private async Task QueryAndPostDatabaseStatusAsync(CancellationToken cancellationToken)
    {
        if (_databaseRuntime is null)
        {
            var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.";
            ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.NotConfigured);
            ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.NotConfigured);
            PostDatabaseStatus(new[]
            {
                CreateUnavailableStatus("oracle", message),
                CreateUnavailableStatus("mariaDb", message)
            });
            return;
        }

        try
        {
            var statuses = await _databaseRuntime.HealthService
                .CheckAllAsync(cancellationToken);
            foreach (var status in statuses)
            {
                ObserveNativeDatabaseState(status.Source, status.State);
            }

            PostDatabaseStatus(statuses.Select(status => new
            {
                source = ToWireValue(status.Source),
                configured = _databaseRuntime.Options.IsConfigured(status.Source),
                healthy = status.State == DatabaseHealthState.Healthy,
                state = status.State.ToString().ToLowerInvariant(),
                message = status.UserMessage,
                checkedAt = status.CheckedAt,
                latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds)
            }));
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch
        {
            ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.Unhealthy);
            ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.Unhealthy);
            PostDatabaseStatus(new[]
            {
                CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."),
                CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.")
            });
        }
    }

    private void PostDatabaseStatus(object sources)
    {
        PostMessage("database-status", new
        {
            sequence = Interlocked.Increment(ref _databaseStatusSequence),
            sources
        });
    }

    private void CancelActiveDatabaseHealthCheck()
    {
        var cancellation = Volatile.Read(ref _databaseHealthCancellation);
        try
        {
            cancellation?.Cancel();
        }
        catch (ObjectDisposedException)
        {
            // Completion won the race after the reference was read.
        }
    }

    private void CancelActiveMarketDataRequest()
    {
        CancelRequest(Volatile.Read(ref _marketDataCancellation));
        CancelRequest(Volatile.Read(ref _stockSearchCancellation));
        CancelRequest(Volatile.Read(ref _worldStockSearchCancellation));
        CancelRequest(Volatile.Read(ref _overseasIndustrySearchCancellation));
        CancelRequest(Volatile.Read(ref _themeSelectionCancellation));
        CancelRequest(Volatile.Read(ref _industrySelectionCancellation));
        CancelRequest(Volatile.Read(ref _tradingHaltSelectionCancellation));
        CancelRequest(Volatile.Read(ref _expertSelectionCancellation));
        CancelRequest(Volatile.Read(ref _namedPlaylistReadCancellation));
        CancelRequest(Volatile.Read(ref _namedPlaylistWriteCancellation));
        CancelRequest(Volatile.Read(ref _playlistPagePlanCancellation));
        CancelRequest(Volatile.Read(ref _legacyComparisonImportCancellation));
        CancelOperatorCatalogReadsForPlayout();
        CancelManualFinancialReadsForPlayout();
    }

    private static void CancelRequest(CancellationTokenSource? cancellation)
    {
        try
        {
            cancellation?.Cancel();
        }
        catch (ObjectDisposedException)
        {
            // Completion won the race after the reference was read.
        }
    }

    private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            await PostDatabaseStatusAsync(cancellationToken, periodic: true);

            try
            {
                await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                break;
            }
        }
    }

    private static object CreateUnavailableStatus(string source, string message) => new
    {
        source,
        configured = false,
        healthy = false,
        state = "notconfigured",
        message,
        checkedAt = DateTimeOffset.Now,
        latencyMs = 0L
    };

    private static string? GetString(JsonElement payload, string propertyName) =>
        payload.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
            ? value.GetString()
            : null;

    private static bool TryParseMarketDataView(string? value, out MarketDataView view)
    {
        view = value?.ToLowerInvariant() switch
        {
            "kospi" => MarketDataView.Kospi,
            "kosdaq" => MarketDataView.Kosdaq,
            "index" => MarketDataView.Index,
            "overseas" => MarketDataView.Overseas,
            _ => default
        };

        return value?.ToLowerInvariant() is "kospi" or "kosdaq" or "index" or "overseas";
    }

    private static bool TryParseIndustryMarket(string? value, out IndustryMarket market)
    {
        market = value?.ToLowerInvariant() switch
        {
            "kospi" => IndustryMarket.Kospi,
            "kosdaq" => IndustryMarket.Kosdaq,
            _ => default
        };
        return value?.ToLowerInvariant() is "kospi" or "kosdaq";
    }

    private static bool TryParseThemeMarket(string? value, out ThemeMarket market)
    {
        market = value?.ToLowerInvariant() switch
        {
            "krx" => ThemeMarket.Krx,
            "nxt" => ThemeMarket.Nxt,
            _ => default
        };
        return value?.ToLowerInvariant() is "krx" or "nxt";
    }

    private static bool TryParseThemeNxtSession(string? value, out ThemeSession session)
    {
        session = value?.ToLowerInvariant() switch
        {
            "premarket" => ThemeSession.PreMarket,
            "aftermarket" => ThemeSession.AfterMarket,
            _ => default
        };
        return value?.ToLowerInvariant() is "premarket" or "aftermarket";
    }

    private static bool TryParseThemeSession(string? value, out ThemeSession session)
    {
        session = value?.ToLowerInvariant() switch
        {
            "notapplicable" => ThemeSession.NotApplicable,
            "premarket" => ThemeSession.PreMarket,
            "aftermarket" => ThemeSession.AfterMarket,
            _ => default
        };
        return value?.ToLowerInvariant() is "notapplicable" or "premarket" or "aftermarket";
    }

    private static string ToWireValue(MarketDataView view) => view.ToString().ToLowerInvariant();

    private static string ToWireValue(StockMarket market) => market switch
    {
        StockMarket.Kospi => "kospi",
        StockMarket.Kosdaq => "kosdaq",
        StockMarket.NxtKospi => "nxt-kospi",
        StockMarket.NxtKosdaq => "nxt-kosdaq",
        _ => "unknown"
    };

    private static string ToWireValue(IndustryMarket market) => market switch
    {
        IndustryMarket.Kospi => "kospi",
        IndustryMarket.Kosdaq => "kosdaq",
        _ => "unknown"
    };

    private static string ToWireValue(TradingHaltMarket market) => market switch
    {
        TradingHaltMarket.Kospi => "kospi",
        TradingHaltMarket.Kosdaq => "kosdaq",
        _ => "unknown"
    };

    private static string ToWireValue(ThemeMarket market) => market switch
    {
        ThemeMarket.Krx => "krx",
        ThemeMarket.Nxt => "nxt",
        _ => "unknown"
    };

    private static string ToWireValue(ThemeSession session) => session switch
    {
        ThemeSession.NotApplicable => "notApplicable",
        ThemeSession.PreMarket => "preMarket",
        ThemeSession.AfterMarket => "afterMarket",
        _ => "unknown"
    };

    private static string ToWireValue(DataSourceKind source) => source switch
    {
        DataSourceKind.Oracle => "oracle",
        DataSourceKind.MariaDb => "mariaDb",
        _ => "unknown"
    };

    private void PostAppInfo()
    {
        var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0";

        PostMessage("app-info", new
        {
            name = "MBN Stock WebView",
            version,
            framework = ".NET 8 / WinUI 3",
            windowsAppSdk = "1.8",
            architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(),
            package = "MSIX",
            migratedQueryFiles = 71,
            databaseConfigured = _databaseRuntime is not null &&
                new[] { DataSourceKind.Oracle, DataSourceKind.MariaDb }
                    .Any(_databaseRuntime.Options.IsConfigured)
        });
    }

    private void PostMessage(string type, object payload)
    {
        if (!_webViewReady)
        {
            return;
        }

        Browser.CoreWebView2.PostWebMessageAsJson(
            JsonSerializer.Serialize(new { type, payload }, JsonOptions));
    }

    private static async Task OpenExternalUriAsync(Uri uri)
    {
        if (uri.Scheme is "https" or "http")
        {
            await Launcher.LaunchUriAsync(uri);
        }
    }

    private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
    {
        QuarantineIfBrowserCorrelationCanBeLost();
        ShowError("WebView2 프로세스가 종료되었습니다.", args.ProcessFailedKind.ToString());
    }

    private void ShowError(string title, string message)
    {
        LoadingOverlay.Visibility = Visibility.Collapsed;
        ErrorBar.Title = title;
        ErrorBar.Message = message;
        ErrorBar.IsOpen = true;
    }

    private void OnBackClicked(object sender, RoutedEventArgs e)
    {
        if (Browser.CanGoBack)
        {
            Browser.GoBack();
        }
    }

    private void OnForwardClicked(object sender, RoutedEventArgs e)
    {
        if (Browser.CanGoForward)
        {
            Browser.GoForward();
        }
    }

    private void OnReloadClicked(object sender, RoutedEventArgs e)
    {
        if (_webViewReady)
        {
            ReloadBrowserSafely();
        }
    }

    private void ReloadBrowserSafely()
    {
        QuarantineIfBrowserCorrelationCanBeLost();
        Browser.Reload();
    }

    private void QuarantineIfBrowserCorrelationCanBeLost()
    {
        InvalidateNamedPlaylistBrowserRequests();
        InvalidatePlaylistPagePlanBrowserRequest();
        InvalidateOperatorCatalogBrowserRequests();
        InvalidateManualOperatorBrowserRequests();
        InvalidateManualFinancialBrowserRequests();
        InvalidateLegacyComparisonImportRequest();
        if (Volatile.Read(ref _playoutCommandInFlight) != 0)
        {
            // Navigation or a renderer failure destroys the JavaScript request
            // correlation. Latch native OutcomeUnknown before the document can go away.
            _ = QuarantineForBrowserCorrelationLossAsync();
        }
    }

    private void OnClosed(object sender, WindowEventArgs args)
    {
        DetachCloseConfirmation();
        LogNativeOperatorShutdown();
        var wasWebViewReady = _webViewReady;
        _webViewReady = false;
        _lifetimeCancellation.Cancel();
        CancelActiveDatabaseHealthCheck();
        ShutdownPlayoutRuntime();
        var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null);
        requestCancellation?.Cancel();
        var stockSearchCancellation = Interlocked.Exchange(ref _stockSearchCancellation, null);
        stockSearchCancellation?.Cancel();
        var worldStockSearchCancellation = Interlocked.Exchange(
            ref _worldStockSearchCancellation,
            null);
        worldStockSearchCancellation?.Cancel();
        var overseasIndustrySearchCancellation = Interlocked.Exchange(
            ref _overseasIndustrySearchCancellation,
            null);
        overseasIndustrySearchCancellation?.Cancel();
        var themeSelectionCancellation = Interlocked.Exchange(
            ref _themeSelectionCancellation,
            null);
        themeSelectionCancellation?.Cancel();
        var industrySelectionCancellation = Interlocked.Exchange(
            ref _industrySelectionCancellation,
            null);
        industrySelectionCancellation?.Cancel();
        var tradingHaltSelectionCancellation = Interlocked.Exchange(
            ref _tradingHaltSelectionCancellation,
            null);
        CancelRequest(tradingHaltSelectionCancellation);
        var expertSelectionCancellation = Interlocked.Exchange(
            ref _expertSelectionCancellation,
            null);
        CancelRequest(expertSelectionCancellation);
        var namedPlaylistReadCancellation = Interlocked.Exchange(
            ref _namedPlaylistReadCancellation,
            null);
        CancelRequest(namedPlaylistReadCancellation);
        var namedPlaylistWriteCancellation = Interlocked.Exchange(
            ref _namedPlaylistWriteCancellation,
            null);
        CancelRequest(namedPlaylistWriteCancellation);
        var playlistPagePlanCancellation = Interlocked.Exchange(
            ref _playlistPagePlanCancellation,
            null);
        CancelRequest(playlistPagePlanCancellation);
        ShutdownOperatorCatalogRuntime();
        ShutdownManualFinancialRuntime();
        ShutdownManualOperatorDataRuntime();
        ShutdownLegacyComparisonImportRuntime();
        DataQueryExecutor.Reset();

        if (!wasWebViewReady)
        {
            _lifetimeCancellation.Dispose();
            return;
        }

        Browser.CoreWebView2.NavigationStarting -= OnNavigationStarting;
        Browser.CoreWebView2.NavigationCompleted -= OnNavigationCompleted;
        Browser.CoreWebView2.HistoryChanged -= OnHistoryChanged;
        Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested;
        Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
        Browser.CoreWebView2.ProcessFailed -= OnProcessFailed;
        _lifetimeCancellation.Dispose();
    }

    private sealed record WebRequest(string Type, JsonElement Payload);
}
