/* Application shell: Sidebar (collapsible, accordion) + Header. */

const { useState, useEffect, useLayoutEffect, useRef, useMemo } = React;

const NAV = [
  {
    id: 'overview',
    items: [
      { id: 'monitoring', icon: 'alert' },
      { id: 'dashboard', icon: 'dashboard' },
      { id: 'observations', icon: 'query' },
      { id: 'dns', icon: 'globe' },
    ],
  },
  {
    id: 'traffic',
    items: [
      { id: 'explorer', icon: 'explorer' },
      { id: 'dns-explorer', icon: 'search' },
      { id: 'top', icon: 'top' },
    ],
  },
  {
    id: 'settings',
    icon: 'sliders',
    groups: [
      {
        id: 'data',
        icon: 'collectors',
        items: [
          { id: 'collectors', icon: 'collectors' },
          { id: 'snmp', icon: 'network' },
          { id: 'bmp', icon: 'router' },
        ],
      },
      {
        id: 'netmodel',
        icon: 'refs',
        items: [
          { id: 'traffic-classification', icon: 'filter' },
          { id: 'cidr', icon: 'cidr' },
          { id: 'dns-resolvers', icon: 'db' },
          { id: 'interface-roles', icon: 'link' },
          { id: 'port-services', icon: 'flow' },
        ],
      },
      {
        id: 'admin',
        items: [
          { id: 'users', icon: 'shield' },
          { id: 'audit', icon: 'clock' },
          { id: 'clients', icon: 'users' },
          { id: 'smtp', icon: 'export' },
          { id: 'api-integration-erp', icon: 'globe' },
          { id: 'ttl', icon: 'clock' },
          { id: 'versions', icon: 'tag' },
        ],
      },
    ],
  },
];

function t(key, params) {
  return GrapesI18n.t(key, params);
}

function navSectionTitle(sectionId) {
  return t(`nav.section.${sectionId}`);
}

function navItemTitle(itemId) {
  return t(`nav.item.${itemId}`);
}

function withNavLabels(sec) {
  if (sec.groups) {
    return {
      ...sec,
      section: navSectionTitle(sec.id),
      groups: sec.groups.map((grp) => ({
        ...grp,
        section: navSectionTitle(grp.id),
        items: (grp.items || []).map((it) => ({ ...it, label: navItemTitle(it.id) })),
      })),
    };
  }
  return {
    ...sec,
    section: navSectionTitle(sec.id),
    items: (sec.items || []).map((it) => ({ ...it, label: navItemTitle(it.id) })),
  };
}

const CABINET_NAV = [
  {
    id: 'cabinet',
    section: '',
    items: [
      { id: 'dashboard', label: '', icon: 'dashboard' },
      { id: 'observations', label: '', icon: 'query' },
      { id: 'explorer', label: '', icon: 'explorer' },
      { id: 'dns', label: '', icon: 'globe' },
      { id: 'cabinet-settings', label: '', icon: 'sliders' },
    ],
  },
];

const CABINET_PAGE_IDS = new Set(['dashboard', 'explorer', 'observations', 'dns', 'cabinet-settings']);

function isCabinetMode(user) {
  const mode = user?.cabinet?.mode;
  return mode === 'client' || mode === 'impersonation';
}

function isImpersonating(user) {
  return user?.cabinet?.mode === 'impersonation';
}

const PAGES_WITHOUT_HEADER_FILTERS = new Set([
  'monitoring',
  'explorer',
  'dns-explorer',
  'observations',
  'diagnostics',
  'users',
  'audit',
  'clients',
  'cabinet-settings',
  'collectors',
  'snmp',
  'bmp',
  'entities',
  'cidr',
  'dns-resolvers',
  'interface-roles',
  'traffic-classification',
  'port-services',
  'flow-exclusions',
  'vlan',
  'smtp',
  'ttl',
  'versions',
]);

function hasNavPermission(effectivePermissions, pageId) {
  if (!effectivePermissions) return true;
  if (pageId === 'snmp') return !!(effectivePermissions.snmp || effectivePermissions.collectors);
  // SMTP API и доступ завязаны на diagnostics (только администратор).
  if (pageId === 'smtp' || pageId === 'versions') return !!effectivePermissions.diagnostics;
  // «Моя сеть» — общая оболочка для cidr / entities / vlan.
  if (pageId === 'cidr') {
    return !!(effectivePermissions.cidr || effectivePermissions.entities || effectivePermissions.vlan);
  }
  return !!effectivePermissions[pageId];
}

const DEFAULT_OPEN_SECTIONS = new Set(['overview', 'traffic']);

function getSectionItems(sec) {
  if (sec.items) return sec.items;
  if (sec.groups) return sec.groups.flatMap((g) => g.items || []);
  return [];
}

function pinAdminVersionsLast(items) {
  const versions = items.filter((it) => it.id === 'versions');
  if (!versions.length) return items;
  return [...items.filter((it) => it.id !== 'versions'), ...versions];
}

function filterNav(nav, effectivePermissions) {
  if (!effectivePermissions) return nav;
  return nav.map((sec) => {
    if (sec.groups) {
      const groups = sec.groups.map((grp) => {
        let items = grp.items.filter((it) => hasNavPermission(effectivePermissions, it.id));
        if (grp.id === 'admin') items = pinAdminVersionsLast(items);
        if (!items.length) return null;
        return { ...grp, items };
      }).filter(Boolean);
      if (!groups.length) return null;
      return { ...sec, groups };
    }
    const items = sec.items.filter((it) => hasNavPermission(effectivePermissions, it.id));
    if (!items.length) return null;
    return { ...sec, items };
  }).filter(Boolean);
}

/* =============== Sidebar =============== */
function Sidebar({ current, onNavigate, collapsed, effectivePermissions, cabinetMode, cabinetBindMode, clientDisplayName, locale }) {
  const visibleNav = useMemo(
    () => {
      let raw = cabinetMode ? CABINET_NAV : filterNav(NAV, effectivePermissions);
      if (cabinetMode && cabinetBindMode === 'ports') {
        raw = raw.map((sec) => ({
          ...sec,
          items: (sec.items || []).filter((it) => it.id !== 'dns'),
        }));
      }
      return raw.map(withNavLabels);
    },
    [cabinetMode, cabinetBindMode, effectivePermissions, locale],
  );
  const [openSections, setOpenSections] = useState(() => {
    const o = {};
    visibleNav.forEach((sec) => {
      // В кабинете одна секция — держим открытой. В админке по умолчанию
      // только «Главное» и «Анализ трафика».
      o[sec.id] = cabinetMode || DEFAULT_OPEN_SECTIONS.has(sec.id);
    });
    return o;
  });
  useEffect(() => {
    visibleNav.forEach((sec) => {
      if (sec.groups) {
        sec.groups.forEach((grp) => {
          if (grp.items.some((it) => it.id === current)) {
            setOpenSections((s) => ({ ...s, [sec.id]: true, [grp.id]: true }));
          }
        });
      } else if (sec.items.some((it) => it.id === current)) {
        setOpenSections((s) => ({ ...s, [sec.id]: true }));
      }
    });
  }, [current, visibleNav]);

  const toggleSection = (sid) => setOpenSections((s) => ({ ...s, [sid]: !s[sid] }));

  return (
    <aside className="sidebar">
      <div className="sidebar__brand" onClick={() => onNavigate('dashboard')} style={{cursor: 'pointer'}} title={cabinetMode ? clientDisplayName : undefined}>
        <BrandLogo variant="full" className="brand-logo--sidebar brand-logo--sidebar-full" />
        <BrandLogo variant="symbol" className="brand-logo--sidebar brand-logo--sidebar-symbol" />
      </div>
      <nav className="sidebar__nav">
        {visibleNav.map((sec) => (
          <NavSection
            key={sec.id}
            sec={sec}
            open={openSections[sec.id] ?? false}
            onToggle={() => toggleSection(sec.id)}
            openSections={openSections}
            onToggleSection={toggleSection}
            current={current}
            onNavigate={onNavigate}
            collapsed={collapsed}
          />
        ))}
      </nav>
      <button
        type="button"
        className={`sidebar__footer${collapsed ? ' sidebar__footer--collapsed' : ''}`}
        onClick={() => onNavigate('__toggle')}
        aria-label={collapsed ? t('nav.sidebar.expand') : t('nav.sidebar.collapse')}
      >
        <Icon name="chevL" size={16} />
        <span className="sidebar__footer-label">
          {collapsed ? t('nav.sidebar.expand') : t('nav.sidebar.collapse')}
        </span>
      </button>
    </aside>
  );
}

function NavItem({ item, active, onNavigate, collapsed }) {
  const content = (
    <>
      <Icon name={item.icon} size={18} className="nav-item__icon" />
      <span className="nav-item__label">{item.label}</span>
      {item.badge && <span className="nav-item__badge">{item.badge}</span>}
      {item.soon && <span className="tag" style={{ fontSize: 10, padding: '1px 6px' }}>{t('chrome.soon')}</span>}
    </>
  );

  if (item.soon) {
    return (
      <div className="nav-item" title={collapsed ? item.label : undefined}>
        {content}
      </div>
    );
  }

  // <a href="#…"> даёт «Открыть в новой вкладке» и клик колёсиком.
  // Обычный левый клик без модификаторов оставляем на SPA-navigate.
  const handleClick = (e) => {
    if (e.defaultPrevented) return;
    if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
    e.preventDefault();
    onNavigate(item.id);
  };

  return (
    <a
      href={`#${item.id}`}
      className={`nav-item ${active ? 'nav-item--active' : ''}`}
      onClick={handleClick}
      onMouseEnter={() => PageChunks?.preload?.(item.id)}
      title={collapsed ? item.label : undefined}
      aria-current={active ? 'page' : undefined}
    >
      {content}
    </a>
  );
}

