import type { Monitor, PanelConfig, MapLayers } from '@/types';
import { normalizeExclusiveChoropleths } from '@/components/resilience-choropleth-utils';
import type { AppContext } from '@/app/app-context';
import {
  REFRESH_INTERVALS,
  DEFAULT_PANELS,
  DEFAULT_MAP_LAYERS,
  MOBILE_DEFAULT_MAP_LAYERS,
  STORAGE_KEYS,
  SITE_VARIANT,
  ALL_PANELS,
  VARIANT_DEFAULTS,
  getEffectivePanelConfig,
  enforceFreePanelLimit,
  restoreFreeMapPanelAccess,
  FREE_MAX_PANELS,
  FREE_MAX_SOURCES,
} from '@/config';
import { sanitizeLayersForVariant } from '@/config/map-layer-definitions';
import type { MapVariant } from '@/config/map-layer-definitions';
import { getStoredMapModePreference } from '@/services/map-mode-preference';
import {
  initDB,
  cleanOldSnapshots,
  isAisConfigured,
  initAisStream,
  isOutagesConfigured,
  disconnectAisStream,
  startFlightHistoryCleanup,
  stopFlightHistoryCleanup,
} from '@/services';
import { enableVesselRuntime, stopLoadedVesselHistoryCleanup } from '@/services/military-vessels-lazy';
import { isProUser } from '@/services/widget-store';
import { mlWorker } from '@/services/ml-worker';
import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } from '@/services/ai-flow-settings';
import { startLearning } from '@/services/country-instability';
import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice, showToast } from '@/utils';
import { clearPanelSpans, invalidatePanelStorageCacheForKeys } from '@/utils/panel-storage';
import type { ParsedMapUrlState } from '@/utils';
import { BreakingNewsBanner } from '@/components/BreakingNewsBanner';
import { initBreakingNewsAlerts, destroyBreakingNewsAlerts } from '@/services/breaking-news-alerts';
import { markLcpDebug } from '@/utils/lcp-debug';
import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel';
import type { MonitorPanel } from '@/components/MonitorPanel';
import type { StablecoinPanel } from '@/components/StablecoinPanel';
import type { EnergyCrisisPanel } from '@/components/EnergyCrisisPanel';
import type { ETFFlowsPanel } from '@/components/ETFFlowsPanel';
import type { MacroSignalsPanel } from '@/components/MacroSignalsPanel';
import type { FearGreedPanel } from '@/components/FearGreedPanel';
import type { HormuzPanel } from '@/components/HormuzPanel';
import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel';
import type { StrategicRiskPanel } from '@/components/StrategicRiskPanel';
import type { GulfEconomiesPanel } from '@/components/GulfEconomiesPanel';
import type { GroceryBasketPanel } from '@/components/GroceryBasketPanel';
import type { BigMacPanel } from '@/components/BigMacPanel';
import type { FuelPricesPanel } from '@/components/FuelPricesPanel';
import type { FaoFoodPriceIndexPanel } from '@/components/FaoFoodPriceIndexPanel';
import type { OilInventoriesPanel } from '@/components/OilInventoriesPanel';
import type { PipelineStatusPanel } from '@/components/PipelineStatusPanel';
import type { StorageFacilityMapPanel } from '@/components/StorageFacilityMapPanel';
import type { FuelShortagePanel } from '@/components/FuelShortagePanel';
import type { EnergyDisruptionsPanel } from '@/components/EnergyDisruptionsPanel';
import type { EnergyRiskOverviewPanel } from '@/components/EnergyRiskOverviewPanel';
import type { ChokepointStripPanel } from '@/components/ChokepointStripPanel';
import type { ClimateNewsPanel } from '@/components/ClimateNewsPanel';
import type { ConsumerPricesPanel } from '@/components/ConsumerPricesPanel';
import type { DefensePatentsPanel } from '@/components/DefensePatentsPanel';
import type { MacroTilesPanel } from '@/components/MacroTilesPanel';
import type { FSIPanel } from '@/components/FSIPanel';
import type { YieldCurvePanel } from '@/components/YieldCurvePanel';
import type { EarningsCalendarPanel } from '@/components/EarningsCalendarPanel';
import type { EconomicCalendarPanel } from '@/components/EconomicCalendarPanel';
import type { CotPositioningPanel } from '@/components/CotPositioningPanel';
import type { LiquidityShiftsPanel } from '@/components/LiquidityShiftsPanel';
import type { PositioningPanel } from '@/components/PositioningPanel';
import type { GoldIntelligencePanel } from '@/components/GoldIntelligencePanel';
import { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime';
import { hasPremiumAccess } from '@/services/panel-gating';
import { BETA_MODE } from '@/config/beta';
import { trackEvent, trackDeeplinkOpened, initAuthAnalytics } from '@/services/analytics';
import { preloadCountryGeometry, isCountryGeometryLoaded, getCountryNameByCode } from '@/services/country-geometry';
import { initI18n, t, I18N_RESOURCES_LOADED_EVENT, type I18nResourcesLoadedDetail } from '@/services/i18n';
import { initDeferredDashboardFonts } from '@/bootstrap/secondary-startup';

import { computeDefaultDisabledSources, getLocaleBoostedSources, getTotalFeedCount, FEEDS, INTEL_SOURCES } from '@/config/feeds';
import { selectSourcesUnderCap, findFullyDisabledCategories } from '@/services/source-cap';
import {
  cancelBootstrapSlowTier,
  fetchBootstrapData,
  getBootstrapHydrationState,
  markBootstrapAsLive,
  waitForBootstrapSlowTier,
  type BootstrapHydrationState,
} from '@/services/bootstrap';
import { ensureWmSession, installWmSessionFetchInterceptor } from '@/services/wm-session';
import { describeFreshness } from '@/services/persistent-cache';
import { DesktopUpdater } from '@/app/desktop-updater';
import { CountryIntelManager } from '@/app/country-intel';
import { registerWebMcpTools } from '@/services/webmcp';
import { refreshDataFreshnessFromHealth } from '@/services/health-freshness';
import type { SearchManager } from '@/app/search-manager';
import { RefreshScheduler } from '@/app/refresh-scheduler';
import { PanelLayoutManager } from '@/app/panel-layout';
import { DataLoaderManager } from '@/app/data-loader';
import { EventHandlerManager } from '@/app/event-handlers';
import { replaceRawI18nKeyPlaceholders } from '@/app/i18n-raw-key-healer';
import { resolveUserRegion, resolvePreciseUserCoordinates, type PreciseCoordinates } from '@/utils/user-location';
import { showProBanner } from '@/components/ProBanner';
import { initAuthState, subscribeAuthState } from '@/services/auth-state';
import {
  CLOUD_PREFS_APPLIED_EVENT,
  install as installCloudPrefsSync,
  onSignIn as cloudPrefsSignIn,
  onSignOut as cloudPrefsSignOut,
  type CloudPrefsAppliedDetail,
} from '@/utils/cloud-prefs-sync';
import { getConvexClient, getConvexApi, waitForConvexAuth } from '@/services/convex-client';
import { initEntitlementSubscription, destroyEntitlementSubscription, resetEntitlementState, onEntitlementChange } from '@/services/entitlements';
import { initSubscriptionWatch, destroySubscriptionWatch } from '@/services/billing';
import {
  FREE_TIER_FOLLOW_LIMIT,
  WM_FOLLOWED_COUNTRIES_CAP_DROP,
  installFollowedCountriesAuthListener,
} from '@/services/followed-countries';
import {
  capturePendingCheckoutIntentFromUrl,
  initCheckoutWatchers,
  resumePendingCheckout,
} from '@/services/checkout';
import {
  clearStoredAnonIdentity,
  getFreshStoredAnonClaimToken,
  getStoredAnonId,
} from '@/services/anonymous-identity-storage';
import { captureReferralFromUrl } from '@/services/referral-capture';
// CorrelationEngine + its 4 adapters are dynamic-imported at the post-loadAllData
// run site (#4486) so the engine bytes stay off the eager boot graph. The TYPE is
// referenced via the inline `import(...)` type in app-context.ts (erased at build).
import type { CorrelationPanel } from '@/components/CorrelationPanel';

const CYBER_LAYER_ENABLED = import.meta.env.VITE_ENABLE_CYBER_LAYER === 'true';
const FREE_MAP_PANEL_ACCESS_KEY = 'worldmonitor-free-map-panel-access-v1';
type SignalModalInstance = import('@/components/SignalModal').SignalModal;

export type { CountryBriefSignals } from '@/app/app-context';

export class App {
  private state: AppContext;
  private pendingDeepLinkCountry: string | null = null;
  private pendingDeepLinkExpanded = false;
  private pendingDeepLinkStoryCode: string | null = null;

  private panelLayout: PanelLayoutManager;
  private dataLoader: DataLoaderManager;
  private eventHandlers: EventHandlerManager;
  private searchManager: SearchManager | null = null;
  private searchManagerLoad: Promise<SearchManager> | null = null;
  private signalModalLoad: Promise<SignalModalInstance> | null = null;
  // Monotonic epoch: every openSearch() call supersedes earlier in-flight ones.
  // searchToggleDesiredOpen accumulates the net intent of rapid Cmd+K presses
  // while the lazy chunk loads (XOR: odd → open, even → cancel). (#4403 review)
  private openSearchEpoch = 0;
  private searchToggleDesiredOpen = false;
  private latestSearchAdsb: Parameters<SearchManager['updateFlightSource']>[0] = [];
  private latestSearchMilitary: Parameters<SearchManager['updateFlightSource']>[1] = [];
  private countryIntel: CountryIntelManager;
  private refreshScheduler: RefreshScheduler;
  private desktopUpdater: DesktopUpdater;

  private modules: { destroy(): void }[] = [];
  private unsubAiFlow: (() => void) | null = null;
  private unsubFreeTier: (() => void) | null = null;
  private unsubEntitlementPremiumLoaders: (() => void) | null = null;
  // Resolves once Phase-4 UI modules have initialised so WebMCP bindings can
  // await readiness before touching nullable UI targets. Avoids the startup
  // race where an agent
  // discovers a tool via early registerTool and invokes it before the
  // target panel exists.
  private uiReady!: Promise<void>;
  private resolveUiReady!: () => void;
  // Returned by registerWebMcpTools when running in a registerTool-capable
  // browser — aborting it unregisters every tool. destroy() triggers it
  // so that test harnesses / same-document re-inits don't accumulate
  // duplicate registrations.
  private webMcpController: AbortController | null = null;
  private visiblePanelPrimed = new Set<string>();
  private visiblePanelPrimeRaf: number | null = null;
  private followedCountriesCapDropToastTimer: number | null = null;
  private bootstrapHydrationState: BootstrapHydrationState = getBootstrapHydrationState();
  private cachedModeBannerEl: HTMLElement | null = null;
  private readonly handleViewportPrime = (): void => {
    if (this.visiblePanelPrimeRaf !== null) return;
    this.visiblePanelPrimeRaf = window.requestAnimationFrame(() => {
      this.visiblePanelPrimeRaf = null;
      void this.primeVisiblePanelData();
      // loadAllData covers panels primeVisiblePanelData does not (news,
      // markets, intelligence, fred, …). Now that bootstrap runs with
      // forceAll=false, below-fold panels need this re-trigger on scroll
      // so their data lands when they enter the viewport. Both are
      // viewport-gated and inflight-guarded — repeat invocations are
      // cheap.
      void this.dataLoader.loadAllData();
    });
  };
  private readonly handleConnectivityChange = (): void => {
    this.updateConnectivityUi();
  };
  private readonly handleI18nResourcesLoaded = (ev: Event): void => {
    const language = (ev as CustomEvent<I18nResourcesLoadedDetail>).detail?.language;
    if (language !== 'en') return;
    // Scope this to the app container: body-level modals are user-opened after
    // startup, by which point the full English bundle should already be loaded.
    replaceRawI18nKeyPlaceholders(this.state.container, t);
  };
  private readonly handleFollowedCountriesCapDrop = (ev: Event): void => {
    const detail = (ev as CustomEvent<{ kept?: unknown; dropped?: unknown }>).detail;
    const dropped = typeof detail?.dropped === 'number' ? detail.dropped : 0;
    const kept = typeof detail?.kept === 'number' ? detail.kept : FREE_TIER_FOLLOW_LIMIT;
    if (dropped <= 0) return;
    this.showFollowedCountriesCapDropToast(kept, dropped);
  };
  private readonly handleCloudPrefsApplied = (ev: Event): void => {
    const keys = (ev as CustomEvent<CloudPrefsAppliedDetail>).detail?.keys ?? [];
    this.applyCloudSyncedPrefsToRuntime(keys);
  };

  private applyCloudSyncedPrefsToRuntime(keys: readonly string[]): void {
    if (keys.length === 0) return;

    const keySet = new Set(keys);
    invalidatePanelStorageCacheForKeys(keys);

    if (keySet.has(STORAGE_KEYS.panels)) {
      this.state.panelSettings = loadFromStorage<Record<string, PanelConfig>>(
        STORAGE_KEYS.panels,
        this.state.panelSettings,
      );
      this.panelLayout.applyPanelSettings();
      this.state.unifiedSettings?.refreshPanelToggles();
    }

    const panelOrderKey = this.state.PANEL_ORDER_KEY;
    if (keySet.has(panelOrderKey) || keySet.has(`${panelOrderKey}-bottom-set`)) {
      this.panelLayout.applySavedPanelOrder();
    }

    if (keySet.has(STORAGE_KEYS.mapLayers) && !this.state.initialUrlState?.layers) {
      const nextLayers = normalizeExclusiveChoropleths(
        sanitizeLayersForVariant(
          loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, this.state.mapLayers),
          SITE_VARIANT as MapVariant,
        ),
        this.state.mapLayers,
      );
      if (!CYBER_LAYER_ENABLED) nextLayers.cyberThreats = false;
      this.state.mapLayers = nextLayers;
      this.state.map?.setLayers(nextLayers);
      this.dataLoader.syncDataFreshnessWithLayers();
    }

    if (keySet.has(STORAGE_KEYS.mapMode)) {
      const mode = getStoredMapModePreference();
      if (mode === 'globe') this.state.map?.switchToGlobe();
      else this.state.map?.switchToFlat();
    }

    if (keySet.has(STORAGE_KEYS.disabledFeeds)) {
      this.state.disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
    }

    if (keySet.has(STORAGE_KEYS.monitors)) {
      this.state.monitors = loadFromStorage<Monitor[]>(STORAGE_KEYS.monitors, []);
      const monitorPanel = this.state.panels['monitors'] as MonitorPanel | undefined;
      monitorPanel?.setMonitors(this.state.monitors);
      this.dataLoader.updateMonitorResults();
    }
  }

  private isPanelNearViewport(panelId: string, marginPx = 400): boolean {
    const panel = this.state.panels[panelId] as { isNearViewport?: (marginPx?: number) => boolean } | undefined;
    return panel?.isNearViewport?.(marginPx) ?? false;
  }

  private isAnyPanelNearViewport(panelIds: string[], marginPx = 400): boolean {
    return panelIds.some((panelId) => this.isPanelNearViewport(panelId, marginPx));
  }

  private shouldRefreshIntelligence(): boolean {
    return this.isAnyPanelNearViewport(['cii', 'strategic-risk', 'strategic-posture'])
      || !!this.state.countryBriefPage?.isVisible();
  }

  private shouldRefreshFirms(): boolean {
    return this.isPanelNearViewport('satellite-fires');
  }

  private shouldRefreshCorrelation(): boolean {
    return this.isAnyPanelNearViewport(['military-correlation', 'escalation-correlation', 'economic-correlation', 'disaster-correlation']);
  }

  private getCachedBootstrapUpdatedAt(): number | null {
    const cachedTierTimestamps = Object.values(this.bootstrapHydrationState.tiers)
      .filter((tier) => tier.source === 'cached')
      .map((tier) => tier.updatedAt)
      .filter((value): value is number => typeof value === 'number' && Number.isFinite(value));

    if (cachedTierTimestamps.length === 0) return null;
    return Math.min(...cachedTierTimestamps);
  }

  private updateConnectivityUi(): void {
    const statusIndicator = this.state.container.querySelector('.status-indicator');
    const statusLabel = statusIndicator?.querySelector('span:last-child');
    const online = typeof navigator === 'undefined' ? true : navigator.onLine !== false;
    // Only treat a complete cache fallback (no live data at all) as "cached" for UI purposes.
    // 'mixed' means live data was partially fetched — showing "Live data unavailable" would be misleading.
    const usingCachedBootstrap = this.bootstrapHydrationState.source === 'cached';
    const cachedUpdatedAt = this.getCachedBootstrapUpdatedAt();

    let statusMode: 'live' | 'cached' | 'unavailable' = 'live';
    let bannerMessage: string | null = null;

    if (!online) {
      // Offline: show banner regardless of mixed/cached (any cached data is better than nothing)
      const hasAnyCached = this.bootstrapHydrationState.source === 'cached' || this.bootstrapHydrationState.source === 'mixed';
      if (hasAnyCached) {
        statusMode = 'cached';
        const offlineCachedAt = this.bootstrapHydrationState.tiers
          ? Math.min(...Object.values(this.bootstrapHydrationState.tiers)
              .filter((tier) => tier.source === 'cached' || tier.source === 'mixed')
              .map((tier) => tier.updatedAt)
              .filter((v): v is number => typeof v === 'number' && Number.isFinite(v)))
          : NaN;
        const freshness = Number.isFinite(offlineCachedAt) ? describeFreshness(offlineCachedAt) : t('common.cached').toLowerCase();
        bannerMessage = t('connectivity.offlineCached', { freshness });
      } else {
        statusMode = 'unavailable';
        bannerMessage = t('connectivity.offlineUnavailable');
      }
    } else if (usingCachedBootstrap) {
      statusMode = 'cached';
      const freshness = cachedUpdatedAt ? describeFreshness(cachedUpdatedAt) : t('common.cached').toLowerCase();
      bannerMessage = t('connectivity.cachedFallback', { freshness });
    }

    if (statusIndicator && statusLabel) {
      statusIndicator.classList.toggle('status-indicator--cached', statusMode === 'cached');
      statusIndicator.classList.toggle('status-indicator--unavailable', statusMode === 'unavailable');
      statusLabel.textContent = statusMode === 'live'
        ? t('header.live')
        : statusMode === 'cached'
          ? t('header.cached')
          : t('header.unavailable');
    }

    if (bannerMessage) {
      if (!this.cachedModeBannerEl) {
        this.cachedModeBannerEl = document.createElement('div');
        this.cachedModeBannerEl.className = 'cached-mode-banner';
        this.cachedModeBannerEl.setAttribute('role', 'status');
        this.cachedModeBannerEl.setAttribute('aria-live', 'polite');

        const badge = document.createElement('span');
        badge.className = 'cached-mode-banner__badge';
        const text = document.createElement('span');
        text.className = 'cached-mode-banner__text';
        this.cachedModeBannerEl.append(badge, text);

        const header = this.state.container.querySelector('.header');
        if (header?.parentElement) {
          header.insertAdjacentElement('afterend', this.cachedModeBannerEl);
        } else {
          this.state.container.prepend(this.cachedModeBannerEl);
        }
      }

      this.cachedModeBannerEl.classList.toggle('cached-mode-banner--unavailable', statusMode === 'unavailable');
      const badge = this.cachedModeBannerEl.querySelector('.cached-mode-banner__badge')!;
      const text = this.cachedModeBannerEl.querySelector('.cached-mode-banner__text')!;
      badge.textContent = statusMode === 'cached' ? t('header.cached') : t('header.unavailable');
      text.textContent = bannerMessage;
      return;
    }

    this.cachedModeBannerEl?.remove();
    this.cachedModeBannerEl = null;
  }

  private async primeVisiblePanelData(forceAll = false): Promise<void> {
    const tasks: Promise<unknown>[] = [];
    const primeTask = (key: string, task: () => Promise<unknown>): void => {
      if (this.visiblePanelPrimed.has(key) || this.state.inFlight.has(key)) return;
      const wrapped = (async () => {
        this.state.inFlight.add(key);
        try {
          await task();
          this.visiblePanelPrimed.add(key);
        } finally {
          this.state.inFlight.delete(key);
        }
      })();
      tasks.push(wrapped);
    };

    const shouldPrime = (id: string): boolean => forceAll || this.isPanelNearViewport(id);
    const shouldPrimeAny = (ids: string[]): boolean => forceAll || this.isAnyPanelNearViewport(ids);

    if (shouldPrime('service-status')) {
      const panel = this.state.panels['service-status'] as ServiceStatusPanel | undefined;
      if (panel) primeTask('service-status', () => panel.fetchStatus());
    }
    if (shouldPrime('macro-signals')) {
      const panel = this.state.panels['macro-signals'] as MacroSignalsPanel | undefined;
      if (panel) primeTask('macro-signals', () => panel.fetchData());
    }
    if (shouldPrime('fear-greed')) {
      const panel = this.state.panels['fear-greed'] as FearGreedPanel | undefined;
      if (panel) primeTask('fear-greed', () => panel.fetchData());
    }
    if (shouldPrime('hormuz-tracker')) {
      const panel = this.state.panels['hormuz-tracker'] as HormuzPanel | undefined;
      if (panel) primeTask('hormuz-tracker', () => panel.fetchData());
    }
    if (shouldPrime('etf-flows')) {
      const panel = this.state.panels['etf-flows'] as ETFFlowsPanel | undefined;
      if (panel) primeTask('etf-flows', () => panel.fetchData());
    }
    if (shouldPrime('stablecoins')) {
      const panel = this.state.panels.stablecoins as StablecoinPanel | undefined;
      if (panel) primeTask('stablecoins', () => panel.fetchData());
    }
    if (shouldPrime('energy-crisis')) {
      const panel = this.state.panels['energy-crisis'] as EnergyCrisisPanel | undefined;
      if (panel) primeTask('energy-crisis', () => panel.fetchData());
    }
    if (shouldPrime('telegram-intel')) {
      primeTask('telegram-intel', () => this.dataLoader.loadTelegramIntel());
    }
    if (shouldPrime('gulf-economies')) {
      const panel = this.state.panels['gulf-economies'] as GulfEconomiesPanel | undefined;
      if (panel) primeTask('gulf-economies', () => panel.fetchData());
    }
    if (shouldPrime('grocery-basket')) {
      const panel = this.state.panels['grocery-basket'] as GroceryBasketPanel | undefined;
      if (panel) primeTask('grocery-basket', () => panel.fetchData());
    }
    if (shouldPrime('bigmac')) {
      const panel = this.state.panels['bigmac'] as BigMacPanel | undefined;
      if (panel) primeTask('bigmac', () => panel.fetchData());
    }
    if (shouldPrime('fuel-prices')) {
      const panel = this.state.panels['fuel-prices'] as FuelPricesPanel | undefined;
      if (panel) primeTask('fuel-prices', () => panel.fetchData());
    }
    if (shouldPrime('fao-food-price-index')) {
      const panel = this.state.panels['fao-food-price-index'] as FaoFoodPriceIndexPanel | undefined;
      if (panel) primeTask('fao-food-price-index', () => panel.fetchData());
    }
    if (shouldPrime('oil-inventories')) {
      const panel = this.state.panels['oil-inventories'] as OilInventoriesPanel | undefined;
      if (panel) primeTask('oil-inventories', () => panel.fetchData());
    }
    // Energy Atlas panels — each self-fetches via bootstrap cache + RPC fallback
    // (scripts/seed-pipelines-{gas,oil}.mjs, seed-storage-facilities.mjs,
    // seed-fuel-shortages.mjs, seed-energy-disruptions.mjs). Without these
    // primeTask wires the panels sit at showLoading() forever because
    // Panel's constructor calls showLoading() but nothing else triggers
    // fetchData() on attach — App.ts's primeTask table is the sole
    // near-viewport kickoff path.
    if (shouldPrime('pipeline-status')) {
      const panel = this.state.panels['pipeline-status'] as PipelineStatusPanel | undefined;
      if (panel) primeTask('pipeline-status', () => panel.fetchData());
    }
    if (shouldPrime('storage-facility-map')) {
      const panel = this.state.panels['storage-facility-map'] as StorageFacilityMapPanel | undefined;
      if (panel) primeTask('storage-facility-map', () => panel.fetchData());
    }
    if (shouldPrime('fuel-shortages')) {
      const panel = this.state.panels['fuel-shortages'] as FuelShortagePanel | undefined;
      if (panel) primeTask('fuel-shortages', () => panel.fetchData());
    }
    if (shouldPrime('energy-disruptions')) {
      const panel = this.state.panels['energy-disruptions'] as EnergyDisruptionsPanel | undefined;
      if (panel) primeTask('energy-disruptions', () => panel.fetchData());
    }
    if (shouldPrime('energy-risk-overview')) {
      const panel = this.state.panels['energy-risk-overview'] as EnergyRiskOverviewPanel | undefined;
      if (panel) primeTask('energy-risk-overview', () => panel.fetchData());
    }
    if (shouldPrime('chokepoint-strip')) {
      // Without this primeTask entry the panel mounts via panel-layout.ts and
      // ENERGY_PANELS but its constructor only calls showLoading() — fetchData()
      // never fires, so the panel sits at "Loading..." forever. Hard-learned in
      // PR #3386; tracked as skill panel-stuck-loading-means-missing-primetask.
      const panel = this.state.panels['chokepoint-strip'] as ChokepointStripPanel | undefined;
      if (panel) primeTask('chokepoint-strip', () => panel.fetchData());
    }
    if (shouldPrime('climate-news')) {
      const panel = this.state.panels['climate-news'] as ClimateNewsPanel | undefined;
      if (panel) primeTask('climate-news', () => panel.fetchData());
    }
    if (shouldPrime('consumer-prices')) {
      const panel = this.state.panels['consumer-prices'] as ConsumerPricesPanel | undefined;
      if (panel) primeTask('consumer-prices', () => panel.fetchData());
    }
    if (shouldPrime('defense-patents')) {
      const panel = this.state.panels['defense-patents'] as DefensePatentsPanel | undefined;
      if (panel) primeTask('defense-patents', () => { panel.refresh(); return Promise.resolve(); });
    }
    if (shouldPrime('macro-tiles')) {
      const panel = this.state.panels['macro-tiles'] as MacroTilesPanel | undefined;
      if (panel) primeTask('macro-tiles', () => panel.fetchData());
    }
    if (shouldPrime('fsi')) {
      const panel = this.state.panels['fsi'] as FSIPanel | undefined;
      if (panel) primeTask('fsi', () => panel.fetchData());
    }
    if (shouldPrime('yield-curve')) {
      const panel = this.state.panels['yield-curve'] as YieldCurvePanel | undefined;
      if (panel) primeTask('yield-curve', () => panel.fetchData());
    }
    if (shouldPrime('earnings-calendar')) {
      const panel = this.state.panels['earnings-calendar'] as EarningsCalendarPanel | undefined;
      if (panel) primeTask('earnings-calendar', () => panel.fetchData());
    }
    if (shouldPrime('economic-calendar')) {
      const panel = this.state.panels['economic-calendar'] as EconomicCalendarPanel | undefined;
      if (panel) primeTask('economic-calendar', () => panel.fetchData());
    }
    if (shouldPrime('cot-positioning')) {
      const panel = this.state.panels['cot-positioning'] as CotPositioningPanel | undefined;
      if (panel) primeTask('cot-positioning', () => panel.fetchData());
    }
    if (shouldPrime('liquidity-shifts')) {
      const panel = this.state.panels['liquidity-shifts'] as LiquidityShiftsPanel | undefined;
      if (panel) primeTask('liquidity-shifts', () => panel.fetchData());
    }
    if (shouldPrime('positioning-247')) {
      const panel = this.state.panels['positioning-247'] as PositioningPanel | undefined;
      if (panel) primeTask('positioning-247', () => panel.fetchData());
    }
    if (shouldPrime('gold-intelligence')) {
      const panel = this.state.panels['gold-intelligence'] as GoldIntelligencePanel | undefined;
      if (panel) primeTask('gold-intelligence', () => panel.fetchData());
    }
    if (shouldPrime('aaii-sentiment')) {
      primeTask('aaiiSentiment', () => this.dataLoader.loadAaiiSentiment());
    }
    if (shouldPrime('market-breadth')) {
      primeTask('marketBreadth', () => this.dataLoader.loadMarketBreadth());
    }
    if (shouldPrimeAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex'])) {
      primeTask('markets', () => this.dataLoader.loadMarkets());
    }
    if (shouldPrime('polymarket')) {
      primeTask('predictions', () => this.dataLoader.loadPredictions());
    }
    if (shouldPrime('economic')) {
      primeTask('fred', () => this.dataLoader.loadFredData());
      primeTask('spending', () => this.dataLoader.loadGovernmentSpending());
      primeTask('bis', () => this.dataLoader.loadBisData());
    }
    if (shouldPrime('energy-complex')) {
      primeTask('oil', () => this.dataLoader.loadOilAnalytics());
    }
    // trade-policy moved into the _wmAccess block below — see fix for
    // anonymous 401 bug where loadTradePolicy fired 6 PRO-gated RPCs
    // unconditionally on every page load.
    if (shouldPrime('supply-chain')) {
      primeTask('supplyChain', () => this.dataLoader.loadSupplyChain());
    }
    if (shouldPrime('cross-source-signals')) {
      primeTask('crossSourceSignals', () => this.dataLoader.loadCrossSourceSignals());
    }

    const _wmAccess = hasPremiumAccess();
    if (_wmAccess) {
      if (shouldPrime('trade-policy')) {
        primeTask('tradePolicy', () => this.dataLoader.loadTradePolicy());
      }
      if (shouldPrime('stock-analysis')) {
        primeTask('stockAnalysis', () => this.dataLoader.loadStockAnalysis());
      }
      if (shouldPrime('stock-backtest')) {
        primeTask('stockBacktest', () => this.dataLoader.loadStockBacktest());
      }
      if (shouldPrime('daily-market-brief')) {
        primeTask('dailyMarketBrief', () => this.dataLoader.loadDailyMarketBrief());
      }
      if (shouldPrime('market-implications')) {
        primeTask('marketImplications', () => this.dataLoader.loadMarketImplications());
      }
    }

    if (tasks.length > 0) {
      await Promise.allSettled(tasks);
    }
  }

  constructor(containerId: string) {
    const el = document.getElementById(containerId);
    if (!el) throw new Error(`Container ${containerId} not found`);

    this.uiReady = new Promise<void>((resolve) => {
      this.resolveUiReady = resolve;
    });

    const PANEL_ORDER_KEY = 'panel-order';
    const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';

    const isMobile = isMobileDevice();
    const isDesktopApp = isDesktopRuntime();
    const monitors = loadFromStorage<Monitor[]>(STORAGE_KEYS.monitors, []);

    // Use mobile-specific defaults on first load (no saved layers)
    const defaultLayers = isMobile ? MOBILE_DEFAULT_MAP_LAYERS : DEFAULT_MAP_LAYERS;

    let mapLayers: MapLayers;
    let panelSettings: Record<string, PanelConfig>;

    // Panels that must survive variant switches: desktop config, user-created widgets, MCP panels.
    const isDynamicPanel = (k: string) => !ALL_PANELS[k] && (k === 'runtime-config' || k.startsWith('cw-') || k.startsWith('mcp-'));

    // Check if variant changed - reset all settings to variant defaults
    const storedVariant = localStorage.getItem('worldmonitor-variant');
    const currentVariant = SITE_VARIANT;
    console.log(`[App] Variant check: stored="${storedVariant}", current="${currentVariant}"`);
    if (storedVariant !== currentVariant) {
      // Variant changed — seed new variant's panels, disable panels not in the new variant
      console.log('[App] Variant changed - seeding new defaults, disabling cross-variant panels');
      localStorage.setItem('worldmonitor-variant', currentVariant);
      // Reset map layers for the new variant (map layers are not user-personalized the same way)
      localStorage.removeItem(STORAGE_KEYS.mapLayers);
      mapLayers = normalizeExclusiveChoropleths(
        sanitizeLayersForVariant({ ...defaultLayers }, currentVariant as MapVariant), null,
      );
      // Load existing panel prefs (if any), disable panels not belonging to the new variant
      panelSettings = loadFromStorage<Record<string, PanelConfig>>(STORAGE_KEYS.panels, {});
      const newVariantKeys = new Set(VARIANT_DEFAULTS[currentVariant] ?? []);
      for (const key of Object.keys(panelSettings)) {
        if (!newVariantKeys.has(key) && !isDynamicPanel(key) && panelSettings[key]) {
          panelSettings[key] = { ...panelSettings[key]!, enabled: false };
        }
      }
      for (const key of newVariantKeys) {
        if (!(key in panelSettings)) {
          panelSettings[key] = { ...getEffectivePanelConfig(key, currentVariant) };
        }
      }
    } else {
      mapLayers = normalizeExclusiveChoropleths(
        sanitizeLayersForVariant(
          loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, defaultLayers),
          currentVariant as MapVariant,
        ), null,
      );
      panelSettings = loadFromStorage<Record<string, PanelConfig>>(
        STORAGE_KEYS.panels,
        DEFAULT_PANELS
      );

      // One-time migration: preserve user preferences across panel key renames.
      const PANEL_KEY_RENAMES_MIGRATION_KEY = 'worldmonitor-panel-key-renames-v2.6.8';
      if (!localStorage.getItem(PANEL_KEY_RENAMES_MIGRATION_KEY)) {
        let migrated = false;
        const keyRenames: Array<[string, string]> = [
          ['live-youtube', 'live-webcams'],
          ['pinned-webcams', 'windy-webcams'],
          ...(SITE_VARIANT === 'finance' ? [['regulation', 'fin-regulation'] as [string, string]] : []),
        ];
        // In non-finance variants, 'regulation' was dead config (no feeds). Just prune it.
        if (SITE_VARIANT !== 'finance' && panelSettings['regulation']) {
          delete panelSettings['regulation'];
          migrated = true;
        }
        for (const [legacyKey, nextKey] of keyRenames) {
          if (!panelSettings[legacyKey] || panelSettings[nextKey]) continue;
          panelSettings[nextKey] = {
            ...DEFAULT_PANELS[nextKey],
            ...panelSettings[legacyKey],
            name: DEFAULT_PANELS[nextKey]?.name ?? panelSettings[legacyKey].name,
          };
          delete panelSettings[legacyKey];
          migrated = true;
        }
        // Also migrate saved panel order/bottom-set entries for renamed keys
        for (const [legacyKey, nextKey] of keyRenames) {
          for (const orderKey of [PANEL_ORDER_KEY, PANEL_ORDER_KEY + '-bottom-set', PANEL_ORDER_KEY + '-bottom']) {
            try {
              const raw = localStorage.getItem(orderKey);
              if (!raw) continue;
              const arr = JSON.parse(raw);
              if (!Array.isArray(arr)) continue;
              const idx = arr.indexOf(legacyKey);
              if (idx !== -1) { arr[idx] = nextKey; localStorage.setItem(orderKey, JSON.stringify(arr)); migrated = true; }
            } catch { /* corrupt storage, skip */ }
          }
        }
        if (migrated) saveToStorage(STORAGE_KEYS.panels, panelSettings);
        localStorage.setItem(PANEL_KEY_RENAMES_MIGRATION_KEY, 'done');
      }

      // Merge in any panels from ALL_PANELS that didn't exist when settings were saved
      for (const key of Object.keys(ALL_PANELS)) {
        if (!(key in panelSettings)) {
          const config = getEffectivePanelConfig(key, SITE_VARIANT);
          const isInVariant = (VARIANT_DEFAULTS[SITE_VARIANT] ?? []).includes(key);
          panelSettings[key] = { ...config, enabled: isInVariant && config.enabled };
        }
      }

      // One-time migration: expose all panels to existing users (previously variant-gated)
      const UNIFIED_MIGRATION_KEY = 'worldmonitor-unified-panels-v1';
      if (!localStorage.getItem(UNIFIED_MIGRATION_KEY)) {
        const variantDefaults = new Set(VARIANT_DEFAULTS[SITE_VARIANT] ?? []);
        for (const key of Object.keys(ALL_PANELS)) {
          if (!(key in panelSettings)) {
            const config = getEffectivePanelConfig(key, SITE_VARIANT);
            panelSettings[key] = { ...config, enabled: variantDefaults.has(key) && config.enabled };
          }
        }
        saveToStorage(STORAGE_KEYS.panels, panelSettings);
        localStorage.setItem(UNIFIED_MIGRATION_KEY, 'done');
      }

      // One-time migration: fix happy variant sessions that got cross-variant panels enabled
      // (regression from #1911 unified panel registry which failed to disable non-variant panels on variant switch)
      const HAPPY_PANEL_FIX_KEY = 'worldmonitor-happy-panel-fix-v1';
      if (SITE_VARIANT === 'happy' && !localStorage.getItem(HAPPY_PANEL_FIX_KEY)) {
        const happyKeys = new Set(VARIANT_DEFAULTS['happy'] ?? []);
        let fixed = false;
        for (const key of Object.keys(panelSettings)) {
          if (!happyKeys.has(key) && !isDynamicPanel(key) && panelSettings[key]?.enabled) {
            panelSettings[key] = { ...panelSettings[key]!, enabled: false };
            fixed = true;
          }
        }
        if (fixed) saveToStorage(STORAGE_KEYS.panels, panelSettings);
        localStorage.setItem(HAPPY_PANEL_FIX_KEY, 'done');
      }

      console.log('[App] Loaded panel settings from storage:', Object.entries(panelSettings).filter(([_, v]) => !v.enabled).map(([k]) => k));

      // One-time migration: reorder panels for existing users (v1.9 panel layout)
      const PANEL_ORDER_MIGRATION_KEY = 'worldmonitor-panel-order-v1.9';
      if (!localStorage.getItem(PANEL_ORDER_MIGRATION_KEY)) {
        const savedOrder = localStorage.getItem(PANEL_ORDER_KEY);
        if (savedOrder) {
          try {
            const order: string[] = JSON.parse(savedOrder);
            const priorityPanels = ['insights', 'strategic-posture', 'cii', 'strategic-risk'];
            const filtered = order.filter(k => !priorityPanels.includes(k) && k !== 'live-news');
            const liveNewsIdx = order.indexOf('live-news');
            const newOrder = liveNewsIdx !== -1 ? ['live-news'] : [];
            newOrder.push(...priorityPanels.filter(p => order.includes(p)));
            newOrder.push(...filtered);
            localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder));
            console.log('[App] Migrated panel order to v1.9 layout');
          } catch {
            // Invalid saved order, will use defaults
          }
        }
        localStorage.setItem(PANEL_ORDER_MIGRATION_KEY, 'done');
      }

      // Tech variant migration: move insights to top (after live-news)
      if (currentVariant === 'tech') {
        const TECH_INSIGHTS_MIGRATION_KEY = 'worldmonitor-tech-insights-top-v1';
        if (!localStorage.getItem(TECH_INSIGHTS_MIGRATION_KEY)) {
          const savedOrder = localStorage.getItem(PANEL_ORDER_KEY);
          if (savedOrder) {
            try {
              const order: string[] = JSON.parse(savedOrder);
              const filtered = order.filter(k => k !== 'insights' && k !== 'live-news');
              const newOrder: string[] = [];
              if (order.includes('live-news')) newOrder.push('live-news');
              if (order.includes('insights')) newOrder.push('insights');
              newOrder.push(...filtered);
              localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder));
              console.log('[App] Tech variant: Migrated insights panel to top');
            } catch {
              // Invalid saved order, will use defaults
            }
          }
          localStorage.setItem(TECH_INSIGHTS_MIGRATION_KEY, 'done');
        }
      }
    }

    // One-time migration: prune removed panel keys from stored settings and order
    const PANEL_PRUNE_KEY = 'worldmonitor-panel-prune-v1';
    if (!localStorage.getItem(PANEL_PRUNE_KEY)) {
      const validKeys = new Set(Object.keys(ALL_PANELS));
      let pruned = false;
      for (const key of Object.keys(panelSettings)) {
        if (!validKeys.has(key) && key !== 'runtime-config') {
          delete panelSettings[key];
          pruned = true;
        }
      }
      if (pruned) saveToStorage(STORAGE_KEYS.panels, panelSettings);
      for (const orderKey of [PANEL_ORDER_KEY, PANEL_ORDER_KEY + '-bottom-set', PANEL_ORDER_KEY + '-bottom']) {
        try {
          const raw = localStorage.getItem(orderKey);
          if (!raw) continue;
          const arr = JSON.parse(raw);
          if (!Array.isArray(arr)) continue;
          const filtered = arr.filter((k: string) => validKeys.has(k));
          if (filtered.length !== arr.length) localStorage.setItem(orderKey, JSON.stringify(filtered));
        } catch { localStorage.removeItem(orderKey); }
      }
      localStorage.setItem(PANEL_PRUNE_KEY, 'done');
    }

    // One-time migration: clear stale panel ordering and sizing state
    const LAYOUT_RESET_MIGRATION_KEY = 'worldmonitor-layout-reset-v2.5';
    if (!localStorage.getItem(LAYOUT_RESET_MIGRATION_KEY)) {
      const hadSavedOrder = !!localStorage.getItem(PANEL_ORDER_KEY);
      const hadSavedSpans = !!localStorage.getItem(PANEL_SPANS_KEY);
      if (hadSavedOrder || hadSavedSpans) {
        localStorage.removeItem(PANEL_ORDER_KEY);
        localStorage.removeItem(PANEL_ORDER_KEY + '-bottom');
        localStorage.removeItem(PANEL_ORDER_KEY + '-bottom-set');
        clearPanelSpans();
        console.log('[App] Applied layout reset migration (v2.5): cleared panel order/spans');
      }
      localStorage.setItem(LAYOUT_RESET_MIGRATION_KEY, 'done');
    }

    // Desktop key management panel must always remain accessible in Tauri.
    if (isDesktopApp) {
      if (!panelSettings['runtime-config'] || !panelSettings['runtime-config'].enabled) {
        panelSettings['runtime-config'] = {
          ...panelSettings['runtime-config'],
          name: panelSettings['runtime-config']?.name ?? 'Desktop Configuration',
          enabled: true,
          priority: panelSettings['runtime-config']?.priority ?? 2,
        };
        saveToStorage(STORAGE_KEYS.panels, panelSettings);
      }
    }

    const initialUrlState: ParsedMapUrlState | null = parseMapUrlState(window.location.search, mapLayers);
    if (initialUrlState.layers) {
      mapLayers = normalizeExclusiveChoropleths(
        sanitizeLayersForVariant(initialUrlState.layers, currentVariant as MapVariant), null,
      );
      initialUrlState.layers = mapLayers;
    }
    if (!CYBER_LAYER_ENABLED) {
      mapLayers.cyberThreats = false;
    }
    // One-time migration: reduce default-enabled sources (full variant only)
    if (currentVariant === 'full') {
      const baseKey = 'worldmonitor-sources-reduction-v3';
      if (!localStorage.getItem(baseKey)) {
        const defaultDisabled = computeDefaultDisabledSources();
        saveToStorage(STORAGE_KEYS.disabledFeeds, defaultDisabled);
        localStorage.setItem(baseKey, 'done');
        const total = getTotalFeedCount();
        console.log(`[App] Sources reduction: ${defaultDisabled.length} disabled, ${total - defaultDisabled.length} enabled`);
      }
      // Locale boost: additively enable locale-matched sources (runs once per locale).
      // Reads the explicit-choice key (`wm-locale-explicit`, written by Settings →
      // Language) before falling back to navigator. Mirrors the i18n.ts:99
      // `wmExplicit` detector — without this, a user whose browser is en-US who
      // picks Magyar in Settings never gets the locale boost (the migration's
      // first run with `userLang='en'` sets `worldmonitor-locale-boost-en` and
      // the `userLang !== 'en'` short-circuit means the boost block never re-fires
      // for any subsequent locale choice). Direct localStorage read because
      // i18next isn't initialized yet here in the constructor — `initI18n()` is
      // called later inside `init()`.
      let explicitLocale = '';
      try { explicitLocale = localStorage.getItem('wm-locale-explicit') || ''; } catch { /* private mode */ }
      const userLang = ((explicitLocale || navigator.language || 'en').split('-')[0] ?? 'en').toLowerCase();
      const localeKey = `worldmonitor-locale-boost-${userLang}`;
      if (userLang !== 'en' && !localStorage.getItem(localeKey)) {
        const boosted = getLocaleBoostedSources(userLang);
        if (boosted.size > 0) {
          const current = loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []);
          const updated = current.filter(name => !boosted.has(name));
          saveToStorage(STORAGE_KEYS.disabledFeeds, updated);
          console.log(`[App] Locale boost (${userLang}): enabled ${current.length - updated.length} sources`);
        }
        localStorage.setItem(localeKey, 'done');
      }
    }

    const disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));

    // Build shared state object
    this.state = {
      map: null,
      isMobile,
      isDesktopApp,
      container: el,
      panels: {},
      newsPanels: {},
      panelSettings,
      mapLayers,
      allNews: [],
      newsByCategory: {},
      latestMarkets: [],
      latestPredictions: [],
      latestTechEvents: [],
      latestClusters: [],
      intelligenceCache: {},
      cyberThreatsCache: null,
      disabledSources,
      currentTimeRange: '7d',
      inFlight: new Set(),
      seenGeoAlerts: new Set(),
      monitors,
      signalModal: null,
      ensureSignalModal: () => this.ensureSignalModal(),
      statusPanel: null,
      searchModal: null,
      findingsBadge: null,
      breakingBanner: null,
      playbackControl: null,
      exportPanel: null,
      unifiedSettings: null,
      pizzintIndicator: null,
      correlationEngine: null,
      llmStatusIndicator: null,
      countryBriefPage: null,
      countryTimeline: null,
      positivePanel: null,
      countersPanel: null,
      progressPanel: null,
      breakthroughsPanel: null,
      heroPanel: null,
      digestPanel: null,
      speciesPanel: null,
      renewablePanel: null,
      authModal: null,
      authHeaderWidget: null,
      tvMode: null,
      happyAllItems: [],
      isDestroyed: false,
      isPlaybackMode: false,
      isIdle: false,
      initialLoadComplete: false,
      resolvedLocation: 'global',
      initialUrlState,
      PANEL_ORDER_KEY,
      PANEL_SPANS_KEY,
    };

    // Instantiate modules (callbacks wired after all modules exist)
    this.refreshScheduler = new RefreshScheduler(this.state);
    this.countryIntel = new CountryIntelManager(this.state);
    this.desktopUpdater = new DesktopUpdater(this.state);

    this.dataLoader = new DataLoaderManager(this.state, {
      renderCriticalBanner: (postures) => this.panelLayout.renderCriticalBanner(postures),
      refreshOpenCountryBrief: () => this.countryIntel.refreshOpenBrief(),
    });

    this.panelLayout = new PanelLayoutManager(this.state, {
      openCountryStory: (code, name) => {
        void this.countryIntel.openCountryStory(code, name).catch((err) => {
          console.error('[CountryStory] Failed to open story:', err);
          showToast('Country story failed to open. Please try again.');
        });
      },
      openCountryBrief: (code) => {
        const name = CountryIntelManager.resolveCountryName(code);
        void this.countryIntel.openCountryBriefByCode(code, name).catch((err) => {
          console.error('[CountryBrief] Failed to open country brief:', err);
          this.state.map?.setRenderPaused(false);
          showToast('Country brief failed to open. Please try again.');
        });
      },
      loadAllData: () => this.dataLoader.loadAllData(),
      updateMonitorResults: () => this.dataLoader.updateMonitorResults(),
      loadSecurityAdvisories: () => this.dataLoader.loadSecurityAdvisories(),
      applyMapLayerChange: (layer, enabled, source) => this.eventHandlers.applyMapLayerChange(layer, enabled, source),
    });

    this.eventHandlers = new EventHandlerManager(this.state, {
      openSearch: (options) => { void this.openSearch(options); },
      updateSearchIndex: () => this.updateSearchIndexIfReady(),
      loadAllData: () => this.dataLoader.loadAllData(),
      flushStaleRefreshes: () => this.refreshScheduler.flushStaleRefreshes(),
      setHiddenSince: (ts) => this.refreshScheduler.setHiddenSince(ts),
      loadDataForLayer: (layer) => { void this.dataLoader.loadDataForLayer(layer as keyof MapLayers); },
      waitForAisData: () => this.dataLoader.waitForAisData(),
      syncDataFreshnessWithLayers: () => this.dataLoader.syncDataFreshnessWithLayers(),
      ensureCorrectZones: () => this.panelLayout.ensureCorrectZones(),
      applySavedPanelOrder: (panelOrder?: string[]) => this.panelLayout.applySavedPanelOrder(panelOrder),
      refreshCiiAfterFocalPointsReady: () => this.dataLoader.refreshCiiAfterFocalPointsReady(),
      stopLayerActivity: (layer) => this.dataLoader.stopLayerActivity(layer),
      mountLiveNewsIfReady: () => this.panelLayout.mountLiveNewsIfReady(),
      updateFlightSource: (adsb, military) => this.updateFlightSourceIfReady(adsb, military),
    });

    // Wire cross-module callback: DataLoader → SearchManager
    this.dataLoader.updateSearchIndex = () => this.updateSearchIndexIfReady();

    // Track destroy order (reverse of init)
    this.modules = [
      this.desktopUpdater,
      this.panelLayout,
      this.countryIntel,
      this.dataLoader,
      this.refreshScheduler,
      this.eventHandlers,
    ];
  }

  private ensureSignalModal(): Promise<SignalModalInstance> {
    if (this.state.signalModal) return Promise.resolve(this.state.signalModal);
    if (this.signalModalLoad) return this.signalModalLoad;

    this.signalModalLoad = import('@/components/SignalModal')
      .then(({ SignalModal }) => {
        if (this.state.isDestroyed) {
          throw new Error('App destroyed before signal modal loaded');
        }
        const signalModal = new SignalModal();
        signalModal.setLocationClickHandler((lat, lon) => {
          this.state.map?.setCenter(lat, lon, 4);
        });
        this.state.signalModal = signalModal;
        return signalModal;
      })
      .catch((err) => {
        this.signalModalLoad = null;
        throw err;
      });

    return this.signalModalLoad;
  }

  private ensureSearchManager(): Promise<SearchManager> {
    if (this.searchManager) return Promise.resolve(this.searchManager);
    if (this.searchManagerLoad) return this.searchManagerLoad;

    this.searchManagerLoad = import('@/app/search-manager')
      .then(({ SearchManager }) => {
        if (this.state.isDestroyed) {
          throw new Error('App destroyed before search manager loaded');
        }

        const manager = new SearchManager(this.state, {
          openCountryBriefByCode: (code, country) => {
            void this.countryIntel.openCountryBriefByCode(code, country).catch((err) => {
              console.error('[CountryBrief] Failed to open country brief:', err);
              this.state.map?.setRenderPaused(false);
              showToast('Country brief failed to open. Please try again.');
            });
          },
          enablePanel: (panelId) => this.eventHandlers.enablePanelById(panelId),
        });
        manager.init();
        manager.updateFlightSource(this.latestSearchAdsb, this.latestSearchMilitary);
        this.searchManager = manager;
        this.modules.push(manager);
        return manager;
      })
      .finally(() => {
        this.searchManagerLoad = null;
      });

    return this.searchManagerLoad;
  }

  private updateSearchIndexIfReady(): void {
    this.searchManager?.updateSearchIndex();
  }

  private updateFlightSourceIfReady(
    adsb: Parameters<SearchManager['updateFlightSource']>[0],
    military: Parameters<SearchManager['updateFlightSource']>[1],
  ): void {
    this.latestSearchAdsb = adsb;
    this.latestSearchMilitary = military;
    this.searchManager?.updateFlightSource(adsb, military);
  }

  private async openSearch(options: { toggle?: boolean; throwOnFailure?: boolean } = {}): Promise<void> {
    // Concurrency model: each press registers its intent, then claims a
    // monotonic epoch. After the lazy load resolves, only the latest epoch acts
    // — superseded presses bail. This yields one deterministic modal.open() for
    // any Cmd+K / button interleaving during the first load (replacing the prior
    // two-field pending-toggle bookkeeping), while preserving net-toggle parity:
    // the XOR flip happens BEFORE the epoch claim so every rapid Cmd+K still
    // counts (odd → open, even → cancel), even the ones that get superseded.
    let epoch = this.openSearchEpoch;
    try {
      await this.waitForUiReady();

      const existingModal = this.state.searchModal;
      if (options.toggle && existingModal?.isOpen()) {
        existingModal.close();
        return;
      }

      const togglingBeforeLoad = Boolean(options.toggle) && !this.searchManager;
      if (togglingBeforeLoad) {
        this.searchToggleDesiredOpen = !this.searchToggleDesiredOpen;
      }

      epoch = ++this.openSearchEpoch;
      const manager = await this.ensureSearchManager();
      if (this.openSearchEpoch !== epoch) return;

      const wantOpen = togglingBeforeLoad ? this.searchToggleDesiredOpen : true;
      if (!wantOpen) return;

      manager.updateSearchIndex();
      const modal = this.state.searchModal;
      if (!modal) throw new Error('Search modal is not initialised');
      modal.open();
    } catch (error) {
      if (!this.state.isDestroyed) {
        console.warn('[search] Failed to load search manager:', error);
        if (!options.throwOnFailure) showToast('Search failed to load. Please try again.');
      }
      if (options.throwOnFailure) throw error;
    } finally {
      // Reset the toggle accumulator once the latest press settles.
      if (this.openSearchEpoch === epoch) this.searchToggleDesiredOpen = false;
    }
  }

  private async waitForSlowBootstrapCheckpoint(): Promise<void> {
    markLcpDebug('wm:data:slow-tier-wait-start');
    try {
      const settled = await waitForBootstrapSlowTier(isDesktopRuntime() ? 8_500 : 3_500);
      markLcpDebug('wm:data:slow-tier-wait-end', { settled });
      if (this.state.isDestroyed) return;
      this.bootstrapHydrationState = getBootstrapHydrationState();
      this.updateConnectivityUi();
    } catch {
      markLcpDebug('wm:data:slow-tier-wait-error');
    }
  }

  private async preloadCountryGeometryForPostLcpWork(): Promise<void> {
    markLcpDebug('wm:data:country-geometry-start');
    try {
      await preloadCountryGeometry();
      markLcpDebug('wm:data:country-geometry-ready');
    } catch {
      markLcpDebug('wm:data:country-geometry-error');
    }
  }

  private startPostLcpIntelligence(countryGeometryReady: Promise<void>, geometryAlreadyApplied: boolean): void {
    void countryGeometryReady.finally(() => {
      if (this.state.isDestroyed) return;
      // Replay geometry-dependent CII only when the fan-out ingested before
      // precision geometry was ready; otherwise the first-pass attribution is
      // already correct and a replay is a redundant compute + repaint (#4512).
      if (!geometryAlreadyApplied) {
        this.dataLoader.refreshGeometryDependentCiiAfterCountryGeometry();
      }
      // Correlation and country-learning use precision geometry/name matching,
      // but they are post-initial-data work and should not hold the LCP path.
      void this.loadInitialCorrelationEngine();
      startLearning();
    });
  }

  private async loadInitialCorrelationEngine(): Promise<void> {
    try {
      const {
        CorrelationEngine,
        militaryAdapter,
        escalationAdapter,
        economicAdapter,
        disasterAdapter,
      } = await import('@/services/correlation-engine');

      if (this.state.isDestroyed) return;
      const engine = new CorrelationEngine();
      engine.registerAdapter(militaryAdapter);
      engine.registerAdapter(escalationAdapter);
      engine.registerAdapter(economicAdapter);
      engine.registerAdapter(disasterAdapter);
      this.state.correlationEngine = engine;

      await engine.run(this.state);
      if (this.state.isDestroyed) return;
      for (const domain of ['military', 'escalation', 'economic', 'disaster'] as const) {
        const panel = this.state.panels[`${domain}-correlation`] as CorrelationPanel | undefined;
        panel?.updateCards(engine.getCards(domain));
      }
    } catch (error) {
      console.warn('[CorrelationEngine] Initial lazy load/run failed:', error);
    }
  }

  public async init(): Promise<void> {
    const initStart = performance.now();
    markLcpDebug('wm:boot:app-init-start');

    // WebMCP — register synchronously before any init awaits so agent
    // scanners (isitagentready.com, in-browser agents) find the tools on
    // their first probe. No-op in browsers without navigator.modelContext.
    // Bindings await `this.uiReady` (resolves after Phase-4 UI init) so
    // a tool invoked during the startup window waits for the target
    // panel to exist instead of throwing. A 10s timeout keeps a genuinely
    // broken state from hanging the caller. Store the returned controller
    // so destroy() can unregister every tool on teardown.
    this.webMcpController = registerWebMcpTools({
      openCountryBriefByCode: async (code, country) => {
        await this.waitForUiReady();
        if (!this.state.countryBriefPage) {
          throw new Error('Country brief panel is not initialised');
        }
        await this.countryIntel.openCountryBriefByCode(code, country);
      },
      resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
      openSearch: async () => {
        // openSearch() awaits UI readiness internally and throws on failure when
        // throwOnFailure is set, so the agent receives a real success/failure.
        // (Re-checking searchModal here would spuriously throw if a concurrent
        // Cmd+K closed it between open and the check — #4403 review ADV-4.)
        await this.openSearch({ throwOnFailure: true });
      },
    });

    window.addEventListener(I18N_RESOURCES_LOADED_EVENT, this.handleI18nResourcesLoaded);

    await initDB();
    startFlightHistoryCleanup();
    // Re-arm the lazy vessel runtime (a no-op on first boot; matters on a
    // same-document re-init after a prior App.destroy() disarmed it). The
    // history-cleanup interval itself still starts lazily on first vessel use.
    enableVesselRuntime();
    await initI18n();
    markLcpDebug('wm:boot:i18n-ready');
    initDeferredDashboardFonts();
    // Localize the static index.html shell — <title>, meta description, and
    // sr-only <h1> are baked in English so search crawlers see something
    // before JS runs; once i18n is ready we swap them to the user's locale.
    document.title = t('shell.documentTitle');
    const setMeta = (sel: string, val: string) => {
      const el = document.querySelector(sel);
      if (el) el.setAttribute('content', val);
    };
    setMeta('meta[name="description"]', t('shell.metaDescription'));
    setMeta('meta[property="og:title"]', t('shell.documentTitle'));
    setMeta('meta[property="og:description"]', t('shell.metaDescription'));
    setMeta('meta[name="twitter:title"]', t('shell.documentTitle'));
    setMeta('meta[name="twitter:description"]', t('shell.metaDescription'));
    // Mirror of OG_LOCALE in pro-test/src/i18n.ts. The two packages have
    // separate Vite roots and bundlers and can't share an import — keep the
    // tables aligned by hand when adding a locale here OR there.
    const ogLocaleMap: Record<string, string> = {
      en: 'en_US', bg: 'bg_BG', cs: 'cs_CZ', fr: 'fr_FR', de: 'de_DE', el: 'el_GR',
      es: 'es_ES', hr: 'hr_HR', hu: 'hu_HU', it: 'it_IT', pl: 'pl_PL', pt: 'pt_BR',
      nl: 'nl_NL', sv: 'sv_SE', ru: 'ru_RU', ar: 'ar_SA', fa: 'fa_IR', zh: 'zh_CN',
      ja: 'ja_JP', ko: 'ko_KR', ro: 'ro_RO', tr: 'tr_TR', th: 'th_TH', vi: 'vi_VN',
      hi: 'hi_IN',
    };
    const baseLang = (document.documentElement.lang || 'en').split('-')[0] || 'en';
    setMeta('meta[property="og:locale"]', ogLocaleMap[baseLang] || `${baseLang}_${baseLang.toUpperCase()}`);
    const srH1 = document.querySelector('body > h1');
    if (srH1) srH1.textContent = t('shell.documentTitle');
    const aiFlow = getAiFlowSettings();
    if (aiFlow.browserModel || isDesktopRuntime()) {
      await mlWorker.init();
      if (BETA_MODE) mlWorker.loadModel('summarization-beta').catch(() => { });
    }

    // Headline Memory requires Browser Local Model to be ON — `isHeadlineMemoryEnabled()`
    // ANDs both flags. Without this gate, leaving Headline Memory on while turning
    // Browser Local Model off would silently download/run an embeddings model the user
    // opted out of via the parent toggle.
    if (isHeadlineMemoryEnabled()) {
      mlWorker.init().then(ok => {
        if (ok) mlWorker.loadModel('embeddings').catch(() => { });
      }).catch(() => { });
    }

    this.unsubAiFlow = subscribeAiFlowChange((key) => {
      if (key === 'browserModel') {
        const s = getAiFlowSettings();
        if (s.browserModel) {
          mlWorker.init().then(ok => {
            // Re-honor Headline Memory's persisted value on parent re-enable.
            if (ok && isHeadlineMemoryEnabled()) {
              mlWorker.loadModel('embeddings').catch(() => { });
            }
          }).catch(() => { });
        } else if (!isDesktopRuntime()) {
          // Browser Local Model is the parent toggle for ALL local-model use,
          // including Headline Memory. Terminate unconditionally on web —
          // any persisted Headline Memory value is now non-effective.
          mlWorker.terminate();
        }
      }
      if (key === 'headlineMemory') {
        if (isHeadlineMemoryEnabled()) {
          mlWorker.init().then(ok => {
            if (ok) mlWorker.loadModel('embeddings').catch(() => { });
          }).catch(() => { });
        } else {
          mlWorker.unloadModel('embeddings').catch(() => { });
          const s = getAiFlowSettings();
          if (!s.browserModel && !isDesktopRuntime()) {
            mlWorker.terminate();
          }
        }
      }
    });

    // Check AIS configuration before init
    if (!isAisConfigured()) {
      this.state.mapLayers.ais = false;
    } else if (this.state.mapLayers.ais) {
      initAisStream();
    }

    // Wait for sidecar readiness on desktop so bootstrap hits a live server
    if (isDesktopRuntime()) {
      await waitForSidecarReady(3000);
      markLcpDebug('wm:boot:sidecar-ready');
    }

    // Anonymous browser session token (issue #3541). Server's validateApiKey
    // no longer trusts header-only signals (Origin / Referer / Sec-Fetch-Site
    // are all forgeable). Install a fetch interceptor ONCE, then mint a
    // wms_-prefixed HMAC token before the first API call. Desktop has its own
    // API key path and doesn't need this; Clerk-authenticated users will pass
    // their JWT in a Bearer header and the interceptor steps aside.
    if (!isDesktopRuntime()) {
      installWmSessionFetchInterceptor();
      await ensureWmSession();
      markLcpDebug('wm:boot:session-ready');
    }

    // Hydrate in-memory cache from bootstrap endpoint. Awaits only the fast tier; the slow
    // tier loads in the background (off the first-paint critical path, #4488) and calls back
    // when it lands so the connectivity indicator re-snapshots (no reactive emitter exists).
    await fetchBootstrapData(() => {
      if (this.state.isDestroyed) return;
      this.bootstrapHydrationState = getBootstrapHydrationState();
      this.updateConnectivityUi();
    });
    markLcpDebug('wm:boot:fast-bootstrap-ready');
    this.bootstrapHydrationState = getBootstrapHydrationState();

    // Verify OAuth OTT and hydrate auth session BEFORE any UI subscribes to auth state
    await initAuthState();
    initAuthAnalytics();
    installCloudPrefsSync(SITE_VARIANT);
    window.addEventListener(CLOUD_PREFS_APPLIED_EVENT, this.handleCloudPrefsApplied);
    // Install the followed-countries auth listener once. Drives the
    // anon→signed-in handoff (mergeAnonymousLocal mutation) and sign-out
    // cleanup. Idempotent.
    installFollowedCountriesAuthListener();
    window.addEventListener(WM_FOLLOWED_COUNTRIES_CAP_DROP, this.handleFollowedCountriesCapDrop);
    this.enforceFreeTierLimits();

    let _prevUserId: string | null = null;
    // Track the last-seen PRO entitlement so we can re-fire PRO-gated loaders
    // ONCE on a false→true transition (user signs in / purchase lands mid-session).
    // Without this, loaders gated behind hasPremiumAccess() at init time (e.g.
    // loadTradePolicy) would sit empty until the next scheduled refresh — for
    // trade-policy that's a 10-minute wait post-sign-in. See PR #3295 review.
    let _prevHadPremium = hasPremiumAccess();
    // Pro-loader fan-out runs on EITHER Clerk auth changes OR Convex
    // entitlement changes — Pro can come from either signal (Clerk
    // user.role === 'pro' OR Convex tier >= 1 via Dodo). User-reported
    // on commodity.worldmonitor.app: Trade Policy panel stuck at "Loading…"
    // for a Pro Monthly subscriber because the original listener only
    // watched subscribeAuthState (Clerk-only); Convex Free→Pro transitions
    // never re-fired loadTradePolicy. Same root cause as PR #3409 layer-unlock.
    const firePremiumLoaders = (): void => {
      this.enforceFreeTierLimits();
      const hadPremium = _prevHadPremium;
      const nowPremium = hasPremiumAccess();
      if (nowPremium && !hadPremium) {
        // Entitlement just resolved → fire PRO-gated initial loads that were
        // skipped at boot. Each loader early-returns if the panel isn't
        // mounted and re-checks hasPremiumAccess() internally, so these
        // calls are safe and idempotent. Without this, panels would sit empty
        // until the next scheduled refresh (10+ min for trade-policy; FOREVER
        // on the full variant for stock-analysis / stock-backtest / daily-
        // market-brief / market-implications because their schedulers are
        // gated to SITE_VARIANT === 'finance'). The audit-locking regression
        // test in tests/premium-loaders-fan-out-coverage.test.mts asserts
        // every `hasPremiumAccess() && shouldLoad('X')` gate in data-loader.ts
        // has a matching call here.
        void this.dataLoader.loadTradePolicy();
        void this.dataLoader.loadStockAnalysis();
        void this.dataLoader.loadStockBacktest();
        void this.dataLoader.loadDailyMarketBrief();
        void this.dataLoader.loadMarketImplications();
        void this.dataLoader.loadWsbTickers();
        void this.dataLoader.loadResilienceRanking();
      }
      _prevHadPremium = nowPremium;
    };
    this.unsubEntitlementPremiumLoaders = onEntitlementChange(() => firePremiumLoaders());
    this.unsubFreeTier = subscribeAuthState((session) => {
      firePremiumLoaders();

      const userId = session.user?.id ?? null;
      if (userId !== null && userId !== _prevUserId) {
        void cloudPrefsSignIn(userId, SITE_VARIANT);

        // Rebind Convex watches to the real Clerk userId (was bound to anon UUID at init)
        destroyEntitlementSubscription();
        destroySubscriptionWatch();
        void initEntitlementSubscription(userId);
        void initSubscriptionWatch(userId);

        // Claim any anonymous purchase made before sign-in (anon → real user migration)
        const anonId = getStoredAnonId();
        if (anonId) {
          void (async () => {
            const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
            if (!client || !api) return;
            // Wait for ConvexClient WebSocket auth handshake to complete.
            // Without this, mutations arrive at Convex before the server
            // has the JWT → "Authentication required" errors.
            const ready = await waitForConvexAuth(10_000);
            if (!ready) {
              console.warn('[billing] claimSubscription skipped — Convex auth not ready');
              return;
            }
            const claimToken = getFreshStoredAnonClaimToken() ?? undefined;
            const result = await client.mutation(api.payments.billing.claimSubscription, {
              anonId,
              ...(claimToken ? { claimToken } : {}),
            });
            const claimed = result.claimed;
            const totalClaimed = claimed.subscriptions + claimed.entitlements +
                                 claimed.customers + claimed.payments;
            if (totalClaimed > 0) {
              console.log('[billing] Claimed anon subscription on sign-in:', claimed);
            }
            // Always remove after non-throwing completion — mutation is idempotent.
            // Prevents cold Convex init + mutation on every sign-in for non-purchasers.
            clearStoredAnonIdentity();
          })().catch((err: unknown) => {
            console.warn('[billing] claimSubscription failed:', err);
            // Non-fatal — anon ID preserved for retry on next page load
          });
        }
        void resumePendingCheckout({
          openAuth: () => this.state.authModal?.open(),
        });
      } else if (userId === null && _prevUserId !== null) {
        destroyEntitlementSubscription();
        destroySubscriptionWatch();
        cloudPrefsSignOut();
        resetEntitlementState();
      }
      _prevUserId = userId;
    });


    const geoCoordsPromise: Promise<PreciseCoordinates | null> =
      this.state.isMobile && this.state.initialUrlState?.lat === undefined && this.state.initialUrlState?.lon === undefined
        ? resolvePreciseUserCoordinates(5000)
        : Promise.resolve(null);

    const resolvedRegion = await resolveUserRegion();
    this.state.resolvedLocation = resolvedRegion;

    // Phase 1: Layout (creates map + panels — they'll find hydrated data).
    // init() is async so the dynamic MapContainer import can resolve before
    // downstream code (e.g. mobileGeoCoords→state.map.setCenter) reads ctx.map.
    markLcpDebug('wm:layout:init-start');
    await this.panelLayout.init();
    markLcpDebug('wm:layout:init-complete');
    this.eventHandlers.setupSearchControls();
    showProBanner(this.state.container);
    this.updateConnectivityUi();
    window.addEventListener('online', this.handleConnectivityChange);
    window.addEventListener('offline', this.handleConnectivityChange);

    const mobileGeoCoords = await geoCoordsPromise;
    if (mobileGeoCoords && this.state.map) {
      this.state.map.setCenter(mobileGeoCoords.lat, mobileGeoCoords.lon, 6);
    }

    // Happy variant: pre-populate panels from persistent cache for instant render
    if (SITE_VARIANT === 'happy') {
      await this.dataLoader.hydrateHappyPanelsFromCache();
    }

    // Phase 2: Shared UI components
    if (!this.state.isMobile) {
      void this.initFindingsBadge();
    }

    initBreakingNewsAlerts();
    this.state.breakingBanner = new BreakingNewsBanner();

    // Phase 3: UI setup methods
    this.eventHandlers.startHeaderClock();
    this.eventHandlers.setupPlaybackControl();
    this.eventHandlers.setupStatusPanel();
    this.eventHandlers.setupPizzIntIndicator();
    this.eventHandlers.setupLlmStatusIndicator();
    this.eventHandlers.setupExportPanel();
    this.eventHandlers.setupSearchControls();

    // Correlation engine is constructed lazily at its post-loadAllData run site
    // (Phase 6 below) so its bytes + adapters stay off the eager boot graph (#4486).
    this.eventHandlers.setupUnifiedSettings();
    this.eventHandlers.setupAuthWidget();
    // Capture any ?ref= / ?wm_referral= from the URL into localStorage
    // and strip from the visible URL. Runs BEFORE the pending-checkout
    // capture so a /dashboard?ref=X&checkoutProduct=Y landing preserves both
    // signals. Pure read of current URL — no-op when neither param is
    // present.
    captureReferralFromUrl();
    // Wire checkout-attempt lifecycle watchers (sign-out clear) before
    // any capture/resume path runs, so a stale session from a prior
    // user can't bleed into the current one.
    initCheckoutWatchers();
    // Stale attempt records are ignored by loadCheckoutAttempt() via
    // the 24h TTL — no separate sweep needed. The attempt record's
    // only consumer (the failure-retry banner) runs handleCheckoutReturn
    // synchronously during panel-layout mount, which is after the
    // captureePendingCheckoutIntentFromUrl repopulates it for any /pro
    // handoff — so no race exists that would want to sweep pre-capture.
    const pendingCheckout = capturePendingCheckoutIntentFromUrl();
    if (pendingCheckout) {
      // Checkout intent from /pro page redirect. Resume immediately if
      // already authenticated, otherwise the auth callback handles it.
      void resumePendingCheckout({
        openAuth: () => this.state.authModal?.open(),
      });
    }

    // Phase 4: MapLayerHandlers, CountryIntel. SearchManager is lazy-loaded
    // on first CMD+K/search-button open so its modal catalog stays off startup.
    this.eventHandlers.setupMapLayerHandlers();
    await this.countryIntel.init();
    // Unblock any WebMCP tool invocations that arrived during startup.
    this.resolveUiReady();

    // Phase 5: Event listeners + URL sync
    this.eventHandlers.init();
    // Capture deep link params BEFORE URL sync overwrites them
    const initState = parseMapUrlState(window.location.search, this.state.mapLayers);
    this.pendingDeepLinkCountry = initState.country ?? null;
    this.pendingDeepLinkExpanded = initState.expanded === true;
    const earlyParams = new URLSearchParams(window.location.search);
    this.pendingDeepLinkStoryCode = earlyParams.get('c') ?? null;
    this.eventHandlers.setupUrlStateSync();
    if (import.meta.env.VITE_E2E === '1') {
      document.documentElement.dataset.wmEventHandlersReady = 'true';
    }

    this.state.countryBriefPage?.onStateChange?.(() => {
      this.eventHandlers.syncUrlState();
    });

    // Start deep link handling early — its retry loop polls hasSufficientData()
    // independently, so it must not be gated behind loadAllData() which can hang.
    this.handleDeepLinks();

    // Phase 6: Data loading
    this.dataLoader.syncDataFreshnessWithLayers();
    const slowTierReady = this.waitForSlowBootstrapCheckpoint();
    if (this.state.isDestroyed) return;
    // Prime panel-specific data concurrently with bulk loading.
    // primeVisiblePanelData owns ETF, Stablecoins, Gulf Economies, etc. that
    // are NOT part of loadAllData. Running them in parallel prevents those
    // panels from being blocked when a loadAllData batch is slow.
    window.addEventListener('scroll', this.handleViewportPrime, { passive: true });
    window.addEventListener('resize', this.handleViewportPrime);
    // forceAll=false at bootstrap: data-loader's existing per-panel
    // viewport gate (shouldLoad(id) = forceAll || isPanelNearViewport(id))
    // now actually fires, cutting the ~80-request fan-out down to the
    // panels currently above the fold. IntersectionObserver wiring in
    // panel-layout.ts plus handleViewportPrime above re-trigger
    // loadAllData() as below-fold panels enter the viewport. (#3990)
    // Slow-tier hydration keys are consume-once (getHydratedData deletes on
    // read) and the visible-data consumers in loadAllData read them at task
    // start. If the fan-out runs before the slow tier settles, those reads miss
    // and fall back to per-panel RPCs that never re-read the late payload —
    // wasting the ~500 KB slow-tier bootstrap. The shell LCP element already
    // painted back in panelLayout.init() (Phase 1), so awaiting here is OFF the
    // LCP critical path; it stays bounded by waitForBootstrapSlowTier's timeout
    // (3.5 s browser / 8.5 s desktop). (#4512)
    await slowTierReady;
    if (this.state.isDestroyed) return;
    // Snapshot whether precision geometry was already loaded BEFORE the fan-out
    // (the map renderer triggers the memoized fetch early). If so, the fan-out's
    // geometry-dependent CII ingests already attributed correctly and the
    // post-LCP replay would just be a redundant second CII compute + choropleth
    // repaint, so we skip it below. (#4512)
    const geometryReadyBeforeFanout = isCountryGeometryLoaded();
    markLcpDebug('wm:data:initial-fanout-start');
    await Promise.all([
      this.dataLoader.loadAllData(),
      this.primeVisiblePanelData(),
    ]);
    markLcpDebug('wm:data:initial-fanout-complete');
    const countryGeometryReady = this.preloadCountryGeometryForPostLcpWork();

    // If bootstrap was served from cache but live data just loaded, promote the status indicator
    markBootstrapAsLive();
    this.bootstrapHydrationState = getBootstrapHydrationState();
    this.updateConnectivityUi();

    // Initial correlation engine run is post-LCP background work. Wait for
    // precision country geometry there instead of before visible data fan-out.
    this.startPostLcpIntelligence(countryGeometryReady, geometryReadyBeforeFanout);

    // Hide unconfigured layers after first data load
    if (!isAisConfigured()) {
      this.state.map?.hideLayerToggle('ais');
    }
    if (isOutagesConfigured() === false) {
      this.state.map?.hideLayerToggle('outages');
    }
    if (!CYBER_LAYER_ENABLED) {
      this.state.map?.hideLayerToggle('cyberThreats');
    }

    // Phase 7: Refresh scheduling
    this.setupRefreshIntervals();
    this.eventHandlers.setupSnapshotSaving();
    cleanOldSnapshots().catch((e) => console.warn('[Storage] Snapshot cleanup failed:', e));

    // Phase 8: Update checks
    this.desktopUpdater.init();

    // Analytics
    trackEvent('wm_app_loaded', {
      load_time_ms: Math.round(performance.now() - initStart),
      panel_count: Object.keys(this.state.panels).length,
    });
    this.eventHandlers.setupPanelViewTracking();
  }

  /**
   * Enforce free-tier panel and source limits.
   * Reads current values from storage, trims if necessary, and saves back.
   * Safe to call multiple times (idempotent) — e.g. on auth state changes.
   */
  private enforceFreeTierLimits(): void {
    // ── One-time v1 cap-bug recovery ──────────────────────────────────
    // Pre-2026-05-01 the source cap was enforced by Array.sort().slice(),
    // which silently auto-disabled every source past alphabetical position
    // FREE_MAX_SOURCES — catastrophically erasing late-alphabet categories
    // (Layoffs, Semiconductors, IPO, Funding, Product Hunt, …). Storage
    // didn't track auto-disabled vs user-disabled, so a heuristic that runs
    // on every load would silently undo a user who legitimately disabled
    // every source in a category — and re-undo it on every refresh forever.
    //
    // Migration approach: run findFullyDisabledCategories ONCE, gated by
    // disabledFeedsSchema version. After the migration completes, bump
    // schema → 1 so subsequent loads skip recovery entirely. Users who
    // explicitly toggle off every source in a category post-migration
    // keep that preference permanently. Trade-off: a user who BEFORE the
    // migration legitimately disabled every source in a category will lose
    // those preferences once. That's acceptable since v1 victims have been
    // suffering silent breakage and the explicit-full-category-disable
    // pattern is rare (users typically hide the whole panel instead).
    const schemaVersion = loadFromStorage<number>(STORAGE_KEYS.disabledFeedsSchema, 0);
    if (schemaVersion < 1) {
      const disabled = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
      const recoverable = findFullyDisabledCategories(FEEDS, disabled);
      if (recoverable.length > 0) {
        for (const name of recoverable) disabled.delete(name);
        saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(disabled));
        console.log(`[App] One-time v1-cap-bug migration: re-enabled ${recoverable.length} source(s) from fully-disabled categories. This will not run again.`);
      }
      saveToStorage(STORAGE_KEYS.disabledFeedsSchema, 1);
    }

    if (isProUser()) return;

    // --- Panel limit ---
    // Delegate to the shared enforceFreePanelLimit helper so this boot path and
    // the dashboard-tab add/switch/load paths stay in lockstep (same cw-* and
    // count rules). isPro is false here — the isProUser() early-return above
    // already short-circuited pro users.
    let panelSettings = loadFromStorage<Record<string, PanelConfig>>(STORAGE_KEYS.panels, {});
    let panelsChanged = false;
    if (!localStorage.getItem(FREE_MAP_PANEL_ACCESS_KEY)) {
      const restoredPanels = restoreFreeMapPanelAccess(panelSettings);
      if (panelSettings.map?.enabled !== restoredPanels.map?.enabled) {
        panelSettings = restoredPanels;
        panelsChanged = true;
      }
      localStorage.setItem(FREE_MAP_PANEL_ACCESS_KEY, 'done');
    }
    const clampedPanels = enforceFreePanelLimit(panelSettings, false);
    for (const key of Object.keys(panelSettings)) {
      if (panelSettings[key]?.enabled !== clampedPanels[key]?.enabled) {
        panelsChanged = true;
        break;
      }
    }
    if (panelsChanged) {
      saveToStorage(STORAGE_KEYS.panels, clampedPanels);
      this.state.panelSettings = clampedPanels;
      console.log(`[App] Free tier: enforced ${FREE_MAX_PANELS}-panel limit (disabled over-cap / cw-* panels)`);
    }

    // --- Source limit ---
    // Free-tier 80-source cap. Pre-2026-05-01 this used `Array.sort().slice()`
    // which silently auto-disabled every source past alphabetical position 80,
    // catastrophically erasing late-alphabet categories (Layoffs, Semiconductors,
    // IPO & SPAC, Funding & VC, Product Hunt, …) and producing the "All sources
    // disabled" red panel state on the homepage with no user explanation.
    // Replaced with round-robin per-category distribution from `selectSourcesUnderCap`.
    // (v1-bug recovery for stuck localStorage state is handled once at the top
    // of this function via the schema-version migration.)
    const disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
    const totalEligible = (() => {
      const s = new Set<string>();
      Object.values(FEEDS).forEach((feeds) => feeds?.forEach((f) => s.add(f.name)));
      INTEL_SOURCES.forEach((f) => s.add(f.name));
      let count = 0;
      for (const name of s) if (!disabledSources.has(name)) count++;
      return count;
    })();
    if (totalEligible > FREE_MAX_SOURCES) {
      // Protect locale-boosted sources from the cap. Without this, locale-
      // tagged feeds that sit late in their category bucket (e.g. Hungarian
      // entries in the Europe bucket, declared AFTER the existing en/de/it/
      // nl/sv defaults) get round-robin'd out — the locale boost re-enables
      // them, then the cap immediately auto-disables them again. Free-tier
      // users on the boosted locale lose their locale's defaults entirely.
      // userLang derivation mirrors the locale-boost migration (earlier in
      // the App constructor) and the i18n.ts:99 `wmExplicit` detector:
      // explicit Settings choice wins, navigator is the fallback. Direct
      // localStorage read because i18next isn't initialized yet at the
      // constructor stage where enforceFreeTierLimits also runs.
      let explicitLocale = '';
      try { explicitLocale = localStorage.getItem('wm-locale-explicit') || ''; } catch { /* private mode */ }
      const userLang = ((explicitLocale || navigator.language || 'en').split('-')[0] ?? 'en').toLowerCase();
      const protectedNames = userLang === 'en' ? new Set<string>() : getLocaleBoostedSources(userLang);
      const { keep, autoDisabled } = selectSourcesUnderCap(FEEDS, INTEL_SOURCES, disabledSources, FREE_MAX_SOURCES, protectedNames);
      // Defense in depth: feeds.ts has 35+ source names that appear in
      // multiple category buckets. The helper guarantees keep ∩ autoDisabled
      // = ∅, but a regression there would silently re-disable a kept source
      // here. The keep.has() guard makes the cross-set invariant explicit
      // at the caller too — if it ever fires it's a helper-bug signal.
      for (const name of autoDisabled) {
        if (!keep.has(name)) disabledSources.add(name);
      }
      saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(disabledSources));
      console.log(`[App] Free tier: round-robin disabled ${autoDisabled.size} source(s) to enforce ${FREE_MAX_SOURCES}-source limit (per-category fairness)`);
    }
  }

  public destroy(): void {
    this.state.isDestroyed = true;
    cancelBootstrapSlowTier();
    window.removeEventListener('scroll', this.handleViewportPrime);
    window.removeEventListener('resize', this.handleViewportPrime);
    window.removeEventListener('online', this.handleConnectivityChange);
    window.removeEventListener('offline', this.handleConnectivityChange);
    window.removeEventListener(I18N_RESOURCES_LOADED_EVENT, this.handleI18nResourcesLoaded);
    window.removeEventListener(WM_FOLLOWED_COUNTRIES_CAP_DROP, this.handleFollowedCountriesCapDrop);
    window.removeEventListener(CLOUD_PREFS_APPLIED_EVENT, this.handleCloudPrefsApplied);
    if (this.visiblePanelPrimeRaf !== null) {
      window.cancelAnimationFrame(this.visiblePanelPrimeRaf);
      this.visiblePanelPrimeRaf = null;
    }

    // Destroy all modules in reverse order
    for (let i = this.modules.length - 1; i >= 0; i--) {
      this.modules[i]!.destroy();
    }

    // Clean up subscriptions, map, AIS, and breaking news
    this.unsubAiFlow?.();
    this.unsubFreeTier?.();
    this.unsubEntitlementPremiumLoaders?.();
    this.state.findingsBadge?.destroy();
    this.state.findingsBadge = null;
    this.state.breakingBanner?.destroy();
    destroyBreakingNewsAlerts();
    this.cachedModeBannerEl?.remove();
    this.cachedModeBannerEl = null;
    if (this.followedCountriesCapDropToastTimer !== null) {
      window.clearTimeout(this.followedCountriesCapDropToastTimer);
      this.followedCountriesCapDropToastTimer = null;
    }
    this.state.map?.destroy();
    disconnectAisStream();
    stopFlightHistoryCleanup();
    stopLoadedVesselHistoryCleanup();
    // Unregister every WebMCP tool so a same-document re-init (tests,
    // HMR, SPA harness) doesn't leave the browser with stale bindings
    // pointing at a disposed App.
    this.webMcpController?.abort();
    this.webMcpController = null;
  }

  private async initFindingsBadge(): Promise<void> {
    try {
      const { IntelligenceGapBadge } = await import('@/components/IntelligenceGapBadge');
      if (this.state.isDestroyed) return;
      this.state.findingsBadge = new IntelligenceGapBadge();
      this.state.findingsBadge.setOnSignalClick((signal) => {
        if (this.state.countryBriefPage?.isVisible()) return;
        if (localStorage.getItem('wm-settings-open') === '1') return;
        void this.state.ensureSignalModal()
          .then((signalModal) => {
            if (!this.state.isDestroyed) signalModal.showSignal(signal);
          })
          .catch((err) => {
            console.warn('[SignalModal] Failed to show signal:', err);
          });
      });
      this.state.findingsBadge.setOnAlertClick((alert) => {
        if (this.state.countryBriefPage?.isVisible()) return;
        if (localStorage.getItem('wm-settings-open') === '1') return;
        void this.state.ensureSignalModal()
          .then((signalModal) => {
            if (!this.state.isDestroyed) signalModal.showAlert(alert);
          })
          .catch((err) => {
            console.warn('[SignalModal] Failed to show alert:', err);
          });
      });
    } catch (error) {
      console.warn('[IntelligenceGapBadge] Lazy init failed:', error);
    }
  }

  private showFollowedCountriesCapDropToast(kept: number, dropped: number): void {
    if (this.followedCountriesCapDropToastTimer !== null) {
      window.clearTimeout(this.followedCountriesCapDropToastTimer);
      this.followedCountriesCapDropToastTimer = null;
    }
    document.querySelector('.wm-followed-cap-drop-toast')?.remove();

    const toast = document.createElement('div');
    toast.className = 'wm-followed-cap-drop-toast update-toast';
    toast.setAttribute('role', 'status');
    toast.setAttribute('aria-live', 'polite');

    const body = document.createElement('div');
    body.className = 'update-toast-body';

    const title = document.createElement('div');
    title.className = 'update-toast-title';
    title.textContent = 'Follow limit reached';

    const detail = document.createElement('div');
    detail.className = 'update-toast-detail';
    const countryWord = dropped === 1 ? 'country was' : 'countries were';
    detail.textContent = `${kept} kept. ${dropped} ${countryWord} not added because the free plan supports ${FREE_TIER_FOLLOW_LIMIT} followed countries.`;

    body.append(title, detail);

    const action = document.createElement('button');
    action.type = 'button';
    action.className = 'update-toast-action';
    action.dataset.action = 'upgrade';
    action.textContent = 'Upgrade';

    const dismiss = document.createElement('button');
    dismiss.type = 'button';
    dismiss.className = 'update-toast-dismiss';
    dismiss.dataset.action = 'dismiss';
    dismiss.setAttribute('aria-label', 'Dismiss');
    dismiss.textContent = '\u00d7';

    toast.append(body, action, dismiss);

    this.followedCountriesCapDropToastTimer = window.setTimeout(() => {
      toast.remove();
      this.followedCountriesCapDropToastTimer = null;
    }, 8000);
    toast.addEventListener('click', (e) => {
      const clickedAction = (e.target as HTMLElement)
        .closest<HTMLElement>('[data-action]')
        ?.dataset.action;
      if (clickedAction === 'upgrade') {
        window.open('/pro#pricing', '_blank', 'noopener,noreferrer');
        if (this.followedCountriesCapDropToastTimer !== null) {
          window.clearTimeout(this.followedCountriesCapDropToastTimer);
          this.followedCountriesCapDropToastTimer = null;
        }
        toast.remove();
      } else if (clickedAction === 'dismiss') {
        if (this.followedCountriesCapDropToastTimer !== null) {
          window.clearTimeout(this.followedCountriesCapDropToastTimer);
          this.followedCountriesCapDropToastTimer = null;
        }
        toast.remove();
      }
    });

    document.body.appendChild(toast);
    window.requestAnimationFrame(() => toast.classList.add('visible'));
  }

  // Waits for Phase-4 UI modules to finish initialising. WebMCP bindings call
  // this before touching nullable UI
  // state so a tool invoked during startup waits rather than throwing;
  // the timeout guards against a genuinely broken init path hanging the
  // agent forever.
  private async waitForUiReady(timeoutMs = 10_000): Promise<void> {
    let timer: ReturnType<typeof setTimeout> | null = null;
    const timeout = new Promise<never>((_, reject) => {
      timer = setTimeout(
        () => reject(new Error(`UI did not initialise within ${timeoutMs}ms`)),
        timeoutMs,
      );
    });
    try {
      await Promise.race([this.uiReady, timeout]);
    } finally {
      if (timer !== null) clearTimeout(timer);
    }
  }

  private handleDeepLinks(): void {
    const url = new URL(window.location.href);
    const DEEP_LINK_INITIAL_DELAY_MS = 1500;

    // Check for country brief deep link: ?c=IR (captured early before URL sync)
    const storyCode = this.pendingDeepLinkStoryCode ?? url.searchParams.get('c');
    this.pendingDeepLinkStoryCode = null;
    if (url.pathname === '/story' || storyCode) {
      const countryCode = storyCode;
      if (countryCode) {
        trackDeeplinkOpened('country', countryCode);
        const countryName = getCountryNameByCode(countryCode.toUpperCase()) || countryCode;
        setTimeout(() => {
          void this.countryIntel.openCountryBriefByCode(countryCode.toUpperCase(), countryName, {
            maximize: true,
          }).catch((err) => {
            console.error('[CountryBrief] Failed to open country brief:', err);
            this.state.map?.setRenderPaused(false);
            showToast('Country brief failed to open. Please try again.');
          });
          this.eventHandlers.syncUrlState();
        }, DEEP_LINK_INITIAL_DELAY_MS);
        return;
      }
    }

    // Check for country brief deep link: ?country=UA or ?country=UA&expanded=1
    const deepLinkCountry = this.pendingDeepLinkCountry;
    const deepLinkExpanded = this.pendingDeepLinkExpanded;
    this.pendingDeepLinkCountry = null;
    this.pendingDeepLinkExpanded = false;
    if (deepLinkCountry) {
      trackDeeplinkOpened('country', deepLinkCountry);
      const cName = CountryIntelManager.resolveCountryName(deepLinkCountry);
      setTimeout(() => {
        void this.countryIntel.openCountryBriefByCode(deepLinkCountry, cName, {
          maximize: deepLinkExpanded,
        }).catch((err) => {
          console.error('[CountryBrief] Failed to open country brief:', err);
          this.state.map?.setRenderPaused(false);
          showToast('Country brief failed to open. Please try again.');
        });
        this.eventHandlers.syncUrlState();
      }, DEEP_LINK_INITIAL_DELAY_MS);
    }
  }

  private setupRefreshIntervals(): void {
    // Always refresh news for all variants
    this.refreshScheduler.scheduleRefresh('news', () => this.dataLoader.loadNews(), REFRESH_INTERVALS.feeds);
    this.refreshScheduler.scheduleRefresh(
      'health-freshness',
      async () => { await refreshDataFreshnessFromHealth(); },
      REFRESH_INTERVALS.healthFreshness,
      undefined,
      { runImmediately: true },
    );

    // Happy variant only refreshes news -- skip all geopolitical/financial/military refreshes
    if (SITE_VARIANT !== 'happy') {
      this.refreshScheduler.registerAll([
        {
          name: 'markets',
          fn: () => this.dataLoader.loadMarkets(),
          intervalMs: REFRESH_INTERVALS.markets,
          condition: () => this.isAnyPanelNearViewport(['markets', 'heatmap', 'commodities', 'crypto', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens']),
        },
        {
          name: 'predictions',
          fn: () => this.dataLoader.loadPredictions(),
          intervalMs: REFRESH_INTERVALS.predictions,
          condition: () => this.isPanelNearViewport('polymarket'),
        },
        {
          name: 'forecasts',
          fn: () => this.dataLoader.loadForecasts(),
          intervalMs: REFRESH_INTERVALS.forecasts,
          condition: () => this.isPanelNearViewport('forecast'),
        },
        { name: 'pizzint', fn: () => this.dataLoader.loadPizzInt(), intervalMs: REFRESH_INTERVALS.pizzint, condition: () => SITE_VARIANT === 'full' },
        { name: 'natural', fn: () => this.dataLoader.loadNatural(), intervalMs: REFRESH_INTERVALS.natural, condition: () => this.state.mapLayers.natural },
        { name: 'weather', fn: () => this.dataLoader.loadWeatherAlerts(), intervalMs: REFRESH_INTERVALS.weather, condition: () => this.state.mapLayers.weather },
        { name: 'fred', fn: () => this.dataLoader.loadFredData(), intervalMs: REFRESH_INTERVALS.fred, condition: () => this.isPanelNearViewport('economic') },
        { name: 'spending', fn: () => this.dataLoader.loadGovernmentSpending(), intervalMs: REFRESH_INTERVALS.spending, condition: () => this.isPanelNearViewport('economic') },
        { name: 'bis', fn: () => this.dataLoader.loadBisData(), intervalMs: REFRESH_INTERVALS.bis, condition: () => this.isPanelNearViewport('economic') },
        { name: 'oil', fn: () => this.dataLoader.loadOilAnalytics(), intervalMs: REFRESH_INTERVALS.oil, condition: () => this.isPanelNearViewport('energy-complex') },
        { name: 'firms', fn: () => this.dataLoader.loadFirmsData(), intervalMs: REFRESH_INTERVALS.firms, condition: () => this.shouldRefreshFirms() },
        { name: 'ais', fn: () => this.dataLoader.loadAisSignals(), intervalMs: REFRESH_INTERVALS.ais, condition: () => this.state.mapLayers.ais },
        { name: 'cables', fn: () => this.dataLoader.loadCableActivity(), intervalMs: REFRESH_INTERVALS.cables, condition: () => this.state.mapLayers.cables },
        { name: 'cableHealth', fn: () => this.dataLoader.loadCableHealth(), intervalMs: REFRESH_INTERVALS.cableHealth, condition: () => this.state.mapLayers.cables },
        { name: 'flights', fn: () => this.dataLoader.loadFlightDelays(), intervalMs: REFRESH_INTERVALS.flights, condition: () => this.state.mapLayers.flights },
        {
          name: 'cyberThreats', fn: () => {
            this.state.cyberThreatsCache = null;
            return this.dataLoader.loadCyberThreats();
          }, intervalMs: REFRESH_INTERVALS.cyberThreats, condition: () => CYBER_LAYER_ENABLED && this.state.mapLayers.cyberThreats
        },
      ]);
    }

    if (SITE_VARIANT === 'finance') {
      this.refreshScheduler.scheduleRefresh(
        'stock-analysis',
        () => this.dataLoader.loadStockAnalysis(),
        REFRESH_INTERVALS.stockAnalysis,
        () => hasPremiumAccess() && this.isPanelNearViewport('stock-analysis'),
      );
      this.refreshScheduler.scheduleRefresh(
        'daily-market-brief',
        () => this.dataLoader.loadDailyMarketBrief(),
        REFRESH_INTERVALS.dailyMarketBrief,
        () => hasPremiumAccess() && this.isPanelNearViewport('daily-market-brief'),
      );
      this.refreshScheduler.scheduleRefresh(
        'stock-backtest',
        () => this.dataLoader.loadStockBacktest(),
        REFRESH_INTERVALS.stockBacktest,
        () => hasPremiumAccess() && this.isPanelNearViewport('stock-backtest'),
      );
      this.refreshScheduler.scheduleRefresh(
        'market-implications',
        () => this.dataLoader.loadMarketImplications(),
        REFRESH_INTERVALS.marketImplications,
        () => hasPremiumAccess() && this.isPanelNearViewport('market-implications'),
      );
    }

    // Panel-level refreshes (moved from panel constructors into scheduler for hidden-tab awareness + jitter)
    this.refreshScheduler.scheduleRefresh(
      'service-status',
      () => (this.state.panels['service-status'] as ServiceStatusPanel).fetchStatus(),
      REFRESH_INTERVALS.serviceStatus,
      () => this.isPanelNearViewport('service-status')
    );
    this.refreshScheduler.scheduleRefresh(
      'stablecoins',
      () => (this.state.panels.stablecoins as StablecoinPanel).fetchData(),
      REFRESH_INTERVALS.stablecoins,
      () => this.isPanelNearViewport('stablecoins')
    );
    this.refreshScheduler.scheduleRefresh(
      'energy-crisis',
      () => (this.state.panels['energy-crisis'] as EnergyCrisisPanel).fetchData(),
      REFRESH_INTERVALS.energyCrisis,
      () => this.isPanelNearViewport('energy-crisis')
    );
    this.refreshScheduler.scheduleRefresh(
      'etf-flows',
      () => (this.state.panels['etf-flows'] as ETFFlowsPanel).fetchData(),
      REFRESH_INTERVALS.etfFlows,
      () => this.isPanelNearViewport('etf-flows')
    );
    this.refreshScheduler.scheduleRefresh(
      'macro-signals',
      () => (this.state.panels['macro-signals'] as MacroSignalsPanel).fetchData(),
      REFRESH_INTERVALS.macroSignals,
      () => this.isPanelNearViewport('macro-signals')
    );
    this.refreshScheduler.scheduleRefresh(
      'defense-patents',
      () => { (this.state.panels['defense-patents'] as DefensePatentsPanel).refresh(); return Promise.resolve(); },
      REFRESH_INTERVALS.defensePatents,
      () => this.isPanelNearViewport('defense-patents')
    );
    this.refreshScheduler.scheduleRefresh(
      'fear-greed',
      () => (this.state.panels['fear-greed'] as FearGreedPanel).fetchData(),
      REFRESH_INTERVALS.fearGreed,
      () => this.isPanelNearViewport('fear-greed')
    );
    this.refreshScheduler.scheduleRefresh(
      'hormuz-tracker',
      () => (this.state.panels['hormuz-tracker'] as HormuzPanel).fetchData(),
      REFRESH_INTERVALS.hormuzTracker,
      () => this.isPanelNearViewport('hormuz-tracker')
    );
    this.refreshScheduler.scheduleRefresh(
      'positioning-247',
      () => (this.state.panels['positioning-247'] as PositioningPanel).fetchData(),
      REFRESH_INTERVALS.hyperliquidFlow,
      () => this.isPanelNearViewport('positioning-247')
    );
    this.refreshScheduler.scheduleRefresh(
      'strategic-posture',
      () => (this.state.panels['strategic-posture'] as StrategicPosturePanel).refresh(),
      REFRESH_INTERVALS.strategicPosture,
      () => this.isPanelNearViewport('strategic-posture')
    );
    this.refreshScheduler.scheduleRefresh(
      'strategic-risk',
      () => (this.state.panels['strategic-risk'] as StrategicRiskPanel).refresh(),
      REFRESH_INTERVALS.strategicRisk,
      () => this.isPanelNearViewport('strategic-risk')
    );

    this.refreshScheduler.scheduleRefresh(
      'wsb-tickers',
      () => this.dataLoader.loadWsbTickers(),
      REFRESH_INTERVALS.wsbTickers,
      () => hasPremiumAccess() && this.isPanelNearViewport('wsb-ticker-scanner'),
    );

    // Server-side temporal anomalies (news + satellite_fires)
    if (SITE_VARIANT !== 'happy') {
      this.refreshScheduler.scheduleRefresh('temporalBaseline', () => this.dataLoader.refreshTemporalBaseline(), REFRESH_INTERVALS.temporalBaseline, () => this.shouldRefreshIntelligence());
    }

    // WTO trade policy data — annual data, poll every 10 min to avoid hammering upstream.
    // PRO-gated: the isNearViewport check is a visibility gate, not an entitlement gate,
    // so without hasPremiumAccess() here we'd still hit the 6 WTO RPCs every poll for
    // free users once the panel scrolled into view.
    if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'commodity' || SITE_VARIANT === 'energy') {
      this.refreshScheduler.scheduleRefresh('tradePolicy', () => this.dataLoader.loadTradePolicy(), REFRESH_INTERVALS.tradePolicy, () => hasPremiumAccess() && this.isPanelNearViewport('trade-policy'));
      this.refreshScheduler.scheduleRefresh('supplyChain', () => this.dataLoader.loadSupplyChain(), REFRESH_INTERVALS.supplyChain, () => this.isPanelNearViewport('supply-chain'));
    }

    this.refreshScheduler.scheduleRefresh(
      'cross-source-signals',
      () => this.dataLoader.loadCrossSourceSignals(),
      REFRESH_INTERVALS.crossSourceSignals,
      () => this.isPanelNearViewport('cross-source-signals'),
    );

    // Telegram Intel (near real-time, 60s refresh)
    this.refreshScheduler.scheduleRefresh(
      'telegram-intel',
      () => this.dataLoader.loadTelegramIntel(),
      REFRESH_INTERVALS.telegramIntel,
      () => this.isPanelNearViewport('telegram-intel')
    );

    this.refreshScheduler.scheduleRefresh(
      'gulf-economies',
      () => (this.state.panels['gulf-economies'] as GulfEconomiesPanel).fetchData(),
      REFRESH_INTERVALS.gulfEconomies,
      () => this.isPanelNearViewport('gulf-economies')
    );

    this.refreshScheduler.scheduleRefresh(
      'grocery-basket',
      () => (this.state.panels['grocery-basket'] as GroceryBasketPanel).fetchData(),
      REFRESH_INTERVALS.groceryBasket,
      () => this.isPanelNearViewport('grocery-basket')
    );

    this.refreshScheduler.scheduleRefresh(
      'bigmac',
      () => (this.state.panels['bigmac'] as BigMacPanel).fetchData(),
      REFRESH_INTERVALS.groceryBasket,
      () => this.isPanelNearViewport('bigmac')
    );

    this.refreshScheduler.scheduleRefresh(
      'fuel-prices',
      () => (this.state.panels['fuel-prices'] as FuelPricesPanel).fetchData(),
      REFRESH_INTERVALS.fuelPrices,
      () => this.isPanelNearViewport('fuel-prices')
    );

    this.refreshScheduler.scheduleRefresh(
      'fao-food-price-index',
      () => (this.state.panels['fao-food-price-index'] as FaoFoodPriceIndexPanel).fetchData(),
      REFRESH_INTERVALS.faoFoodPriceIndex,
      () => this.isPanelNearViewport('fao-food-price-index')
    );

    this.refreshScheduler.scheduleRefresh(
      'oil-inventories',
      () => (this.state.panels['oil-inventories'] as OilInventoriesPanel).fetchData(),
      REFRESH_INTERVALS.oilInventories,
      () => this.isPanelNearViewport('oil-inventories')
    );

    this.refreshScheduler.scheduleRefresh(
      'pipeline-status',
      () => (this.state.panels['pipeline-status'] as PipelineStatusPanel).fetchData(),
      REFRESH_INTERVALS.pipelineStatus,
      () => this.isPanelNearViewport('pipeline-status')
    );

    this.refreshScheduler.scheduleRefresh(
      'storage-facility-map',
      () => (this.state.panels['storage-facility-map'] as StorageFacilityMapPanel).fetchData(),
      REFRESH_INTERVALS.storageFacilityMap,
      () => this.isPanelNearViewport('storage-facility-map')
    );

    this.refreshScheduler.scheduleRefresh(
      'fuel-shortages',
      () => (this.state.panels['fuel-shortages'] as FuelShortagePanel).fetchData(),
      REFRESH_INTERVALS.fuelShortages,
      () => this.isPanelNearViewport('fuel-shortages')
    );

    this.refreshScheduler.scheduleRefresh(
      'energy-disruptions',
      () => (this.state.panels['energy-disruptions'] as EnergyDisruptionsPanel).fetchData(),
      REFRESH_INTERVALS.energyDisruptions,
      () => this.isPanelNearViewport('energy-disruptions')
    );

    this.refreshScheduler.scheduleRefresh(
      'energy-risk-overview',
      () => (this.state.panels['energy-risk-overview'] as EnergyRiskOverviewPanel).fetchData(),
      REFRESH_INTERVALS.energyRiskOverview,
      () => this.isPanelNearViewport('energy-risk-overview')
    );

    this.refreshScheduler.scheduleRefresh(
      'chokepoint-strip',
      () => (this.state.panels['chokepoint-strip'] as ChokepointStripPanel).fetchData(),
      REFRESH_INTERVALS.chokepointStrip,
      () => this.isPanelNearViewport('chokepoint-strip')
    );

    this.refreshScheduler.scheduleRefresh(
      'climate-news',
      () => (this.state.panels['climate-news'] as ClimateNewsPanel).fetchData(),
      REFRESH_INTERVALS.climateNews,
      () => this.isPanelNearViewport('climate-news')
    );

    this.refreshScheduler.scheduleRefresh(
      'macro-tiles',
      () => (this.state.panels['macro-tiles'] as MacroTilesPanel).fetchData(),
      REFRESH_INTERVALS.macroTiles,
      () => this.isPanelNearViewport('macro-tiles')
    );
    this.refreshScheduler.scheduleRefresh(
      'fsi',
      () => (this.state.panels['fsi'] as FSIPanel).fetchData(),
      REFRESH_INTERVALS.fsi,
      () => this.isPanelNearViewport('fsi')
    );
    this.refreshScheduler.scheduleRefresh(
      'yield-curve',
      () => (this.state.panels['yield-curve'] as YieldCurvePanel).fetchData(),
      REFRESH_INTERVALS.yieldCurve,
      () => this.isPanelNearViewport('yield-curve')
    );
    this.refreshScheduler.scheduleRefresh(
      'earnings-calendar',
      () => (this.state.panels['earnings-calendar'] as EarningsCalendarPanel).fetchData(),
      REFRESH_INTERVALS.earningsCalendar,
      () => this.isPanelNearViewport('earnings-calendar')
    );
    this.refreshScheduler.scheduleRefresh(
      'economic-calendar',
      () => (this.state.panels['economic-calendar'] as EconomicCalendarPanel).fetchData(),
      REFRESH_INTERVALS.economicCalendar,
      () => this.isPanelNearViewport('economic-calendar')
    );
    this.refreshScheduler.scheduleRefresh(
      'cot-positioning',
      () => (this.state.panels['cot-positioning'] as CotPositioningPanel).fetchData(),
      REFRESH_INTERVALS.cotPositioning,
      () => this.isPanelNearViewport('cot-positioning')
    );
    this.refreshScheduler.scheduleRefresh(
      'gold-intelligence',
      () => (this.state.panels['gold-intelligence'] as GoldIntelligencePanel).fetchData(),
      REFRESH_INTERVALS.goldIntelligence,
      () => this.isPanelNearViewport('gold-intelligence')
    );
    this.refreshScheduler.scheduleRefresh(
      'aaii-sentiment',
      () => this.dataLoader.loadAaiiSentiment(),
      REFRESH_INTERVALS.aaiiSentiment,
      () => this.isPanelNearViewport('aaii-sentiment')
    );
    this.refreshScheduler.scheduleRefresh(
      'market-breadth',
      () => this.dataLoader.loadMarketBreadth(),
      REFRESH_INTERVALS.marketBreadth,
      () => this.isPanelNearViewport('market-breadth')
    );

    // Refresh intelligence signals for CII (geopolitical variant only)
    if (SITE_VARIANT === 'full') {
      this.refreshScheduler.scheduleRefresh('intelligence', () => {
        const { military, iranEvents } = this.state.intelligenceCache;
        this.state.intelligenceCache = {};
        if (military) this.state.intelligenceCache.military = military;
        if (iranEvents) this.state.intelligenceCache.iranEvents = iranEvents;
        return this.dataLoader.loadIntelligenceSignals();
      }, REFRESH_INTERVALS.intelligence, () => this.shouldRefreshIntelligence());
    }

    // Correlation engine refresh
    this.refreshScheduler.scheduleRefresh(
      'correlation-engine',
      async () => {
        const engine = this.state.correlationEngine;
        if (!engine) return;
        await engine.run(this.state);
        for (const domain of ['military', 'escalation', 'economic', 'disaster'] as const) {
          const panel = this.state.panels[`${domain}-correlation`] as CorrelationPanel | undefined;
          panel?.updateCards(engine.getCards(domain));
        }
      },
      REFRESH_INTERVALS.correlationEngine,
      () => this.shouldRefreshCorrelation(),
    );
  }
}
