(() => {
  "use strict";

  const state = {
    language: localStorage.getItem("hockey.language") || "ru",
    isAdmin: false,
    open: false,
    section: "teams",
    teams: null,
    penalties: null,
    timerRules: null,
    timerRulesVmixInventory: null,
    vmixSettings: null,
    vmixChannel: null,
    vmixActiveKey: "",
    vmixPanel: "configs",
    vmixSourceFields: null,
    vmixImportPreview: null,
    mappingDevices: null,
    mappingProfiles: null,
    mappingActiveProfile: null,
    mappingCreateDeviceId: "",
    mappingSelectedInputKey: "",
    mappingTargetField: "",
    mappingSelectedSourceKey: "",
    mappingDataSearch: "",
    mappingInputSearch: "",
    mappingFieldFilter: "text",
    mappingHideLinked: false,
    mappingRuleField: "",
    mappingVmixScrollTop: 0,
    mappingSelectedTableSource: "",
    mappingSelectedTableRow: 1,
    mappingSelectedTableColumn: "",
    mappingTableRowSearch: "",
    mappingSqlCellOpen: false,
    mappingSqlTableOpen: false,
    mappingOpenDataGroups: {},
    mappingTestDeviceId: "",
    mappingWorkspaceTab: "links",
    mappingCatalog: null,
    mappingContextVariables: null,
    mappingSqlSources: null,
    mappingDatabaseSchema: null,
    mappingSelectedSqlSourceId: null,
    mappingSqlPreview: null,
    mappingSqlDraft: null,
    mappingLoadErrors: {},
    mappingContextRefreshTimer: null,
    countries: null,
    players: null,
    referees: null,
    coaches: null,
    teamLeague: "",
    directorySearch: "",
    editingTeam: null,
    editingPenalty: null,
    editingRecord: null,
    status: "",
    statusError: false,
    modal: null,
  };

  const escapeHtml = (value) => String(value ?? "")
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");

  const normalizeTeamColorHex = (value) => {
    let color = String(value ?? "").trim().toUpperCase();
    if (!color) return "";
    if (!color.startsWith("#")) color = `#${color}`;
    return /^#[0-9A-F]{6}$/.test(color) ? color : "";
  };

  const teamColorPreview = (value) => {
    const color = normalizeTeamColorHex(value);
    return color || "#FFFFFF";
  };

  const countryFlagMarkup = (item) => {
    const code = String(item?.iso2 || item?.country_code || "").trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 2);
    const url = String(item?.flag_url || (code.length === 2 ? `/hockey-assets/flags/${code}.svg` : ""));
    if (url) return `<img src="${escapeHtml(url)}" alt="${escapeHtml(code.toUpperCase())}" title="${escapeHtml(item?.country_name || item?.name || code.toUpperCase())}" loading="lazy" onerror="this.hidden=true;this.nextElementSibling.hidden=false"><span class="hockey-directory-flag-fallback" hidden>${escapeHtml(item?.flag || "")}</span>`;
    return escapeHtml(item?.flag || "");
  };

  async function request(url, options = {}) {
    const response = await fetch(url, {
      credentials: "same-origin",
      ...options,
      headers: {
        ...(options.body ? { "Content-Type": "application/json" } : {}),
        ...(options.headers || {}),
      },
    });
    const data = await response.json().catch(() => ({}));
    if (!response.ok) {
      throw new Error(data.detail || `Ошибка запроса: ${response.status}`);
    }
    return data;
  }



  function downloadJson(filename, data) {
    const safe = String(filename || "mapping.json").replace(/[\\/:*?"<>|]+/g, "_");
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const anchor = document.createElement("a");
    anchor.href = url; anchor.download = safe; document.body.appendChild(anchor); anchor.click(); anchor.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  function mappingApplyTransportSummary(result) {
    if (String(result?.transport || "") === "sequential_no_ack") {
      const inputs = Number(result?.input_groups || 0);
      const sent = Number(result?.mapping_commands_sent ?? result?.applied ?? 0);
      const parts = [];
      if (inputs) parts.push(`Input: ${inputs}`);
      parts.push(`команд Agent: ${sent}`);
      parts.push("без ожидания ACK");
      return ` · ${parts.join(" · ")}`;
    }
    const chunksTotal = Number(result?.batch_chunks_total || 0);
    const chunksApplied = Number(result?.batch_chunks_applied || 0);
    const retries = Number(result?.batch_retries || 0);
    const fallback = Number(result?.fallback_commands || 0);
    const inputs = Number(result?.input_groups || 0);
    const parts = [];
    if (inputs) parts.push(`Input: ${inputs}`);
    if (chunksTotal) parts.push(`пакетов ${chunksApplied}/${chunksTotal}`);
    if (retries) parts.push(`повторов ${retries}`);
    if (fallback) parts.push(`одиночных ${fallback}`);
    return parts.length ? ` · ${parts.join(" · ")}` : "";
  }

  function mappingTransferReport(result, action = "Mapping перенесён") {
    const report = result?.report || {};
    const mapped = Number(report.mapped || 0);
    const total = Number(report.total || mapped);
    const skipped = Number(report.skipped || 0);
    const by = report.matched_by || {};
    const methods = [
      Number(by.key || 0) ? `key ${Number(by.key || 0)}` : "",
      Number(by.title || 0) ? `название ${Number(by.title || 0)}` : "",
      Number(by.number || 0) ? `номер ${Number(by.number || 0)}` : "",
    ].filter(Boolean).join(" · ");
    return `${action}: ${mapped}/${total} связей${skipped ? ` · пропущено ${skipped}` : ""}${methods ? ` · ${methods}` : ""}`;
  }

  function selectedContext() {
    const data = window.UIBuilderRuntime?.getData?.() || {};
    const game = data.hockey?.selected_game || null;
    const tournament = data.hockey?.selected_tournament || null;
    const operatorSession = data.hockey?.operator_session || data.hockey?.session || null;
    return {
      game,
      tournament,
      tournamentLevel: String(tournament?.level || game?.league_key || game?.level || "").toLowerCase(),
      tournamentId: String(
        game?.tournament_external_id
        || localStorage.getItem("hockey.selectedTournamentId")
        || ""
      ),
      token: String(
        operatorSession?.channel_key
        || operatorSession?.token
        || state.vmixChannel?.channel_key
        || state.vmixChannel?.token
        || localStorage.getItem("hockey.operatorSessionToken")
        || ""
      ),
      language: state.language === "en" ? "en" : "ru",
    };
  }

  function isKhlTournamentSelected() {
    const context = selectedContext();
    return context.tournamentLevel === "khl";
  }

  function patchRuntimePenalties(items) {
    const activeItems = (items || [])
      .filter((item) => item.active !== false)
      .map((item) => ({
        ...item,
        name: state.language === "en"
          ? (item.name_en || item.name_ru || item.code)
          : (item.name_ru || item.name_en || item.code),
      }));
    window.UIBuilderRuntime?.patchData?.({
      hockey: {
        penalty_directory: activeItems,
      },
    });
  }

  async function loadPenalties(includeInactive = false) {
    const params = new URLSearchParams({ language: state.language });
    if (includeInactive) params.set("include_inactive", "true");
    const data = await request(`/api/hockey/directories/penalties?${params}`);
    state.penalties = data;
    patchRuntimePenalties(data.items);
    return data;
  }

  async function loadTimerRules() {
    const deviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
    const [settings, inventory] = await Promise.all([
      request("/api/hockey/settings"),
      request(`/api/hockey/agents/vmix-inventory${deviceId ? `?device_id=${encodeURIComponent(deviceId)}` : ""}`).catch(() => null),
    ]);
    state.timerRules = settings;
    state.timerRulesVmixInventory = inventory;
    return state.timerRules;
  }

  async function loadTeams() {
    const context = selectedContext();
    const params = new URLSearchParams({
      language: state.language,
      include_inactive: "true",
    });
    if (context.tournamentId) {
      params.set("tournament_external_id", context.tournamentId);
    } else if (state.teamLeague) {
      params.set("league_key", state.teamLeague);
    }
    state.teams = await request(`/api/hockey/directories/teams?${params}`);
    if (context.tournamentId) {
      state.teamLeague = state.teams.selected_league || "";
    }
    return state.teams;
  }

  function setStatus(message, isError = false) {
    state.status = String(message || "");
    state.statusError = isError;
  }

  function statusMarkup() {
    if (!state.status) return "";
    return `<div class="hockey-directory-status ${state.statusError ? "is-error" : ""}">${escapeHtml(state.status)}</div>`;
  }

  function isMappingSection(section = state.section) {
    return ["mapping", "mapping_context", "mapping_sql"].includes(String(section || ""));
  }

  function mappingTabFromSection(section = state.section) {
    if (section === "mapping_context") return "context";
    if (section === "mapping_sql") return "sql";
    return "links";
  }

  function ensureModal() {
    if (state.modal) return state.modal;
    const modal = document.createElement("div");
    modal.className = "hockey-directory-backdrop hidden";
    modal.addEventListener("mousedown", (event) => {
      if (event.target === modal) closeModal();
    });
    document.body.appendChild(modal);
    state.modal = modal;
    return modal;
  }

  function closeModal() {
    state.open = false;
    state.editingTeam = null;
    state.editingPenalty = null;
    state.editingRecord = null;
    state.status = "";
    ensureModal().classList.add("hidden");
    document.body.classList.remove("hockey-admin-settings-open");
    window.dispatchEvent(new CustomEvent("hockey:admin-settings-visibility", { detail: { open: false } }));
  }

  function shellMarkup(content) {
    return `
      <section class="hockey-directory-modal ${state.section === "khl_site" ? "is-khl-site" : ""} ${isMappingSection() ? "is-mapping" : ""}" role="dialog" aria-modal="true" aria-label="Справочники базы данных">
        <header>
          <div>
            <span>НАСТРОЙКИ</span>
            <strong>Общие настройки хоккея</strong>
          </div>
          <button type="button" class="hockey-directory-close" data-directory-close aria-label="Закрыть">×</button>
        </header>
        <nav>
          <button type="button" data-directory-section="teams" class="${state.section === "teams" ? "is-active" : ""}">Команды</button>
          <button type="button" data-directory-section="players" class="${state.section === "players" ? "is-active" : ""}">Игроки</button>
          <button type="button" data-directory-section="coaches" class="${state.section === "coaches" ? "is-active" : ""}">Тренеры</button>
          <button type="button" data-directory-section="referees" class="${state.section === "referees" ? "is-active" : ""}">Судьи</button>
          <button type="button" data-directory-section="countries" class="${state.section === "countries" ? "is-active" : ""}">Страны</button>
          <button type="button" data-directory-section="timer_rules" class="${state.section === "timer_rules" ? "is-active" : ""}">Таймеры и составы</button>
          <button type="button" data-directory-section="penalties" class="${state.section === "penalties" ? "is-active" : ""}">Удаления</button>
          <button type="button" data-directory-section="vmix" class="${state.section === "vmix" ? "is-active" : ""}">vMix JSON</button>
          <button type="button" data-directory-section="mapping" class="${state.section === "mapping" ? "is-active" : ""}">Mapping</button>
          <button type="button" data-directory-section="mapping_context" class="${state.section === "mapping_context" ? "is-active" : ""}">Переменные</button>
          <button type="button" data-directory-section="mapping_sql" class="${state.section === "mapping_sql" ? "is-active" : ""}">SQL источники</button>
          ${isKhlTournamentSelected() ? `<button type="button" data-directory-section="khl_site" class="${state.section === "khl_site" ? "is-active" : ""}">КХЛ сайт</button>` : ""}
        </nav>
        <div class="hockey-directory-content">${content}</div>
      </section>
    `;
  }

  function leagueOptions(selected) {
    const leagues = state.teams?.leagues || [];
    return leagues.map((item) => `
      <option value="${escapeHtml(item.value)}" ${item.value === selected ? "selected" : ""}>${escapeHtml(item.label)} (${escapeHtml(item.value.toUpperCase())})</option>
    `).join("");
  }

  function renderTeams() {
    const data = state.teams || { items: [], leagues: [] };
    const context = selectedContext();
    const lockedLeague = context.tournamentId ? data.selected_league : "";
    const editing = state.editingTeam;
    const formLeague = editing?.league_key || lockedLeague || state.teamLeague || "";
    const formTournament = editing?.tournament_external_id || context.tournamentId || "";
    const leagueControl = lockedLeague
      ? `<div class="hockey-directory-context"><span>Лига выбранного матча</span><strong>${escapeHtml(data.selected_league_label || lockedLeague.toUpperCase())}</strong></div>`
      : `
        <label class="hockey-directory-filter">
          <span>Показать лигу</span>
          <select data-team-league-filter>
            <option value="">Все лиги</option>
            ${leagueOptions(state.teamLeague)}
          </select>
        </label>
      `;

    const rows = data.items.length
      ? data.items.map((item) => `
          <tr class="${item.active ? "" : "is-inactive"}">
            <td><code>${escapeHtml(item.external_id)}</code></td>
            <td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.short_name_ru || "")}</small></td>
            <td><strong>${escapeHtml(item.name_en || "—")}</strong><small>${escapeHtml(item.short_name_en || "")}</small></td>
            <td>${escapeHtml(item.city_ru || item.city_en || "—")}</td>
            <td>${item.color_hex ? `<span class="hockey-team-color-cell"><i style="--team-color:${escapeHtml(item.color_hex)}"></i><code>${escapeHtml(item.color_hex)}</code></span>` : "—"}</td>
            <td><span class="hockey-directory-source">${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")}</span></td>
            <td><button type="button" class="hockey-directory-edit" data-edit-team="${item.id}">Изменить</button></td>
          </tr>
        `).join("")
      : `<tr><td colspan="7" class="hockey-directory-empty">Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».</td></tr>`;

    const content = `
      <div class="hockey-directory-toolbar">
        ${leagueControl}
        <div class="hockey-directory-toolbar-actions">
          <button type="button" data-sync-teams>Обновить из API</button>
          <button type="button" class="is-accent" data-new-team>Добавить команду</button>
        </div>
      </div>
      <p class="hockey-directory-note">Команды формируются из матчей Stat2TV и разделяются по лигам. При выбранном матче показывается только его лига.</p>
      ${statusMarkup()}
      <div class="hockey-directory-grid">
        <div class="hockey-directory-table-wrap">
          <table>
            <thead><tr><th>ID API</th><th>Русский</th><th>English</th><th>Город</th><th>Цвет</th><th>Источник</th><th></th></tr></thead>
            <tbody>${rows}</tbody>
          </table>
        </div>
        <form class="hockey-directory-form" data-team-form>
          <h3>${editing ? "Редактирование команды" : "Новая команда"}</h3>
          <label><span>ID команды из API</span><input name="external_id" maxlength="64" value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : "required"}></label>
          <label><span>Лига</span><input name="league_key" list="hockey-league-list" maxlength="32" value="${escapeHtml(formLeague)}" ${lockedLeague ? "readonly" : "required"}></label>
          <datalist id="hockey-league-list">${leagueOptions(formLeague)}</datalist>
          <input type="hidden" name="tournament_external_id" value="${escapeHtml(formTournament)}">
          <div class="hockey-directory-form-columns">
            <label><span>Название (русский)</span><input name="name_ru" maxlength="500" value="${escapeHtml(editing?.name_ru || "")}"></label>
            <label><span>Name (English)</span><input name="name_en" maxlength="500" value="${escapeHtml(editing?.name_en || "")}"></label>
            <label><span>Короткое (русский)</span><input name="short_name_ru" maxlength="255" value="${escapeHtml(editing?.short_name_ru || "")}"></label>
            <label><span>Short name (English)</span><input name="short_name_en" maxlength="255" value="${escapeHtml(editing?.short_name_en || "")}"></label>
            <label><span>Город (русский)</span><input name="city_ru" maxlength="255" value="${escapeHtml(editing?.city_ru || "")}"></label>
            <label><span>City (English)</span><input name="city_en" maxlength="255" value="${escapeHtml(editing?.city_en || "")}"></label>
          </div>
          <label><span>Логотип (URL)</span><input name="logo_url" maxlength="2000" value="${escapeHtml(editing?.logo_url || "")}"></label>
          <div class="hockey-team-color-field">
            <label>
              <span>Цвет команды</span>
              <div class="hockey-team-color-control">
                <button type="button" class="hockey-team-color-picker-wrap" data-team-color-open title="Выбрать цвет">
                  <i data-team-color-preview style="--team-color:${escapeHtml(teamColorPreview(editing?.color_hex))}"></i>
                  <input type="color" data-team-color-picker value="${escapeHtml(teamColorPreview(editing?.color_hex))}" tabindex="-1" aria-label="Выбрать цвет команды">
                </button>
                <input name="color_hex" data-team-color-text maxlength="7" placeholder="#282E66" value="${escapeHtml(editing?.color_hex || "")}" autocomplete="off" spellcheck="false">
                <button type="button" class="hockey-team-color-clear" data-team-color-clear title="Очистить цвет">×</button>
              </div>
              <small>HEX, например #282E66</small>
            </label>
          </div>
          <label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Активна</span></label>
          <div class="hockey-directory-form-actions">
            ${editing ? `<button type="button" data-cancel-team>Отмена</button>` : ""}
            <button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button>
          </div>
        </form>
      </div>
    `;
    const modal = ensureModal();
    modal.innerHTML = shellMarkup(content);
    bindShell();
    bindTeams();
  }

  function renderPenalties() {
    const editing = state.editingPenalty;
    const items = state.penalties?.items || [];
    const rows = items.length
      ? items.map((item) => `
          <tr class="${item.active ? "" : "is-inactive"}">
            <td><code>${escapeHtml(item.code)}</code></td>
            <td>${escapeHtml(item.name_ru)}</td>
            <td>${escapeHtml(item.name_en)}</td>
            <td><strong>${escapeHtml(item.default_preset)}</strong></td>
            <td>${item.team_penalty ? "Да" : "Нет"}</td>
            <td>${item.active ? "Да" : "Нет"}</td>
            <td><button type="button" class="hockey-directory-edit" data-edit-penalty="${item.id}">Изменить</button></td>
          </tr>
        `).join("")
      : `<tr><td colspan="7" class="hockey-directory-empty">Справочник удалений пуст.</td></tr>`;

    const content = `
      <div class="hockey-directory-toolbar">
        <div class="hockey-directory-context"><span>Общий справочник</span><strong>Для всех видов хоккея</strong></div>
        <button type="button" class="is-accent" data-new-penalty>Добавить удаление</button>
      </div>
      <p class="hockey-directory-note">Активные записи автоматически появляются в списке нарушений основного меню.</p>
      ${statusMarkup()}
      <div class="hockey-directory-grid">
        <div class="hockey-directory-table-wrap">
          <table>
            <thead><tr><th>Код</th><th>Русский</th><th>English</th><th>Штраф</th><th>Командное</th><th>Активно</th><th></th></tr></thead>
            <tbody>${rows}</tbody>
          </table>
        </div>
        <form class="hockey-directory-form" data-penalty-form>
          <h3>${editing ? "Редактирование удаления" : "Новое удаление"}</h3>
          <label><span>Код</span><input name="code" maxlength="64" required value="${escapeHtml(editing?.code || "")}"></label>
          <label><span>Название на русском</span><input name="name_ru" maxlength="500" required value="${escapeHtml(editing?.name_ru || "")}"></label>
          <label><span>Name in English</span><input name="name_en" maxlength="500" required value="${escapeHtml(editing?.name_en || "")}"></label>
          <div class="hockey-directory-form-columns">
            <label><span>Штраф по умолчанию</span><select name="default_preset">
              ${["2", "2+2", "4", "5", "5+20", "10"].map((value) => `<option value="${value}" ${(editing?.default_preset || "2") === value ? "selected" : ""}>${value}</option>`).join("")}
            </select></label>
            <label><span>Порядок</span><input name="sort_order" type="number" min="0" max="100000" value="${escapeHtml(editing?.sort_order ?? "")}"></label>
          </div>
          <label class="hockey-directory-check"><input type="checkbox" name="team_penalty" ${editing?.team_penalty ? "checked" : ""}><span>Командное удаление — игрок не выбирается</span></label>
          <label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Показывать в основном меню</span></label>
          <div class="hockey-directory-form-actions">
            ${editing ? `<button type="button" data-cancel-penalty>Отмена</button>` : ""}
            <button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button>
          </div>
        </form>
      </div>
    `;
    const modal = ensureModal();
    modal.innerHTML = shellMarkup(content);
    bindShell();
    bindPenalties();
  }

  const genericSections = {
    countries: { title: "Страны", endpoint: "countries", syncLabel: "Обновить players-countries.xml" },
    players: { title: "Игроки", endpoint: "players", syncLabel: "Загрузить игроков лиги" },
    referees: { title: "Судьи", endpoint: "referees", syncLabel: "Загрузить судей лиги" },
    coaches: { title: "Тренеры", endpoint: "coaches", syncLabel: "Загрузить тренеров лиги" },
  };

  async function loadGeneric(section) {
    const config = genericSections[section];
    if (!config) return null;
    const context = selectedContext();
    const params = new URLSearchParams({ language: state.language, include_inactive: "true" });
    if (state.directorySearch) params.set("search", state.directorySearch);
    if (section !== "countries" && context.tournamentId) params.set("tournament_external_id", context.tournamentId);
    const data = await request(`/api/hockey/directories/${config.endpoint}?${params}`);
    state[section] = data;
    return data;
  }

  function genericPersonFields(section, editing, context) {
    const base = `
      <label><span>ID из API</span><input name="external_id" maxlength="64" required value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : ""}></label>
      <div class="hockey-directory-form-columns">
        <label><span>Полное имя (русский)</span><input name="full_name_ru" maxlength="500" value="${escapeHtml(editing?.full_name_ru || "")}"></label>
        <label><span>Full name (English)</span><input name="full_name_en" maxlength="500" value="${escapeHtml(editing?.full_name_en || "")}"></label>
        <label><span>Имя (русский)</span><input name="first_name_ru" maxlength="255" value="${escapeHtml(editing?.first_name_ru || "")}"></label>
        <label><span>First name</span><input name="first_name_en" maxlength="255" value="${escapeHtml(editing?.first_name_en || "")}"></label>
        <label><span>Фамилия (русский)</span><input name="last_name_ru" maxlength="255" value="${escapeHtml(editing?.last_name_ru || "")}"></label>
        <label><span>Last name</span><input name="last_name_en" maxlength="255" value="${escapeHtml(editing?.last_name_en || "")}"></label>
        <label><span>Дата рождения</span><input name="birth_date" type="date" value="${escapeHtml(editing?.birth_date || "")}"></label>
        <label><span>Код страны</span><input name="country_code" maxlength="8" value="${escapeHtml(editing?.country_code || "")}"></label>
      </div>`;
    if (section === "players") return `${base}
      <input type="hidden" name="tournament_external_id" value="${escapeHtml(context.tournamentId || editing?.tournament_external_id || "")}">
      <div class="hockey-directory-form-columns">
        <label><span>Амплуа (русский)</span><input name="position_ru" value="${escapeHtml(editing?.position_ru || "")}"></label>
        <label><span>Position (English)</span><input name="position_en" value="${escapeHtml(editing?.position_en || "")}"></label>
        <label><span>Рост, см</span><input name="height_cm" type="number" min="0" max="300" value="${escapeHtml(editing?.height_cm || "")}"></label>
        <label><span>Вес, кг</span><input name="weight_kg" type="number" min="0" max="300" value="${escapeHtml(editing?.weight_kg || "")}"></label>
        <label><span>Хват / stick</span><input name="stick" maxlength="32" value="${escapeHtml(editing?.stick || "")}"></label>
        <label><span>Номер</span><input name="jersey_number" maxlength="16" value="${escapeHtml(editing?.jersey_number || "")}"></label>
        <label><span>ID клуба</span><input name="club_external_id" maxlength="64" value="${escapeHtml(editing?.club_external_id || "")}"></label>
        <label><span>ID команды турнира</span><input name="team_entry_external_id" maxlength="64" value="${escapeHtml(editing?.team_entry_external_id || "")}"></label>
        <label><span>Команда (русский)</span><input name="team_name_ru" value="${escapeHtml(editing?.team_name_ru || "")}"></label>
        <label><span>Team (English)</span><input name="team_name_en" value="${escapeHtml(editing?.team_name_en || "")}"></label>
      </div>`;
    if (section === "coaches") return `${base}
      <input type="hidden" name="tournament_external_id" value="${escapeHtml(context.tournamentId || editing?.tournament_external_id || "")}">
      <div class="hockey-directory-form-columns">
        <label><span>ID команды</span><input name="team_external_id" maxlength="64" value="${escapeHtml(editing?.team_external_id || "")}"></label>
        <label><span>Команда (русский)</span><input name="team_name_ru" value="${escapeHtml(editing?.team_name_ru || "")}"></label>
        <label><span>Team (English)</span><input name="team_name_en" value="${escapeHtml(editing?.team_name_en || "")}"></label>
        <label><span>Роль (русский)</span><input name="role_ru" value="${escapeHtml(editing?.role_ru || "Главный тренер")}"></label>
        <label><span>Role (English)</span><input name="role_en" value="${escapeHtml(editing?.role_en || "Head coach")}"></label>
      </div>`;
    return `${base}
      <div class="hockey-directory-form-columns">
        <label><span>Роль / код</span><input name="position_code" maxlength="32" value="${escapeHtml(editing?.position_code || "")}"></label>
        <label><span>Номер</span><input name="number" maxlength="16" value="${escapeHtml(editing?.number || "")}"></label>
        <label><span>Страна (русский)</span><input name="country_ru" value="${escapeHtml(editing?.country_ru || "")}"></label>
        <label><span>Country (English)</span><input name="country_en" value="${escapeHtml(editing?.country_en || "")}"></label>
        <label><span>Город (русский)</span><input name="town_ru" value="${escapeHtml(editing?.town_ru || "")}"></label>
        <label><span>Town (English)</span><input name="town_en" value="${escapeHtml(editing?.town_en || "")}"></label>
      </div>`;
  }

  function renderGeneric(section) {
    const config = genericSections[section];
    const data = state[section] || { items: [] };
    const context = selectedContext();
    const editing = state.editingRecord;
    const rows = data.items.length ? data.items.map((item) => {
      if (section === "countries") return `<tr class="${item.active ? "" : "is-inactive"}"><td class="hockey-directory-flag">${countryFlagMarkup(item)}</td><td><code>${escapeHtml(item.iso2 || item.iso3 || item.external_id)}</code></td><td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.name_en || "")}</small></td><td>${escapeHtml(item.iso3 || "—")}</td><td><button type="button" class="hockey-directory-edit" data-edit-record="${item.id}">Изменить</button></td></tr>`;
      const team = item.team_name_ru || item.team_name_en || "";
      const extras = section === "players"
        ? `${item.position_ru || item.position_en || "—"} · ${item.height_cm || "—"} см · ${item.weight_kg || "—"} кг · ${item.stick || "—"}`
        : section === "referees" ? `${item.position_code || "—"} · № ${item.number || "—"}` : `${item.role_ru || item.role_en || "—"}`;
      return `<tr class="${item.active ? "" : "is-inactive"}"><td class="hockey-directory-flag">${countryFlagMarkup(item)}</td><td><code>${escapeHtml(item.external_id)}</code></td><td><strong>${escapeHtml(item.full_name_ru || item.name || "—")}</strong><small>${escapeHtml(item.full_name_en || "")}</small></td><td><strong>${escapeHtml(team || "—")}</strong><small>${escapeHtml(extras)}</small></td><td>${item.birth_date ? `${escapeHtml(item.birth_date)}${item.age !== null && item.age !== undefined ? ` · ${escapeHtml(item.age)} лет` : ""}` : "—"}</td><td><button type="button" class="hockey-directory-edit" data-edit-record="${item.id}">Изменить</button></td></tr>`;
    }).join("") : `<tr><td colspan="6" class="hockey-directory-empty">Данные ещё не загружены.</td></tr>`;
    const needsTournament = section !== "countries";
    const form = section === "countries" ? `
      <label><span>ID / код API</span><input name="external_id" maxlength="64" required value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : ""}></label>
      <div class="hockey-directory-form-columns"><label><span>ISO-2</span><input name="iso2" maxlength="2" value="${escapeHtml(editing?.iso2 || "")}"></label><label><span>ISO-3</span><input name="iso3" maxlength="3" value="${escapeHtml(editing?.iso3 || "")}"></label><label><span>Название на русском</span><input name="name_ru" value="${escapeHtml(editing?.name_ru || "")}"></label><label><span>Name in English</span><input name="name_en" value="${escapeHtml(editing?.name_en || "")}"></label></div>` : genericPersonFields(section, editing, context);
    const content = `
      <div class="hockey-directory-toolbar">
        <label class="hockey-directory-filter"><span>Поиск</span><input data-generic-search value="${escapeHtml(state.directorySearch)}" placeholder="Имя, ID, команда"></label>
        <div class="hockey-directory-toolbar-actions"><button type="button" data-sync-generic ${needsTournament && !context.tournamentId ? "disabled" : ""}>${escapeHtml(config.syncLabel)}</button><button type="button" class="is-accent" data-new-record>Добавить</button></div>
      </div>
      <p class="hockey-directory-note">${needsTournament ? (context.tournamentId ? `Текущий турнир: ${escapeHtml(context.tournamentId)}.` : "Выберите матч или турнир, чтобы загрузить данные его лиги и сезона.") : "Глобальный справочник стран и связей игроков со странами."}</p>
      ${statusMarkup()}
      <div class="hockey-directory-grid">
        <div class="hockey-directory-table-wrap"><table><thead><tr>${section === "countries" ? "<th>Флаг</th><th>Код</th><th>Название</th><th>ISO-3</th><th></th>" : "<th>Флаг</th><th>ID</th><th>Имя</th><th>Команда / данные</th><th>Дата рождения</th><th></th>"}</tr></thead><tbody>${rows}</tbody></table></div>
        <form class="hockey-directory-form" data-generic-form><h3>${editing ? `Редактирование: ${escapeHtml(config.title)}` : `Новая запись: ${escapeHtml(config.title)}`}</h3>${form}<label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Активна</span></label><div class="hockey-directory-form-actions">${editing ? '<button type="button" data-cancel-record>Отмена</button>' : ""}<button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button></div></form>
      </div>`;
    const modal = ensureModal(); modal.innerHTML = shellMarkup(content); bindShell(); bindGeneric(section);
  }

  function formPayload(form) {
    const values = new FormData(form); const payload = {};
    for (const [key, value] of values.entries()) payload[key] = value;
    ["height_cm", "weight_kg"].forEach((key) => { if (key in payload) payload[key] = Number(payload[key] || 0); });
    payload.active = values.has("active"); return payload;
  }

  function bindGeneric(section) {
    const modal = ensureModal(); const config = genericSections[section];
    modal.querySelector("[data-generic-search]")?.addEventListener("change", async (event) => { state.directorySearch = event.target.value.trim(); await loadGeneric(section); renderGeneric(section); });
    modal.querySelector("[data-sync-generic]")?.addEventListener("click", async (event) => {
      const button=event.currentTarget; const context=selectedContext(); const params=new URLSearchParams();
      if (section !== "countries") params.set("tournament_external_id", context.tournamentId);
      button.disabled=true;
      try { const result=await request(`/api/hockey/directories/${config.endpoint}/sync?${params}`, {method:"POST"}); await loadGeneric(section); setStatus(`Справочник обновлён: ${result.players ?? result.referees ?? result.coaches ?? result.countries ?? state[section]?.count ?? 0} записей.`); }
      catch(error){ setStatus(error.message,true); }
      renderGeneric(section);
    });
    modal.querySelector("[data-new-record]")?.addEventListener("click",()=>{state.editingRecord=null;setStatus("");renderGeneric(section);});
    modal.querySelectorAll("[data-edit-record]").forEach((button)=>button.addEventListener("click",()=>{state.editingRecord=(state[section]?.items||[]).find((item)=>String(item.id)===button.dataset.editRecord)||null;setStatus("");renderGeneric(section);}));
    modal.querySelector("[data-cancel-record]")?.addEventListener("click",()=>{state.editingRecord=null;setStatus("");renderGeneric(section);});
    modal.querySelector("[data-generic-form]")?.addEventListener("submit",async(event)=>{event.preventDefault();const payload=formPayload(event.currentTarget);const id=state.editingRecord?.id;try{await request(id?`/api/hockey/directories/${config.endpoint}/${id}`:`/api/hockey/directories/${config.endpoint}`,{method:id?"PUT":"POST",body:JSON.stringify(payload)});state.editingRecord=null;await loadGeneric(section);setStatus("Запись сохранена.");}catch(error){setStatus(error.message,true);}renderGeneric(section);});
  }

  function timerRuleModeOptions(selected) {
    const value = String(selected || "pp");
    return `
      <option value="pp" ${value === "pp" ? "selected" : ""}>Только PP / PK</option>
      <option value="numbers" ${value === "numbers" ? "selected" : ""}>Только состав: 5×4 / 4×3</option>
      <option value="pp_numbers" ${value === "pp_numbers" ? "selected" : ""}>PP / PK вместе с составом</option>
    `;
  }

  const STRENGTH_LABEL_PHASES = [
    { key: "regulation", title: "Основное время", hint: "Базовый состав 5×5", states: ["5x5", "5x4", "4x4", "5x3", "4x3", "3x3"] },
    { key: "regular_overtime", title: "Овертайм регулярки", hint: "Базовый состав 3×3; удаления добавляют игроков сопернику", states: ["3x3", "4x3", "4x4", "5x3", "5x4", "5x5"] },
    { key: "playoff_overtime", title: "Овертайм плей-офф", hint: "Базовый состав 5×5", states: ["5x5", "5x4", "4x4", "5x3", "4x3", "3x3"] },
  ];

  const PERIOD_STATUS_ROWS = [
    { key: "1", title: "1 период" },
    { key: "2", title: "2 период" },
    { key: "3", title: "3 период" },
    { key: "ot", title: "Овертайм регулярки" },
    { key: "ot_numbered", title: "Номерной овертайм плей-офф", hint: "Можно использовать {n}" },
    { key: "so", title: "Буллиты" },
    { key: "finished", title: "Матч завершён" },
  ];

  const SCOREBOARD_TEAM_STATE_ROWS = [
    { key: "home_delayed_penalty", side: "HOME", title: "Отложенный штраф", defaultRu: "Отложенный штраф", defaultEn: "Delayed penalty" },
    { key: "home_empty_net", side: "HOME", title: "Пустые ворота", defaultRu: "Пустые ворота", defaultEn: "Empty net" },
    { key: "away_delayed_penalty", side: "AWAY", title: "Отложенный штраф", defaultRu: "Отложенный штраф", defaultEn: "Delayed penalty" },
    { key: "away_empty_net", side: "AWAY", title: "Пустые ворота", defaultRu: "Пустые ворота", defaultEn: "Empty net" },
  ];

  function timerRulesInventoryInputs() {
    const inputs = state.timerRulesVmixInventory?.inventory?.inputs;
    return Array.isArray(inputs) ? [...inputs] : [];
  }

  function scoreboardTeamStateValue(settings, key) {
    const source = settings?.scoreboard_team_states?.[key];
    return source && typeof source === "object" ? source : {};
  }

  function scoreboardInputOptions(selectedValue, selectedTitle = "") {
    const selected = String(selectedValue || "").trim();
    const inputs = timerRulesInventoryInputs();
    const options = [`<option value="">— не выводить отдельный Input —</option>`];
    let found = !selected;
    inputs
      .sort((a, b) => Number(a.number || 0) - Number(b.number || 0))
      .forEach((input) => {
        const key = String(input.key || input.title || input.number || "").trim();
        if (!key) return;
        const title = String(input.title || input.name || key).trim();
        const number = String(input.number || "").trim();
        const label = `${number ? `#${number} · ` : ""}${title}`;
        if (key === selected) found = true;
        options.push(`<option value="${escapeHtml(key)}" data-input-title="${escapeHtml(title)}" ${key === selected ? "selected" : ""}>${escapeHtml(label)}</option>`);
      });
    if (selected && !found) {
      options.push(`<option value="${escapeHtml(selected)}" data-input-title="${escapeHtml(selectedTitle)}" selected>Сохранённый Input · ${escapeHtml(selectedTitle || selected)}</option>`);
    }
    return options.join("");
  }

  function scoreboardTeamStatesMarkup(settings) {
    return SCOREBOARD_TEAM_STATE_ROWS.map((row) => {
      const value = scoreboardTeamStateValue(settings, row.key);
      const overlay = ["1", "2", "3", "4", "5", "6", "7", "8"].includes(String(value.overlay || "")) ? String(value.overlay) : (row.key.includes("empty_net") ? "3" : "2");
      return `
        <div class="hockey-team-state-setting-row">
          <div class="hockey-team-state-setting-kind"><b>${escapeHtml(row.side)}</b><strong>${escapeHtml(row.title)}</strong></div>
          <label><span>Русская надпись</span><input name="team_state_${row.key}_ru" maxlength="48" value="${escapeHtml(value.ru || row.defaultRu)}"></label>
          <label><span>English label</span><input name="team_state_${row.key}_en" maxlength="48" value="${escapeHtml(value.en || row.defaultEn)}"></label>
          <label><span>vMix Input</span><select name="team_state_${row.key}_input">${scoreboardInputOptions(value.input, value.input_title)}</select></label>
          <label><span>Overlay</span><select name="team_state_${row.key}_overlay">${[1,2,3,4,5,6,7,8].map((item) => `<option value="${item}" ${String(item) === overlay ? "selected" : ""}>Overlay ${item}</option>`).join("")}</select></label>
          <label class="hockey-rules-switch compact"><input name="team_state_${row.key}_enabled" type="checkbox" ${value.enabled !== false ? "checked" : ""}><i></i><span><strong>Использовать</strong></span></label>
        </div>`;
    }).join("");
  }

  function collectScoreboardTeamStates(values, form) {
    const result = {};
    SCOREBOARD_TEAM_STATE_ROWS.forEach((row) => {
      const select = form.querySelector(`[name="team_state_${row.key}_input"]`);
      const selectedOption = select?.selectedOptions?.[0];
      result[row.key] = {
        ru: String(values.get(`team_state_${row.key}_ru`) || "").trim(),
        en: String(values.get(`team_state_${row.key}_en`) || "").trim(),
        input: String(values.get(`team_state_${row.key}_input`) || "").trim(),
        input_title: String(selectedOption?.dataset?.inputTitle || selectedOption?.textContent || "").replace(/^#\d+\s*·\s*/, "").replace(/^Сохранённый Input\s*·\s*/, "").trim(),
        overlay: String(values.get(`team_state_${row.key}_overlay`) || "2"),
        enabled: values.has(`team_state_${row.key}_enabled`),
      };
    });
    return result;
  }

  function periodStatusLabelValue(settings, periodKey, formatKey, language) {
    return String(settings?.period_status_labels?.[periodKey]?.[formatKey]?.[language] || "");
  }

  function periodStatusLabelsMarkup(settings) {
    return PERIOD_STATUS_ROWS.map((row) => `
      <div class="hockey-period-status-group">
        <header><strong>${escapeHtml(row.title)}</strong>${row.hint ? `<small>${escapeHtml(row.hint)}</small>` : ""}</header>
        <div class="hockey-period-status-table">
          <div class="hockey-period-status-row is-head"><span>Формат</span><span>Русский</span><span>English</span></div>
          ${[["compact","Компактный"],["short","Короткий"],["long","Полный"]].map(([formatKey, label]) => `
            <label class="hockey-period-status-row">
              <b>${escapeHtml(label)}</b>
              <input name="period_status_${row.key}_${formatKey}_ru" maxlength="64" value="${escapeHtml(periodStatusLabelValue(settings, row.key, formatKey, "ru"))}" placeholder="RU">
              <input name="period_status_${row.key}_${formatKey}_en" maxlength="64" value="${escapeHtml(periodStatusLabelValue(settings, row.key, formatKey, "en"))}" placeholder="EN">
            </label>`).join("")}
        </div>
      </div>`).join("");
  }

  function collectPeriodStatusLabels(values) {
    const result = {};
    PERIOD_STATUS_ROWS.forEach((row) => {
      result[row.key] = {};
      ["compact", "short", "long"].forEach((formatKey) => {
        result[row.key][formatKey] = {
          ru: String(values.get(`period_status_${row.key}_${formatKey}_ru`) || "").trim(),
          en: String(values.get(`period_status_${row.key}_${formatKey}_en`) || "").trim(),
        };
      });
    });
    return result;
  }

  function strengthStateLabelValue(settings, phase, stateKey, language) {
    return String(settings?.strength_state_labels?.[phase]?.[stateKey]?.[language] || "");
  }

  function strengthStateLabelsMarkup(settings) {
    return STRENGTH_LABEL_PHASES.map((phase) => `
      <div class="hockey-strength-label-group">
        <header><strong>${escapeHtml(phase.title)}</strong><small>${escapeHtml(phase.hint)}</small></header>
        <div class="hockey-strength-label-table">
          <div class="hockey-strength-label-row is-head"><span>Состав</span><span>Русская подпись</span><span>English label</span></div>
          ${phase.states.map((stateKey) => `
            <label class="hockey-strength-label-row">
              <b>${escapeHtml(stateKey.replace("x", "×"))}</b>
              <input name="strength_state_${phase.key}_${stateKey}_ru" maxlength="40" value="${escapeHtml(strengthStateLabelValue(settings, phase.key, stateKey, "ru"))}" placeholder="Например: ${stateKey === "5x4" ? "PP" : stateKey.replace("x", " на ")}">
              <input name="strength_state_${phase.key}_${stateKey}_en" maxlength="40" value="${escapeHtml(strengthStateLabelValue(settings, phase.key, stateKey, "en"))}" placeholder="Example: ${stateKey === "5x4" ? "PP" : stateKey.replace("x", " on ")}">
            </label>`).join("")}
        </div>
      </div>`).join("");
  }

  function collectStrengthStateLabels(values) {
    const result = {};
    STRENGTH_LABEL_PHASES.forEach((phase) => {
      result[phase.key] = {};
      phase.states.forEach((stateKey) => {
        result[phase.key][stateKey] = {
          ru: String(values.get(`strength_state_${phase.key}_${stateKey}_ru`) || "").trim(),
          en: String(values.get(`strength_state_${phase.key}_${stateKey}_en`) || "").trim(),
        };
      });
    });
    return result;
  }

  function renderTimerRules() {
    const value = state.timerRules || {};
    const modal = ensureModal();
    modal.innerHTML = shellMarkup(`
      <div class="hockey-rules-page">
        <div class="hockey-rules-intro">
          <div>
            <span>ОБЩИЕ ПРАВИЛА МАТЧА</span>
            <h2>Таймеры и численные составы</h2>
            <p>Эти значения используются для новых матчей, кнопки «Сброс» и автоматической установки времени при смене периода.</p>
          </div>
          <button type="button" data-open-penalty-directory>Открыть справочник удалений</button>
        </div>
        ${statusMarkup()}
        <form class="hockey-rules-form" data-timer-rules-form>
          <section class="hockey-rules-card">
            <header><span>01</span><div><strong>Длительность периодов</strong><small>Укажите своё время в минутах.</small></div></header>
            <div class="hockey-rules-fields three">
              <label><span>Обычный период</span><div class="hockey-rules-number"><input name="timer_period_minutes" type="number" min="1" max="60" value="${Number(value.timer_period_minutes || 20)}"><b>мин</b></div><small>1, 2 и 3 периоды</small></label>
              <label><span>Овертайм регулярки</span><div class="hockey-rules-number"><input name="timer_regular_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_regular_overtime_minutes || 5)}"><b>мин</b></div><small>Обычно 5 минут</small></label>
              <label><span>Овертайм плей-офф</span><div class="hockey-rules-number"><input name="timer_playoff_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_playoff_overtime_minutes || 20)}"><b>мин</b></div><small>Каждый дополнительный период</small></label>
            </div>
            <label class="hockey-rules-switch">
              <input name="timer_reset_on_period_change" type="checkbox" ${value.timer_reset_on_period_change !== false ? "checked" : ""}>
              <i></i><span><strong>Автоматически ставить время нового периода</strong><small>При переключении периода таймер остановится и получит заданную выше длительность.</small></span>
            </label>
          </section>

          <section class="hockey-rules-card">
            <header><span>02</span><div><strong>Базовый численный состав</strong><small>Количество полевых игроков до удалений.</small></div></header>
            <div class="hockey-rules-fields four">
              <label><span>Основное время</span><div class="hockey-rules-number"><input name="strength_regulation_skaters" type="number" min="3" max="6" value="${Number(value.strength_regulation_skaters || 5)}"><b>×</b></div></label>
              <label><span>ОТ регулярки</span><div class="hockey-rules-number"><input name="strength_regular_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_regular_overtime_skaters || 3)}"><b>×</b></div></label>
              <label><span>ОТ плей-офф</span><div class="hockey-rules-number"><input name="strength_playoff_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_playoff_overtime_skaters || 5)}"><b>×</b></div></label>
              <label><span>Минимум игроков</span><div class="hockey-rules-number"><input name="strength_min_skaters" type="number" min="2" max="5" value="${Number(value.strength_min_skaters || 3)}"><b>×</b></div></label>
            </div>
            <div class="hockey-rules-example">
              <strong>Пример:</strong> при базовом составе 3×3 удаление одной команды отображает фактический состав 4×3 для соперника.
            </div>
          </section>

          <section class="hockey-rules-card">
            <header><span>03</span><div><strong>Подписи численных составов в верхнем счёте</strong><small>Для каждого состояния задаются отдельные русское и английское названия.</small></div></header>
            <div class="hockey-rules-example">
              <strong>Как работает:</strong> программа сама считает активные удаления. В основном времени одно удаление даёт 5×4, два удаления одной команды — 5×3, по одному у каждой — 4×4. В овертайме регулярки одно удаление даёт 4×3, два — 5×3, а по одному у каждой команды — 4×4.
            </div>
            <div class="hockey-strength-label-groups">${strengthStateLabelsMarkup(value)}</div>
          </section>

          <section class="hockey-rules-card">
            <header><span>04</span><div><strong>Статус периода для Mapping</strong><small>Отдельные русские и английские подписи в трёх форматах. Для номерного овертайма доступен шаблон {n}.</small></div></header>
            <div class="hockey-rules-example">
              <strong>В Mapping:</strong> доступны period.compact, period.short, period.long, а также независимые period.ru.* и period.en.*. Можно сделать, например, «2», «2 ПЕР» и «2 период» одновременно для разных титров.
            </div>
            <div class="hockey-period-status-groups">${periodStatusLabelsMarkup(value)}</div>
          </section>

          <section class="hockey-rules-card">
            <header><span>05</span><div><strong>Состояния команд в верхнем счёте</strong><small>Кнопки «Пустые ворота» и «Отложенный штраф»: подписи RU/EN и отдельные vMix Inputs.</small></div></header>
            <div class="hockey-rules-example">
              <strong>Логика:</strong> кнопка только включает состояние матча. Если верхний счёт уже в эфире, дополнительный Input включится/выключится сразу. Если счёт ещё не выдан, Input появится автоматически вместе с верхним счётом. В настройке сохраняется стабильный vMix key, а номер Input используется только для отображения.
            </div>
            <div class="hockey-team-state-settings">${scoreboardTeamStatesMarkup(value)}</div>
            <small class="hockey-team-state-inventory-note">vMix: ${escapeHtml(state.timerRulesVmixInventory?.device_name || "Agent не выбран / inventory не загружен")}. Для обновления списка Inputs закройте и снова откройте этот раздел после выбора Agent.</small>
          </section>

          <section class="hockey-rules-card">
            <header><span>06</span><div><strong>Внешний счёт SQL / Mapping</strong><small>Источник начального счёта и кнопки ↻ синхронизации.</small></div></header>
            <div class="hockey-rules-example">
              <strong>Как использовать:</strong> укажите ключи данных из Mapping для счёта HOME и AWAY, например <code>score_sql.home</code> и <code>score_sql.away</code>. Пока ручной режим не включён, LIVE следует этим значениям. После первого +/− счёт фиксируется вручную; ↻ снова принимает актуальный SQL. Если поля оставить пустыми, используется счёт из карточки матча/API.
            </div>
            <div class="hockey-rules-fields two">
              <label><span>HOME · ключ SQL/Mapping</span><input name="score_external_home_key" maxlength="160" value="${escapeHtml(value.score_external_home_key || "")}" placeholder="Например: score_sql.home"><small>Скопируйте Data key из раздела Mapping.</small></label>
              <label><span>AWAY · ключ SQL/Mapping</span><input name="score_external_away_key" maxlength="160" value="${escapeHtml(value.score_external_away_key || "")}" placeholder="Например: score_sql.away"><small>Скопируйте Data key из раздела Mapping.</small></label>
            </div>
          </section>

          <footer class="hockey-rules-actions">
            <div><small>Изменения применяются сразу и сохраняются в общих настройках сервера.</small><strong data-rules-save-status></strong></div>
            <button type="submit">Сохранить настройки</button>
          </footer>
        </form>
      </div>
    `);
    bindShell();
    bindTimerRules();
  }

  function bindTimerRules() {
    const modal = ensureModal();
    modal.querySelector("[data-open-penalty-directory]")?.addEventListener("click", async () => {
      state.section = "penalties";
      state.status = "";
      try { await loadPenalties(true); } catch (error) { setStatus(error.message, true); }
      render();
    });
    modal.querySelector("[data-timer-rules-form]")?.addEventListener("submit", async (event) => {
      event.preventDefault();
      const form = event.currentTarget;
      const values = new FormData(form);
      const button = form.querySelector("button[type='submit']");
      const inlineStatus = form.querySelector("[data-rules-save-status]");
      button.disabled = true;
      inlineStatus.textContent = "Сохранение…";
      try {
        const payload = {
          timer_period_minutes: Number(values.get("timer_period_minutes") || 20),
          timer_regular_overtime_minutes: Number(values.get("timer_regular_overtime_minutes") || 5),
          timer_playoff_overtime_minutes: Number(values.get("timer_playoff_overtime_minutes") || 20),
          timer_reset_on_period_change: values.has("timer_reset_on_period_change"),
          strength_regulation_skaters: Number(values.get("strength_regulation_skaters") || 5),
          strength_regular_overtime_skaters: Number(values.get("strength_regular_overtime_skaters") || 3),
          strength_playoff_overtime_skaters: Number(values.get("strength_playoff_overtime_skaters") || 5),
          strength_min_skaters: Number(values.get("strength_min_skaters") || 3),
          strength_state_labels: collectStrengthStateLabels(values),
          period_status_labels: collectPeriodStatusLabels(values),
          scoreboard_team_states: collectScoreboardTeamStates(values, form),
          score_external_home_key: String(values.get("score_external_home_key") || "").trim(),
          score_external_away_key: String(values.get("score_external_away_key") || "").trim(),
        };
        state.timerRules = await request("/api/hockey/settings", { method: "PUT", body: JSON.stringify(payload) });
        inlineStatus.textContent = "Настройки сохранены";
        inlineStatus.classList.remove("is-error");
        window.dispatchEvent(new CustomEvent("hockey:settings-updated", { detail: { settings: state.timerRules } }));
      } catch (error) {
        inlineStatus.textContent = error.message;
        inlineStatus.classList.add("is-error");
      } finally {
        button.disabled = false;
      }
    });
  }

  async function loadVmixSettings(force = false) {
    if (state.vmixSettings && !force) return state.vmixSettings;
    const [data, channelData] = await Promise.all([
      request("/api/hockey/vmix/settings"),
      request(`/api/hockey/vmix-channel?language=${encodeURIComponent(state.language === "en" ? "en" : "ru")}`).catch(() => ({ channel: null })),
    ]);
    state.vmixSettings = data;
    state.vmixChannel = channelData?.channel || null;
    const configs = vmixConfigs();
    if (!configs.some((item) => item.key === state.vmixActiveKey)) {
      state.vmixActiveKey = String(data.settings?.active_vmix_json || configs[0]?.key || "");
    }
    return data;
  }

  function vmixConfigs() {
    if (!state.vmixSettings) state.vmixSettings = { vmix_json: { version: 1, configs: [] }, vmix_functions: { categories: [] }, source_types: [] };
    if (!state.vmixSettings.vmix_json || !Array.isArray(state.vmixSettings.vmix_json.configs)) {
      state.vmixSettings.vmix_json = { version: 1, configs: [] };
    }
    return state.vmixSettings.vmix_json.configs;
  }

  function activeVmixConfig() {
    return vmixConfigs().find((item) => item.key === state.vmixActiveKey) || vmixConfigs()[0] || null;
  }

  function vmixSafeKey(value, fallback = "json") {
    return String(value || "").trim().replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || fallback;
  }

  function vmixEndpointTemplate(key) {
    return `/vmix/hockey/channel/{channel}/{language}/custom/${vmixSafeKey(key)}.json`;
  }

  function vmixLiveEndpoint(config) {
    const context = selectedContext();
    if (!context.token || !config) return "";
    return `${location.origin}/vmix/hockey/channel/${encodeURIComponent(context.token)}/${context.language}/custom/${encodeURIComponent(config.key)}.json`;
  }

  function vmixSourceOptions(selected) {
    const items = state.vmixSettings?.source_types || [];
    return items.map((item) => `<option value="${escapeHtml(item.value)}" ${item.value === selected ? "selected" : ""}>${escapeHtml(item.label)} · ${escapeHtml(item.value)}</option>`).join("");
  }

  function vmixColumnsMarkup(config) {
    const columns = Array.isArray(config?.columns) ? config.columns : [];
    if (!columns.length) return `<tr><td colspan="8" class="hockey-vmix-empty">Колонки не заданы. В режиме «Все поля» исходные поля будут переданы полностью.</td></tr>`;
    return columns.map((column, index) => `
      <tr data-vmix-column="${index}">
        <td><input type="checkbox" data-vmix-col="enabled" ${column.enabled !== false ? "checked" : ""}></td>
        <td><input type="text" data-vmix-col="key" value="${escapeHtml(column.key || "")}"></td>
        <td><input type="text" data-vmix-col="label" value="${escapeHtml(column.label || "")}"></td>
        <td><input type="text" data-vmix-col="source" value="${escapeHtml(column.source || "")}"></td>
        <td>
          <select data-vmix-col="mode">
            <option value="" ${!column.mode ? "selected" : ""}>auto</option>
            <option value="field" ${column.mode === "field" ? "selected" : ""}>field</option>
            <option value="template" ${column.mode === "template" ? "selected" : ""}>template</option>
            <option value="expr" ${column.mode === "expr" ? "selected" : ""}>expr</option>
          </select>
        </td>
        <td><input type="text" data-vmix-col="expr" value="${escapeHtml(column.expr || "")}" placeholder="if_eq(period, '1', 'I', period)"></td>
        <td><input type="text" data-vmix-col="default" value="${escapeHtml(column.default || "")}"></td>
        <td class="hockey-vmix-col-actions">
          <button type="button" data-vmix-col-action="up" data-index="${index}" aria-label="Вверх">↑</button>
          <button type="button" data-vmix-col-action="down" data-index="${index}" aria-label="Вниз">↓</button>
          <button type="button" data-vmix-col-action="delete" data-index="${index}" aria-label="Удалить">×</button>
        </td>
      </tr>
    `).join("");
  }

  function vmixConfigPageMarkup() {
    const configs = vmixConfigs();
    const config = activeVmixConfig();
    const context = selectedContext();
    const liveEndpoint = vmixLiveEndpoint(config);
    const sourceFields = state.vmixSourceFields?.sourceType === config?.source_type ? state.vmixSourceFields.fields : [];
    return `
      <div class="hockey-vmix-layout">
        <aside class="hockey-vmix-list-panel">
          <div class="hockey-vmix-list-head"><strong>JSON-конфигурации</strong><small>${configs.length} шт.</small></div>
          <div class="hockey-vmix-config-list">
            ${configs.map((item) => `
              <button type="button" data-vmix-config="${escapeHtml(item.key)}" class="${item.key === state.vmixActiveKey ? "is-active" : ""}">
                <strong>${escapeHtml(item.title || item.key)}</strong>
                <span>${escapeHtml(item.key)}</span>
                <em>${escapeHtml(item.source_type || "scoreboard")}</em>
              </button>
            `).join("") || `<div class="hockey-vmix-empty">Конфигураций пока нет.</div>`}
          </div>
          <div class="hockey-vmix-list-actions">
            <button type="button" data-vmix-new>+ Новый</button>
            <button type="button" data-vmix-duplicate ${config ? "" : "disabled"}>Копия</button>
            <button type="button" data-vmix-delete ${config ? "" : "disabled"}>Удалить</button>
          </div>
        </aside>
        <section class="hockey-vmix-editor">
          ${config ? `
            <header class="hockey-vmix-editor-head">
              <div><span>VMIX JSON EDITOR</span><strong>${escapeHtml(config.title || config.key)}</strong></div>
              <div class="hockey-vmix-editor-actions">
                <button type="button" data-vmix-fields>Поля источника</button>
                <button type="button" data-vmix-preview ${liveEndpoint ? "" : "disabled"}>Открыть JSON</button>
                <button type="button" class="is-accent" data-vmix-save>Сохранить</button>
              </div>
            </header>
            <div class="hockey-vmix-form-grid">
              <label><span>Ключ JSON</span><input data-vmix-main="key" value="${escapeHtml(config.key || "")}"></label>
              <label><span>Название</span><input data-vmix-main="title" value="${escapeHtml(config.title || "")}"></label>
              <label class="is-wide"><span>Адрес-шаблон</span><input data-vmix-main="endpoint" value="${escapeHtml(vmixEndpointTemplate(config.key))}" readonly></label>
              <label><span>Источник</span><select data-vmix-main="source_type">${vmixSourceOptions(config.source_type)}</select></label>
              <label><span>Вывод</span><select data-vmix-main="output_mode"><option value="columns" ${config.output_mode !== "all" ? "selected" : ""}>Только выбранные колонки</option><option value="all" ${config.output_mode === "all" ? "selected" : ""}>Все поля</option></select></label>
              <label><span>Лимит строк, 0 — без лимита</span><input type="number" min="0" max="1000" data-vmix-main="default_limit" value="${Number(config.default_limit || 0)}"></label>
              <label class="hockey-directory-check"><input type="checkbox" data-vmix-main="enabled" ${config.enabled !== false ? "checked" : ""}><span>Конфигурация включена</span></label>
            </div>
            <div class="hockey-vmix-live-link ${liveEndpoint ? "" : "is-disabled"}">
              <span>${context.token ? `Постоянный канал аккаунта ${escapeHtml(state.vmixChannel?.channel_owner || "")}` : "Сначала откройте матч, чтобы создать персональный канал vMix"}</span>
              <code>${escapeHtml(liveEndpoint || vmixEndpointTemplate(config.key))}</code>
            </div>
            <div class="hockey-vmix-fields ${sourceFields.length ? "" : "hidden"}" data-vmix-fields-box>
              ${sourceFields.map((field) => `<button type="button" data-vmix-source-field="${escapeHtml(field)}">${escapeHtml(field)}</button>`).join("")}
            </div>
            <div class="hockey-vmix-columns-head">
              <div><strong>Колонки JSON</strong><small>field — поле, template — шаблон, expr — формула</small></div>
              <button type="button" data-vmix-add-column>+ Колонка</button>
            </div>
            <div class="hockey-vmix-table-wrap">
              <table class="hockey-vmix-columns-table">
                <thead><tr><th>Вкл</th><th>key</th><th>label</th><th>source</th><th>mode</th><th>expr</th><th>default</th><th></th></tr></thead>
                <tbody>${vmixColumnsMarkup(config)}</tbody>
              </table>
            </div>
            <details class="hockey-vmix-joins">
              <summary>Объединение дополнительных источников</summary>
              <p>Необязательный массив правил joins из переносимого модуля. Источник указывается как <code>source_type</code>.</p>
              <textarea data-vmix-joins spellcheck="false">${escapeHtml(JSON.stringify(config.joins || [], null, 2))}</textarea>
            </details>
          ` : `<div class="hockey-vmix-empty is-large">Создайте первую JSON-конфигурацию.</div>`}
        </section>
      </div>
    `;
  }

  function vmixFunctionsMarkup() {
    const categories = state.vmixSettings?.vmix_functions?.categories || [];
    return `<div class="hockey-vmix-functions">${categories.map((category) => `
      <details open>
        <summary>${escapeHtml(category.title || "Функции")}</summary>
        <div>${(category.items || []).map((item) => `<article><code>${escapeHtml(item.example || item.name || "")}</code><span>${escapeHtml(item.description || "")}</span></article>`).join("")}</div>
      </details>
    `).join("") || `<div class="hockey-vmix-empty">Справочник функций пуст.</div>`}</div>`;
  }

  function vmixImportMarkup() {
    const preview = state.vmixImportPreview;
    const configs = preview?.configs || preview?.configs_preview || preview?.vmix_configs || [];
    return `
      <div class="hockey-vmix-import">
        <section>
          <span>IMPORT</span><strong>Импорт настроек</strong>
          <p>Можно выбрать <code>vmix_json.json</code>, другой JSON с настройками или ZIP проекта. Перед применением показывается найденный список конфигураций.</p>
          <div class="hockey-vmix-import-actions">
            <input type="file" data-vmix-import-file accept=".json,.zip,application/json,application/zip">
            <button type="button" data-vmix-import-preview>Предпросмотр</button>
            <button type="button" class="is-accent" data-vmix-import-apply ${preview ? "" : "disabled"}>Добавить конфигурации</button>
          </div>
          ${preview ? `<div class="hockey-vmix-import-preview"><strong>${escapeHtml(preview.file_name || "Файл")}</strong><span>Найдено: ${Number(preview.configs_count ?? configs.length ?? 0)}</span><div>${configs.map((item) => `<em>${escapeHtml(item.title || item.key || "JSON")}</em>`).join("")}</div></div>` : ""}
        </section>
        <section>
          <span>EXPORT</span><strong>Экспорт текущих настроек</strong>
          <p>Скачивается переносимый JSON, который можно импортировать в другую хоккейную сборку.</p>
          <button type="button" data-vmix-export>Скачать vmix_json.json</button>
        </section>
      </div>
    `;
  }

  function renderVmixSettings() {
    const panelContent = state.vmixPanel === "functions"
      ? vmixFunctionsMarkup()
      : state.vmixPanel === "import"
        ? vmixImportMarkup()
        : vmixConfigPageMarkup();
    const content = `
      <div class="hockey-vmix-page">
        <div class="hockey-vmix-intro">
          <div><span>PORTABLE VMIX SETTINGS</span><h2>Конструктор JSON для vMix</h2><p>Каждый аккаунт имеет постоянный канал vMix. При выборе нового матча данные меняются, а ссылка в проекте vMix остаётся прежней.</p></div>
          <nav>
            <button type="button" data-vmix-panel="configs" class="${state.vmixPanel === "configs" ? "is-active" : ""}">JSON</button>
            <button type="button" data-vmix-panel="functions" class="${state.vmixPanel === "functions" ? "is-active" : ""}">Формулы</button>
            <button type="button" data-vmix-panel="import" class="${state.vmixPanel === "import" ? "is-active" : ""}">Импорт / экспорт</button>
          </nav>
        </div>
        ${statusMarkup()}
        ${panelContent}
      </div>
    `;
    const modal = ensureModal();
    modal.innerHTML = shellMarkup(content);
    bindShell();
    bindVmixSettings();
  }

  function collectVmixEditor() {
    const config = activeVmixConfig();
    const modal = ensureModal();
    if (!config || state.vmixPanel !== "configs") return true;
    const oldKey = config.key;
    const key = vmixSafeKey(modal.querySelector('[data-vmix-main="key"]')?.value, oldKey || "json");
    config.key = key;
    config.title = modal.querySelector('[data-vmix-main="title"]')?.value.trim() || key;
    config.endpoint = vmixEndpointTemplate(key);
    config.source_type = modal.querySelector('[data-vmix-main="source_type"]')?.value || "scoreboard";
    config.output_mode = modal.querySelector('[data-vmix-main="output_mode"]')?.value === "all" ? "all" : "columns";
    config.default_limit = Math.max(0, Number(modal.querySelector('[data-vmix-main="default_limit"]')?.value || 0));
    config.enabled = modal.querySelector('[data-vmix-main="enabled"]')?.checked !== false;
    config.columns = Array.from(modal.querySelectorAll("[data-vmix-column]")).map((row) => ({
      enabled: row.querySelector('[data-vmix-col="enabled"]')?.checked !== false,
      key: row.querySelector('[data-vmix-col="key"]')?.value.trim() || "",
      label: row.querySelector('[data-vmix-col="label"]')?.value.trim() || "",
      source: row.querySelector('[data-vmix-col="source"]')?.value || "",
      mode: row.querySelector('[data-vmix-col="mode"]')?.value || "",
      expr: row.querySelector('[data-vmix-col="expr"]')?.value || "",
      default: row.querySelector('[data-vmix-col="default"]')?.value || "",
    }));
    const joinsText = modal.querySelector("[data-vmix-joins]")?.value.trim() || "[]";
    try {
      const joins = JSON.parse(joinsText);
      if (!Array.isArray(joins)) throw new Error("joins должен быть массивом");
      config.joins = joins;
    } catch (error) {
      setStatus(`Ошибка в joins: ${error.message}`, true);
      return false;
    }
    if (oldKey !== key) state.vmixActiveKey = key;
    return true;
  }

  function createVmixConfig(copy = false) {
    collectVmixEditor();
    const current = activeVmixConfig();
    const config = copy && current ? JSON.parse(JSON.stringify(current)) : {
      key: "new_json", title: "Новый JSON", description: "", source_type: "scoreboard", output_mode: "columns", root_key: "rows", default_limit: 0, enabled: true, columns: [], joins: [],
    };
    const used = new Set(vmixConfigs().map((item) => item.key));
    const base = vmixSafeKey(config.key, "new_json");
    let key = base;
    let index = 2;
    while (used.has(key)) key = `${base}_${index++}`;
    config.key = key;
    config.title = copy ? `${config.title || base} — копия` : config.title;
    config.endpoint = vmixEndpointTemplate(key);
    vmixConfigs().push(config);
    state.vmixActiveKey = key;
    state.vmixSourceFields = null;
    renderVmixSettings();
  }

  async function saveVmixConfigs() {
    if (!collectVmixEditor()) {
      renderVmixSettings();
      return;
    }
    try {
      const data = await request("/api/hockey/vmix/configs", { method: "PUT", body: JSON.stringify({ vmix_json: state.vmixSettings.vmix_json }) });
      state.vmixSettings.vmix_json = data.vmix_json;
      state.vmixActiveKey = activeVmixConfig()?.key || state.vmixSettings.vmix_json.configs[0]?.key || "";
      setStatus("Настройки vMix сохранены.");
    } catch (error) {
      setStatus(error.message, true);
    }
    renderVmixSettings();
  }

  async function loadVmixSourceFields() {
    if (!collectVmixEditor()) return renderVmixSettings();
    const config = activeVmixConfig();
    const context = selectedContext();
    if (!context.token) {
      setStatus("Сначала откройте любой матч: после этого для аккаунта будет создан постоянный канал vMix.", true);
      return renderVmixSettings();
    }
    try {
      const params = new URLSearchParams({ token: context.token, language: context.language, source_type: config.source_type || "scoreboard" });
      const data = await request(`/api/hockey/vmix/source-fields?${params}`);
      state.vmixSourceFields = { sourceType: config.source_type, fields: data.fields || [] };
      setStatus(`Найдено полей: ${(data.fields || []).length}. Нажмите на поле, чтобы добавить колонку.`);
    } catch (error) {
      setStatus(error.message, true);
    }
    renderVmixSettings();
  }

  function fileToBase64(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(String(reader.result || "").split(",")[1] || "");
      reader.onerror = reject;
      reader.readAsDataURL(file);
    });
  }

  async function previewVmixImport() {
    const file = ensureModal().querySelector("[data-vmix-import-file]")?.files?.[0];
    if (!file) {
      setStatus("Выберите JSON или ZIP.", true);
      return renderVmixSettings();
    }
    try {
      const contentBase64 = await fileToBase64(file);
      state.vmixImportPreview = await request("/api/hockey/vmix/import/preview-file", { method: "POST", body: JSON.stringify({ file_name: file.name, content_base64: contentBase64 }) });
      setStatus("Предпросмотр импорта готов.");
    } catch (error) {
      state.vmixImportPreview = null;
      setStatus(error.message, true);
    }
    renderVmixSettings();
  }

  async function applyVmixImport() {
    if (!state.vmixImportPreview) return;
    try {
      const data = await request("/api/hockey/vmix/import/apply", { method: "POST", body: JSON.stringify({ preview: state.vmixImportPreview, import_vmix: true, mode: "add_new" }) });
      state.vmixSettings.vmix_json = data.vmix_json;
      state.vmixActiveKey = data.vmix_json?.configs?.[0]?.key || "";
      state.vmixImportPreview = null;
      setStatus("Конфигурации импортированы и адаптированы к хоккейным источникам.");
      state.vmixPanel = "configs";
    } catch (error) {
      setStatus(error.message, true);
    }
    renderVmixSettings();
  }

  async function exportVmixSettings() {
    try {
      const data = await request("/api/hockey/vmix/export");
      const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = "hockey_vmix_json.json";
      document.body.appendChild(link);
      link.click();
      link.remove();
      URL.revokeObjectURL(url);
      setStatus("Настройки экспортированы.");
    } catch (error) {
      setStatus(error.message, true);
    }
    renderVmixSettings();
  }

  function bindVmixSettings() {
    const modal = ensureModal();
    modal.querySelectorAll("[data-vmix-panel]").forEach((button) => button.addEventListener("click", () => {
      if (state.vmixPanel === "configs" && !collectVmixEditor()) return renderVmixSettings();
      state.vmixPanel = button.dataset.vmixPanel;
      state.status = "";
      renderVmixSettings();
    }));
    modal.querySelectorAll("[data-vmix-config]").forEach((button) => button.addEventListener("click", () => {
      if (!collectVmixEditor()) return renderVmixSettings();
      state.vmixActiveKey = button.dataset.vmixConfig;
      state.vmixSourceFields = null;
      state.status = "";
      renderVmixSettings();
    }));
    modal.querySelector("[data-vmix-new]")?.addEventListener("click", () => createVmixConfig(false));
    modal.querySelector("[data-vmix-duplicate]")?.addEventListener("click", () => createVmixConfig(true));
    modal.querySelector("[data-vmix-delete]")?.addEventListener("click", () => {
      const config = activeVmixConfig();
      if (!config || !confirm(`Удалить JSON «${config.title || config.key}»?`)) return;
      const index = vmixConfigs().indexOf(config);
      vmixConfigs().splice(index, 1);
      state.vmixActiveKey = vmixConfigs()[Math.max(0, index - 1)]?.key || vmixConfigs()[0]?.key || "";
      state.vmixSourceFields = null;
      renderVmixSettings();
    });
    modal.querySelector("[data-vmix-save]")?.addEventListener("click", saveVmixConfigs);
    modal.querySelector("[data-vmix-fields]")?.addEventListener("click", loadVmixSourceFields);
    modal.querySelector("[data-vmix-preview]")?.addEventListener("click", () => {
      if (!collectVmixEditor()) return renderVmixSettings();
      const endpoint = vmixLiveEndpoint(activeVmixConfig());
      if (endpoint) window.open(endpoint, "_blank", "noopener");
    });
    modal.querySelector("[data-vmix-add-column]")?.addEventListener("click", () => {
      if (!collectVmixEditor()) return renderVmixSettings();
      const config = activeVmixConfig();
      config.columns.push({ key: `field_${config.columns.length + 1}`, label: "", source: "", mode: "field", expr: "", default: "", enabled: true });
      config.output_mode = "columns";
      renderVmixSettings();
    });
    modal.querySelectorAll("[data-vmix-col-action]").forEach((button) => button.addEventListener("click", () => {
      if (!collectVmixEditor()) return renderVmixSettings();
      const config = activeVmixConfig();
      const index = Number(button.dataset.index);
      const action = button.dataset.vmixColAction;
      if (action === "delete") config.columns.splice(index, 1);
      if (action === "up" && index > 0) config.columns.splice(index - 1, 0, config.columns.splice(index, 1)[0]);
      if (action === "down" && index < config.columns.length - 1) config.columns.splice(index + 1, 0, config.columns.splice(index, 1)[0]);
      renderVmixSettings();
    }));
    modal.querySelectorAll("[data-vmix-source-field]").forEach((button) => button.addEventListener("click", () => {
      if (!collectVmixEditor()) return renderVmixSettings();
      const field = button.dataset.vmixSourceField || "field";
      activeVmixConfig().columns.push({ key: vmixSafeKey(field.replaceAll(".", "_"), "field"), label: field, source: field, mode: "field", expr: "", default: "", enabled: true });
      activeVmixConfig().output_mode = "columns";
      renderVmixSettings();
    }));
    modal.querySelector('[data-vmix-main="source_type"]')?.addEventListener("change", () => { state.vmixSourceFields = null; });
    modal.querySelector("[data-vmix-import-preview]")?.addEventListener("click", previewVmixImport);
    modal.querySelector("[data-vmix-import-apply]")?.addEventListener("click", applyVmixImport);
    modal.querySelector("[data-vmix-export]")?.addEventListener("click", exportVmixSettings);
  }

  function mappingRequestContext() {
    const runtime = window.UIBuilderRuntime?.getData?.()?.hockey || {};
    const selected = selectedContext();
    const game = runtime.selected_game || selected.game || {};
    const tournament = runtime.selected_tournament || selected.tournament || {};
    const selectedPlayer = runtime.selected_player || runtime.player || runtime.match_details?.selected_player || {};
    const home = runtime.home || game.home || {};
    const away = runtime.away || game.away || {};
    const value = (obj, keys) => {
      for (const key of keys) {
        const parts = String(key).split(".");
        let current = obj; let ok = true;
        for (const part of parts) {
          if (current == null || !(part in Object(current))) { ok = false; break; }
          current = current[part];
        }
        if (ok && current !== null && current !== undefined && String(current) !== "") return String(current);
      }
      return "";
    };
    return {
      game_id: value(game, ["external_id", "id"]),
      tournament_id: value(game, ["tournament_external_id"]) || value(tournament, ["external_id", "id"]) || selected.tournamentId || "",
      team1_id: value(game, ["home_team_external_id"]) || value(home, ["external_id", "id", "team_id"]),
      team2_id: value(game, ["away_team_external_id"]) || value(away, ["external_id", "id", "team_id"]),
      selected_player_id: value(selectedPlayer, ["external_id", "id", "player_id"]),
      selected_player_team_id: value(selectedPlayer, ["team.external_id", "team.id", "team_id"]),
      ui_language: String(runtime.language?.display || state.language || "ru").toLowerCase() === "en" ? "en" : "ru",
      session_token: selected.token || "",
    };
  }

  async function loadMappingCatalog() {
    try {
      state.mappingCatalog = await request("/api/hockey/admin/mapping-data/catalog", {
        method: "POST", body: JSON.stringify({ context: mappingRequestContext() }),
      });
    } catch (error) {
      state.mappingCatalog = { items: [], variables: [], sources: [], context: mappingRequestContext(), error: error.message || String(error) };
    }
    return state.mappingCatalog;
  }

  async function loadMapping(profileId = null) {
    state.mappingWorkspaceTab = mappingTabFromSection();
    state.mappingLoadErrors = {};
    const safe = async (key, url, fallback) => {
      try { return await request(url); }
      catch (error) { state.mappingLoadErrors[key] = error.message || String(error); return fallback; }
    };
    const [devices, profiles, variables, sources, databaseSchema] = await Promise.all([
      safe("devices", "/api/hockey/admin/vmix-mapping/devices", { devices: [] }),
      safe("profiles", "/api/hockey/admin/vmix-mapping/profiles", { profiles: [] }),
      safe("variables", "/api/hockey/admin/mapping-context/variables", { variables: [] }),
      safe("sources", "/api/hockey/admin/mapping-data/sources", { sources: [] }),
      safe("databaseSchema", "/api/hockey/admin/mapping-data/database-schema", { tables: [], table_count: 0 }),
    ]);
    state.mappingDevices = devices;
    state.mappingProfiles = profiles;
    state.mappingContextVariables = variables;
    state.mappingSqlSources = sources;
    state.mappingDatabaseSchema = databaseSchema;
    const id = profileId ?? state.mappingActiveProfile?.id ?? profiles.profiles?.[0]?.id ?? null;
    if (id) {
      try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`); }
      catch (error) { state.mappingActiveProfile = null; state.mappingLoadErrors.profile = error.message || String(error); }
    } else state.mappingActiveProfile = null;
    if (!state.mappingSelectedSqlSourceId && sources.sources?.length) state.mappingSelectedSqlSourceId = sources.sources[0].id;
    await loadMappingCatalog();
    if (state.mappingCatalog?.error) state.mappingLoadErrors.catalog = state.mappingCatalog.error;
    return { devices, profiles, variables, sources, databaseSchema };
  }

  function mappingInventoryInputs(profile) {
    // Mapping picker is operator-facing: show Inputs in vMix numeric order so a
    // title can be found by its familiar # quickly. Stable links still use key/title.
    const inputs = Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];
    return inputs.sort((left, right) => {
      const leftNumber = Number(left?.number);
      const rightNumber = Number(right?.number);
      const leftRank = Number.isFinite(leftNumber) ? leftNumber : Number.MAX_SAFE_INTEGER;
      const rightRank = Number.isFinite(rightNumber) ? rightNumber : Number.MAX_SAFE_INTEGER;
      return leftRank - rightRank
        || String(left?.title || "").localeCompare(String(right?.title || ""), "ru", { numeric: true, sensitivity: "base" });
    });
  }

  function mappingVmixFieldOrder(left, right) {
    // Natural sort keeps Player2.Text before Player10.Text and makes long GT
    // templates much easier to scan. Type suffix stays part of the comparison.
    return String(left?.name || "").localeCompare(String(right?.name || ""), "ru", {
      numeric: true,
      sensitivity: "base",
    });
  }

  function mappingInputByKey(profile, key, title = "") {
    const inputs = mappingInventoryInputs(profile);
    return inputs.find((item) => String(item.key || item.title || item.number || "") === String(key || ""))
      || inputs.find((item) => String(item.title || "") === String(title || ""))
      || null;
  }

  function mappingInputValue(item) {
    return String(item?.key || item?.title || item?.number || "");
  }

  function mappingInputLabel(item) {
    if (!item) return "—";
    return `${item.number ? `#${item.number} · ` : ""}${item.title || "Без названия"}${item.type ? ` [${item.type}]` : ""}`;
  }

  function mappingFilteredInputs(profile) {
    const inputs = mappingInventoryInputs(profile);
    const query = String(state.mappingInputSearch || "").trim().toLowerCase();
    if (!query) return inputs;
    return inputs.filter((item) => `${item.number || ""} ${item.title || ""} ${item.key || ""} ${item.type || ""}`.toLowerCase().includes(query));
  }

  function mappingFieldKind(field) {
    const name = String(field?.name || "").trim().toLowerCase();
    if (name.endsWith(".source")) return "image";
    if (name.endsWith(".color")) return "color";
    if (name.endsWith(".text")) return "text";
    const raw = String(field?.type || "text").toLowerCase();
    if (raw.includes("image") || raw.includes("source")) return "image";
    if (raw.includes("color")) return "color";
    return "text";
  }

  function mappingFieldFilterMatch(field, filter) {
    const name = String(field?.name || "").trim().toLowerCase();
    if (filter === "image") return name.endsWith(".source");
    if (filter === "color") return name.endsWith(".color");
    return name.endsWith(".text");
  }

  function mappingPathValue(root, paths, fallback = "") {
    for (const path of paths) {
      let value = root;
      let ok = true;
      for (const part of String(path).split(".")) {
        if (value == null || !(part in Object(value))) { ok = false; break; }
        value = value[part];
      }
      if (ok && value !== null && value !== undefined && value !== "") return value;
    }
    return fallback;
  }

  function mappingDisplayValue(value, kind = "text") {
    if (value === null || value === undefined || value === "") return "—";
    if (typeof value === "boolean") return value ? "Да" : "Нет";
    if (Array.isArray(value)) return value.length ? `${value.length} элементов` : "—";
    if (typeof value === "object") {
      const text = value.name || value.title || value.label || value.full_name || value.value;
      return text ? String(text) : "Данные доступны";
    }
    const text = String(value);
    if (kind === "image") {
      const part = text.split(/[\\/]/).pop() || text;
      return part.length > 52 ? `${part.slice(0, 49)}…` : part;
    }
    return text.length > 74 ? `${text.slice(0, 71)}…` : text;
  }

  function mappingSource(key, label, category, value, options = {}) {
    return {
      key,
      label,
      category,
      value,
      kind: options.kind || "text",
      description: options.description || "",
    };
  }

  function mappingDataCatalog() {
    const payload = state.mappingCatalog || {};
    const items = Array.isArray(payload.items) ? payload.items.map((item) => ({
      key: String(item.key || ""),
      label: String(item.label || item.key || "Данные"),
      category: String(item.category || "Данные"),
      value: item.value,
      kind: String(item.kind || "text"),
      description: String(item.description || ""),
      source_code: String(item.source_code || ""),
      source_name: String(item.source_name || ""),
      column: String(item.column || ""),
      row_index: Number(item.row_index || 0),
      row_label: String(item.row_label || ""),
      table_cell: Boolean(item.table_cell),
      localized: Boolean(item.localized),
      language: String(item.language || payload.language || ""),
      rus_column: String(item.rus_column || ""),
      eng_column: String(item.eng_column || ""),
      resolved_column: String(item.resolved_column || ""),
      language_explicit: Boolean(item.language_explicit),
    })) : [];
    const byKey = Object.fromEntries(items.map((item) => [item.key, item.value]));
    const context = payload.context || mappingRequestContext();
    const gameId = context.game_id || "";
    const home = byKey["game.home.name"] || "Хозяева";
    const away = byKey["game.away.name"] || "Гости";
    return {
      game: gameId ? { external_id: gameId } : {},
      items,
      gameLabel: gameId ? `${gameId} · ${home} — ${away}` : "Матч не выбран",
      variables: Array.isArray(payload.variables) ? payload.variables : [],
      sourceStatus: Array.isArray(payload.sources) ? payload.sources : [],
      tables: Array.isArray(payload.tables) ? payload.tables : [],
      error: payload.error || "",
    };
  }

  function mappingSourceByKey(key) {
    return mappingDataCatalog().items.find((item) => item.key === key) || null;
  }

  function mappingCurrentInput(profile) {
    const inputs = mappingInventoryInputs(profile);
    if (!inputs.length) return null;
    let current = inputs.find((item) => mappingInputValue(item) === state.mappingSelectedInputKey);
    if (!current) {
      const linked = (profile?.fields || [])[0];
      current = linked ? mappingInputByKey(profile, linked.vmix_input_key, linked.vmix_input_title) : null;
    }
    current ||= inputs[0];
    state.mappingSelectedInputKey = mappingInputValue(current);
    return current;
  }

  function mappingRowForField(profile, input, fieldName) {
    return (profile?.fields || []).find((row) => {
      const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
        || (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
      return sameInput && String(row.vmix_field || "") === String(fieldName || "");
    }) || null;
  }

  function mappingSetLink(profile, input, field, source) {
    const fields = [...(profile?.fields || [])];
    const index = fields.findIndex((row) => {
      const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
        || (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
      return sameInput && String(row.vmix_field || "") === String(field?.name || "");
    });
    const previous = index >= 0 ? fields[index] : null;
    const previousRule = previous?.rule && typeof previous.rule === "object" ? { ...previous.rule } : {};
    if (!previousRule.left_key || String(previousRule.left_key) === String(previous?.data_key || "")) {
      previousRule.left_key = source.key;
    }
    const next = {
      graphic: String(input?.title || "graphic").trim().slice(0, 100),
      data_key: source.key,
      vmix_input_key: String(input?.key || ""),
      // Input number is positional in vMix and changes after reordering.
      // Persist key/title only; number remains display-only in inventory.
      vmix_input_number: "",
      vmix_input_title: String(input?.title || ""),
      vmix_field: String(field?.name || ""),
      field_type: mappingFieldKind(field),
      rule: previousRule,
      enabled: true,
    };
    if (index >= 0) fields[index] = next;
    else fields.push(next);
    profile.fields = fields;
  }

  function mappingRemoveLink(profile, input, fieldName) {
    profile.fields = (profile?.fields || []).filter((row) => {
      const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
        || (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
      return !(sameInput && String(row.vmix_field || "") === String(fieldName || ""));
    });
  }

  function mappingNormaliseRule(row, kind = "text") {
    const raw = row?.rule && typeof row.rule === "object" ? row.rule : {};
    const actionDefault = kind === "image" ? "visibility" : "text_colour";
    return {
      enabled: Boolean(raw.enabled),
      action: String(raw.action || actionDefault),
      left_key: String(raw.left_key || row?.data_key || ""),
      operator: String(raw.operator || "gt"),
      right_mode: String(raw.right_mode || "field"),
      right_key: String(raw.right_key || ""),
      right_value: raw.right_value == null ? "" : String(raw.right_value),
      true_value: raw.true_value == null ? (actionDefault === "visibility" ? "on" : "#E5CEA8") : String(raw.true_value),
      false_value: raw.false_value == null ? (actionDefault === "visibility" ? "off" : "#FFFFFF") : String(raw.false_value),
    };
  }

  function mappingRuleSources(row, excludeKey = "") {
    const items = (mappingDataCatalog().items || []).filter((item) => item.kind !== "image" && (!excludeKey || item.key !== excludeKey));
    const current = mappingSourceByKey(row?.data_key);
    const sameRow = current?.table_cell ? items.filter((item) => item.table_cell && item.source_code === current.source_code && Number(item.row_index) === Number(current.row_index)) : [];
    const preferred = [...sameRow, ...items.filter((item) => !sameRow.includes(item) && !item.table_cell), ...items.filter((item) => !sameRow.includes(item) && item.table_cell)];
    return preferred.slice(0, 600);
  }

  function mappingRuleNumeric(value) {
    if (value == null || typeof value === "boolean") return null;
    if (typeof value === "number" && Number.isFinite(value)) return value;
    const text = String(value).trim();
    if (!text) return null;
    const compact = text.replaceAll(" ", "").replaceAll("%", "");
    if (/^[-+]?\d{1,4}:\d{1,2}(?::\d{1,2}(?:[.,]\d+)?)?$/.test(compact)) {
      const parts = compact.replace(",", ".").split(":").map(Number);
      if (parts.every(Number.isFinite)) return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
    }
    const number = Number(compact.replace(",", "."));
    return Number.isFinite(number) ? number : null;
  }

  function mappingRuleMatches(left, operator, right) {
    const op = String(operator || "eq").toLowerCase();
    const aText = left == null ? "" : String(left).trim();
    const bText = right == null ? "" : String(right).trim();
    if (["empty", "is_empty"].includes(op)) return aText === "";
    if (["not_empty", "is_not_empty"].includes(op)) return aText !== "";
    if (op === "contains") return aText.toLowerCase().includes(bText.toLowerCase());
    if (op === "not_contains") return !aText.toLowerCase().includes(bText.toLowerCase());
    const an = mappingRuleNumeric(left); const bn = mappingRuleNumeric(right);
    const a = an != null && bn != null ? an : aText.toLowerCase();
    const b = an != null && bn != null ? bn : bText.toLowerCase();
    if (["gt", ">"].includes(op)) return a > b;
    if (["lt", "<"].includes(op)) return a < b;
    if (["gte", ">="].includes(op)) return a >= b;
    if (["lte", "<="].includes(op)) return a <= b;
    if (["neq", "!=", "<>"].includes(op)) return a !== b;
    return a === b;
  }

  function mappingRuleCommand(row, field) {
    const kind = mappingFieldKind(field);
    const rule = mappingNormaliseRule(row, kind);
    if (!rule.enabled || kind === "color") return null;
    const left = mappingSourceByKey(rule.left_key || row.data_key);
    if (!left) return null;
    let right = null;
    if (!["empty", "is_empty", "not_empty", "is_not_empty"].includes(rule.operator)) {
      right = rule.right_mode === "value" ? { value: rule.right_value } : mappingSourceByKey(rule.right_key);
      if (!right) return null;
    }
    const matched = mappingRuleMatches(left.value, rule.operator, right?.value);
    if (rule.action === "visibility") {
      const raw = matched ? rule.true_value : rule.false_value;
      const visible = !["0", "false", "off", "hide", "hidden", "no"].includes(String(raw).toLowerCase());
      return {
        input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
        selected_name: row.vmix_field,
        value: "",
        field_type: kind,
        function: kind === "image" ? (visible ? "SetImageVisibleOn" : "SetImageVisibleOff") : (visible ? "SetTextVisibleOn" : "SetTextVisibleOff"),
        data_key: `rule:${row.data_key}`,
      };
    }
    if (kind !== "text") return null;
    const colour = matched ? rule.true_value : rule.false_value;
    if (!String(colour || "").trim()) return null;
    return {
      input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
      selected_name: row.vmix_field,
      value: String(colour),
      field_type: kind,
      function: "SetTextColour",
      data_key: `rule:${row.data_key}`,
    };
  }

  function mappingRuleEditor(row, field, input) {
    const kind = mappingFieldKind(field);
    if (!row || kind === "color") return "";
    const rule = mappingNormaliseRule(row, kind);
    const open = state.mappingRuleField === String(field.name || "");
    const leftSources = mappingRuleSources(row);
    const sources = mappingRuleSources(row, rule.left_key);
    const leftExists = leftSources.some((item) => item.key === rule.left_key);
    const rightExists = sources.some((item) => item.key === rule.right_key);
    const actionOptions = kind === "image"
      ? `<option value="visibility" selected>Видимость</option>`
      : `<option value="text_colour" ${rule.action === "text_colour" ? "selected" : ""}>Цвет текста</option><option value="visibility" ${rule.action === "visibility" ? "selected" : ""}>Видимость</option>`;
    const leftOptions = `${rule.left_key && !leftExists ? `<option value="${escapeHtml(rule.left_key)}" selected>${escapeHtml(rule.left_key)}</option>` : ""}${leftSources.map((item) => `<option value="${escapeHtml(item.key)}" ${item.key === rule.left_key ? "selected" : ""}>${escapeHtml(item.label || item.key)} · ${escapeHtml(mappingDisplayValue(item.value, item.kind))}</option>`).join("")}`;
    const sourceOptions = `${rule.right_key && !rightExists ? `<option value="${escapeHtml(rule.right_key)}" selected>${escapeHtml(rule.right_key)}</option>` : ""}${sources.map((item) => `<option value="${escapeHtml(item.key)}" ${item.key === rule.right_key ? "selected" : ""}>${escapeHtml(item.label || item.key)} · ${escapeHtml(mappingDisplayValue(item.value, item.kind))}</option>`).join("")}`;
    const operatorOptions = [["gt", "> больше"], ["lt", "< меньше"], ["gte", "≥"], ["lte", "≤"], ["eq", "= равно"], ["neq", "≠ не равно"], ["contains", "содержит"], ["not_contains", "не содержит"], ["empty", "пусто"], ["not_empty", "не пусто"]]
      .map(([value, label]) => `<option value="${value}" ${rule.operator === value ? "selected" : ""}>${label}</option>`).join("");
    const summary = !rule.enabled ? "Правило выключено" : (rule.action === "visibility" ? `Visibility · ${rule.operator}` : `TextColour · ${rule.operator}`);
    const valueEditor = rule.action === "visibility"
      ? `<div class="hockey-map-rule-values"><label>ЕСЛИ TRUE<select data-map-rule-true data-rule-field="${escapeHtml(field.name)}"><option value="on" ${String(rule.true_value).toLowerCase() === "on" ? "selected" : ""}>Показать</option><option value="off" ${String(rule.true_value).toLowerCase() === "off" ? "selected" : ""}>Скрыть</option></select></label><label>ELSE<select data-map-rule-false data-rule-field="${escapeHtml(field.name)}"><option value="off" ${String(rule.false_value).toLowerCase() === "off" ? "selected" : ""}>Скрыть</option><option value="on" ${String(rule.false_value).toLowerCase() === "on" ? "selected" : ""}>Показать</option></select></label></div>`
      : `<div class="hockey-map-rule-values"><label>Цвет TRUE<input data-map-rule-true data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.true_value)}" placeholder="#E5CEA8 или red"></label><label>Цвет ELSE<input data-map-rule-false data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.false_value)}" placeholder="#FFFFFF"></label></div>`;
    return `
      <div class="hockey-map-rule-wrap ${rule.enabled ? "is-enabled" : ""}">
        <button type="button" class="hockey-map-rule-toggle" data-map-rule-toggle="${escapeHtml(field.name)}"><span>⚡ Правило</span><small>${escapeHtml(summary)}</small><b>${open ? "−" : "+"}</b></button>
        ${open ? `<div class="hockey-map-rule-editor">
          <div class="hockey-map-rule-head"><label><input type="checkbox" data-map-rule-enabled data-rule-field="${escapeHtml(field.name)}" ${rule.enabled ? "checked" : ""}> <span>Включить правило</span></label><button type="button" data-map-rule-clear="${escapeHtml(field.name)}">Сбросить</button></div>
          <div class="hockey-map-rule-presets"><span>Быстро:</span><button type="button" data-map-rule-preset="gt" data-rule-field="${escapeHtml(field.name)}">Больше</button><button type="button" data-map-rule-preset="lt" data-rule-field="${escapeHtml(field.name)}">Меньше</button><button type="button" data-map-rule-preset="eq" data-rule-field="${escapeHtml(field.name)}">Равно</button></div>
          <div class="hockey-map-rule-grid">
            <label>Действие<select data-map-rule-action data-rule-field="${escapeHtml(field.name)}">${actionOptions}</select></label>
            <label>Левое значение<select data-map-rule-left-key data-rule-field="${escapeHtml(field.name)}">${leftOptions}</select></label>
            <label>Условие<select data-map-rule-operator data-rule-field="${escapeHtml(field.name)}">${operatorOptions}</select></label>
            <label>Сравнивать с<select data-map-rule-right-mode data-rule-field="${escapeHtml(field.name)}"><option value="field" ${rule.right_mode === "field" ? "selected" : ""}>Другим полем</option><option value="value" ${rule.right_mode === "value" ? "selected" : ""}>Значением</option></select></label>
            ${rule.right_mode === "value" ? `<label class="is-wide">Значение<input data-map-rule-right-value data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.right_value)}" placeholder="10"></label>` : `<label class="is-wide">Поле для сравнения<select data-map-rule-right-key data-rule-field="${escapeHtml(field.name)}"><option value="">— выберите поле —</option>${sourceOptions}</select></label>`}
          </div>
          ${valueEditor}
          <div class="hockey-map-rule-actions"><button type="button" data-map-rule-mirror="${escapeHtml(field.name)}" ${rule.right_mode === "field" && rule.right_key ? "" : "disabled"}>↔ Зеркально на второе поле</button>${mappingParseTableCellKey(row.data_key) && mappingFieldSequence(input, field.name) ? `<button type="button" data-map-rule-series="${escapeHtml(field.name)}">↧ Размножить правило по строкам</button>` : ""}</div>
        </div>` : ""}
      </div>`;
  }

  function mappingRuleRow(profile, input, fieldName) {
    const row = mappingRowForField(profile, input, fieldName);
    const field = (input?.fields || []).find((item) => String(item.name || "") === String(fieldName || ""));
    return { row, field };
  }

  function mappingPatchRule(fieldName, patch) {
    const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
    const { row, field } = mappingRuleRow(profile, input, fieldName);
    if (!row || !field) return null;
    const base = mappingNormaliseRule(row, mappingFieldKind(field));
    row.rule = { ...base, ...patch, left_key: Object.prototype.hasOwnProperty.call(patch, "left_key") ? String(patch.left_key || row.data_key) : (base.left_key || row.data_key) };
    return row.rule;
  }

  function mappingMirrorRule(fieldName) {
    const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
    const { row, field } = mappingRuleRow(profile, input, fieldName);
    if (!row || !field) return { ok: false, reason: "Связь не найдена." };
    const rule = mappingNormaliseRule(row, mappingFieldKind(field));
    if (rule.right_mode !== "field" || !rule.right_key) return { ok: false, reason: "Сначала выберите второе поле." };
    const peer = (profile?.fields || []).find((item) => {
      const sameInput = String(item.vmix_input_key || "") === String(input?.key || "") || (!item.vmix_input_key && String(item.vmix_input_title || "") === String(input?.title || ""));
      return sameInput && String(item.data_key || "") === rule.right_key;
    });
    if (!peer) return { ok: false, reason: "Второе поле данных ещё не связано с элементом vMix этого Input." };
    const invert = { gt: "lt", lt: "gt", gte: "lte", lte: "gte", ">": "<", "<": ">", ">=": "<=", "<=": ">=" };
    peer.rule = { ...rule, left_key: peer.data_key, right_key: row.data_key, operator: invert[rule.operator] || rule.operator, enabled: true };
    return { ok: true, target: peer.vmix_field };
  }

  function mappingCloneRuleSeries(fieldName) {
    const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
    const { row, field } = mappingRuleRow(profile, input, fieldName);
    const anchorCell = mappingParseTableCellKey(row?.data_key);
    const sequence = mappingFieldSequence(input, fieldName);
    if (!row || !field || !anchorCell || !sequence) return { changed: 0 };
    const rule = mappingNormaliseRule(row, mappingFieldKind(field));
    const shiftKey = (key, delta) => {
      const cell = mappingParseTableCellKey(key);
      return cell && cell.sourceCode === anchorCell.sourceCode ? `${cell.sourceCode}.row.${cell.rowIndex + delta}.${cell.column}` : key;
    };
    let changed = 0;
    for (const target of sequence.matches) {
      const targetRow = mappingRowForField(profile, input, target.field.name);
      if (!targetRow) continue;
      const delta = target.index - sequence.anchorIndex;
      targetRow.rule = { ...rule, left_key: shiftKey(rule.left_key || row.data_key, delta), right_key: rule.right_mode === "field" ? shiftKey(rule.right_key, delta) : rule.right_key };
      changed += 1;
    }
    return { changed };
  }

  function mappingParseTableCellKey(key) {
    const match = String(key || "").match(/^(.*)\.row\.(\d+)\.(.+)$/);
    if (!match) return null;
    return { sourceCode: match[1], rowIndex: Number(match[2] || 0), column: match[3] };
  }

  function mappingEscapeRegExp(value) {
    return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  }

  // Find the numeric token in a GT field name which forms the strongest sequence
  // inside the current Input. Example: Player1.Text -> Player2.Text -> Player3.Text.
  // If a name has several numbers, the token with the largest matching family wins.
  function mappingFieldSequence(input, fieldName) {
    const name = String(fieldName || "");
    const tokens = [...name.matchAll(/\d+/g)];
    let best = null;
    for (const token of tokens) {
      const start = Number(token.index || 0);
      const finish = start + String(token[0] || "").length;
      const prefix = name.slice(0, start);
      const suffix = name.slice(finish);
      const regex = new RegExp(`^${mappingEscapeRegExp(prefix)}(\\d+)${mappingEscapeRegExp(suffix)}$`);
      const matches = (input?.fields || []).map((field) => {
        const hit = String(field.name || "").match(regex);
        return hit ? { field, index: Number(hit[1]) } : null;
      }).filter(Boolean).sort((a, b) => a.index - b.index);
      if (!best || matches.length > best.matches.length) {
        best = { anchorIndex: Number(token[0]), prefix, suffix, matches };
      }
    }
    return best && best.matches.length >= 2 ? best : null;
  }

  function mappingSeriesPlan(profile, input, anchorFieldName, mode = "column") {
    const anchorRow = mappingRowForField(profile, input, anchorFieldName);
    const anchorCell = anchorRow ? mappingParseTableCellKey(anchorRow.data_key) : null;
    if (!anchorRow || !anchorCell) return { links: [], reason: "Связь должна вести на ячейку SQL-таблицы." };

    let seedRows = [anchorRow];
    if (mode === "row") {
      seedRows = (profile?.fields || []).filter((row) => {
        const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
          || (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
        if (!sameInput) return false;
        const cell = mappingParseTableCellKey(row.data_key);
        return cell && cell.sourceCode === anchorCell.sourceCode && cell.rowIndex === anchorCell.rowIndex;
      });
    }

    const planned = new Map();
    let sequentialSeeds = 0;
    for (const seed of seedRows) {
      const seedCell = mappingParseTableCellKey(seed.data_key);
      const sequence = mappingFieldSequence(input, seed.vmix_field);
      if (!seedCell || !sequence) continue;
      sequentialSeeds += 1;
      const seedField = (input?.fields || []).find((field) => String(field.name || "") === String(seed.vmix_field || ""));
      const targetKind = seedField ? mappingFieldKind(seedField) : String(seed.field_type || "text");
      for (const target of sequence.matches) {
        const sourceRowIndex = seedCell.rowIndex + (target.index - sequence.anchorIndex);
        if (sourceRowIndex < 1) continue;
        const sourceKey = `${seedCell.sourceCode}.row.${sourceRowIndex}.${seedCell.column}`;
        const source = mappingSourceByKey(sourceKey);
        if (!source || !mappingCompatible(source, targetKind)) continue;
        planned.set(String(target.field.name || ""), {
          field: target.field,
          source,
          sourceRowIndex,
          column: seedCell.column,
          sourceCode: seedCell.sourceCode,
          targetIndex: target.index,
        });
      }
    }
    const links = [...planned.values()].sort((a, b) => a.targetIndex - b.targetIndex || String(a.field.name).localeCompare(String(b.field.name), "ru", { numeric: true }));
    if (!sequentialSeeds) return { links: [], reason: "В имени поля vMix не найдена числовая последовательность, например Name1.Text, Name2.Text…" };
    if (links.length < 2) return { links: [], reason: "Не найдено следующих полей vMix или строк SQL для продолжения серии." };
    return { links, seedCount: sequentialSeeds, anchorCell };
  }

  function mappingApplySeries(profile, input, anchorFieldName, mode = "column") {
    const plan = mappingSeriesPlan(profile, input, anchorFieldName, mode);
    if (!plan.links.length) return plan;
    let changed = 0;
    let overwritten = 0;
    for (const item of plan.links) {
      const existing = mappingRowForField(profile, input, item.field.name);
      if (existing && String(existing.data_key || "") !== String(item.source.key || "")) overwritten += 1;
      if (!existing || String(existing.data_key || "") !== String(item.source.key || "")) changed += 1;
      mappingSetLink(profile, input, item.field, item.source);
    }
    return { ...plan, changed, overwritten };
  }

  function mappingCompatible(source, targetKind) {
    if (!targetKind) return true;
    if (targetKind === "image") return source.kind === "image";
    return source.kind !== "image";
  }

  function mappingDataBrowser(profile, currentInput) {
    const catalog = mappingDataCatalog();
    const targetField = (currentInput?.fields || []).find((field) => String(field.name || "") === state.mappingTargetField) || null;
    const targetKind = targetField ? mappingFieldKind(targetField) : "";
    const query = String(state.mappingDataSearch || "").trim().toLowerCase();
    const multiRowCodes = new Set((catalog.tables || []).filter((table) => Number(table.row_count || table.rows?.length || 0) > 1).map((table) => String(table.code || "")));
    const categories = [];
    for (const item of catalog.items) {
      if (item.table_cell) continue;
      // Raw *_RUS/*_ENG columns stay addressable for backwards compatibility, but
      // the visual picker prefers one virtual AUTO language field.
      if (item.language_explicit) continue;
      // Multi-row SQL sources are configured through the table/cell picker below.
      if (multiRowCodes.has(String(item.source_code || ""))) continue;
      if (!mappingCompatible(item, targetKind)) continue;
      const haystack = `${item.label} ${item.category} ${item.key} ${mappingDisplayValue(item.value, item.kind)}`.toLowerCase();
      if (query && !haystack.includes(query)) continue;
      let category = categories.find((row) => row.name === item.category);
      if (!category) { category = { name: item.category, items: [] }; categories.push(category); }
      category.items.push(item);
    }
    const selectedSource = state.mappingSelectedSourceKey;
    const groups = categories.map((category) => `
      <details class="hockey-map-data-group" data-map-data-group="${escapeHtml(category.name)}" ${query || state.mappingOpenDataGroups[category.name] ? "open" : ""}>
        <summary><span>${escapeHtml(category.name)}</span><b>${category.items.length}</b></summary>
        <div>${category.items.map((item) => `
          <button type="button" class="hockey-map-data-item ${selectedSource === item.key ? "is-selected" : ""}" data-map-source="${escapeHtml(item.key)}">
            <span class="hockey-map-data-main"><strong>${escapeHtml(item.label)}${item.localized ? ` <i class="hockey-map-lang-auto">AUTO ${String(item.language || "ru").toUpperCase()}</i>` : ""}</strong><em>${escapeHtml(mappingDisplayValue(item.value, item.kind))}</em></span>
            <small>${escapeHtml(item.key)}${item.localized ? ` · ${escapeHtml(item.resolved_column || "")}` : ""}</small>
          </button>`).join("")}</div>
      </details>`).join("");

    const tables = (catalog.tables || []).filter((table) => Number(table.row_count || table.rows?.length || 0) > 1);
    if (tables.length && !tables.some((table) => String(table.code) === String(state.mappingSelectedTableSource))) {
      state.mappingSelectedTableSource = String(tables[0].code || "");
    }
    const selectedTable = tables.find((table) => String(table.code) === String(state.mappingSelectedTableSource)) || null;
    const selectedTableRows = selectedTable?.rows || [];
    let selectedTableRow = selectedTableRows.find((row) => Number(row.index || 0) === Number(state.mappingSelectedTableRow || 0)) || selectedTableRows[0] || null;
    if (selectedTableRow) state.mappingSelectedTableRow = Number(selectedTableRow.index || 1);
    const quickCompatibleColumns = (selectedTable?.columns || []).filter((column) => {
      if (!selectedTableRow) return false;
      const key = `${selectedTable.code}.row.${selectedTableRow.index}.${column.key}`;
      const source = mappingSourceByKey(key);
      return source && (!targetKind || mappingCompatible(source, targetKind));
    });
    if (!quickCompatibleColumns.some((column) => String(column.key) === String(state.mappingSelectedTableColumn))) {
      state.mappingSelectedTableColumn = String(quickCompatibleColumns[0]?.key || "");
    }
    const quickCellKey = selectedTable && selectedTableRow && state.mappingSelectedTableColumn
      ? `${selectedTable.code}.row.${selectedTableRow.index}.${state.mappingSelectedTableColumn}` : "";
    const quickCellSource = quickCellKey ? mappingSourceByKey(quickCellKey) : null;
    const quickLinkEnabled = Boolean(targetField && quickCellSource && mappingCompatible(quickCellSource, targetKind));
    let tableMarkup = "";
    let quickTableMarkup = "";
    if (tables.length) {
      quickTableMarkup = `
        <details class="hockey-map-sql-cell-quick ${targetField ? "is-target-ready" : ""}" data-map-sql-cell-details ${state.mappingSqlCellOpen ? "open" : ""}>
          <summary>
            <div><span>SQL ЯЧЕЙКА</span><strong>Источник → строка → столбец</strong></div>
            <small>${targetField ? `Для ${escapeHtml(currentInput?.title || "Input")} → ${escapeHtml(targetField.name || "")}` : "Сначала выберите поле vMix справа"}</small>
          </summary>
          <div class="hockey-map-sql-cell-grid">
            <label><span>SQL источник</span><select data-map-quick-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)}</option>`).join("")}</select></label>
            <label><span>Строка</span><select data-map-quick-table-row ${selectedTableRows.length ? "" : "disabled"}>${selectedTableRows.map((row) => `<option value="${Number(row.index || 0)}" ${Number(row.index || 0) === Number(selectedTableRow?.index || 0) ? "selected" : ""}>#${Number(row.index || 0)} · ${escapeHtml(row.label || `Строка ${row.index}`)}</option>`).join("")}</select></label>
            <label><span>Столбец</span><select data-map-quick-table-column ${quickCompatibleColumns.length ? "" : "disabled"}>${quickCompatibleColumns.map((column) => `<option value="${escapeHtml(column.key)}" ${String(column.key) === String(state.mappingSelectedTableColumn) ? "selected" : ""}>${escapeHtml(column.label || column.key)}${column.localized ? ` · AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}` : ""} · ${escapeHtml(column.key)}</option>`).join("")}</select></label>
          </div>
          <div class="hockey-map-sql-cell-preview">
            <div><span>Текущее значение</span><strong>${escapeHtml(mappingDisplayValue(quickCellSource?.value, quickCellSource?.kind || "text"))}</strong><code>${escapeHtml(quickCellKey || "—")}</code></div>
            <button type="button" data-map-quick-cell-link ${quickLinkEnabled ? "" : "disabled"}>${targetField ? `Связать с ${escapeHtml(targetField.name || "полем")}` : "Выберите поле vMix"}</button>
          </div>
        </details>`;
      const rowSearch = String(state.mappingTableRowSearch || "").trim().toLowerCase();
      const allRows = selectedTable?.rows || [];
      const filteredRows = allRows.filter((row) => {
        if (!rowSearch) return true;
        const values = Object.values(row.values || {}).map((value) => mappingDisplayValue(value)).join(" ");
        return `${row.index || ""} ${row.label || ""} ${values}`.toLowerCase().includes(rowSearch);
      });
      const visibleRows = filteredRows.slice(0, 120);
      const columns = selectedTable?.columns || [];
      const tableGridHeight = Math.min(430, Math.max(230, visibleRows.length * 44 + 42));
      const cells = visibleRows.map((row) => `
        <tr>
          <th><b>${Number(row.index || 0)}</b><span>${escapeHtml(row.label || `Строка ${row.index}`)}</span></th>
          ${columns.map((column) => {
            const key = `${selectedTable.code}.row.${row.index}.${column.key}`;
            const source = mappingSourceByKey(key);
            const compatible = source && mappingCompatible(source, targetKind);
            return `<td><button type="button" data-map-source="${escapeHtml(key)}" class="${selectedSource === key ? "is-selected" : ""}" ${compatible ? "" : "disabled"} title="${escapeHtml(key)}"><strong>${escapeHtml(mappingDisplayValue(row.values?.[column.key], column.kind || "text"))}</strong><small>${escapeHtml(column.label || column.key)}</small></button></td>`;
          }).join("")}
        </tr>`).join("");
      tableMarkup = `
        <details class="hockey-map-table-picker" data-map-sql-table-details ${state.mappingSqlTableOpen ? "open" : ""}>
          <summary><div><span>ТАБЛИЦА SQL</span><strong>Выберите конкретную строку и столбец</strong></div><b>${Number(selectedTable?.row_count || 0)} строк</b></summary>
          <div class="hockey-map-table-controls">
            <select data-map-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)} · ${Number(table.row_count || 0)} строк</option>`).join("")}</select>
            <label><span>⌕</span><input data-map-table-row-search value="${escapeHtml(state.mappingTableRowSearch)}" placeholder="Поиск строки по любому значению…"></label>
          </div>
          ${selectedTable?.skipped ? `<div class="hockey-directory-empty">Не хватает параметров: ${escapeHtml((selectedTable.missing || []).map((x) => `:${x}`).join(", "))}</div>` : selectedTable?.error ? `<div class="hockey-directory-empty is-error">${escapeHtml(selectedTable.error)}</div>` : `
            <div class="hockey-map-table-grid" style="height:${tableGridHeight}px"><table><thead><tr><th>Строка</th>${columns.map((column) => `<th><strong>${escapeHtml(column.label || column.key)}${column.localized ? ` <i class="hockey-map-lang-auto">AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}</i>` : ""}</strong><small>${escapeHtml(column.key)}</small></th>`).join("")}</tr></thead><tbody>${cells || `<tr><td colspan="${columns.length + 1}">Нет строк</td></tr>`}</tbody></table></div>
            ${filteredRows.length > visibleRows.length ? `<small class="hockey-map-table-limit">Показаны первые ${visibleRows.length} из ${filteredRows.length} строк. Используйте поиск строки.</small>` : ""}`}
        </details>`;
    }

    const targetHint = targetField
      ? `<div class="hockey-map-target-hint is-active"><span>Сейчас настраиваем</span><strong>${escapeHtml(currentInput?.title || "Input")} → ${escapeHtml(targetField.name || "")}</strong><small>Можно выбрать обычное значение или ячейку SQL-таблицы</small></div>`
      : `<div class="hockey-map-target-hint"><span>Как связать</span><strong>Выберите поле vMix справа</strong><small>Затем выберите значение или конкретную ячейку таблицы</small></div>`;
    return `
      <section class="hockey-map-data-browser">
        <header><div><span>ДАННЫЕ МАТЧА</span><strong>${escapeHtml(catalog.gameLabel)}</strong></div><button type="button" data-map-live-refresh title="Обновить живые значения">↻</button></header>
        ${targetHint}
        ${quickTableMarkup}
        ${tableMarkup}
        <label class="hockey-map-data-search"><span>⌕</span><input data-map-data-search value="${escapeHtml(state.mappingDataSearch)}" placeholder="Обычные значения: название, счёт, игрок, ключ…"></label>
        <div class="hockey-map-data-scroll">${groups || `<div class="hockey-directory-empty">Подходящих одиночных данных не найдено.</div>`}</div>
      </section>`;
  }

  function mappingVmixBrowser(profile, currentInput, testDevices) {
    const allInputs = mappingInventoryInputs(profile);
    const filteredInputs = mappingFilteredInputs(profile);
    if (!currentInput) return `<div class="hockey-directory-empty">Agent не передал Inputs текущего vMix.</div>`;
    const visibleInputs = filteredInputs.some((item) => mappingInputValue(item) === mappingInputValue(currentInput))
      ? filteredInputs
      : [currentInput, ...filteredInputs];
    const allFields = [...(currentInput.fields || [])].sort(mappingVmixFieldOrder);
    const filter = ["text", "image", "color"].includes(state.mappingFieldFilter) ? state.mappingFieldFilter : "text";
    const counts = {
      text: allFields.filter((field) => mappingFieldFilterMatch(field, "text")).length,
      image: allFields.filter((field) => mappingFieldFilterMatch(field, "image")).length,
      color: allFields.filter((field) => mappingFieldFilterMatch(field, "color")).length,
    };
    const visibleFields = allFields.filter((field) => {
      if (!mappingFieldFilterMatch(field, filter)) return false;
      if (state.mappingHideLinked && mappingRowForField(profile, currentInput, field.name)) return false;
      return true;
    });
    const rows = visibleFields.map((field) => {
      const row = mappingRowForField(profile, currentInput, field.name);
      const source = row ? mappingSourceByKey(row.data_key) : null;
      const kind = mappingFieldKind(field);
      const isTarget = String(state.mappingTargetField || "") === String(field.name || "");
      const value = source ? mappingDisplayValue(source.value, source.kind) : "";
      const cellInfo = source?.table_cell ? `<small class="hockey-map-cell-address">Строка ${Number(source.row_index || 0)} · ${escapeHtml(source.column || "")}</small>` : "";
      const badge = kind === "image" ? "IMAGE" : kind === "color" ? "COLOR" : "TEXT";
      return `
        <article class="hockey-map-vmix-field ${row ? "is-linked" : ""} ${isTarget ? "is-target" : ""}" data-map-target="${escapeHtml(field.name || "")}">
          <div class="hockey-map-vmix-field-head">
            <div><strong>${escapeHtml(field.name || "Без названия")}</strong><span>${badge}</span></div>
            ${row ? `<button type="button" class="hockey-map-unlink" data-map-unlink="${escapeHtml(field.name || "")}" title="Удалить связь">×</button>` : ""}
          </div>
          ${row ? `
            <div class="hockey-map-link-value">
              <span>${escapeHtml(source?.source_name || source?.category || "Источник")}</span>
              <strong>${escapeHtml(source?.label || row.data_key)}</strong>
              <em>${escapeHtml(value || "—")}</em>
              ${cellInfo}
              <small>${escapeHtml(row.data_key)}</small>
            </div>
            <div class="hockey-map-field-actions">
              <button type="button" data-map-choose="${escapeHtml(field.name || "")}">Изменить данные</button>
              <button type="button" class="is-accent" data-map-test="${escapeHtml(field.name || "")}" ${testDevices.length ? "" : "disabled"}>Тест поля</button>
            </div>
            ${mappingRuleEditor(row, field, currentInput)}
            ${source?.table_cell && mappingFieldSequence(currentInput, field.name) ? `<div class="hockey-map-series-actions">
              <button type="button" data-map-series-column="${escapeHtml(field.name || "")}" title="Продолжить эту колонку по SQL-строкам и последовательным полям vMix">↧ Автосвязать столбец</button>
              <button type="button" data-map-series-row="${escapeHtml(field.name || "")}" title="Размножить все уже настроенные поля этой SQL-строки на следующие строки">⇊ Размножить строку</button>
            </div>` : ""}` : `
            <button type="button" class="hockey-map-empty-link" data-map-choose="${escapeHtml(field.name || "")}">
              <b>+</b><span>Выбрать данные</span><small>Обычное значение или конкретная ячейка SQL</small>
            </button>`}
        </article>`;
    }).join("");
    const linkedCount = allFields.filter((field) => mappingRowForField(profile, currentInput, field.name)).length;
    return `
      <section class="hockey-map-vmix-browser">
        <header class="hockey-map-input-header">
          <div class="hockey-map-input-choice">
            <span>VMIX INPUT</span>
            <label class="hockey-map-input-search"><span>⌕</span><input data-map-input-search value="${escapeHtml(state.mappingInputSearch)}" placeholder="Поиск по №, названию или key…"></label>
            <select data-map-input-picker>${visibleInputs.map((item) => `<option value="${escapeHtml(mappingInputValue(item))}" ${mappingInputValue(item) === mappingInputValue(currentInput) ? "selected" : ""}>${escapeHtml(mappingInputLabel(item))}</option>`).join("")}</select>
            <small>${visibleInputs.length === allInputs.length ? `${allInputs.length} Inputs` : `Найдено ${filteredInputs.length} из ${allInputs.length}`}</small>
            <div class="hockey-map-field-toolbar">
              <div class="hockey-map-field-tabs" role="group" aria-label="Тип поля vMix">
                <button type="button" data-map-field-filter="text" class="${filter === "text" ? "is-active" : ""}">Text <b>${counts.text}</b></button>
                <button type="button" data-map-field-filter="image" class="${filter === "image" ? "is-active" : ""}">Image <b>${counts.image}</b></button>
                <button type="button" data-map-field-filter="color" class="${filter === "color" ? "is-active" : ""}">Color <b>${counts.color}</b></button>
              </div>
              <label class="hockey-map-hide-linked"><input type="checkbox" data-map-hide-linked ${state.mappingHideLinked ? "checked" : ""}><span>Скрывать связанные</span></label>
            </div>
          </div>
          <div class="hockey-map-input-progress"><div><b>${linkedCount}</b><span>/ ${Number(allFields.length || 0)} полей</span></div><button type="button" data-map-apply-input ${linkedCount && testDevices.length ? "" : "disabled"}>Применить Input</button></div>
        </header>
        <div class="hockey-map-vmix-scroll">${rows || `<div class="hockey-directory-empty">${state.mappingHideLinked ? "В этой категории не осталось несвязанных полей." : "У этого Input нет полей выбранного типа."}</div>`}</div>
      </section>`;
  }

  function mappingWorkspaceTabs() {
    const tabs = [
      ["links", "Связи vMix"],
      ["context", "Переменные"],
      ["sql", "SQL источники"],
    ];
    return `<nav class="hockey-map-workspace-tabs">${tabs.map(([key, label]) => `<button type="button" data-map-workspace-tab="${key}" class="${state.mappingWorkspaceTab === key ? "is-active" : ""}">${label}</button>`).join("")}</nav>`;
  }

  function mappingContextPanel() {
    const variables = state.mappingCatalog?.variables || state.mappingContextVariables?.variables || [];
    const rows = variables.map((item) => {
      const system = Boolean(item.is_system || item.source_type === "system");
      return `<article class="hockey-map-context-row ${system ? "is-system" : ""}" data-context-row="${Number(item.id || 0)}">
        <div class="hockey-map-context-main">
          <div><strong>${escapeHtml(item.label || item.key)}</strong><code>${escapeHtml(item.key)}</code></div>
          <div class="hockey-map-context-badges"><span>${escapeHtml(item.entity_type || item.value_type || "text")}</span><span>${escapeHtml(item.scope || "match")}</span><span>${escapeHtml(item.source_type || "manual")}</span>${system ? `<b>SYSTEM</b>` : ""}</div>
        </div>
        <div class="hockey-map-context-live"><span>Сейчас</span><strong>${escapeHtml(mappingDisplayValue(item.value))}</strong></div>
        ${system ? `<div class="hockey-map-context-system-note">Системное значение задаётся программой автоматически.</div>` : `
          <div class="hockey-map-context-edit-grid">
            <label>Название<input data-context-label value="${escapeHtml(item.label || "")}"></label>
            <label>Категория<input data-context-category value="${escapeHtml(item.category || "Пользовательские")}"></label>
            <label>Entity<select data-context-entity>${["", "match", "team", "player", "coach", "referee", "goal", "penalty", "event", "device", "assignment"].map((v) => `<option value="${v}" ${String(item.entity_type || "") === v ? "selected" : ""}>${v || "—"}</option>`).join("")}</select></label>
            <label>Scope<select data-context-scope>${["match", "session", "account", "global", "temporary"].map((v) => `<option value="${v}" ${String(item.scope || "match") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
            <label>Источник<select data-context-source>${["manual", "selection", "derived"].map((v) => `<option value="${v}" ${String(item.source_type || "manual") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
            <label>Текущее значение<input data-context-current value="${escapeHtml(item.value || "")}" placeholder="ID / значение"></label>
          </div>
          <div class="hockey-map-context-actions"><button type="button" data-context-save="${Number(item.id || 0)}">Сохранить описание</button><button type="button" class="is-accent" data-context-set="${escapeHtml(item.key)}">Установить значение</button><button type="button" class="danger" data-context-delete="${Number(item.id || 0)}">Удалить</button></div>`}
      </article>`;
    }).join("");
    return `<section class="hockey-map-admin-panel">
      <header class="hockey-map-admin-head"><div><span>CONTEXT VARIABLES</span><strong>Идентификаторы и выделенные сущности</strong><small>Эти значения можно использовать как <code>:параметр</code> в SQL. Match-scoped значения автоматически изолированы по матчу и аккаунту.</small></div></header>
      <form class="hockey-map-context-create" data-context-create>
        <label>Ключ<input name="key" required placeholder="my_special_player_id"></label>
        <label>Название<input name="label" required placeholder="Мой выделенный игрок"></label>
        <label>Entity<select name="entity_type"><option value="">—</option><option>match</option><option>team</option><option>player</option><option>coach</option><option>referee</option><option>goal</option><option>penalty</option><option>event</option></select></label>
        <label>Scope<select name="scope"><option value="match">match</option><option value="session">session</option><option value="account">account</option><option value="global">global</option></select></label>
        <label>Источник<select name="source_type"><option value="manual">manual</option><option value="selection">selection</option><option value="derived">derived</option></select></label>
        <button type="submit" class="is-accent">+ Создать переменную</button>
      </form>
      <div class="hockey-map-context-list">${rows || `<div class="hockey-directory-empty">Переменных пока нет.</div>`}</div>
    </section>`;
  }

  function mappingSqlDatabaseReference() {
    const payload = state.mappingDatabaseSchema || {};
    const tables = Array.isArray(payload.tables) ? payload.tables : [];
    const count = Number(payload.table_count || tables.length || 0);
    const rows = tables.map((table) => {
      const columns = Array.isArray(table.columns) ? table.columns : [];
      const columnNames = columns.map((column) => String(column.name || "")).filter(Boolean);
      const compactColumns = columnNames.slice(0, 12).join(", ");
      const tail = columnNames.length > 12 ? ` … +${columnNames.length - 12}` : "";
      return `<button type="button" class="hockey-map-sql-table-ref" data-sql-table-name="${escapeHtml(table.name || "")}" title="Вставить ${escapeHtml(table.name || "")} в SQL">
        <strong>${escapeHtml(table.name || "")}</strong>
        <span>${Number(table.column_count || columns.length || 0)} колонок</span>
        <code>${escapeHtml(compactColumns)}${escapeHtml(tail)}</code>
      </button>`;
    }).join("");
    const body = payload.error
      ? `<div class="hockey-directory-empty is-error">Не удалось получить схему БД: ${escapeHtml(payload.error)}</div>`
      : rows || `<div class="hockey-directory-empty">Таблицы не найдены.</div>`;
    return `<details class="hockey-map-sql-reference hockey-map-sql-schema-ref">
      <summary><span>▦</span><div><strong>Справочник таблиц БД</strong><small>${count} таблиц · клик по названию вставляет его в SQL</small></div></summary>
      <div class="hockey-map-sql-reference-body hockey-map-sql-table-list">${body}</div>
    </details>`;
  }

  function mappingSqlHelp() {
    const snippets = [
      ["Текст", "CONCAT_WS", "CONCAT_WS(' ', last_name, first_name)", "Склеить строки через разделитель"],
      ["Текст", "COALESCE", "COALESCE(value, '')", "Подставить значение вместо NULL"],
      ["Текст", "UPPER / LOWER", "UPPER(name)", "Верхний / нижний регистр"],
      ["Текст", "TRIM", "TRIM(name)", "Убрать пробелы по краям"],
      ["Текст", "REPLACE", "REPLACE(name, 'old', 'new')", "Заменить часть строки"],
      ["Текст", "SUBSTRING", "SUBSTRING(name FROM 1 FOR 12)", "Взять часть строки"],
      ["Условия", "CASE", "CASE WHEN score > 0 THEN 'Да' ELSE 'Нет' END", "Условное значение"],
      ["Условия", "NULLIF", "NULLIF(value, '')", "Преобразовать значение в NULL при совпадении"],
      ["Дата/время", "TO_CHAR", "TO_CHAR(game_date, 'DD.MM.YYYY')", "Форматировать дату/время"],
      ["Дата/время", "EXTRACT", "EXTRACT(YEAR FROM game_date)", "Получить год, месяц, день и т.п."],
      ["Дата/время", "CURRENT_DATE", "CURRENT_DATE", "Текущая дата PostgreSQL"],
      ["Дата/время", "Текущее время", "TO_CHAR(NOW(), 'HH24:MI:SS')", "Текущее время PostgreSQL до секунд"],
      ["Дата/время", "Часовой пояс", "TO_CHAR(NOW() AT TIME ZONE 'Europe/Moscow', 'HH24:MI:SS')", "Время в выбранной IANA-зоне"],
      ["Числа", "ROUND", "ROUND(value::numeric, 2)", "Округлить число"],
      ["Числа", "ABS", "ABS(value)", "Модуль числа"],
      ["Числа", "GREATEST / LEAST", "GREATEST(a, b)", "Максимум / минимум из значений"],
      ["Типы", "::text", "value::text", "Быстро привести значение к типу"],
      ["Типы", "CAST", "CAST(value AS integer)", "Стандартное преобразование типа"],
      ["Агрегация", "COUNT", "COUNT(*)", "Количество строк"],
      ["Агрегация", "SUM / AVG", "AVG(value)", "Сумма / среднее"],
      ["Агрегация", "STRING_AGG", "STRING_AGG(name, ', ' ORDER BY name)", "Собрать строки в одну"],
      ["Таблицы", "ROW_NUMBER", "ROW_NUMBER() OVER (ORDER BY points DESC)", "Нумерация строк без схлопывания таблицы"],
    ];
    const groups = [...new Set(snippets.map((row) => row[0]))];
    return `<details class="hockey-map-sql-help">
      <summary><span>?</span><div><strong>Подсказки PostgreSQL</strong><small>Функции, форматы и примеры — клик вставляет пример в SQL</small></div></summary>
      <div class="hockey-map-sql-help-body">
        <div class="hockey-map-sql-help-note"><b>Наш редактор:</b> один read-only <code>SELECT</code> или <code>WITH … SELECT</code>. Завершающий <code>;</code> и SQL-комментарии можно писать. Параметры проекта имеют вид <code>:game_id</code>; двоеточия внутри строк PostgreSQL, например <code>'HH24:MI:SS'</code>, переменными не считаются.</div>
        <div class="hockey-map-sql-help-lang"><b>Автовыбор языка:</b> если запрос возвращает пару <code>name_RUS</code> + <code>name_ENG</code>, Mapping создаёт одно виртуальное поле <code>name</code> и сам выбирает нужную колонку по языку интерфейса.</div>
        ${groups.map((group) => `<section><h5>${escapeHtml(group)}</h5><div>${snippets.filter((row) => row[0] === group).map((row) => `<button type="button" data-sql-snippet="${escapeHtml(row[2])}" title="${escapeHtml(row[3])}"><strong>${escapeHtml(row[1])}</strong><code>${escapeHtml(row[2])}</code><small>${escapeHtml(row[3])}</small></button>`).join("")}</div></section>`).join("")}
      </div>
    </details>`;
  }

  function mappingSqlPanel() {
    const sources = state.mappingSqlSources?.sources || [];
    const selected = state.mappingSelectedSqlSourceId === "new" ? null : sources.find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId)) || sources[0] || null;
    if (selected && !state.mappingSelectedSqlSourceId) state.mappingSelectedSqlSourceId = selected.id;
    const sourceToken = String(state.mappingSelectedSqlSourceId || selected?.id || "new");
    const liveDraft = state.mappingSqlDraft && String(state.mappingSqlDraft.__source_id || "") === sourceToken ? state.mappingSqlDraft : null;
    const draft = liveDraft || selected || { id: "", code: "", name: "", category: "Данные", description: "", sql_text: "SELECT\n    :game_id AS \"game_id\"", enabled: true, auto_refresh_enabled: false, refresh_interval_ms: 1000, sort_order: 1000, field_metadata: {} };
    const preview = state.mappingSqlPreview;
    const variableChips = (state.mappingCatalog?.variables || []).map((item) => `<button type="button" data-sql-param="${escapeHtml(item.key)}" title="${escapeHtml(item.label || item.key)}"><code>:${escapeHtml(item.key)}</code><span>${escapeHtml(mappingDisplayValue(item.value))}</span></button>`).join("");
    const list = [`<button type="button" data-sql-source="new" class="${state.mappingSelectedSqlSourceId === "new" ? "is-active" : ""}"><strong>+ Новый источник</strong><small>Создать SELECT</small></button>`, ...sources.map((item) => `<button type="button" data-sql-source="${item.id}" class="${Number(draft.id) === Number(item.id) && state.mappingSelectedSqlSourceId !== "new" ? "is-active" : ""}"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.code)} · ${item.enabled ? "ON" : "OFF"}${item.auto_refresh_enabled ? ` · AUTO ${Math.max(1, Number(item.refresh_interval_ms || 1000) / 1000)}с` : ""}</small></button>`)].join("");
    let previewMarkup = `<div class="hockey-directory-empty">Нажмите «Проверить SQL», чтобы увидеть реальные колонки и строки текущего матча.</div>`;
    if (preview) {
      if (preview.error) previewMarkup = `<div class="hockey-directory-empty is-error">${escapeHtml(preview.error)}</div>`;
      else if (preview.skipped) previewMarkup = `<div class="hockey-directory-empty">Не хватает переменных: ${escapeHtml((preview.missing || []).map((x) => `:${x}`).join(", "))}</div>`;
      else {
        const columns = preview.columns || [];
        previewMarkup = `<div class="hockey-map-sql-preview-meta">${Number(preview.row_count || 0)} строк · ${columns.length} колонок</div><div class="hockey-map-sql-preview-table"><table><thead><tr>${columns.map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead><tbody>${(preview.rows || []).map((row) => `<tr>${columns.map((c) => `<td>${escapeHtml(mappingDisplayValue(row[c]))}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
      }
    }
    return `<section class="hockey-map-admin-panel is-sql">
      <header class="hockey-map-admin-head"><div><span>SQL DATA SOURCES</span><strong>Динамические ключи из PostgreSQL</strong><small>Read-only PostgreSQL SELECT. Колонки результата автоматически становятся ключами вида <code>source.column</code>. Пары <code>_RUS/_ENG</code> объединяются в языковой AUTO-ключ.</small></div></header>
      <div class="hockey-map-sql-layout">
        <aside class="hockey-map-sql-list">${list}</aside>
        <div class="hockey-map-sql-editor" data-sql-editor data-source-id="${escapeHtml(draft.id || "new")}">
          <div class="hockey-map-sql-form-grid">
            <label>Код источника<input data-sql-code value="${escapeHtml(draft.code || "")}" placeholder="selected_player"></label>
            <label>Название<input data-sql-name value="${escapeHtml(draft.name || "")}" placeholder="Выбранный игрок"></label>
            <label>Категория<input data-sql-category value="${escapeHtml(draft.category || "Данные")}"></label>
            <label>Порядок<input type="number" data-sql-order value="${Number(draft.sort_order || 1000)}"></label>
          </div>
          <label class="hockey-map-sql-description">Описание<input data-sql-description value="${escapeHtml(draft.description || "")}"></label>
          <div class="hockey-map-sql-refresh ${draft.auto_refresh_enabled ? "is-enabled" : ""}">
            <label class="hockey-directory-check"><input type="checkbox" data-sql-auto-refresh ${draft.auto_refresh_enabled ? "checked" : ""}><span>Автообновление в vMix</span></label>
            <label>Период, сек<input type="number" min="1" max="3600" step="1" data-sql-refresh-seconds value="${Math.max(1, Math.round(Number(draft.refresh_interval_ms || 1000) / 1000))}" ${draft.auto_refresh_enabled ? "" : "disabled"}></label>
            <small>Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось.</small>
          </div>
          <details class="hockey-map-sql-reference hockey-map-sql-params-ref">
            <summary><span>:</span><div><strong>Доступные параметры</strong><small>${(state.mappingCatalog?.variables || []).length} идентификаторов · клик вставляет <code>:параметр</code> в SQL</small></div></summary>
            <div class="hockey-map-sql-reference-body"><div class="hockey-map-sql-params"><div>${variableChips}</div></div></div>
          </details>
          ${mappingSqlDatabaseReference()}
          ${mappingSqlHelp()}
          <label class="hockey-map-sql-code"><span>SQL</span><textarea data-sql-text spellcheck="false">${escapeHtml(draft.sql_text || "")}</textarea></label>
          <div class="hockey-map-sql-actions"><label class="hockey-directory-check"><input type="checkbox" data-sql-enabled ${draft.enabled !== false ? "checked" : ""}><span>Источник включён</span></label><button type="button" data-sql-preview>Проверить SQL</button><button type="button" class="is-accent" data-sql-save>${draft.id ? "Сохранить источник" : "Создать источник"}</button>${draft.id ? `<button type="button" class="danger" data-sql-delete>Удалить</button>` : ""}</div>
          <section class="hockey-map-sql-preview"><h4>Живой результат</h4>${previewMarkup}</section>
        </div>
      </div>
    </section>`;
  }

  function renderMapping() {
    const modal = ensureModal();
    const devices = state.mappingDevices?.devices || [];
    const profiles = state.mappingProfiles?.profiles || [];
    const profile = state.mappingActiveProfile;
    const usableDevices = devices.filter((item) => item.project_fingerprint);
    const deviceRows = devices.length ? devices.map((item) => {
      const agentVersion = String(item.agent_version || "").trim();
      const tooOldForMapping = Boolean(agentVersion && item.mapping_supported === false);
      const mappingState = tooOldForMapping
        ? `<b style="color:#f59e0b">Agent ${escapeHtml(agentVersion)} слишком старый для Mapping · нужен 1.3.0+</b>`
        : (item.mapping
          ? `Mapping: <b>${escapeHtml(item.mapping.name)}</b> · v${Number(item.mapping.version || 1)}`
          : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix"));
      return `
      <article class="hockey-mapping-device ${item.online ? "is-online" : ""}">
        <div><strong>${escapeHtml(item.name || item.device_id)}</strong><code>${escapeHtml(item.device_id)}</code></div>
        <span>${item.vmix_connected ? "vMix подключён" : "vMix не найден"}${agentVersion ? ` · Agent ${escapeHtml(agentVersion)}` : ""}</span>
        <span>${Number(item.input_count || 0)} Inputs · ${Number(item.field_count || 0)} полей</span>
        <span>${mappingState}</span>
      </article>`;
    }).join("") : `<div class="hockey-directory-empty">Agent пока не передал структуру ни одного vMix.</div>`;

    const selectedDeviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
    const selectedDevice = devices.find((item) => String(item.device_id || "") === selectedDeviceId) || null;
    const profileButtons = profiles.length ? profiles.map((item) => {
      const isUsed = Boolean(selectedDevice?.mapping && Number(selectedDevice.mapping.source_profile_id || selectedDevice.mapping.id || 0) === Number(item.id));
      return `
      <div class="hockey-mapping-profile-row ${profile?.id === item.id ? "is-active" : ""}">
        <button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}" title="Открыть и редактировать конфиг">
          <strong>${escapeHtml(item.name)}</strong>
          <small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей${item.created_by ? ` · ${escapeHtml(item.created_by)}` : ""}</small>
        </button>
        <div class="hockey-map-profile-actions">
          <button type="button" class="hockey-map-profile-icon hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" title="${isUsed ? "Этот конфиг уже используется на моём Agent" : "Применить к моему Agent"}" aria-label="${isUsed ? "Используется на моём Agent" : "Применить к моему Agent"}">${isUsed ? "✓" : "▶"}</button>
          <button type="button" class="hockey-map-profile-icon hockey-map-copy-profile" data-map-copy-profile="${item.id}" title="Копировать конфиг" aria-label="Копировать конфиг">⧉</button>
        </div>
      </div>`;
    }).join("") : `<div class="hockey-directory-empty">Mapping-конфигов ещё нет.</div>`;

    const loadErrors = Object.entries(state.mappingLoadErrors || {});
    const mappingDiagnostics = loadErrors.length ? `<div class="hockey-map-load-errors"><strong>Часть Mapping API недоступна</strong>${loadErrors.map(([key, value]) => `<span><code>${escapeHtml(key)}</code>${escapeHtml(value)}</span>`).join("")}</div>` : "";

    let editor = "";
    if (state.mappingWorkspaceTab === "context") {
      editor = mappingContextPanel();
    } else if (state.mappingWorkspaceTab === "sql") {
      editor = mappingSqlPanel();
    } else if (!profile) {
      editor = `<div class="hockey-directory-empty">Выберите профиль слева или создайте новый из подключённого vMix.</div>`;
    } else {
      const currentInput = mappingCurrentInput(profile);
      const profileId = Number(profile.id || 0);
      // BUILD113: the test Agent selector is a transport selector, not a strict
      // fingerprint gate. Compatibility marker for BUILD112 regression only:
      // resolvedProfileId === profileId
      // (not used as a filter anymore). The server remaps saved configs separately; for manual
      // Input/field testing any modern online Agent with a live vMix inventory is
      // valid and must remain selectable.
      const matchingDevices = devices.filter((item) => (
        item.online && item.vmix_connected && item.mapping_supported !== false && item.project_fingerprint
      ));
      const onlineOldAgents = devices.filter((item) => item.online && item.vmix_connected && item.mapping_supported === false);
      if (!state.mappingTestDeviceId || !matchingDevices.some((item) => item.device_id === state.mappingTestDeviceId)) state.mappingTestDeviceId = matchingDevices[0]?.device_id || "";
      const catalog = mappingDataCatalog();
      const gameReady = Boolean(catalog.game && (catalog.game.external_id || catalog.game.id));
      editor = `
        <div class="hockey-mapping-editor-head is-visual">
          <div>
            <label>Название<input data-mapping-name value="${escapeHtml(profile.name || "")}"></label>
            <label>Описание<input data-mapping-description value="${escapeHtml(profile.description || "")}"></label>
          </div>
          <div class="hockey-mapping-meta">
            <span>Версия <b>${Number(profile.version || 1)}</b></span>
            <span>Inputs <b>${Number(profile.inventory?.input_count || mappingInventoryInputs(profile).length)}</b></span>
            <span>Связей <b>${Number(profile.fields?.length || 0)}</b></span>
          </div>
        </div>
        <div class="hockey-map-livebar ${gameReady ? "is-ready" : ""}">
          <div><span>${gameReady ? "● ЖИВОЙ ПРИМЕР" : "○ НЕТ ТЕСТОВОГО МАТЧА"}</span><strong>${escapeHtml(catalog.gameLabel)}</strong><small>${gameReady ? "Значения получены через SQL Data Sources из PostgreSQL." : "Выберите матч; системный context game_id заполнится автоматически."}</small></div>
          <label>Тестовый Agent<select data-map-test-device><option value="">${onlineOldAgents.length ? `— Agent ${escapeHtml(onlineOldAgents[0].agent_version || "")} слишком старый, нужен 1.3.0+ —` : "— нет подходящего online Agent —"}</option>${matchingDevices.map((item) => `<option value="${escapeHtml(item.device_id)}" ${item.device_id === state.mappingTestDeviceId ? "selected" : ""}>${escapeHtml(item.name || item.device_id)}${item.agent_version ? ` · v${escapeHtml(item.agent_version)}` : ""}</option>`).join("")}</select></label>
        </div>
        <div class="hockey-map-visual-workspace">
          ${mappingDataBrowser(profile, currentInput)}
          ${mappingVmixBrowser(profile, currentInput, matchingDevices)}
        </div>
        <div class="hockey-mapping-savebar is-visual">
          <div class="hockey-map-maintenance">
            <select data-map-refresh-device><option value="">Обновить структуру из Agent…</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)}/${Number(item.field_count || 0)}</option>`).join("")}</select>
            <button type="button" data-map-refresh>Считать vMix заново</button>
            <button type="button" class="danger" data-map-delete>Удалить конфиг</button>
          </div>
          <div><button type="button" class="is-accent" data-map-save>Сохранить конфиг</button></div>
        </div>`;
    }

    modal.innerHTML = shellMarkup(`
      <div class="hockey-mapping-layout is-visual">
        <aside class="hockey-mapping-sidebar">
          <h3>vMix проекты</h3>
          <div class="hockey-mapping-devices">${deviceRows}</div>
          <h3>Конфиги Mapping</h3>
          <div class="hockey-map-config-hint">▶ применяет готовый конфиг к вашему Agent. ⧉ создаёт независимую копию со всеми связями — её можно переименовать, пересчитать под другой vMix и отредактировать.</div>
          <div class="hockey-mapping-profiles">${profileButtons}</div>
          <details class="hockey-mapping-create-wrap">
            <summary>+ Создать новый конфиг из vMix</summary>
            <form class="hockey-mapping-create" data-mapping-create>
              <label>Agent / текущий vMix<select name="device_id" required><option value="">Выберите устройство</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select></label>
              <label>Название<input name="name" required placeholder="KHL_MAIN_2026"></label>
              <label>Описание<input name="description" placeholder="Основной графический пакет"></label>
              <button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать конфиг</button>
            </form>
          </details>
        </aside>
        <section class="hockey-mapping-workspace">${statusMarkup()}${mappingDiagnostics}${editor}</section>
      </div>`);
    bindShell();
    bindMapping();
  }

  function collectMappingFields() {
    return [...(state.mappingActiveProfile?.fields || [])].map((row, index) => ({
      ...row,
      // Never re-save a positional vMix Input number when a stable key/title is known.
      vmix_input_number: (row.vmix_input_key || row.vmix_input_title) ? "" : String(row.vmix_input_number || ""),
      sort_order: index,
    })).filter((row) => row.data_key && row.vmix_field);
  }

  function bindMapping() {
    const modal = ensureModal();

    // Keep the vMix field list exactly where the operator left it across
    // renderMapping() calls (rule edits, save/reload, test/apply actions).
    const vmixScroll = modal.querySelector(".hockey-map-vmix-scroll");
    if (vmixScroll) {
      vmixScroll.scrollTop = Math.max(0, Number(state.mappingVmixScrollTop || 0));
      vmixScroll.addEventListener("scroll", () => {
        state.mappingVmixScrollTop = vmixScroll.scrollTop;
      }, { passive: true });
    }

    modal.querySelectorAll("[data-map-workspace-tab]").forEach((button) => button.addEventListener("click", () => {
      state.mappingWorkspaceTab = button.dataset.mapWorkspaceTab || "links";
      state.section = state.mappingWorkspaceTab === "context" ? "mapping_context" : (state.mappingWorkspaceTab === "sql" ? "mapping_sql" : "mapping");
      state.mappingSqlPreview = null;
      setStatus("");
      renderMapping();
    }));

    modal.querySelector("[data-context-create]")?.addEventListener("submit", async (event) => {
      event.preventDefault();
      const form = new FormData(event.currentTarget);
      try {
        await request("/api/hockey/admin/mapping-context/variables", {
          method: "POST",
          body: JSON.stringify({
            key: form.get("key"), label: form.get("label"), category: "Пользовательские", description: "",
            value_type: "id", entity_type: form.get("entity_type") || "", scope: form.get("scope") || "match",
            source_type: form.get("source_type") || "manual", default_value: "", enabled: true, sort_order: 1000,
          }),
        });
        await loadMapping(state.mappingActiveProfile?.id || null);
        setStatus("Переменная создана.");
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });

    modal.querySelectorAll("[data-context-save]").forEach((button) => button.addEventListener("click", async () => {
      const id = Number(button.dataset.contextSave || 0);
      const item = (state.mappingCatalog?.variables || state.mappingContextVariables?.variables || []).find((row) => Number(row.id) === id);
      const row = button.closest("[data-context-row]");
      if (!item || !row) return;
      try {
        await request(`/api/hockey/admin/mapping-context/variables/${id}`, {
          method: "PUT",
          body: JSON.stringify({
            key: item.key,
            label: row.querySelector("[data-context-label]")?.value || item.label,
            category: row.querySelector("[data-context-category]")?.value || item.category || "Пользовательские",
            description: item.description || "", value_type: item.value_type || "id",
            entity_type: row.querySelector("[data-context-entity]")?.value || "",
            scope: row.querySelector("[data-context-scope]")?.value || "match",
            source_type: row.querySelector("[data-context-source]")?.value || "manual",
            default_value: item.default_value || "", enabled: item.enabled !== false, sort_order: Number(item.sort_order || 1000),
          }),
        });
        await loadMapping(state.mappingActiveProfile?.id || null); setStatus(`Переменная ${item.key} сохранена.`);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    }));

    modal.querySelectorAll("[data-context-set]").forEach((button) => button.addEventListener("click", async () => {
      const key = button.dataset.contextSet || "";
      const row = button.closest("[data-context-row]");
      const value = row?.querySelector("[data-context-current]")?.value || "";
      try {
        await request(`/api/hockey/context/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify({ value, context: mappingRequestContext() }) });
        await loadMappingCatalog(); setStatus(`${key} = ${value || "—"}`);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    }));

    modal.querySelectorAll("[data-context-delete]").forEach((button) => button.addEventListener("click", async () => {
      const id = Number(button.dataset.contextDelete || 0);
      const item = (state.mappingCatalog?.variables || []).find((row) => Number(row.id) === id);
      if (!id || !window.confirm(`Удалить переменную «${item?.label || item?.key || id}»?`)) return;
      try {
        await request(`/api/hockey/admin/mapping-context/variables/${id}`, { method: "DELETE" });
        await loadMapping(state.mappingActiveProfile?.id || null); setStatus("Переменная удалена.");
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    }));

    modal.querySelectorAll("[data-sql-source]").forEach((button) => button.addEventListener("click", () => {
      state.mappingSelectedSqlSourceId = button.dataset.sqlSource === "new" ? "new" : Number(button.dataset.sqlSource);
      state.mappingSqlPreview = null;
      state.mappingSqlDraft = null;
      renderMapping();
    }));

    modal.querySelectorAll("[data-sql-param]").forEach((button) => button.addEventListener("click", () => {
      const textarea = modal.querySelector("[data-sql-text]");
      if (!textarea) return;
      const token = `:${button.dataset.sqlParam}`;
      const start = textarea.selectionStart ?? textarea.value.length;
      const end = textarea.selectionEnd ?? start;
      textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end);
      textarea.focus(); textarea.setSelectionRange(start + token.length, start + token.length);
    }));

    modal.querySelectorAll("[data-sql-table-name]").forEach((button) => button.addEventListener("click", () => {
      const textarea = modal.querySelector("[data-sql-text]");
      if (!textarea) return;
      const tableName = String(button.dataset.sqlTableName || "").trim();
      if (!tableName) return;
      const start = textarea.selectionStart ?? textarea.value.length;
      const end = textarea.selectionEnd ?? start;
      textarea.value = textarea.value.slice(0, start) + tableName + textarea.value.slice(end);
      textarea.focus(); textarea.setSelectionRange(start + tableName.length, start + tableName.length);
      state.mappingSqlDraft = null;
    }));

    modal.querySelectorAll("[data-sql-snippet]").forEach((button) => button.addEventListener("click", () => {
      const textarea = modal.querySelector("[data-sql-text]");
      if (!textarea) return;
      const snippet = String(button.dataset.sqlSnippet || "");
      const start = textarea.selectionStart ?? textarea.value.length;
      const end = textarea.selectionEnd ?? start;
      textarea.value = textarea.value.slice(0, start) + snippet + textarea.value.slice(end);
      textarea.focus(); textarea.setSelectionRange(start + snippet.length, start + snippet.length);
      state.mappingSqlDraft = null;
    }));

    modal.querySelector("[data-sql-auto-refresh]")?.addEventListener("change", (event) => {
      const seconds = modal.querySelector("[data-sql-refresh-seconds]");
      if (seconds) seconds.disabled = !event.currentTarget.checked;
      event.currentTarget.closest(".hockey-map-sql-refresh")?.classList.toggle("is-enabled", Boolean(event.currentTarget.checked));
    });

    const sqlPayload = () => {
      const selected = (state.mappingSqlSources?.sources || []).find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId));
      return {
        code: modal.querySelector("[data-sql-code]")?.value || "",
        name: modal.querySelector("[data-sql-name]")?.value || "",
        category: modal.querySelector("[data-sql-category]")?.value || "Данные",
        description: modal.querySelector("[data-sql-description]")?.value || "",
        sql_text: modal.querySelector("[data-sql-text]")?.value || "",
        field_metadata: selected?.field_metadata || {},
        enabled: Boolean(modal.querySelector("[data-sql-enabled]")?.checked),
        auto_refresh_enabled: Boolean(modal.querySelector("[data-sql-auto-refresh]")?.checked),
        refresh_interval_ms: Math.max(1000, Math.min(3600000, Number(modal.querySelector("[data-sql-refresh-seconds]")?.value || 1) * 1000)),
        sort_order: Number(modal.querySelector("[data-sql-order]")?.value || 1000),
      };
    };

    modal.querySelector("[data-sql-preview]")?.addEventListener("click", async () => {
      const payload = sqlPayload();
      const selected = (state.mappingSqlSources?.sources || []).find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId));
      // Preview must never reset an unsaved editor. Keep the complete form draft
      // in UI state before the async request and restore it on render.
      state.mappingSqlDraft = {
        ...payload,
        id: selected?.id || "",
        field_metadata: selected?.field_metadata || payload.field_metadata || {},
        __source_id: String(state.mappingSelectedSqlSourceId || selected?.id || "new"),
      };
      try {
        state.mappingSqlPreview = await request("/api/hockey/admin/mapping-data/preview-sql", { method: "POST", body: JSON.stringify({ sql_text: payload.sql_text, context: mappingRequestContext() }) });
        setStatus(state.mappingSqlPreview.skipped ? "SQL корректен, но не хватает параметров." : "SQL выполнен в read-only режиме.");
      } catch (error) { state.mappingSqlPreview = { error: error.message }; setStatus(error.message, true); }
      renderMapping();
    });

    modal.querySelector("[data-sql-save]")?.addEventListener("click", async () => {
      try {
        const payload = sqlPayload();
        const id = state.mappingSelectedSqlSourceId;
        const result = id && id !== "new"
          ? await request(`/api/hockey/admin/mapping-data/sources/${id}`, { method: "PUT", body: JSON.stringify(payload) })
          : await request("/api/hockey/admin/mapping-data/sources", { method: "POST", body: JSON.stringify(payload) });
        state.mappingSelectedSqlSourceId = result.id;
        state.mappingSqlPreview = null;
        state.mappingSqlDraft = null;
        await loadMapping(state.mappingActiveProfile?.id || null);
        setStatus(`SQL-источник «${result.name}» сохранён. Ключи обновлены автоматически.`);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });

    modal.querySelector("[data-sql-delete]")?.addEventListener("click", async () => {
      const id = Number(state.mappingSelectedSqlSourceId || 0);
      const item = (state.mappingSqlSources?.sources || []).find((row) => Number(row.id) === id);
      if (!id || !window.confirm(`Удалить SQL-источник «${item?.name || id}»?`)) return;
      try {
        await request(`/api/hockey/admin/mapping-data/sources/${id}`, { method: "DELETE" });
        state.mappingSelectedSqlSourceId = null; state.mappingSqlPreview = null; state.mappingSqlDraft = null;
        await loadMapping(state.mappingActiveProfile?.id || null); setStatus("SQL-источник удалён.");
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
    modal.querySelector("[data-mapping-name]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.name = event.currentTarget.value; });
    modal.querySelector("[data-mapping-description]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.description = event.currentTarget.value; });
    modal.querySelector("[data-mapping-active]")?.addEventListener("change", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.active = Boolean(event.currentTarget.checked); });
    modal.querySelectorAll("[data-map-use-profile]").forEach((button) => button.addEventListener("click", async (event) => {
      event.stopPropagation();
      const profileId = Number(button.dataset.mapUseProfile || 0);
      if (!profileId) return;
      const selected = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId);
      const deviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
      const sessionToken = String(selectedContext().token || "").trim();
      button.disabled = true;
      button.textContent = "Применяю…";
      try {
        const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/use`, {
          method: "POST",
          body: JSON.stringify({ device_id: deviceId, session_token: sessionToken }),
        });
        await loadMapping(profileId);
        const applied = result.applied || {};
        const report = result.report || {};
        const suffix = Number(report.skipped || 0) ? ` · не сопоставлено ${Number(report.skipped || 0)}` : "";
        const send = applied.ok === false ? ` · ${escapeHtml(applied.reason || "не удалось отправить данные")}` : ` · отправлено ${Number(applied.applied || 0)} из ${Number(applied.total || 0)}`;
        setStatus(`✓ Конфиг «${selected?.name || result.profile?.name || profileId}» выбран для моего Agent${send}${suffix}`, Number(report.skipped || 0) > 0 || applied.ok === false);
      } catch (error) {
        setStatus(error.message || "Не удалось применить Mapping-конфиг", true);
      }
      renderMapping();
    }));

    modal.querySelectorAll("[data-map-copy-profile]").forEach((button) => button.addEventListener("click", async (event) => {
      event.stopPropagation();
      const profileId = Number(button.dataset.mapCopyProfile || 0);
      if (!profileId) return;
      const source = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId);
      button.disabled = true;
      button.textContent = "…";
      try {
        const created = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/duplicate`, { method: "POST" });
        state.mappingSelectedInputKey = "";
        state.mappingTargetField = "";
        state.mappingSelectedSourceKey = "";
        state.mappingVmixScrollTop = 0;
        await loadMapping(created.id);
        setStatus(`Копия «${created.name}» создана · ${Number(created.copied_fields || created.fields?.length || 0)} связей. Можно переименовать, считать структуру другого vMix и редактировать.`);
      } catch (error) {
        setStatus(error.message || `Не удалось скопировать конфиг «${source?.name || profileId}»`, true);
      }
      renderMapping();
    }));

    modal.querySelectorAll("[data-mapping-profile]").forEach((button) => button.addEventListener("click", async () => {
      try {
        state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${button.dataset.mappingProfile}`);
        state.mappingSelectedInputKey = "";
        state.mappingTargetField = "";
        state.mappingSelectedSourceKey = "";
        state.mappingVmixScrollTop = 0;
        setStatus("");
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    }));
    modal.querySelector("[data-mapping-create]")?.addEventListener("submit", async (event) => {
      event.preventDefault();
      const form = new FormData(event.currentTarget);
      try {
        const created = await request("/api/hockey/admin/vmix-mapping/profiles", { method: "POST", body: JSON.stringify({ device_id: form.get("device_id"), name: form.get("name"), description: form.get("description") }) });
        await loadMapping(created.id);
        state.mappingSelectedInputKey = "";
        state.mappingVmixScrollTop = 0;
        setStatus(`Mapping «${created.name}» создан.`);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });

    modal.querySelector("[data-mapping-import]")?.addEventListener("submit", async (event) => {
      event.preventDefault();
      const form = new FormData(event.currentTarget);
      const file = form.get("file");
      if (!(file instanceof File) || !file.size) return setStatus("Выберите JSON-файл Mapping.", true), renderMapping();
      if (file.size > 8 * 1024 * 1024) return setStatus("Файл Mapping слишком большой.", true), renderMapping();
      try {
        const document = JSON.parse(await file.text());
        const result = await request("/api/hockey/admin/vmix-mapping/import", {
          method: "POST",
          body: JSON.stringify({
            device_id: String(form.get("device_id") || ""),
            name: String(form.get("name") || ""),
            replace_existing: Boolean(form.get("replace_existing")),
            apply_now: true,
            document,
          }),
        });
        await loadMapping(result.profile?.id || null);
        state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
        setStatus(mappingTransferReport(result, `Mapping «${result.profile?.name || ""}» импортирован`), Number(result.report?.skipped || 0) > 0);
      } catch (error) { setStatus(error.message || "Не удалось прочитать Mapping JSON", true); }
      renderMapping();
    });
    modal.querySelector("[data-map-input-picker]")?.addEventListener("change", (event) => {
      state.mappingSelectedInputKey = event.currentTarget.value;
      state.mappingTargetField = "";
      state.mappingSelectedSourceKey = "";
      state.mappingVmixScrollTop = 0;
      renderMapping();
    });
    modal.querySelector("[data-map-input-search]")?.addEventListener("input", (event) => {
      state.mappingInputSearch = event.currentTarget.value;
      const value = state.mappingInputSearch;
      renderMapping();
      const input = ensureModal().querySelector("[data-map-input-search]");
      if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
    });
    modal.querySelectorAll("[data-map-field-filter]").forEach((button) => button.addEventListener("click", () => {
      state.mappingFieldFilter = button.dataset.mapFieldFilter || "text";
      state.mappingTargetField = "";
      state.mappingVmixScrollTop = 0;
      renderMapping();
    }));
    modal.querySelector("[data-map-hide-linked]")?.addEventListener("change", (event) => {
      state.mappingHideLinked = Boolean(event.currentTarget.checked);
      if (state.mappingHideLinked) state.mappingTargetField = "";
      renderMapping();
    });
    modal.querySelector("[data-map-table-source]")?.addEventListener("change", (event) => {
      state.mappingSelectedTableSource = event.currentTarget.value;
      state.mappingSelectedTableRow = 1;
      state.mappingSelectedTableColumn = "";
      state.mappingTableRowSearch = "";
      renderMapping();
    });
    modal.querySelectorAll("[data-map-data-group]").forEach((details) => details.addEventListener("toggle", (event) => {
      const key = String(event.currentTarget.dataset.mapDataGroup || "");
      if (key) state.mappingOpenDataGroups[key] = Boolean(event.currentTarget.open);
    }));
    modal.querySelector("[data-map-sql-cell-details]")?.addEventListener("toggle", (event) => {
      state.mappingSqlCellOpen = Boolean(event.currentTarget.open);
    });
    modal.querySelector("[data-map-sql-table-details]")?.addEventListener("toggle", (event) => {
      state.mappingSqlTableOpen = Boolean(event.currentTarget.open);
    });
    modal.querySelector("[data-map-quick-table-source]")?.addEventListener("change", (event) => {
      state.mappingSelectedTableSource = event.currentTarget.value;
      state.mappingSelectedTableRow = 1;
      state.mappingSelectedTableColumn = "";
      renderMapping();
    });
    modal.querySelector("[data-map-quick-table-row]")?.addEventListener("change", (event) => {
      state.mappingSelectedTableRow = Number(event.currentTarget.value || 1);
      state.mappingSelectedTableColumn = "";
      renderMapping();
    });
    modal.querySelector("[data-map-quick-table-column]")?.addEventListener("change", (event) => {
      state.mappingSelectedTableColumn = event.currentTarget.value;
      renderMapping();
    });
    modal.querySelector("[data-map-table-row-search]")?.addEventListener("input", (event) => {
      state.mappingTableRowSearch = event.currentTarget.value;
      const value = state.mappingTableRowSearch;
      renderMapping();
      const input = ensureModal().querySelector("[data-map-table-row-search]");
      if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
    });
    modal.querySelector("[data-map-test-device]")?.addEventListener("change", (event) => { state.mappingTestDeviceId = event.currentTarget.value; });
    modal.querySelector("[data-map-data-search]")?.addEventListener("input", (event) => {
      state.mappingDataSearch = event.currentTarget.value;
      const value = state.mappingDataSearch;
      renderMapping();
      const input = ensureModal().querySelector("[data-map-data-search]");
      if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
    });
    modal.querySelector("[data-map-live-refresh]")?.addEventListener("click", async () => { await loadMappingCatalog(); renderMapping(); });

    const chooseTarget = (fieldName) => {
      const profile = state.mappingActiveProfile;
      const input = mappingCurrentInput(profile);
      const field = (input?.fields || []).find((item) => String(item.name || "") === String(fieldName || ""));
      if (!field) return;
      state.mappingTargetField = fieldName;
      if (state.mappingSelectedSourceKey) {
        const source = mappingSourceByKey(state.mappingSelectedSourceKey);
        if (source && mappingCompatible(source, mappingFieldKind(field))) {
          mappingSetLink(profile, input, field, source);
          state.mappingTargetField = "";
          state.mappingSelectedSourceKey = "";
        }
      }
      renderMapping();
    };
    modal.querySelectorAll("[data-map-target]").forEach((node) => node.addEventListener("click", (event) => {
      // Controls inside a linked vMix card (especially rule <select>s) must not
      // bubble into the card click handler. Otherwise opening a dropdown
      // selects the whole field and renderMapping() destroys the dropdown.
      if (event.target.closest("button, input, select, textarea, label, a, [contenteditable=\"true\"], .hockey-map-rule-wrap")) return;
      chooseTarget(node.dataset.mapTarget);
    }));
    modal.querySelectorAll("[data-map-choose]").forEach((button) => button.addEventListener("click", () => chooseTarget(button.dataset.mapChoose)));
    modal.querySelector("[data-map-quick-cell-link]")?.addEventListener("click", () => {
      const profile = state.mappingActiveProfile;
      const input = mappingCurrentInput(profile);
      const field = (input?.fields || []).find((item) => String(item.name || "") === String(state.mappingTargetField || ""));
      const table = (mappingDataCatalog().tables || []).find((item) => String(item.code || "") === String(state.mappingSelectedTableSource || ""));
      const rowIndex = Number(state.mappingSelectedTableRow || 0);
      const column = String(state.mappingSelectedTableColumn || "");
      const key = table && rowIndex && column ? `${table.code}.row.${rowIndex}.${column}` : "";
      const source = key ? mappingSourceByKey(key) : null;
      if (!input || !field) return setStatus("Сначала выберите поле vMix справа.", true), renderMapping();
      if (!source) return setStatus("Не удалось получить выбранную SQL-ячейку.", true), renderMapping();
      if (!mappingCompatible(source, mappingFieldKind(field))) return setStatus(`Тип SQL-ячейки не подходит для ${field.name}.`, true), renderMapping();
      mappingSetLink(profile, input, field, source);
      state.mappingTargetField = "";
      state.mappingSelectedSourceKey = "";
      setStatus(`Связано: ${field.name} ← ${table.name} / строка ${rowIndex} / ${column} · сейчас «${mappingDisplayValue(source.value, source.kind)}»`);
      renderMapping();
    });
    modal.querySelectorAll("[data-map-source]").forEach((button) => button.addEventListener("click", () => {
      const key = button.dataset.mapSource;
      const source = mappingSourceByKey(key);
      const profile = state.mappingActiveProfile;
      const input = mappingCurrentInput(profile);
      if (!source || !input) return;
      if (!state.mappingTargetField) {
        state.mappingSelectedSourceKey = state.mappingSelectedSourceKey === key ? "" : key;
        setStatus(state.mappingSelectedSourceKey ? `Выбрано «${source.label}». Теперь нажмите поле vMix справа.` : "");
        return renderMapping();
      }
      const field = (input.fields || []).find((item) => String(item.name || "") === String(state.mappingTargetField));
      if (!field) return;
      if (!mappingCompatible(source, mappingFieldKind(field))) {
        setStatus(`Тип данных не подходит для поля ${field.name}.`, true);
        return renderMapping();
      }
      mappingSetLink(profile, input, field, source);
      state.mappingTargetField = "";
      state.mappingSelectedSourceKey = "";
      setStatus(`Связано: ${field.name} ← ${source.label}`);
      renderMapping();
    }));
    const runSeriesLink = (fieldName, mode) => {
      const profile = state.mappingActiveProfile;
      const input = mappingCurrentInput(profile);
      if (!profile || !input) return;
      const plan = mappingSeriesPlan(profile, input, fieldName, mode);
      if (!plan.links.length) {
        setStatus(plan.reason || "Не удалось построить серию связей.", true);
        return renderMapping();
      }
      const anchor = mappingRowForField(profile, input, fieldName);
      const seedCount = Number(plan.seedCount || 1);
      const preview = plan.links.slice(0, 6).map((item) => `${item.field.name} ← row.${item.sourceRowIndex}.${item.column}`).join("\n");
      const extra = plan.links.length > 6 ? `\n… ещё ${plan.links.length - 6}` : "";
      const title = mode === "row"
        ? `Размножить ${seedCount} пол${seedCount === 1 ? "е" : "я"} шаблонной строки на всю серию?`
        : "Автоматически продолжить эту колонку по строкам?";
      if (!window.confirm(`${title}\n\nШаблон: ${fieldName} ← ${anchor?.data_key || ""}\nБудет обработано связей: ${plan.links.length}\n\n${preview}${extra}`)) return;
      const result = mappingApplySeries(profile, input, fieldName, mode);
      setStatus(`${mode === "row" ? "Строка размножена" : "Столбец автосвязан"}: ${result.changed} новых/изменённых связей${result.overwritten ? ` · перезаписано ${result.overwritten}` : ""}. Нажмите «Сохранить mapping».`);
      renderMapping();
    };
    modal.querySelectorAll("[data-map-series-column]").forEach((button) => button.addEventListener("click", (event) => {
      event.stopPropagation();
      runSeriesLink(button.dataset.mapSeriesColumn, "column");
    }));
    modal.querySelectorAll("[data-map-series-row]").forEach((button) => button.addEventListener("click", (event) => {
      event.stopPropagation();
      runSeriesLink(button.dataset.mapSeriesRow, "row");
    }));

    modal.querySelectorAll("[data-map-rule-toggle]").forEach((button) => button.addEventListener("click", (event) => {
      event.stopPropagation();
      state.mappingRuleField = state.mappingRuleField === button.dataset.mapRuleToggle ? "" : button.dataset.mapRuleToggle;
      renderMapping();
    }));
    modal.querySelectorAll("[data-map-rule-enabled]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { enabled: control.checked }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-action]").forEach((control) => control.addEventListener("change", () => {
      const action = control.value;
      mappingPatchRule(control.dataset.ruleField, { action, true_value: action === "visibility" ? "on" : "#E5CEA8", false_value: action === "visibility" ? "off" : "#FFFFFF", enabled: true });
      renderMapping();
    }));
    modal.querySelectorAll("[data-map-rule-left-key]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { left_key: control.value, enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-operator]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { operator: control.value, enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-right-mode]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_mode: control.value, enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-right-key]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_key: control.value, enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-right-value]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_value: control.value, enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-true]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { true_value: control.value, enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-false]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { false_value: control.value, enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-preset]").forEach((button) => button.addEventListener("click", () => { mappingPatchRule(button.dataset.ruleField, { operator: button.dataset.mapRulePreset, right_mode: "field", enabled: true }); renderMapping(); }));
    modal.querySelectorAll("[data-map-rule-clear]").forEach((button) => button.addEventListener("click", () => {
      const { row } = mappingRuleRow(state.mappingActiveProfile, mappingCurrentInput(state.mappingActiveProfile), button.dataset.mapRuleClear);
      if (row) row.rule = {};
      renderMapping();
    }));
    modal.querySelectorAll("[data-map-rule-mirror]").forEach((button) => button.addEventListener("click", () => {
      const result = mappingMirrorRule(button.dataset.mapRuleMirror);
      setStatus(result.ok ? `Зеркальное правило создано для ${result.target}. Нажмите «Сохранить mapping».` : result.reason, !result.ok);
      renderMapping();
    }));
    modal.querySelectorAll("[data-map-rule-series]").forEach((button) => button.addEventListener("click", () => {
      const result = mappingCloneRuleSeries(button.dataset.mapRuleSeries);
      setStatus(result.changed ? `Правило размножено на ${result.changed} связанных полей. Нажмите «Сохранить mapping».` : "Не удалось найти связанную последовательность полей.", !result.changed);
      renderMapping();
    }));

    modal.querySelectorAll("[data-map-unlink]").forEach((button) => button.addEventListener("click", (event) => {
      event.stopPropagation();
      mappingRemoveLink(state.mappingActiveProfile, mappingCurrentInput(state.mappingActiveProfile), button.dataset.mapUnlink);
      if (state.mappingTargetField === button.dataset.mapUnlink) state.mappingTargetField = "";
      renderMapping();
    }));
    modal.querySelectorAll("[data-map-test]").forEach((button) => button.addEventListener("click", async (event) => {
      event.stopPropagation();
      const profile = state.mappingActiveProfile;
      const input = mappingCurrentInput(profile);
      const field = (input?.fields || []).find((item) => String(item.name || "") === String(button.dataset.mapTest || ""));
      const row = field ? mappingRowForField(profile, input, field.name) : null;
      const source = row ? mappingSourceByKey(row.data_key) : null;
      const deviceId = state.mappingTestDeviceId;
      if (!deviceId) return setStatus("Выберите тестовый Agent.", true), renderMapping();
      if (!row || !source) return setStatus("Сначала свяжите поле с данными.", true), renderMapping();
      if (source.value === "" || source.value === null || source.value === undefined) return setStatus(`Для «${source.label}» в текущем матче нет значения.`, true), renderMapping();
      const commands = [{
        input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
        selected_name: row.vmix_field, value: String(source.value),
        field_type: row.field_type || mappingFieldKind(field), data_key: row.data_key,
      }];
      const ruleCommand = mappingRuleCommand(row, field);
      if (ruleCommand) commands.push(ruleCommand);
      button.disabled = true; button.textContent = "Отправка…";
      try {
        const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
        setStatus(`✓ ${row.vmix_field}: значение${ruleCommand ? " + правило" : ""} отправлено в vMix · ${Number(result.applied || 0)} команд.`);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    }));
    modal.querySelector("[data-map-apply-input]")?.addEventListener("click", async () => {
      const deviceId = state.mappingTestDeviceId || modal.querySelector("[data-map-test-device]")?.value || "";
      if (!deviceId) return setStatus("Выберите online Agent для применения Input.", true), renderMapping();
      await loadMappingCatalog();
      const profile = state.mappingActiveProfile;
      const input = mappingCurrentInput(profile);
      const commands = [];
      const missing = [];
      for (const field of (input?.fields || [])) {
        const row = mappingRowForField(profile, input, field.name);
        if (!row) continue;
        const source = mappingSourceByKey(row.data_key);
        if (!source) { missing.push(row.data_key); continue; }
        commands.push({
          input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
          selected_name: row.vmix_field,
          value: source.value == null ? "" : String(source.value),
          field_type: row.field_type || mappingFieldKind(field),
          data_key: row.data_key,
        });
        const ruleCommand = mappingRuleCommand(row, field);
        if (ruleCommand) commands.push(ruleCommand);
      }
      if (!commands.length) return setStatus("У текущего Input нет настроенных связей с доступными данными.", true), renderMapping();
      try {
        const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
        const tail = `${missing.length ? ` · нет данных ${missing.length}` : ""}${result.errors?.length ? ` · ошибок ${result.errors.length}` : ""}${mappingApplyTransportSummary(result)}`;
        setStatus(`Input ${input?.number ? `#${input.number} ` : ""}${input?.title || ""}: отправлено ${Number(result.applied || 0)} из ${Number(result.total || commands.length)} полей${tail}.`, Boolean(result.errors?.length));
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
    modal.querySelector("[data-map-export]")?.addEventListener("click", async () => {
      if (!state.mappingActiveProfile) return;
      try {
        const document = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/export`);
        const base = String(state.mappingActiveProfile.name || "mapping").trim().replace(/\s+/g, "_");
        downloadJson(`${base}.hockey-mapping.json`, document);
        setStatus(`Mapping «${state.mappingActiveProfile.name}» экспортирован.`);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
    modal.querySelector("[data-map-copy-to-device]")?.addEventListener("click", async () => {
      if (!state.mappingActiveProfile) return;
      const deviceId = modal.querySelector("[data-map-copy-device]")?.value || "";
      if (!deviceId) return setStatus("Выберите Agent, на который нужно перенести Mapping.", true), renderMapping();
      const target = (state.mappingDevices?.devices || []).find((item) => item.device_id === deviceId);
      const replaceExisting = Boolean(target?.mapping && target.mapping.id !== state.mappingActiveProfile.id)
        ? window.confirm(`На «${target?.name || deviceId}» уже назначен Mapping «${target.mapping.name}». Заменить его новой копией?`)
        : false;
      if (target?.mapping && target.mapping.id !== state.mappingActiveProfile.id && !replaceExisting) return;
      try {
        const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/copy-to-device`, {
          method: "POST",
          body: JSON.stringify({ device_id: deviceId, replace_existing: replaceExisting, apply_now: true }),
        });
        await loadMapping(result.profile?.id || state.mappingActiveProfile.id);
        state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
        setStatus(mappingTransferReport(result, `Mapping перенесён на ${target?.name || deviceId}`), Number(result.report?.skipped || 0) > 0);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
    modal.querySelector("[data-map-apply-now]")?.addEventListener("click", async () => {
      const deviceId = state.mappingTestDeviceId || modal.querySelector("[data-map-test-device]")?.value || "";
      if (!deviceId) return setStatus("Выберите online Agent для применения Mapping.", true), renderMapping();
      try {
        const result = await request(`/api/hockey/admin/vmix-mapping/apply/${encodeURIComponent(deviceId)}`, { method: "POST" });
        const suffix = `${result.errors?.length ? ` · ошибок ${result.errors.length}` : ""}${mappingApplyTransportSummary(result)}`;
        setStatus(`Mapping применён: ${Number(result.applied || 0)} из ${Number(result.total || 0)} связей${suffix}.`, Boolean(result.errors?.length));
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
    modal.querySelector("[data-map-save]")?.addEventListener("click", async () => {
      if (!state.mappingActiveProfile) return;
      const currentScroll = modal.querySelector(".hockey-map-vmix-scroll");
      if (currentScroll) state.mappingVmixScrollTop = currentScroll.scrollTop;
      const id = state.mappingActiveProfile.id;
      try {
        await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`, { method: "PUT", body: JSON.stringify({ name: modal.querySelector("[data-mapping-name]")?.value || state.mappingActiveProfile.name, description: modal.querySelector("[data-mapping-description]")?.value || "", active: modal.querySelector("[data-mapping-active]") ? Boolean(modal.querySelector("[data-mapping-active]")?.checked) : state.mappingActiveProfile.active !== false }) });
        const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}/fields`, { method: "PUT", body: JSON.stringify({ fields: collectMappingFields() }) });
        await loadMapping(id); setStatus(`Mapping сохранён · версия ${result.version}.`);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
    modal.querySelector("[data-map-refresh]")?.addEventListener("click", async () => {
      const deviceId = modal.querySelector("[data-map-refresh-device]")?.value || "";
      if (!deviceId) return setStatus("Выберите Agent, из которого нужно считать структуру.", true), renderMapping();
      try {
        const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/inventory`, { method: "POST", body: JSON.stringify({ device_id: deviceId }) });
        await loadMapping(result.id); state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
        const report = result.refresh_report || {};
        const preserved = Number(report.preserved || 0);
        const total = Number(report.total_links || preserved);
        const addedInputs = Number(report.new_inputs || 0);
        const addedFields = Number(report.new_fields || 0);
        const unresolved = Number(report.unresolved || 0);
        setStatus(`Структура vMix обновлена · связи ${preserved}/${total} сохранены${addedInputs ? ` · новых Inputs ${addedInputs}` : ""}${addedFields ? ` · новых полей ${addedFields}` : ""}${unresolved ? ` · требуют проверки ${unresolved}` : ""}.`, unresolved > 0);
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
    modal.querySelector("[data-map-delete]")?.addEventListener("click", async () => {
      if (!state.mappingActiveProfile || !window.confirm(`Удалить mapping «${state.mappingActiveProfile.name}»?`)) return;
      try {
        await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}`, { method: "DELETE" });
        state.mappingActiveProfile = null; state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0; await loadMapping(); setStatus("Mapping удалён.");
      } catch (error) { setStatus(error.message, true); }
      renderMapping();
    });
  }

  function renderKhlSite() {
    if (!isKhlTournamentSelected()) {
      state.section = "teams";
      return renderTeams();
    }
    const context = selectedContext();
    const title = context.tournament?.name || context.tournament?.full_name || `Турнир ${context.tournamentId || "КХЛ"}`;
    const content = `
      <div class="hockey-khl-site-head">
        <div><span>КХЛ · официальный сайт</span><strong>${escapeHtml(title)}</strong></div>
        <small>Игроки, тренеры, судьи и статистика KHL.ru</small>
      </div>
      <div class="hockey-khl-site-frame-wrap">
        <iframe class="hockey-khl-site-frame" src="/khl-site/" title="КХЛ сайт" loading="eager"></iframe>
      </div>`;
    const modal = ensureModal();
    modal.innerHTML = shellMarkup(content);
    bindShell();
  }

  function render() {
    if (!state.open) return;
    if (state.section === "timer_rules") renderTimerRules();
    else if (state.section === "penalties") renderPenalties();
    else if (state.section === "vmix") renderVmixSettings();
    else if (isMappingSection()) renderMapping();
    else if (state.section === "khl_site") renderKhlSite();
    else if (state.section === "teams") renderTeams();
    else renderGeneric(state.section);
  }

  function bindShell() {
    const modal = ensureModal();
    modal.querySelector("[data-directory-close]")?.addEventListener("click", closeModal);
    modal.querySelectorAll("[data-directory-section]").forEach((button) => {
      button.addEventListener("click", async () => {
        state.section = button.dataset.directorySection;
        if (isMappingSection()) state.mappingWorkspaceTab = mappingTabFromSection();
        state.editingTeam = null;
        state.editingPenalty = null;
        state.editingRecord = null;
        state.status = "";
        try {
          if (state.section === "timer_rules") await loadTimerRules();
          else if (state.section === "penalties") await loadPenalties(true);
          else if (state.section === "vmix") await loadVmixSettings();
          else if (isMappingSection()) await loadMapping();
          else if (state.section === "khl_site") { /* iframe loads its own KHL module */ }
          else if (state.section === "teams") await loadTeams();
          else await loadGeneric(state.section);
        } catch (error) {
          setStatus(error.message, true);
        }
        render();
      });
    });
  }

  function bindTeams() {
    const modal = ensureModal();
    modal.querySelector("[data-team-league-filter]")?.addEventListener("change", async (event) => {
      state.teamLeague = event.target.value;
      state.editingTeam = null;
      try {
        await loadTeams();
        setStatus("");
      } catch (error) {
        setStatus(error.message, true);
      }
      renderTeams();
    });
    modal.querySelector("[data-sync-teams]")?.addEventListener("click", async (event) => {
      const button = event.currentTarget;
      const tournamentId = selectedContext().tournamentId;
      const params = new URLSearchParams();
      if (tournamentId) params.set("tournament_external_id", tournamentId);
      button.disabled = true;
      try {
        const result = await request(`/api/hockey/directories/teams/sync?${params}`, { method: "POST" });
        await loadTeams();
        setStatus(`Справочник обновлён: новых — ${result.created}, обновлено — ${result.updated}.`);
      } catch (error) {
        setStatus(error.message, true);
      }
      renderTeams();
    });
    modal.querySelector("[data-new-team]")?.addEventListener("click", () => {
      state.editingTeam = null;
      setStatus("");
      renderTeams();
      modal.querySelector("[data-team-form]")?.scrollIntoView({ behavior: "smooth", block: "start" });
    });
    modal.querySelectorAll("[data-edit-team]").forEach((button) => {
      button.addEventListener("click", () => {
        state.editingTeam = state.teams.items.find((item) => String(item.id) === button.dataset.editTeam) || null;
        setStatus("");
        renderTeams();
      });
    });
    modal.querySelector("[data-cancel-team]")?.addEventListener("click", () => {
      state.editingTeam = null;
      setStatus("");
      renderTeams();
    });
    const colorText = modal.querySelector("[data-team-color-text]");
    const colorPicker = modal.querySelector("[data-team-color-picker]");
    const colorPreview = modal.querySelector("[data-team-color-preview]");
    const syncTeamColorUi = (rawValue, { commit = false } = {}) => {
      const normalized = normalizeTeamColorHex(rawValue);
      if (normalized) {
        if (colorText && (commit || colorText.value !== rawValue)) colorText.value = normalized;
        if (colorPicker) colorPicker.value = normalized;
        if (colorPreview) colorPreview.style.setProperty("--team-color", normalized);
        return normalized;
      }
      if (commit && colorText && String(rawValue || "").trim()) {
        setStatus("Цвет команды должен быть в формате #RRGGBB", true);
      }
      if (!String(rawValue || "").trim()) {
        if (colorText && commit) colorText.value = "";
        if (colorPicker) colorPicker.value = "#FFFFFF";
        if (colorPreview) colorPreview.style.setProperty("--team-color", "#FFFFFF");
      }
      return "";
    };
    modal.querySelector("[data-team-color-open]")?.addEventListener("click", () => colorPicker?.click());
    colorPicker?.addEventListener("input", () => {
      if (colorText) colorText.value = String(colorPicker.value || "").toUpperCase();
      syncTeamColorUi(colorPicker.value);
    });
    colorText?.addEventListener("input", () => {
      const raw = String(colorText.value || "");
      const compact = raw.replace(/\s+/g, "").toUpperCase();
      if (raw !== compact) colorText.value = compact;
      const normalized = normalizeTeamColorHex(compact);
      if (normalized) syncTeamColorUi(normalized);
    });
    colorText?.addEventListener("blur", () => syncTeamColorUi(colorText.value, { commit: true }));
    modal.querySelector("[data-team-color-clear]")?.addEventListener("click", () => {
      if (colorText) colorText.value = "";
      syncTeamColorUi("", { commit: true });
      colorText?.focus();
    });

    modal.querySelector("[data-team-form]")?.addEventListener("submit", async (event) => {
      event.preventDefault();
      const form = event.currentTarget;
      const values = new FormData(form);
      const rawTeamColor = String(values.get("color_hex") || "").trim();
      const normalizedTeamColor = normalizeTeamColorHex(rawTeamColor);
      if (rawTeamColor && !normalizedTeamColor) {
        setStatus("Цвет команды должен быть в формате #RRGGBB", true);
        colorText?.focus();
        return;
      }
      const payload = {
        external_id: values.get("external_id") || state.editingTeam?.external_id || "",
        league_key: values.get("league_key") || "",
        tournament_external_id: values.get("tournament_external_id") || "",
        name_ru: values.get("name_ru") || "",
        name_en: values.get("name_en") || "",
        short_name_ru: values.get("short_name_ru") || "",
        short_name_en: values.get("short_name_en") || "",
        city_ru: values.get("city_ru") || "",
        city_en: values.get("city_en") || "",
        color_hex: normalizedTeamColor,
        logo_url: values.get("logo_url") || "",
        active: values.has("active"),
      };
      const editingId = state.editingTeam?.id;
      try {
        await request(
          editingId ? `/api/hockey/directories/teams/${editingId}` : "/api/hockey/directories/teams",
          { method: editingId ? "PUT" : "POST", body: JSON.stringify(payload) }
        );
        state.editingTeam = null;
        await loadTeams();
        setStatus("Команда сохранена.");
      } catch (error) {
        setStatus(error.message, true);
      }
      renderTeams();
    });
  }

  function bindPenalties() {
    const modal = ensureModal();
    modal.querySelector("[data-new-penalty]")?.addEventListener("click", () => {
      state.editingPenalty = null;
      setStatus("");
      renderPenalties();
    });
    modal.querySelectorAll("[data-edit-penalty]").forEach((button) => {
      button.addEventListener("click", () => {
        state.editingPenalty = state.penalties.items.find((item) => String(item.id) === button.dataset.editPenalty) || null;
        setStatus("");
        renderPenalties();
      });
    });
    modal.querySelector("[data-cancel-penalty]")?.addEventListener("click", () => {
      state.editingPenalty = null;
      setStatus("");
      renderPenalties();
    });
    modal.querySelector("[data-penalty-form]")?.addEventListener("submit", async (event) => {
      event.preventDefault();
      const values = new FormData(event.currentTarget);
      const rawOrder = String(values.get("sort_order") || "").trim();
      const payload = {
        code: values.get("code") || "",
        name_ru: values.get("name_ru") || "",
        name_en: values.get("name_en") || "",
        default_preset: values.get("default_preset") || "2",
        team_penalty: values.has("team_penalty"),
        sort_order: rawOrder ? Number(rawOrder) : null,
        active: values.has("active"),
      };
      const editingId = state.editingPenalty?.id;
      try {
        await request(
          editingId ? `/api/hockey/directories/penalties/${editingId}` : "/api/hockey/directories/penalties",
          { method: editingId ? "PUT" : "POST", body: JSON.stringify(payload) }
        );
        state.editingPenalty = null;
        await loadPenalties(true);
        setStatus("Удаление сохранено и обновлено в основном меню.");
        window.dispatchEvent(new CustomEvent("hockey:directories-updated", { detail: { type: "penalties" } }));
      } catch (error) {
        setStatus(error.message, true);
      }
      renderPenalties();
    });
  }

  async function openModal() {
    if (!state.isAdmin) return;
    state.open = true;
    document.body.classList.add("hockey-admin-settings-open");
    window.dispatchEvent(new CustomEvent("hockey:admin-settings-visibility", { detail: { open: true } }));
    state.status = "";
    const modal = ensureModal();
    modal.classList.remove("hidden");
    modal.innerHTML = shellMarkup(`<div class="hockey-directory-loading">Загрузка справочников…</div>`);
    bindShell();
    try {
      if (state.section === "timer_rules") await loadTimerRules();
      else if (state.section === "penalties") await loadPenalties(true);
      else if (state.section === "vmix") await loadVmixSettings();
      else if (isMappingSection()) await loadMapping();
      else if (state.section === "khl_site") { if (!isKhlTournamentSelected()) state.section = "teams"; }
      else if (state.section === "teams") await loadTeams();
      else await loadGeneric(state.section);
    } catch (error) {
      setStatus(error.message, true);
    }
    render();
  }

  async function init() {
    const gear = document.getElementById("runtimeAdminSettingsBtn");
    gear?.addEventListener("click", openModal);

    loadPenalties(false).catch(() => {
      // The hard-coded v20.2 list remains available when the API is offline.
    });
    try {
      const user = await request("/api/hockey/me");
      state.isAdmin = Boolean(user.is_admin);
      gear?.classList.toggle("hidden", !state.isAdmin);
    } catch (_) {
      gear?.classList.add("hidden");
    }
  }

  window.addEventListener("hockey:game-selected", async (event) => {
    state.language = event.detail?.language === "en" ? "en" : "ru";
    try {
      await loadPenalties(state.isAdmin && state.open && state.section === "penalties");
      if (state.open && state.section === "teams") await loadTeams();
      else if (state.open && state.section === "timer_rules") await loadTimerRules();
      else if (state.open && state.section === "vmix") await loadVmixSettings(true);
      else if (state.open && isMappingSection()) await loadMapping();
      else if (state.open && state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams";
      else if (state.open && genericSections[state.section]) await loadGeneric(state.section);
    } catch (_) {
      return;
    }
    render();
  });

  window.addEventListener("hockey:settings-updated", async (event) => {
    const lang = String(event.detail?.settings?.ui_language || event.detail?.settings?.settings?.ui_language || "").toLowerCase();
    if (lang === "ru" || lang === "en") state.language = lang;
    if (!state.open || !isMappingSection()) return;
    try { await loadMappingCatalog(); } catch (_) {}
    render();
  });

  window.addEventListener("hockey:mapping-context-updated", () => {
    if (!state.open || !isMappingSection()) return;
    if (state.mappingContextRefreshTimer) clearTimeout(state.mappingContextRefreshTimer);
    state.mappingContextRefreshTimer = setTimeout(async () => {
      state.mappingContextRefreshTimer = null;
      try { await loadMappingCatalog(); } catch (_) {}
      renderMapping();
    }, 80);
  });

  window.addEventListener("hockey:tournament-selected", () => {
    if (!state.open) return;
    if (state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams";
    render();
  });

  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape" && state.open) closeModal();
  });
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init, { once: true });
  } else {
    init();
  }
})();