function NavSection({
  sec,
  open,
  onToggle,
  openSections,
  onToggleSection,
  current,
  onNavigate,
  collapsed,
  nested = false,
}) {
  const items = getSectionItems(sec);
  const hasActive = items.some((it) => it.id === current);

  if (collapsed) {
    return (
      <>
        {items.map((it) => (
          <NavItem
            key={it.id}
            item={it}
            active={current === it.id}
            onNavigate={onNavigate}
            collapsed={collapsed}
          />
        ))}
      </>
    );
  }

  if (sec.groups) {
    return (
      <div className={`nav-section nav-section--parent ${open ? 'nav-section--open' : ''}`}>
        <div
          className={`nav-section__head ${hasActive && !open ? 'nav-section__head--active' : ''}`}
          onClick={onToggle}
        >
          {sec.icon && <Icon name={sec.icon} size={16} className="nav-section__icon" />}
          <span className="nav-section__label">{sec.section}</span>
          <Icon name="chevR" size={14} className="nav-section__chev" />
        </div>
        <div className="nav-section__body">
          <div className="nav-section__body-inner">
            {sec.groups.map((grp) => (
              <NavSection
                key={grp.id}
                sec={grp}
                open={openSections[grp.id] ?? false}
                onToggle={() => onToggleSection(grp.id)}
                openSections={openSections}
                onToggleSection={onToggleSection}
                current={current}
                onNavigate={onNavigate}
                collapsed={false}
                nested
              />
            ))}
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className={`nav-section${nested ? ' nav-section--nested' : ''} ${open ? 'nav-section--open' : ''}`}>
      <div
        className={`nav-section__head ${hasActive && !open ? 'nav-section__head--active' : ''}`}
        onClick={onToggle}
      >
        {sec.icon && <Icon name={sec.icon} size={16} className="nav-section__icon" />}
        <span className="nav-section__label">{sec.section}</span>
        <Icon name="chevR" size={14} className="nav-section__chev" />
      </div>
      <div className="nav-section__body">
        <div className="nav-section__body-inner">
          {sec.items.map((it) => (
            <NavItem
              key={it.id}
              item={it}
              active={current === it.id}
              onNavigate={onNavigate}
              collapsed={collapsed}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

/* =============== Header =============== */
function parseUserTimestampMs(value) {
  if (value == null || value === '') return null;
  if (typeof value === 'number' && Number.isFinite(value)) return value;
  const text = String(value).trim();
  if (!text) return null;
  if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(text) && !/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(text)) {
    const isoLike = text.replace(' ', 'T');
    const ms = Date.parse(isoLike.endsWith('Z') ? isoLike : `${isoLike}Z`);
    return Number.isNaN(ms) ? null : ms;
  }
  const ms = Date.parse(text);
  return Number.isNaN(ms) ? null : ms;
}

function formatUserDateTime(value, displayTimezone, { dateOnly = false } = {}) {
  const ms = parseUserTimestampMs(value);
  if (ms == null) return value ? String(value) : '—';
  const tz = displayTimezone || (typeof getDisplayTimezone === 'function' ? getDisplayTimezone() : undefined);
  const opts = dateOnly
    ? { dateStyle: 'short', ...(tz ? { timeZone: tz } : {}) }
    : { dateStyle: 'short', timeStyle: 'short', ...(tz ? { timeZone: tz } : {}) };
  return new Date(ms).toLocaleString(GrapesI18n.intlLocale(), opts);
}

function UserAccountMenu({
  currentUser,
  displayTimezone,
  buildInfo,
  onNavigate,
  cabinetMode,
}) {
  const [open, setOpen] = useState(false);
  const [menuStyle, setMenuStyle] = useState(null);
  const rootRef = useRef(null);
  const menuRef = useRef(null);
  const displayName = currentUser?.fullName || currentUser?.username || t('user.fallbackName');
  const displayRole = GrapesI18n.localizedRoleDisplay(
    currentUser?.roleId,
    currentUser?.role?.displayName || currentUser?.roleId,
  );
  const initials = displayName.trim().slice(0, 1).toUpperCase() || 'U';

  const canDiagnostics = !!currentUser?.effectivePermissions?.diagnostics;
  const menuVersionLine = useMemo(() => {
    if (cabinetMode) return null;
    const raw = buildInfo?.version;
    const vf = typeof window !== 'undefined' ? window.VersionsFormat : null;
    if (!raw || !vf) return null;
    const { display } = vf.parseComponentVersion(raw);
    if (!display) return null;
    const date = buildInfo?.versionDate
      ? formatUserDateTime(buildInfo.versionDate, displayTimezone, { dateOnly: true })
      : '—';
    const text = t('user.menuVersion', {
      product: t('versionsPage.product'),
      version: display,
      date,
    });
    return { text, clickable: canDiagnostics };
  }, [buildInfo, cabinetMode, canDiagnostics, displayTimezone]);

  useLayoutEffect(() => {
    if (!open) {
      setMenuStyle(null);
      return undefined;
    }
    const anchor = rootRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      const width = Math.min(320, window.innerWidth - 16);
      setMenuStyle({
        ...computeFixedDropdownStyle(anchor, { minWidth: width, align: 'right' }),
        width,
        minWidth: width,
      });
    };
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [open]);

  useEffect(() => {
    if (!open) return undefined;
    const onPointerDown = (e) => {
      if (rootRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [open]);

  const dropdownBody = (
    <>
      <div className="user-menu__header">
        <span className="user-menu__name">{displayName}</span>
        <span className="user-menu__username">{currentUser?.username}</span>
      </div>
      <div className="time-filter__divider" />
      <div className="user-menu__meta-row">
        <span className="user-menu__meta-label">{t('user.lastPasswordChange')}</span>
        <span className="user-menu__meta-value">{formatUserDateTime(currentUser?.passwordChangedAt, displayTimezone)}</span>
      </div>
      {menuVersionLine && (
        <div className="user-menu__meta-row">
          {menuVersionLine.clickable ? (
            <button
              type="button"
              className="user-menu__meta-link"
              onClick={() => {
                setOpen(false);
                onNavigate?.('versions');
              }}
            >
              {menuVersionLine.text}
            </button>
          ) : (
            <span className="user-menu__meta-value">{menuVersionLine.text}</span>
          )}
        </div>
      )}
    </>
  );

  return (
    <div className="user-menu" ref={rootRef}>
      <button
        type="button"
        className="user-chip"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        title={currentUser?.username || t('user.profile')}
      >
        <span className="user-chip__avatar user-chip__avatar--text">{initials}</span>
        <div className="user-chip__meta">
          <span className="user-chip__name">{displayName}</span>
          <span className="user-chip__role">{displayRole}</span>
        </div>
        <Icon name="chevD" size={14} className="user-chip__chev" />
      </button>
      {open && menuStyle && ReactDOM.createPortal(
        <div
          ref={menuRef}
          className="user-menu__dropdown user-menu__dropdown--portal"
          style={menuStyle}
          role="dialog"
          aria-label={t('user.profileDialog')}
        >
          {dropdownBody}
        </div>,
        document.body,
      )}
    </div>
  );
}

function Header({ current, onNavigate, onToggleSidebar, currentUser, onLogout, onRefresh, theme, onToggleTheme, timeRange, onTimeRangeChange, customPeriod, onCustomPeriodChange, chartZoomDepth, onChartZoomReset, directions, onDirectionsChange, cabinetDirections, onCabinetDirectionsChange, cabinetLocations, cabinetLocationOn, onCabinetLocationsChange, collectorFilter, onCollectorFilterChange, pageTitles, displayTimezone, timezonePref, onTimezonePrefChange, locale, onLocaleChange, cabinetMode, clientDisplayName, onStopImpersonation, buildInfo }) {
  const titles = pageTitles || PAGE_TITLES || {};
  const meta = GrapesI18n.localizedPageMeta(current, titles[current] || titles.dashboard || { title: current, section: '' });
  const hidePageFilters = cabinetMode
    ? (current === 'explorer' || current === 'cabinet-settings')
    : PAGES_WITHOUT_HEADER_FILTERS.has(current);
  const hideDirectionFilter = current === 'dns' || cabinetMode;
  const showCabinetDirectionFilter = cabinetMode && current === 'dashboard' && !!cabinetDirections;
  const showCabinetLocations = showCabinetDirectionFilter && cabinetLocations?.length > 0;
  const hideCollectorFilter = cabinetMode;
  const sectionLabel = cabinetMode ? (clientDisplayName || t('cabinet.clientPortalFallback')) : meta.section;
  return (
    <header className="header">
      <button className="icon-btn" onClick={onToggleSidebar} title={t('chrome.menu')}>
        <Icon name="menu" size={20} />
      </button>
      <div className="header__breadcrumb">
        <span className="crumb-section" title={sectionLabel}>{sectionLabel}</span>
        <span className="crumb-sep">/</span>
        <span className="crumb-cur">{meta.title}</span>
      </div>
      <div className="header__actions">
        <div className="header-filters">
          {!hidePageFilters && (
            <>
              {!hideDirectionFilter && (
                <DirectionFilter
                  directions={directions}
                  onDirectionsChange={onDirectionsChange}
                />
              )}
              {showCabinetDirectionFilter && (
                <DirectionFilter
                  directions={cabinetDirections}
                  onDirectionsChange={onCabinetDirectionsChange}
                  items={getCabinetTrafficDirections()}
                />
              )}
              {showCabinetLocations && (
                <CabinetLocationFilter
                  locations={cabinetLocations}
                  selected={cabinetLocationOn}
                  onChange={onCabinetLocationsChange}
                />
              )}
              {!hideCollectorFilter && (
                <CollectorFilter
                  collectorFilter={collectorFilter}
                  onCollectorFilterChange={onCollectorFilterChange}
                />
              )}
              <TimeFilter
                timeRange={timeRange}
                onTimeRangeChange={onTimeRangeChange}
                customPeriod={customPeriod}
                onCustomPeriodChange={onCustomPeriodChange}
                maxRangeDays={cabinetMode && current === 'dashboard' ? (showCabinetLocations ? 30 : 730) : null}
                locale={locale}
              />
            </>
          )}
          {chartZoomDepth > 0 && (
            <button
              type="button"
              className="time-pill time-pill--reset"
              title={t('chrome.resetZoomTitle')}
              onClick={onChartZoomReset}
            >
              <Icon name="zoom" size={14} />
              <span>{t('chrome.resetZoom')}</span>
            </button>
          )}
        </div>
        <span className="hr-v" />
        <LanguageSelector locale={locale} onLocaleChange={onLocaleChange} />
        <TimezoneSelector
          displayTimezone={displayTimezone}
          timezonePref={timezonePref}
          onTimezonePrefChange={onTimezonePrefChange}
        />
        <PortalTooltip label={t('chrome.refresh')}>
          <button className="icon-btn" title={t('chrome.refresh')} onClick={onRefresh}>
            <Icon name="refresh" size={18} />
          </button>
        </PortalTooltip>
        <PortalTooltip label={theme === 'dark' ? t('chrome.themeLight') : t('chrome.themeDark')}>
          <button
            className="icon-btn"
            title={theme === 'dark' ? t('chrome.themeLight') : t('chrome.themeDark')}
            onClick={onToggleTheme}
          >
            <Icon name={theme === 'dark' ? 'sun' : 'moon'} size={18} />
          </button>
        </PortalTooltip>
        <span className="hr-v" />
        <UserAccountMenu
          currentUser={currentUser}
          displayTimezone={displayTimezone}
          buildInfo={buildInfo}
          onNavigate={onNavigate}
          cabinetMode={cabinetMode}
        />
        {isImpersonating(currentUser) && onStopImpersonation && (
          <Button kind="ghost" size="sm" onClick={onStopImpersonation}>{t('cabinet.exit')}</Button>
        )}
        <PortalTooltip label={t('chrome.logout')}>
          <button className="icon-btn" title={t('chrome.logout')} onClick={onLogout}>
            <Icon name="logOut" size={18} />
          </button>
        </PortalTooltip>
      </div>
    </header>
  );
}

const TIME_RANGE_OPTION_IDS = [
  '30m', '1h', '3h', '6h', '12h', '24h', '2d', '7d', '14d', '30d', 'yesterday',
];

function getTimeRangeOptions() {
  return TIME_RANGE_OPTION_IDS.map((id) => ({ id, label: t(`period.${id}`) }));
}

function calendarWeekdayLabels() {
  return [0, 1, 2, 3, 4, 5, 6].map((idx) => t(`calendar.weekday.${idx}`));
}

function calendarMonthName(month) {
  return t(`calendar.month.${month}`);
}

const TIME_FILTER_PANEL_WIDTH = 440;

const TRAFFIC_DIRECTION_SPECS = [
  { id: 'total', color: '#7E92F8' },
  { id: 'incoming', color: '#51D16D' },
  { id: 'outgoing', color: '#6972F0' },
  { id: 'transit', color: '#F0B400' },
  { id: 'internal', color: '#A4ADFF' },
  { id: 'unclassified', color: '#7F7F9D' },
];

function getTrafficDirections() {
  return TRAFFIC_DIRECTION_SPECS.map((d) => ({ ...d, label: t(`direction.${d.id}`) }));
}

function defaultDirectionsEnabled() {
  return Object.fromEntries(TRAFFIC_DIRECTION_SPECS.map((d) => [d.id, true]));
}

const CABINET_DIRECTION_SPECS = [
  { id: 'in', labelKey: 'cabinet.direction.toYou', color: '#51D16D' },
  { id: 'out', labelKey: 'cabinet.direction.fromYou', color: '#6972F0' },
];

function getCabinetTrafficDirections() {
  return CABINET_DIRECTION_SPECS.map((d) => ({ id: d.id, color: d.color, label: t(d.labelKey) }));
}

function defaultCabinetDirectionsEnabled() {
  return Object.fromEntries(CABINET_DIRECTION_SPECS.map((d) => [d.id, true]));
}

function toDatetimeLocalValue(date) {
  const ms = date instanceof Date ? date.getTime() : new Date(date).getTime();
  return msToDatetimeLocalValue(ms, getDisplayTimezone());
}

function defaultCustomPeriod() {
  const toMs = Date.now();
  const fromMs = toMs - 60 * 60 * 1000;
  return {
    from: msToDatetimeLocalValue(fromMs, getDisplayTimezone()),
    to: msToDatetimeLocalValue(toMs, getDisplayTimezone()),
  };
}

// Арифметика границ периода живёт в /data/period-range.js — её покрывают тесты
// server/period-range.test.js, которые в браузер не тянутся.
const periodRangeApi = () => (typeof window !== 'undefined' && window.PeriodRange) || {};

function parseDatetimeLocalParts(value) {
  return periodRangeApi().parseParts(value);
}

function formatDatetimeLocalParts(parts) {
  return periodRangeApi().formatParts(parts);
}

function addDaysToDateParts(parts, deltaDays) {
  return periodRangeApi().addDays(parts, deltaDays);
}

// Хранимый конец периода — первая невключённая минута; человеку полночь
// показывается как 24:00 предыдущих суток.
function periodEndDisplayParts(value) {
  return periodRangeApi().endDisplayParts(value);
}

function periodEndStoredValue(display) {
  return periodRangeApi().endStoredValue(display);
}

function wholeDayCustomPeriod(dayParts) {
  return periodRangeApi().wholeDayPeriod(dayParts);
}

const END_OF_DAY_HOUR = 24;

function weekdayMondayFirst(y, mo, d) {
  const jsDay = new Date(Date.UTC(y, mo - 1, d)).getUTCDay();
  return jsDay === 0 ? 6 : jsDay - 1;
}

function buildCalendarMonthGrid(year, month) {
  const firstDow = weekdayMondayFirst(year, month, 1);
  const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
  const prevMonth = month === 1 ? 12 : month - 1;
  const prevYear = month === 1 ? year - 1 : year;
  const daysInPrevMonth = new Date(Date.UTC(prevYear, prevMonth, 0)).getUTCDate();
  const cells = [];

  for (let i = firstDow - 1; i >= 0; i -= 1) {
    cells.push({
      y: prevYear,
      mo: prevMonth,
      d: daysInPrevMonth - i,
      outside: true,
    });
  }
  for (let d = 1; d <= daysInMonth; d += 1) {
    cells.push({ y: year, mo: month, d, outside: false });
  }
  let tail = cells[cells.length - 1];
  while (cells.length % 7 !== 0 || cells.length < 35) {
    tail = addDaysToDateParts(tail, 1);
    cells.push({ ...tail, outside: tail.mo !== month || tail.y !== year });
  }
  return cells;
}

function formatCalendarMonthTitle(year, month) {
  return `${calendarMonthName(month)} ${year}${t('period.monthSuffix')}`;
}

function viewMonthFromPeriod(period) {
  const parts = parseDatetimeLocalParts(period?.from);
  if (parts) return { year: parts.y, month: parts.mo };
  const now = parseDatetimeLocalParts(msToDatetimeLocalValue(Date.now(), getDisplayTimezone()));
  return { year: now.y, month: now.mo };
}

// Открывая календарь, показываем уже применённое время: обнуление заставляло
// набирать часы заново после каждого открытия.
function pickerOpenDraft(period) {
  const norm = normalizeCustomPeriod(period);
  if (norm.from && norm.to && norm.from < norm.to) return norm;
  const today = parseDatetimeLocalParts(msToDatetimeLocalValue(Date.now(), getDisplayTimezone()));
  if (!today) return defaultCustomPeriod();
  return wholeDayCustomPeriod(today);
}

function yesterdayCustomPeriod() {
  const today = parseDatetimeLocalParts(msToDatetimeLocalValue(Date.now(), getDisplayTimezone()));
  if (!today) return defaultCustomPeriod();
  return periodRangeApi().yesterdayPeriod(today);
}

function isYesterdayPeriod(period) {
  const expected = yesterdayCustomPeriod();
  return period?.from === expected.from && period?.to === expected.to;
}

// Пустую строку отдаёт как null: пока поле перенабирают, оно должно уметь
// побыть пустым, иначе ведущий ноль не стереть.
function clampTimePart(value, max) {
  return periodRangeApi().clampTimePart(value, max);
}

function calendarDayRangeState(day, fromParts, toParts) {
  return periodRangeApi().dayRangeState(day, fromParts, toParts);
}

function formatCustomPeriodLabel(period) {
  return periodRangeApi().formatPeriodLabel(normalizeCustomPeriod(period));
}

function normalizeCustomPeriodValue(value) {
  const raw = String(value || '').trim();
  if (!raw) return '';
  if (/[zZ]$/.test(raw) || /[+-]\d{2}:\d{2}$/.test(raw)) {
    const ms = new Date(raw).getTime();
    if (!Number.isFinite(ms)) return '';
    return msToDatetimeLocalValue(ms, getDisplayTimezone());
  }
  const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?$/);
  if (!m) return '';
  return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}`;
}

function normalizeCustomPeriod(period) {
  return {
    from: normalizeCustomPeriodValue(period?.from),
    to: normalizeCustomPeriodValue(period?.to),
  };
}

function validateCustomPeriod({ from, to }) {
  if (!from || !to) return t('period.err.required');
  const norm = normalizeCustomPeriod({ from, to });
  if (!norm.from || !norm.to) return t('period.err.invalid');
  if (norm.from >= norm.to) return t('period.err.order');
  return null;
}

const EXPLORER_MAX_RANGE_DAYS = 365;

function explorerRangeLimitDays(maxRangeDays) {
  const days = Number(maxRangeDays);
  return Number.isFinite(days) && days > 0 ? days : EXPLORER_MAX_RANGE_DAYS;
}

function timeRangePresetMs(rangeId) {
  if (rangeId === 'yesterday') return 86400000;
  const match = /^(\d+)([mhd])$/.exec(String(rangeId || ''));
  if (!match) return null;
  const unitMs = match[2] === 'm' ? 60000 : match[2] === 'h' ? 3600000 : 86400000;
  return Number(match[1]) * unitMs;
}

function validateExplorerCustomPeriod({ from, to }, timeRange = '1h', maxRangeDays = EXPLORER_MAX_RANGE_DAYS, periodOptions = {}) {
  if (timeRange !== 'custom') return null;
  const norm = normalizeCustomPeriod({ from, to });
  const err = validateCustomPeriod(norm);
  if (err) return err;
  const fromMs = new Date(norm.from).getTime();
  const toMs = new Date(norm.to).getTime();
  if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return t('period.err.invalid');
  const limitDays = explorerRangeLimitDays(maxRangeDays);
  const limitMs = Number(periodOptions.maxRangeMs) > 0
    ? Number(periodOptions.maxRangeMs)
    : limitDays * 86400000;
  const overMsg = periodOptions.rangeErrorMessage || t('period.err.maxDays', { days: limitDays });
  if (toMs - fromMs > limitMs) {
    return overMsg;
  }
  return null;
}

function dnsBucketSecondsFromMode(bucketMode) {
  return bucketMode === '1m' ? 60 : 300;
}

function explorerGranularityBucketSeconds(granularity) {
  const map = { '1m': 60, '5m': 300, '1h': 3600, '1d': 86400 };
  return map[granularity] || 300;
}

function timeRangeLabel(timeRange, customPeriod) {
  if (timeRange === 'custom') return formatCustomPeriodLabel(customPeriod);
  return t(`period.${timeRange}`) || t('period.1h');
}

function timeRangeChipLabel(timeRange, customPeriod) {
  if (timeRange === 'custom') return formatCustomPeriodLabel(customPeriod);
  const chipKey = `period.chip.${timeRange}`;
  if (GrapesI18n.has(chipKey)) return t(chipKey);
  return timeRangeLabel(timeRange, customPeriod);
}

const TIMEZONE_PRESET_IDS = [
  'auto',
  'Europe/Moscow',
  'Europe/Kaliningrad',
  'Europe/Samara',
  'Asia/Yekaterinburg',
  'Asia/Omsk',
  'Asia/Krasnoyarsk',
  'Asia/Irkutsk',
  'Asia/Yakutsk',
  'Asia/Vladivostok',
  'UTC',
];

function getTimezonePresets() {
  return TIMEZONE_PRESET_IDS.map((id) => ({
    id,
    label: GrapesI18n.timezonePresetLabel(id),
  }));
}

function directionSummaryLabel(directions, items) {
  const dirs = items || getTrafficDirections();
  const enabled = dirs.filter((d) => directions[d.id]);
  if (enabled.length === dirs.length) return t('directions.all');
  if (enabled.length === 0) return t('directions.none');
  if (enabled.length === 1) return enabled[0].label;
  return t('directions.countOf', { n: enabled.length, total: dirs.length });
}

const NO_LOCATION_KEY = '__none__';
function noLocationLabel() {
  return t('collectors.noLocation');
}

function normalizeLocationId(value) {
  const v = String(value ?? '').trim();
  return v || null;
}

function locationScopeId(locationId) {
  if (!locationId || locationId === NO_LOCATION_KEY) return `loc:${NO_LOCATION_KEY}`;
  return `loc:${locationId}`;
}

function isLocationScope(value) {
  return String(value || '').startsWith('loc:');
}

function locationScopeLabel(locations, collectors, locationId) {
  if (locationId === NO_LOCATION_KEY || !locationId) return noLocationLabel();
  const loc = (locations || []).find((l) => normalizeLocationId(l.locationId) === normalizeLocationId(locationId));
  if (loc?.displayName) return loc.displayName;
  const item = (collectors || []).find((c) => normalizeLocationId(c.locationId) === normalizeLocationId(locationId));
  return item?.locationName || locationId;
}

function collectorsInLocation(collectors, locationId) {
  if (locationId === NO_LOCATION_KEY || !locationId) {
    return (collectors || []).filter((c) => !normalizeLocationId(c.locationId));
  }
  const id = normalizeLocationId(locationId);
  return (collectors || []).filter((c) => normalizeLocationId(c.locationId) === id);
}

function sortByName(items, key) {
  return [...items].sort((a, b) => String(a[key] || '').localeCompare(String(b[key] || ''), 'ru'));
}

function buildCollectorFilterGroups(collectors, locations) {
  if (locations?.length) {
    const catalogIds = new Set(
      locations.map((loc) => normalizeLocationId(loc.locationId)).filter(Boolean),
    );
    const groups = sortByName(
      locations.map((loc) => ({
        locationId: normalizeLocationId(loc.locationId),
        locationName: loc.displayName || loc.locationId,
        collectors: sortByName(
          collectors.filter((c) => normalizeLocationId(c.locationId) === normalizeLocationId(loc.locationId)),
          'collectorName',
        ),
      })),
      'locationName',
    );

    const unassigned = sortByName(
      collectors.filter((c) => {
        const id = normalizeLocationId(c.locationId);
        return !id || !catalogIds.has(id);
      }),
      'collectorName',
    );
    if (unassigned.length) {
      groups.push({
        locationId: NO_LOCATION_KEY,
        locationName: noLocationLabel(),
        collectors: unassigned,
      });
    }
    return groups;
  }

  const map = new Map();
  for (const item of collectors) {
    const locId = normalizeLocationId(item.locationId);
    const key = locId || NO_LOCATION_KEY;
    if (!map.has(key)) {
      map.set(key, {
        locationId: locId || NO_LOCATION_KEY,
        locationName: locId ? (item.locationName || locId) : noLocationLabel(),
        collectors: [],
      });
    }
    map.get(key).collectors.push(item);
  }

  return sortByName([...map.values()], 'locationName').map((group) => ({
    ...group,
    collectors: sortByName(group.collectors, 'collectorName'),
  }));
}

function scopeTokenLabel(token, collectors, locations) {
  if (isLocationScope(token)) {
    const locationId = token.slice(4);
    const inLocation = collectorsInLocation(collectors, locationId);
    const name = locationScopeLabel(locations, collectors, locationId);
    return t('collectors.scopeAllInLocation', { name, n: inLocation.length });
  }
  const item = (collectors || []).find((c) => c.collectorId === token);
  if (!item) return token;
  const locationName = locationScopeLabel(locations, collectors, item.locationId) || noLocationLabel();
  return `${locationName} · ${item.collectorName}`;
}

function isLocationSelected(selectedSet, group) {
  return selectedSet.has(locationScopeId(group.locationId));
}

function isCollectorSelected(selectedSet, group, item) {
  return isLocationSelected(selectedSet, group) || selectedSet.has(item.collectorId);
}

function toggleLocationSelection(selected, group) {
  const next = new Set(selected);
  const locScope = locationScopeId(group.locationId);
  const collectorIds = group.collectors.map((c) => c.collectorId);
  if (isLocationSelected(next, group)) {
    next.delete(locScope);
    collectorIds.forEach((id) => next.delete(id));
  } else {
    next.add(locScope);
    collectorIds.forEach((id) => next.delete(id));
  }
  return [...next];
}

function toggleCollectorSelection(selected, group, item) {
  const next = new Set(selected);
  const locScope = locationScopeId(group.locationId);
  const collectorIds = group.collectors.map((c) => c.collectorId);
  if (isCollectorSelected(next, group, item)) {
    if (next.has(locScope)) {
      next.delete(locScope);
      collectorIds.forEach((id) => {
        if (id !== item.collectorId) next.add(id);
      });
    } else {
      next.delete(item.collectorId);
    }
  } else {
    next.add(item.collectorId);
    if (collectorIds.every((id) => next.has(id))) {
      collectorIds.forEach((id) => next.delete(id));
      next.add(locScope);
    }
  }
  return [...next];
}

function collectorFilterLabel(collectorFilter, collectors, locations) {
  const items = collectorFilter || [];
  if (!items.length) return t('collectors.all');
  if (items.length === 1) return scopeTokenLabel(items[0], collectors, locations);
  return t('collectors.picker.selectedCount', { n: items.length });
}

function useCloseOnOutsideClick(open, setOpen, rootRef) {
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => {
      if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
    };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);
}

function computeFixedDropdownStyle(anchorEl, { minWidth = 248, maxHeight = 360, align = 'left' } = {}) {
  const rect = anchorEl.getBoundingClientRect();
  const gap = 6;
  const pad = 8;
  const width = Math.max(rect.width, minWidth);
  const left = align === 'right'
    ? Math.max(pad, rect.right - width)
    : Math.max(pad, Math.min(rect.left, window.innerWidth - width - pad));
  return {
    position: 'fixed',
    top: rect.bottom + gap,
    left,
    width,
    minWidth: width,
    maxHeight,
    zIndex: 1300,
    overflowY: 'auto',
  };
}

function PortalTooltip({ label, children }) {
  const [visible, setVisible] = useState(false);
  const [style, setStyle] = useState(null);
  const anchorRef = useRef(null);

  useLayoutEffect(() => {
    if (!visible) {
      setStyle(null);
      return undefined;
    }
    const anchor = anchorRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      const rect = anchor.getBoundingClientRect();
      setStyle({
        position: 'fixed',
        top: rect.bottom + 6,
        left: rect.left + rect.width / 2,
        transform: 'translateX(-50%)',
        zIndex: 1300,
      });
    };
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [visible]);

  return (
    <>
      <span
        ref={anchorRef}
        className="portal-tooltip-anchor"
        onMouseEnter={() => setVisible(true)}
        onMouseLeave={() => setVisible(false)}
        onFocus={() => setVisible(true)}
        onBlur={() => setVisible(false)}
      >
        {children}
      </span>
      {visible && label && style && ReactDOM.createPortal(
        <div className="portal-tooltip" style={style} role="tooltip">
          {label}
        </div>,
        document.body,
      )}
    </>
  );
}

function LanguageSelector({ locale, onLocaleChange, variant }) {
  const [open, setOpen] = useState(false);
  const [menuStyle, setMenuStyle] = useState(null);
  const rootRef = useRef(null);
  const menuRef = useRef(null);
  const options = GrapesLocale.LOCALES;
  const current = options.find((item) => item.id === locale) || options[0];

  useLayoutEffect(() => {
    if (!open) {
      setMenuStyle(null);
      return undefined;
    }
    const anchor = rootRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      setMenuStyle(computeFixedDropdownStyle(anchor, { minWidth: 160, align: 'right' }));
    };
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [open]);

  useEffect(() => {
    if (!open) return undefined;
    const onPointerDown = (e) => {
      if (rootRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [open]);

  const select = (id) => {
    setOpen(false);
    if (id !== locale) onLocaleChange?.(id);
  };

  return (
    <div className={`locale-filter${variant === 'auth' ? ' locale-filter--auth' : ''}`} ref={rootRef}>
      <button
        type="button"
        className="time-pill time-pill--locale"
        title={t('chrome.language')}
        aria-label={t('chrome.language')}
        aria-expanded={open}
        aria-haspopup="menu"
        onClick={() => setOpen((v) => !v)}
      >
        <span className="time-pill__range">{current.short}</span>
        <Icon name="chevD" size={14} />
      </button>
      {open && menuStyle && ReactDOM.createPortal(
        <div
          ref={menuRef}
          className="time-filter__menu locale-filter__menu locale-filter__menu--portal"
          style={menuStyle}
          role="menu"
        >
          {options.map((item) => (
            <button
              key={item.id}
              type="button"
              role="menuitemradio"
              aria-checked={item.id === current.id}
              className={`time-filter__option ${item.id === current.id ? 'is-active' : ''}`}
              onClick={() => select(item.id)}
            >
              {item.label}
            </button>
          ))}
        </div>,
        document.body,
      )}
    </div>
  );
}

function TimezoneSelector({ displayTimezone, timezonePref, onTimezonePrefChange }) {
  const [open, setOpen] = useState(false);
  const [menuStyle, setMenuStyle] = useState(null);
  const rootRef = useRef(null);
  const menuRef = useRef(null);
  const browserTimezone = detectBrowserTimezone();
  const shortLabel = formatTimezoneShortLabel(displayTimezone);
  const mode = timezonePref?.mode === 'manual' ? 'manual' : 'auto';
  const activeManual = mode === 'manual' ? timezonePref.timezone : null;

  useLayoutEffect(() => {
    if (!open) {
      setMenuStyle(null);
      return undefined;
    }
    const anchor = rootRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      setMenuStyle(computeFixedDropdownStyle(anchor, { minWidth: 280, align: 'right' }));
    };
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [open]);

  useEffect(() => {
    if (!open) return undefined;
    const onPointerDown = (e) => {
      if (rootRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [open]);

  const selectPreset = (presetId) => {
    if (presetId === 'auto') {
      onTimezonePrefChange({ mode: 'auto', timezone: browserTimezone });
    } else {
      onTimezonePrefChange({ mode: 'manual', timezone: presetId });
    }
    setOpen(false);
  };

  return (
    <div className="timezone-filter" ref={rootRef}>
      <button
        type="button"
        className="time-pill time-pill--tz"
        title={formatTimezoneLongLabel(displayTimezone)}
        aria-expanded={open}
        onClick={() => setOpen((v) => !v)}
      >
        <Icon name="globe" size={14} />
        <span className="time-pill__range">{shortLabel}</span>
        <Icon name="chevD" size={14} />
      </button>
      {open && menuStyle && ReactDOM.createPortal(
        <div
          ref={menuRef}
          className="time-filter__menu timezone-filter__menu timezone-filter__menu--portal"
          style={menuStyle}
          role="menu"
        >
          <div className="time-filter__section timezone-filter__section">
            <div className="timezone-filter__hint">
              {t('tz.dataHint', { tz: formatTimezoneShortLabel(getDataTimezone()) })}
            </div>
            {getTimezonePresets().map((preset) => {
              const isAuto = preset.id === 'auto';
              const selected = isAuto ? mode === 'auto' : mode === 'manual' && activeManual === preset.id;
              const subtitle = isAuto ? formatTimezoneLongLabel(browserTimezone) : formatTimezoneShortLabel(preset.id);
              return (
                <button
                  key={preset.id}
                  type="button"
                  role="menuitemradio"
                  aria-checked={selected}
                  className={`time-filter__option timezone-filter__option ${selected ? 'is-active' : ''}`}
                  onClick={() => selectPreset(preset.id)}
                >
                  <span className="timezone-filter__option-label">{preset.label}</span>
                  <span className="timezone-filter__option-sub">{subtitle}</span>
                </button>
              );
            })}
          </div>
        </div>,
        document.body,
      )}
    </div>
  );
}

function computeTimeFilterMenuStyle(anchorEl) {
  const rect = anchorEl.getBoundingClientRect();
  const gap = 6;
  const pad = 8;
  const width = Math.min(TIME_FILTER_PANEL_WIDTH, window.innerWidth - pad * 2);
  let left = rect.right - width;
  if (left < pad) left = Math.max(pad, rect.left);
  if (left + width > window.innerWidth - pad) left = window.innerWidth - width - pad;
  const below = window.innerHeight - rect.bottom - gap - pad;
  const above = rect.top - gap - pad;
  const preferBelow = below >= above;
  const availableHeight = Math.max(200, preferBelow ? below : above);
  const top = preferBelow
    ? rect.bottom + gap
    : Math.max(pad, rect.top - gap - availableHeight);
  return {
    position: 'fixed',
    top,
    left,
    width,
    minWidth: width,
    availableHeight,
    zIndex: 1300,
  };
}

function measureTimeFilterMenuNaturalHeight(menuEl) {
  return menuEl?.scrollHeight || 0;
}

function timeFilterMenuPositionChanged(prev, next) {
  if (!prev || !next) return true;
  return prev.top !== next.top
    || prev.left !== next.left
    || prev.width !== next.width
    || prev.availableHeight !== next.availableHeight;
}

// Пока поле правят, оно держит свой черновик. Управляемый input, который на
// каждый ввод нормализовал значение, не давал стереть ведущий ноль: пустая
// строка тут же превращалась обратно в 0.
function TimePartInput({ id, ariaLabel, value, max, disabled = false, onCommit }) {
  const [draft, setDraft] = useState(null);
  const shown = draft === null ? String(value) : draft;
  return (
    <input
      id={id}
      type="number"
      className="time-filter__time-part"
      min={0}
      max={max}
      value={shown}
      disabled={disabled}
      aria-label={ariaLabel}
      onChange={(e) => {
        const raw = e.target.value;
        setDraft(raw);
        const next = clampTimePart(raw, max);
        if (next !== null) onCommit(next);
      }}
      onBlur={(e) => {
        const next = clampTimePart(e.target.value, max);
        setDraft(null);
        onCommit(next === null ? value : next);
      }}
    />
  );
}

function PeriodTimeField({
  label, hour, minute, onHourChange, onMinuteChange, hourId, minuteId, maxHour = 23,
}) {
  // 24:00 — это конец суток, минут у него не бывает.
  const endOfDay = hour >= END_OF_DAY_HOUR;
  return (
    <div className="time-filter__time-field">
      <span className="time-filter__time-label">{label}</span>
      <div className="time-filter__time-inputs">
        <TimePartInput
          id={hourId}
          ariaLabel={t('period.hourAria', { label })}
          value={hour}
          max={maxHour}
          onCommit={onHourChange}
        />
        <span className="time-filter__time-sep" aria-hidden="true">:</span>
        <TimePartInput
          id={minuteId}
          ariaLabel={t('period.minutesAria', { label })}
          value={endOfDay ? 0 : minute}
          max={59}
          disabled={endOfDay}
          onCommit={onMinuteChange}
        />
      </div>
    </div>
  );
}

function CustomPeriodCalendar({
  draftPeriod,
  onDraftChange,
  viewMonth,
  onViewMonthChange,
  rangeAnchor,
  onRangeAnchorChange,
  periodError,
  hourFromId,
  hourToId,
  minuteFromId,
  minuteToId,
}) {
  const fromParts = parseDatetimeLocalParts(draftPeriod.from);
  const toParts = periodEndDisplayParts(draftPeriod.to);
  const selectionTimesRef = useRef(null);
  const [hoverDay, setHoverDay] = useState(null);
  const monthCells = useMemo(
    () => buildCalendarMonthGrid(viewMonth.year, viewMonth.month),
    [viewMonth.year, viewMonth.month],
  );

  useEffect(() => {
    if (!rangeAnchor) {
      selectionTimesRef.current = null;
      setHoverDay(null);
    }
  }, [rangeAnchor]);

  const buildRangePeriod = (startDay, endDay, fromTime, toTime) => (
    periodRangeApi().buildRangePeriod(startDay, endDay, fromTime, toTime)
  );

  const preview = rangeAnchor && hoverDay ? periodRangeApi().orderDays(rangeAnchor, hoverDay) : null;

  const shiftMonth = (delta) => {
    onViewMonthChange((prev) => {
      let month = prev.month + delta;
      let year = prev.year;
      while (month < 1) {
        month += 12;
        year -= 1;
      }
      while (month > 12) {
        month -= 12;
        year += 1;
      }
      return { year, month };
    });
  };

  // Клик по календарю берёт сутки целиком — именно этого ждут, выбирая одну
  // дату. Сузить период можно полями времени под сеткой.
  const wholeDayTimes = () => ({ from: { h: 0, mi: 0 }, to: { h: END_OF_DAY_HOUR, mi: 0 } });

  const handleDayClick = (day) => {
    if (!rangeAnchor) {
      selectionTimesRef.current = wholeDayTimes();
      onRangeAnchorChange(day);
      onDraftChange(buildRangePeriod(day, day, selectionTimesRef.current.from, selectionTimesRef.current.to));
      return;
    }
    const times = selectionTimesRef.current || wholeDayTimes();
    onRangeAnchorChange(null);
    selectionTimesRef.current = null;
    setHoverDay(null);
    onDraftChange(buildRangePeriod(rangeAnchor, day, times.from, times.to));
  };

  const updateFromTime = (h, mi) => {
    const base = fromParts || parseDatetimeLocalParts(msToDatetimeLocalValue(Date.now(), getDisplayTimezone()));
    if (!base) return;
    if (selectionTimesRef.current) selectionTimesRef.current.from = { h, mi };
    onDraftChange((prev) => ({
      ...prev,
      from: formatDatetimeLocalParts({ ...base, h, mi }),
    }));
  };

  const updateToTime = (h, mi) => {
    const base = toParts || fromParts || parseDatetimeLocalParts(msToDatetimeLocalValue(Date.now(), getDisplayTimezone()));
    if (!base) return;
    const hour = Math.min(END_OF_DAY_HOUR, Math.max(0, h));
    const minute = hour >= END_OF_DAY_HOUR ? 0 : mi;
    if (selectionTimesRef.current) selectionTimesRef.current.to = { h: hour, mi: minute };
    onDraftChange((prev) => ({
      ...prev,
      to: periodEndStoredValue({ ...base, h: hour, mi: minute }),
    }));
  };

  return (
    <div className="time-filter__calendar-pane">
      <div className="time-filter__calendar-header">
        <button type="button" className="time-filter__calendar-nav" onClick={() => shiftMonth(-1)} aria-label={t('period.prevMonth')}>
          <Icon name="chevL" size={14} />
        </button>
        <div className="time-filter__calendar-title">
          <span>{formatCalendarMonthTitle(viewMonth.year, viewMonth.month)}</span>
          <Icon name="calendar" size={14} className="time-filter__calendar-title-icon" />
        </div>
        <button type="button" className="time-filter__calendar-nav" onClick={() => shiftMonth(1)} aria-label={t('period.nextMonth')}>
          <Icon name="chevR" size={14} />
        </button>
      </div>
      <div className="time-filter__calendar-weekdays" aria-hidden="true">
        {calendarWeekdayLabels().map((label, idx) => (
          <span key={`${label}-${idx}`} className="time-filter__calendar-weekday">{label}</span>
        ))}
      </div>
      <div
        className={`time-filter__calendar-grid${rangeAnchor ? ' time-filter__calendar-grid--selecting' : ''}`}
        role="grid"
        aria-label={t('period.calendar')}
        onMouseLeave={() => setHoverDay(null)}
      >
        {monthCells.map((day) => {
          // Пока тянут диапазон, наведение только подсвечивает: менять сам
          // период мышью нельзя, иначе «Выбрать» применял последний день,
          // над которым прошёл курсор, а не тот, по которому кликнули.
          const range = preview
            ? calendarDayRangeState(day, preview.start, preview.end)
            : calendarDayRangeState(day, fromParts, toParts);
          const cls = [
            'time-filter__calendar-day',
            day.outside && 'is-outside',
            range.inRange && 'is-in-range',
            range.isStart && 'is-range-start',
            range.isEnd && 'is-range-end',
            range.isMiddle && 'is-range-middle',
            rangeAnchor && range.isEnd && 'is-range-preview-end',
          ].filter(Boolean).join(' ');
          const key = `${day.y}-${day.mo}-${day.d}`;
          return (
            <button
              key={key}
              type="button"
              role="gridcell"
              className={cls}
              aria-pressed={range.inRange}
              onClick={() => handleDayClick(day)}
              onMouseEnter={() => setHoverDay(day)}
            >
              <span className="time-filter__calendar-day-label">{day.d}</span>
            </button>
          );
        })}
      </div>
      <div className="time-filter__time-row">
        <PeriodTimeField
          label={t('period.timeFrom')}
          hour={fromParts?.h ?? 0}
          minute={fromParts?.mi ?? 0}
          hourId={hourFromId}
          minuteId={minuteFromId}
          onHourChange={(h) => updateFromTime(h, fromParts?.mi ?? 0)}
          onMinuteChange={(mi) => updateFromTime(fromParts?.h ?? 0, mi)}
        />
        <PeriodTimeField
          label={t('period.timeTo')}
          hour={toParts?.h ?? END_OF_DAY_HOUR}
          minute={toParts?.mi ?? 0}
          hourId={hourToId}
          minuteId={minuteToId}
          maxHour={END_OF_DAY_HOUR}
          onHourChange={(h) => updateToTime(h, toParts?.mi ?? 0)}
          onMinuteChange={(mi) => updateToTime(toParts?.h ?? END_OF_DAY_HOUR, mi)}
        />
      </div>
      {periodError && <div className="time-filter__custom-error" role="alert">{periodError}</div>}
    </div>
  );
}

function TimeFilter({
  timeRange, onTimeRangeChange, customPeriod, onCustomPeriodChange,
  variant = 'header', maxRangeDays = null, appearance = 'default', locale,
}) {
  const isExplorer = variant === 'explorer';
  const [open, setOpen] = useState(false);
  const [draftPeriod, setDraftPeriod] = useState(customPeriod);
  const [periodError, setPeriodError] = useState(null);
  const [menuPosition, setMenuPosition] = useState(null);
  const [needsScroll, setNeedsScroll] = useState(false);
  const [viewMonth, setViewMonth] = useState(() => viewMonthFromPeriod(customPeriod));
  const [rangeAnchor, setRangeAnchor] = useState(null);
  const rootRef = useRef(null);
  const menuRef = useRef(null);
  const panelRef = useRef(null);
  const availableHeightRef = useRef(0);
  const hourFromId = isExplorer ? 'time-filter-hour-from-explorer' : 'time-filter-hour-from';
  const minuteFromId = isExplorer ? 'time-filter-minute-from-explorer' : 'time-filter-minute-from';
  const hourToId = isExplorer ? 'time-filter-hour-to-explorer' : 'time-filter-hour-to';
  const minuteToId = isExplorer ? 'time-filter-minute-to-explorer' : 'time-filter-minute-to';

  const rangeLabel = timeRangeLabel(timeRange, customPeriod);
  const presetOptions = useMemo(() => {
    const limitMs = Number(maxRangeDays) > 0 ? Number(maxRangeDays) * 86400000 : null;
    const options = getTimeRangeOptions();
    if (!limitMs) return options;
    return options.filter((o) => {
      const presetMs = timeRangePresetMs(o.id);
      return presetMs == null || presetMs <= limitMs;
    });
  }, [maxRangeDays, locale]);

  useEffect(() => {
    if (!open) return;
    setDraftPeriod(pickerOpenDraft(customPeriod));
    setViewMonth(viewMonthFromPeriod(customPeriod));
    setRangeAnchor(null);
    setPeriodError(null);
  }, [open, customPeriod]);

  useLayoutEffect(() => {
    if (!open) {
      setMenuPosition(null);
      setNeedsScroll(false);
      return undefined;
    }
    const anchor = rootRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      const next = computeTimeFilterMenuStyle(anchor);
      availableHeightRef.current = next.availableHeight;
      setMenuPosition((prev) => (timeFilterMenuPositionChanged(prev, next) ? next : prev));
    };
    updatePosition();
    const onScroll = (e) => {
      if (menuRef.current?.contains(e.target)) return;
      updatePosition();
    };
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', onScroll, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', onScroll, true);
    };
  }, [open]);

  useLayoutEffect(() => {
    if (!open || !menuPosition) {
      setNeedsScroll(false);
      return undefined;
    }
    const updateScrollNeed = () => {
      const menu = menuRef.current;
      if (!menu) return;
      const naturalHeight = measureTimeFilterMenuNaturalHeight(menu);
      const next = naturalHeight > availableHeightRef.current;
      setNeedsScroll((prev) => (prev === next ? prev : next));
    };
    updateScrollNeed();
    window.addEventListener('resize', updateScrollNeed);
    return () => window.removeEventListener('resize', updateScrollNeed);
  }, [open, menuPosition, draftPeriod, viewMonth]);

  useEffect(() => {
    if (!open) return undefined;
    const onPointerDown = (e) => {
      if (rootRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [open]);

  const applyCustomPeriod = () => {
    const err = (isExplorer || Number(maxRangeDays) > 0)
      ? validateExplorerCustomPeriod(draftPeriod, 'custom', maxRangeDays)
      : validateCustomPeriod(draftPeriod);
    if (err) {
      setPeriodError(err);
      return;
    }
    setPeriodError(null);
    onTimeRangeChange('custom');
    onCustomPeriodChange(draftPeriod);
    setOpen(false);
  };

  const pickPreset = (optionId) => {
    if (optionId === 'yesterday') {
      const period = yesterdayCustomPeriod();
      onTimeRangeChange('custom');
      onCustomPeriodChange(period);
      setOpen(false);
      return;
    }
    if (timeRange === optionId) return;
    onTimeRangeChange(optionId);
    setOpen(false);
  };

  const isPresetCurrent = (optionId) => {
    if (optionId === 'yesterday') return timeRange === 'custom' && isYesterdayPeriod(customPeriod);
    return timeRange === optionId;
  };

  const handleDraftChange = (next) => {
    setDraftPeriod(next);
    setPeriodError(null);
  };

  const openMenu = () => {
    setDraftPeriod(pickerOpenDraft(customPeriod));
    setViewMonth(viewMonthFromPeriod(customPeriod));
    setRangeAnchor(null);
    setPeriodError(null);
    setOpen(true);
  };

  const toggleMenu = () => {
    if (open) {
      setOpen(false);
      return;
    }
    openMenu();
  };

  const menuStyle = menuPosition ? {
    position: menuPosition.position,
    top: menuPosition.top,
    left: menuPosition.left,
    width: menuPosition.width,
    minWidth: menuPosition.minWidth,
    zIndex: menuPosition.zIndex,
    ...(needsScroll ? { maxHeight: menuPosition.availableHeight } : {}),
  } : null;

  const menu = open && menuStyle ? (
    <div
      ref={menuRef}
      className={`time-filter__menu time-filter__menu--fixed time-filter__menu--panel${needsScroll ? ' time-filter__menu--scrollable' : ''}${isExplorer ? ' time-filter__menu--explorer' : ''}`}
      role="dialog"
      aria-label={t('period.dialogLabel')}
      style={menuStyle}
    >
      <div ref={panelRef} className="time-filter__panel">
        <div className="time-filter__sidebar">
          {presetOptions.map((o) => {
            const current = isPresetCurrent(o.id);
            if (isExplorer) {
              return (
                <div
                  key={o.id}
                  className={`direction-picker-item${current ? ' is-active' : ''}`}
                  role="menuitem"
                  aria-current={current ? 'true' : undefined}
                  onClick={() => pickPreset(o.id)}
                >
                  {o.label}
                </div>
              );
            }
            return (
              <button
                key={o.id}
                type="button"
                role="menuitemradio"
                aria-checked={current}
                className={`time-filter__option ${current ? 'is-active' : ''}`}
                onClick={() => pickPreset(o.id)}
              >
                {o.label}
              </button>
            );
          })}
        </div>
        <div className="time-filter__panel-main">
          <CustomPeriodCalendar
            draftPeriod={draftPeriod}
            onDraftChange={handleDraftChange}
            viewMonth={viewMonth}
            onViewMonthChange={setViewMonth}
            rangeAnchor={rangeAnchor}
            onRangeAnchorChange={setRangeAnchor}
            periodError={periodError}
            hourFromId={hourFromId}
            minuteFromId={minuteFromId}
            hourToId={hourToId}
            minuteToId={minuteToId}
          />
          <div className="time-filter__custom-actions">
            <Button kind="ghost" size="sm" type="button" className="time-filter__cancel-btn" onClick={() => setOpen(false)}>
              {t('period.cancel')}
            </Button>
            <Button kind="primary" size="sm" type="button" onClick={applyCustomPeriod}>
              {t('period.apply')}
            </Button>
          </div>
        </div>
      </div>
    </div>
  ) : null;

  const chipLabel = timeRangeChipLabel(timeRange, customPeriod);

  return (
    <div className={`time-filter${isExplorer ? ' time-filter--explorer' : ''}${appearance === 'chip' ? ' time-filter--chip' : ''}`} ref={rootRef}>
      {isExplorer && appearance === 'chip' ? (
        <button
          type="button"
          className="explorer-query-chip explorer-query-chip--period"
          onClick={toggleMenu}
          aria-expanded={open}
          title={timeRange === 'custom' ? rangeLabel : undefined}
        >
          <span className="explorer-query-chip__label">{chipLabel}</span>
          <Icon name="chevD" size={12} />
        </button>
      ) : isExplorer ? (
        <button type="button" className="period-select" onClick={toggleMenu} aria-expanded={open}>
          <span className="period-select__label" title={timeRange === 'custom' ? rangeLabel : undefined}>{rangeLabel}</span>
          <Icon name="chevD" size={14} />
        </button>
      ) : (
        <button type="button" className="time-pill" onClick={toggleMenu} aria-expanded={open}>
          <span className="live-dot" />
          <Icon name="clock" size={14} />
          <span className="time-pill__label" title={timeRange === 'custom' ? rangeLabel : undefined}>{rangeLabel}</span>
          <Icon name="chevD" size={14} />
        </button>
      )}
      {menu && ReactDOM.createPortal(menu, document.body)}
    </div>
  );
}

function CabinetLocationFilter({ locations, selected, onChange }) {
  const [open, setOpen] = useState(false);
  const [menuStyle, setMenuStyle] = useState(null);
  const rootRef = useRef(null);
  const menuRef = useRef(null);
  const names = locations || [];
  const onMap = selected || {};
  const enabled = names.filter((name) => onMap[name] !== false);
  const allOn = enabled.length === names.length;

  const summary = allOn
    ? t('cabinet.location.all')
    : enabled.length === 0
      ? t('cabinet.location.none')
      : enabled.length === 1
        ? enabled[0]
        : t('cabinet.location.count', { n: enabled.length, total: names.length });

  const toggle = (name) => {
    onChange?.({ ...onMap, [name]: onMap[name] === false });
  };

  const setAll = (on) => {
    onChange?.(Object.fromEntries(names.map((name) => [name, on])));
  };

  useLayoutEffect(() => {
    if (!open) {
      setMenuStyle(null);
      return undefined;
    }
    const anchor = rootRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      const rect = anchor.getBoundingClientRect();
      setMenuStyle({
        position: 'fixed',
        top: rect.bottom + 6,
        left: Math.max(8, rect.left),
        width: Math.max(rect.width, 248),
        maxHeight: 360,
        zIndex: 1400,
        overflowY: 'auto',
      });
    };
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [open]);

  useEffect(() => {
    if (!open) return undefined;
    const onPointerDown = (e) => {
      if (rootRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [open]);

  const menu = open && menuStyle ? ReactDOM.createPortal(
    <div
      ref={menuRef}
      className="direction-filter__menu direction-filter__menu--portal"
      style={menuStyle}
      role="menu"
    >
      <div className="time-filter__heading">
        <span>{t('cabinet.location.title')}</span>
        <button type="button" className="time-filter__link" onClick={() => setAll(!allOn)}>
          {allOn ? t('directions.clearAll') : t('directions.selectAll')}
        </button>
      </div>
      <div className="time-filter__section time-filter__section--directions">
        {names.map((name) => {
          const on = onMap[name] !== false;
          return (
            <label key={name} className={`direction-option ${on ? 'is-on' : ''}`}>
              <input type="checkbox" checked={on} onChange={() => toggle(name)} />
              <span className="direction-option__label">{name}</span>
            </label>
          );
        })}
      </div>
    </div>,
    document.body,
  ) : null;

  return (
    <div className="direction-filter" ref={rootRef}>
      <button
        type="button"
        className="direction-pill"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        title={t('cabinet.location.title')}
      >
        <span className="direction-pill__label">{summary}</span>
        <Icon name="chevD" size={14} />
      </button>
      {menu}
    </div>
  );
}

function DirectionFilter({ directions, onDirectionsChange, embedded = false, formatSummary, items }) {
  const [open, setOpen] = useState(false);
  const [menuStyle, setMenuStyle] = useState(null);
  const rootRef = useRef(null);
  const addRef = useRef(null);
  const menuRef = useRef(null);

  const trafficDirections = items || getTrafficDirections();
  const enabledDirections = trafficDirections.filter((d) => directions[d.id]);
  const enabledCount = enabledDirections.length;
  const allEnabled = enabledCount === trafficDirections.length;
  const summary = formatSummary ? formatSummary(directions) : directionSummaryLabel(directions, items);

  const toggleDirection = (id) => {
    onDirectionsChange({ ...directions, [id]: !directions[id] });
  };

  const setAllDirections = (on) => {
    onDirectionsChange(Object.fromEntries(trafficDirections.map((d) => [d.id, on])));
  };

  const removeDirection = (id) => {
    onDirectionsChange({ ...directions, [id]: false });
  };

  const pickDirection = (id) => {
    onDirectionsChange({ ...directions, [id]: true });
    setOpen(false);
  };

  const pickAllDirections = () => {
    setAllDirections(true);
    setOpen(false);
  };

  useLayoutEffect(() => {
    if (!open) {
      setMenuStyle(null);
      return undefined;
    }
    const anchor = embedded ? addRef.current : rootRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      const rect = anchor.getBoundingClientRect();
      const width = embedded ? 248 : Math.max(rect.width, 248);
      setMenuStyle({
        position: 'fixed',
        top: rect.bottom + 6,
        left: Math.max(8, rect.left),
        width,
        maxHeight: 360,
        zIndex: 1400,
        overflowY: 'auto',
      });
    };
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [embedded, open]);

  useEffect(() => {
    if (!open) return undefined;
    const onPointerDown = (e) => {
      if (rootRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [open]);

  const headerMenu = open && menuStyle && !embedded ? ReactDOM.createPortal(
    <div
      ref={menuRef}
      className="direction-filter__menu direction-filter__menu--portal"
      style={menuStyle}
      role="menu"
    >
      <div className="time-filter__heading">
        <span>{t('directions.heading')}</span>
        <button
          type="button"
          className="time-filter__link"
          onClick={() => setAllDirections(!allEnabled)}
        >
          {allEnabled ? t('directions.clearAll') : t('directions.selectAll')}
        </button>
      </div>
      <div className="time-filter__section time-filter__section--directions">
        {trafficDirections.map((d) => {
          const on = !!directions[d.id];
          return (
            <label key={d.id} className={`direction-option ${on ? 'is-on' : ''}`}>
              <input
                type="checkbox"
                checked={on}
                onChange={() => toggleDirection(d.id)}
              />
              <span className="direction-option__swatch" style={{ background: d.color }} />
              <span className="direction-option__label">{d.label}</span>
            </label>
          );
        })}
      </div>
    </div>,
    document.body,
  ) : null;

  const embeddedMenu = open && menuStyle ? ReactDOM.createPortal(
    <div
      ref={menuRef}
      className="direction-filter__menu direction-filter__menu--portal"
      style={menuStyle}
      role="menu"
    >
      <div
        className={`direction-picker-item${allEnabled ? ' is-disabled' : ''}`}
        role="menuitem"
        aria-disabled={allEnabled}
        onClick={() => !allEnabled && pickAllDirections()}
      >
        {t('directions.allShort')}
      </div>
      {trafficDirections.map((d) => {
        const selected = !!directions[d.id];
        return (
          <div
            key={d.id}
            className={`direction-picker-item${selected ? ' is-disabled' : ''}`}
            role="menuitem"
            aria-disabled={selected}
            onClick={() => !selected && pickDirection(d.id)}
          >
            {d.label}
          </div>
        );
      })}
    </div>,
    document.body,
  ) : null;

  if (embedded) {
    return (
      <div className="direction-filter direction-filter--embedded direction-filter--chips" ref={rootRef}>
        <div className="direction-filter__chips">
          {allEnabled ? (
            <span className="badge badge--info direction-chip">
              <span className="direction-chip__label">{t('directions.allShort')}</span>
              <button
                type="button"
                className="direction-chip__remove"
                title={t('directions.removeAllTitle')}
                onClick={() => setAllDirections(false)}
              >
                <Icon name="x" size={10} stroke={2.5} />
              </button>
            </span>
          ) : (
            enabledDirections.map((d) => (
              <span key={d.id} className="badge badge--info direction-chip">
                <span className="direction-chip__label">{d.label}</span>
                <button
                  type="button"
                  className="direction-chip__remove"
                  title={t('directions.removeTitle', { label: d.label })}
                  onClick={() => removeDirection(d.id)}
                >
                  <Icon name="x" size={10} stroke={2.5} />
                </button>
              </span>
            ))
          )}
          {enabledCount === 0 && (
            <span className="direction-filter__empty-hint">{t('directions.none')}</span>
          )}
          <div className="direction-filter__add" ref={addRef}>
            <Button
              kind="ghost"
              size="xs"
              icon="plus"
              type="button"
              aria-expanded={open}
              onClick={() => setOpen((v) => !v)}
            >
              {t('directions.add')}
            </Button>
          </div>
        </div>
        {embeddedMenu}
      </div>
    );
  }

  return (
    <div className="direction-filter" ref={rootRef}>
      <button
        type="button"
        className="direction-pill"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        title={t('directions.filterTitle')}
      >
        <span className="direction-pill__chips" aria-hidden="true">
          {trafficDirections.map((d) => (
            <span
              key={d.id}
              className={`direction-pill__chip ${directions[d.id] ? 'is-on' : ''}`}
              style={{ background: d.color }}
            />
          ))}
        </span>
        <span className="direction-pill__label">{summary}</span>
        <Icon name="chevD" size={14} />
      </button>
      {headerMenu}
    </div>
  );
}

function CollectorFilter({ collectorFilter, onCollectorFilterChange, embedded = false }) {
  const [open, setOpen] = useState(false);
  const [menuStyle, setMenuStyle] = useState(null);
  const [collectors, setCollectors] = useState([]);
  const [locations, setLocations] = useState([]);
  const rootRef = useRef(null);
  const menuRef = useRef(null);

  useEffect(() => {
    let cancelled = false;
    ApiClient.loadDashboardCollectors().then(({ collectors: collectorRows, locations: locationRows }) => {
      if (cancelled) return;
      setCollectors(collectorRows);
      setLocations(locationRows);
    });
    return () => { cancelled = true; };
  }, []);

  useLayoutEffect(() => {
    if (!open) {
      setMenuStyle(null);
      return undefined;
    }
    const anchor = rootRef.current;
    if (!anchor) return undefined;
    const updatePosition = () => {
      const rect = anchor.getBoundingClientRect();
      setMenuStyle({
        position: 'fixed',
        top: rect.bottom + 6,
        left: Math.max(8, embedded ? rect.left : rect.right - Math.max(rect.width, 248)),
        width: Math.max(rect.width, 248),
        maxHeight: 360,
        zIndex: 1400,
        overflowY: 'auto',
      });
    };
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [embedded, open]);

  useEffect(() => {
    if (!open) return undefined;
    const onPointerDown = (e) => {
      if (rootRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, [open]);

  const selected = useMemo(() => new Set(collectorFilter || []), [collectorFilter]);
  const grouped = useMemo(
    () => buildCollectorFilterGroups(collectors, locations),
    [collectors, locations],
  );
  const label = collectorFilterLabel(collectorFilter, collectors, locations);

  const applySelection = (nextItems) => {
    onCollectorFilterChange(nextItems);
  };

  const menuBody = (
    <>
      <button
        type="button"
        role="menuitemcheckbox"
        aria-checked={!collectorFilter?.length}
        className={`time-filter__option ${!collectorFilter?.length ? 'is-active' : ''}`}
        onClick={() => applySelection([])}
      >
        <input type="checkbox" checked={!collectorFilter?.length} readOnly tabIndex={-1} />
        {t('collectors.all')}
      </button>
      {grouped.map((group) => {
        const locationSelected = isLocationSelected(selected, group);
        return (
          <div key={group.locationId} className="collector-filter__group">
            <button
              type="button"
              role="menuitemcheckbox"
              aria-checked={locationSelected}
              className={`collector-filter__location ${locationSelected ? 'is-active' : ''}`}
              onClick={() => applySelection(toggleLocationSelection(selected, group))}
            >
              <input type="checkbox" checked={locationSelected} readOnly tabIndex={-1} />
              <span>{group.locationName}</span>
            </button>
            {group.collectors.map((item) => {
              const collectorChecked = isCollectorSelected(selected, group, item);
              return (
                <button
                  key={item.collectorId}
                  type="button"
                  role="menuitemcheckbox"
                  aria-checked={collectorChecked}
                  className={`time-filter__option time-filter__option--nested time-filter__option--collector ${collectorChecked ? 'is-active' : ''}`}
                  onClick={() => applySelection(toggleCollectorSelection(selected, group, item))}
                >
                  <input type="checkbox" checked={collectorChecked} readOnly tabIndex={-1} />
                  {item.collectorName}
                </button>
              );
            })}
          </div>
        );
      })}
    </>
  );

  return (
    <div className={`collector-filter${embedded ? ' collector-filter--embedded' : ''}`} ref={rootRef}>
      <button
        type="button"
        className={embedded ? 'input' : 'collector-pill'}
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        title={t('collectors.filterTitle')}
        style={embedded ? {
          minWidth: 0,
          flex: 1,
          textAlign: 'left',
          justifyContent: 'space-between',
          gap: 6,
        } : undefined}
      >
        {!embedded && <Icon name="collectors" size={14} />}
        <span
          className={embedded ? undefined : 'collector-pill__label'}
          style={embedded ? { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } : undefined}
        >
          {label}
        </span>
        <Icon name="chevD" size={embedded ? 12 : 14} />
      </button>
      {open && menuStyle && ReactDOM.createPortal(
        <div
          ref={menuRef}
          className="collector-filter__menu collector-filter__menu--portal"
          style={menuStyle}
          role="menu"
        >
          {menuBody}
        </div>,
        document.body,
      )}
    </div>
  );
}

/* Official Grapes NTA brand mark (SVG from logobook; theme picks white vs purple). */
function BrandLogo({ variant = 'full', className = '' }) {
  const isSymbol = variant === 'symbol';
  const srcDark = isSymbol ? '/brand/Grapes-symbol-white.svg' : '/brand/GrapesNTA-white.svg';
  const srcLight = isSymbol ? '/brand/Grapes-symbol-purple.svg' : '/brand/GrapesNTA-purple.svg';
  const wrapClass = [
    'brand-logo',
    isSymbol ? 'brand-logo--symbol' : 'brand-logo--full',
    className,
  ].filter(Boolean).join(' ');
  return (
    <span className={wrapClass} role="img" aria-label="Grapes NTA">
      <img className="brand-logo__img brand-logo__img--on-dark" src={srcDark} alt="" aria-hidden="true" />
      <img className="brand-logo__img brand-logo__img--on-light" src={srcLight} alt="" aria-hidden="true" />
    </span>
  );
}

/* Legacy inline glyph (unused in chrome; kept for compatibility). */
function GrapesGlyph({ size = 18 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="9"  cy="9"  r="2.6" fill="currentColor" />
      <circle cx="15" cy="9"  r="2.6" fill="currentColor" fillOpacity="0.7" />
      <circle cx="12" cy="13" r="2.6" fill="currentColor" />
      <circle cx="9"  cy="17" r="2.4" fill="currentColor" fillOpacity="0.85" />
      <circle cx="15" cy="17" r="2.4" fill="currentColor" fillOpacity="0.6" />
      <path d="M12 7 L11 4 L13 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
    </svg>
  );
}

function parseAppHash() {
  const raw = (location.hash || '#dashboard').slice(1);
  const qIdx = raw.indexOf('?');
  const pageId = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
  const params = qIdx >= 0 ? new URLSearchParams(raw.slice(qIdx + 1)) : new URLSearchParams();
  return { pageId, params };
}

function parseJsonSearchParam(raw) {
  if (!raw) return null;
  try {
    return JSON.parse(raw);
  } catch {
    try {
      return JSON.parse(decodeURIComponent(raw));
    } catch {
      return null;
    }
  }
}

function parseDirectionsParam(value) {
  if (!value) return null;
  const ids = new Set(value.split(',').map((s) => s.trim()).filter(Boolean));
  if (!ids.size) return null;
  return Object.fromEntries(TRAFFIC_DIRECTION_SPECS.map((d) => [d.id, ids.has(d.id)]));
}

function applyTopTalkersUrlGlobals(params) {
  const next = {};
  const range = params.get('range');
  if (range) next.timeRange = range;
  const from = params.get('from');
  const to = params.get('to');
  if (range === 'custom' && from && to && !validateCustomPeriod({ from, to })) {
    next.customPeriod = { from, to };
  }
  const dirs = parseDirectionsParam(params.get('dirs'));
  if (dirs) next.directions = dirs;
  if (params.has('collectors')) {
    next.collectorFilter = params.get('collectors')
      ? params.get('collectors').split(',').map((s) => s.trim()).filter(Boolean)
      : [];
  }
  return next;
}

const TOP_TALKERS_PAGE_METRICS = ['bps', 'volume', 'pps', 'fps'];
const TOP_TALKERS_PAGE_GROUPS = ['src', 'dst', 'pair'];

// Shared metric catalogue used by top-talkers.jsx (referenced as
// the global METRICS). It used to live in explorer.jsx and was dropped when the
// Explorer page was rewritten; restore it here so those pages keep resolving labels.
const METRICS = [
  { id: 'bps', label: 'Средняя скорость / Average bitrate', unit: 'бит/с', icon: 'flow' },
  { id: 'volume', label: 'Объём / Volume', unit: 'байт', icon: 'layers' },
  { id: 'pps', label: 'Пакеты/с / Packets per second', unit: 'п/с', icon: 'top' },
  { id: 'fps', label: 'Потоки/с / Flows per second', unit: 'ф/с', icon: 'explorer' },
  { id: 'flows', label: 'Всего потоков / Total flows', unit: '', icon: 'layers' },
  { id: 'uniq_src', label: 'Уникальных IP источника / Unique source IPs', unit: '', icon: 'network' },
];

function parseTopTalkersPageParams(params) {
  const group = params.get('group');
  const metric = params.get('metric');
  return {
    direction: TOP_TALKERS_PAGE_GROUPS.includes(group) ? group : 'src',
    metric: TOP_TALKERS_PAGE_METRICS.includes(metric) ? metric : 'bps',
    search: params.get('q') || '',
  };
}

function readTopTalkersPageParamsFromHash() {
  const { pageId, params } = parseAppHash();
  if (pageId !== 'top' || !params.toString()) return null;
  return parseTopTalkersPageParams(params);
}

function buildTopTalkersShareUrl({
  direction,
  metric,
  search,
  timeRange,
  customPeriod,
  directions,
  collectorFilter,
}) {
  const params = new URLSearchParams();
  params.set('group', direction);
  params.set('metric', metric);
  if (search) params.set('q', search);
  params.set('range', timeRange);
  if (timeRange === 'custom' && customPeriod?.from && customPeriod?.to) {
    params.set('from', customPeriod.from);
    params.set('to', customPeriod.to);
  }
  const enabledDirs = TRAFFIC_DIRECTION_SPECS.filter((d) => directions?.[d.id]).map((d) => d.id);
  if (enabledDirs.length && enabledDirs.length < TRAFFIC_DIRECTION_SPECS.length) {
    params.set('dirs', enabledDirs.join(','));
  }
  if (collectorFilter?.length) params.set('collectors', collectorFilter.join(','));
  return `${window.location.origin}${window.location.pathname}#top?${params.toString()}`;
}

function parseExplorerPageParams(params) {
  const snapshot = String(params.get('snapshot') || '').trim();
  if (snapshot) {
    return { snapshot };
  }
  const metric = params.get('metric');
  const groupBy = (params.get('groupBy') || '').split(',').map((s) => s.trim()).filter(Boolean);
  const limit = Number(params.get('limit'));
  const vis = params.get('vis');
  const filtersParsed = parseJsonSearchParam(params.get('filters'));
  const filters = Array.isArray(filtersParsed) ? filtersParsed : [];
  const thresholdsParsed = parseJsonSearchParam(params.get('thresholds'));
  const thresholds = Array.isArray(thresholdsParsed) ? thresholdsParsed : [];
  const EXPLORER_VIS_IDS = new Set([
    'contribution', 'dynamics', 'data', 'stack', 'stackShare',
    'lines', 'donut', 'sankey', 'table', 'bars', 'relations',
  ]);
  const EXPLORER_VIS_LEGACY_MAP = {
    lines: 'data',
    dynamics: 'data',
    table: 'data',
    contribution: 'stack',
    donut: 'stack',
    bars: 'stack',
    relations: 'stack',
  };
  const normalizedVis = EXPLORER_VIS_IDS.has(vis)
    ? (EXPLORER_VIS_LEGACY_MAP[vis] || vis)
    : 'stack';
  return {
    metric: metric || 'bps',
    groupBy: groupBy.length ? groupBy : ['src_ip', 'dst_ip'],
    filters: Array.isArray(filters) ? filters : [],
    thresholds: Array.isArray(thresholds) ? thresholds : [],
    limit: Number.isFinite(limit) && limit > 0 ? limit : 25,
    vis: normalizedVis,
  };
}

function readExplorerPageParamsFromHash() {
  const { pageId, params } = parseAppHash();
  if (pageId !== 'explorer' || !params.toString()) return null;
  return parseExplorerPageParams(params);
}

function applyExplorerUrlGlobals(params) {
  const next = applyTopTalkersUrlGlobals(params);
  return next;
}

function buildExplorerShareUrl({
  metric,
  groupBy,
  filters,
  thresholds,
  limit,
  vis,
  timeRange,
  customPeriod,
}) {
  const params = new URLSearchParams();
  params.set('metric', metric || 'bps');
  if (groupBy?.length) params.set('groupBy', groupBy.join(','));
  if (limit) params.set('limit', String(limit));
  if (vis) params.set('vis', vis);
  if (filters?.length) params.set('filters', JSON.stringify(filters));
  if (thresholds?.length) params.set('thresholds', JSON.stringify(thresholds));
  params.set('range', timeRange || '1h');
  if (timeRange === 'custom' && customPeriod?.from && customPeriod?.to) {
    params.set('from', customPeriod.from);
    params.set('to', customPeriod.to);
  }
  return `${window.location.origin}${window.location.pathname}#explorer?${params.toString()}`;
}

function buildExplorerSnapshotShareUrl(token) {
  const params = new URLSearchParams();
  params.set('snapshot', token);
  return `${window.location.origin}${window.location.pathname}#explorer?${params.toString()}`;
}

function parseDnsExplorerPageParams(params) {
  const snapshot = String(params.get('snapshot') || '').trim();
  if (snapshot) {
    return { snapshot };
  }
  const metric = params.get('metric');
  const groupBy = (params.get('groupBy') || '').split(',').map((s) => s.trim()).filter(Boolean);
  const filtersParsed = parseJsonSearchParam(params.get('filters'));
  const filters = Array.isArray(filtersParsed) ? filtersParsed : [];
  return {
    metric: metric || 'queries_per_sec',
    groupBy,
    filters,
  };
}

function readDnsExplorerPageParamsFromHash() {
  const { pageId, params } = parseAppHash();
  if (pageId !== 'dns-explorer' || !params.toString()) return null;
  return parseDnsExplorerPageParams(params);
}

function applyDnsExplorerUrlGlobals(params) {
  return applyTopTalkersUrlGlobals(params);
}

function buildDnsExplorerDraftUrl({
  metric,
  groupBy,
  filters,
  timeRange,
  customPeriod,
}) {
  const params = new URLSearchParams();
  if (metric) params.set('metric', metric);
  if (groupBy?.length) params.set('groupBy', groupBy.join(','));
  if (filters?.length) params.set('filters', JSON.stringify(filters));
  params.set('range', timeRange || '24h');
  if (timeRange === 'custom' && customPeriod?.from && customPeriod?.to) {
    params.set('from', customPeriod.from);
    params.set('to', customPeriod.to);
  }
  return `#dns-explorer?${params.toString()}`;
}

function buildDnsExplorerShareUrl({
  metric,
  groupBy,
  filters,
  timeRange,
  customPeriod,
}) {
  const params = new URLSearchParams();
  params.set('metric', metric || 'queries_per_sec');
  if (groupBy?.length) params.set('groupBy', groupBy.join(','));
  if (filters?.length) params.set('filters', JSON.stringify(filters));
  params.set('range', timeRange || '24h');
  if (timeRange === 'custom' && customPeriod?.from && customPeriod?.to) {
    params.set('from', customPeriod.from);
    params.set('to', customPeriod.to);
  }
  return `${window.location.origin}${window.location.pathname}#dns-explorer?${params.toString()}`;
}

function buildDnsExplorerSnapshotShareUrl(token) {
  const params = new URLSearchParams();
  params.set('snapshot', token);
  return `${window.location.origin}${window.location.pathname}#dns-explorer?${params.toString()}`;
}

let PAGE_TITLES = {};

function ImpersonationBanner({ cabinet, onStop, onRefreshUser }) {
  const [now, setNow] = useState(Date.now());
  const expiresAt = Number(cabinet?.impersonation?.expiresAt) || 0;
  const startedAt = cabinet?.impersonation?.startedAt;
  const clientName = cabinet?.clientDisplayName || cabinet?.clientId || t('cabinet.fallbackClientName');

  useEffect(() => {
    const timer = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(timer);
  }, []);

  useEffect(() => {
    if (!expiresAt) return undefined;
    if (expiresAt <= now) {
      onRefreshUser?.();
      return undefined;
    }
    const delay = Math.max(expiresAt - now, 0);
    const timer = setTimeout(() => onRefreshUser?.(), delay);
    return () => clearTimeout(timer);
  }, [expiresAt, now, onRefreshUser]);

  const remainingMs = Math.max(expiresAt - now, 0);
  const mins = Math.floor(remainingMs / 60000);
  const secs = Math.floor((remainingMs % 60000) / 1000);
  const timerLabel = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
  const bannerText = t('cabinet.impersonation.banner', { name: clientName, time: timerLabel });

  return (
    <div className="impersonation-banner" role="status">
      <div className="impersonation-banner__content">
        <Icon name="users" size={16} />
        <span className="impersonation-banner__text" title={bannerText}>
          {bannerText}
        </span>
        {startedAt && (
          <span className="impersonation-banner__meta">
            {t('cabinet.impersonation.since', { time: formatUserDateTime(startedAt) })}
          </span>
        )}
      </div>
      <Button kind="primary" size="sm" onClick={onStop}>{t('cabinet.exit')}</Button>
    </div>
  );
}

function setPageTitles(map) {
  PAGE_TITLES = map || {};
  window.PAGE_TITLES = PAGE_TITLES;
}

Object.assign(window, {
  Sidebar, Header, NAV, CABINET_NAV, CABINET_PAGE_IDS, PAGE_TITLES, setPageTitles, BrandLogo, GrapesGlyph,
  isCabinetMode, isImpersonating, ImpersonationBanner,
  TRAFFIC_DIRECTION_SPECS, defaultDirectionsEnabled, getCabinetTrafficDirections, defaultCabinetDirectionsEnabled,
  defaultCustomPeriod, formatCustomPeriodLabel, normalizeCustomPeriodValue, normalizeCustomPeriod, validateCustomPeriod, validateExplorerCustomPeriod, explorerRangeLimitDays, timeRangePresetMs, EXPLORER_MAX_RANGE_DAYS, timeRangeLabel, timeRangeChipLabel, yesterdayCustomPeriod, isYesterdayPeriod,
  toDatetimeLocalValue, dnsBucketSecondsFromMode, explorerGranularityBucketSeconds,
  collectorFilterLabel, directionSummaryLabel, LanguageSelector, TimezoneSelector, CollectorFilter,
  parseAppHash, parseJsonSearchParam, parseDirectionsParam, applyTopTalkersUrlGlobals,
  parseTopTalkersPageParams, readTopTalkersPageParamsFromHash, buildTopTalkersShareUrl,
  METRICS,
  parseExplorerPageParams, readExplorerPageParamsFromHash, applyExplorerUrlGlobals, buildExplorerShareUrl, buildExplorerSnapshotShareUrl,
  parseDnsExplorerPageParams, readDnsExplorerPageParamsFromHash, applyDnsExplorerUrlGlobals,
  buildDnsExplorerDraftUrl, buildDnsExplorerShareUrl, buildDnsExplorerSnapshotShareUrl,
});

Object.defineProperty(window, 'TRAFFIC_DIRECTIONS', {
  enumerable: true,
  get: getTrafficDirections,
});
Object.defineProperty(window, 'TIME_RANGE_OPTIONS', {
  enumerable: true,
  get: getTimeRangeOptions,
});
Object.defineProperty(window, 'TIMEZONE_PRESETS', {
  enumerable: true,
  get: getTimezonePresets,
});
