// web-test browser v1.7 — Playwright browser management for 1C web client
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
 * Playwright browser management for 1C web client.
 *
 * Maintains a single browser instance across MCP tool calls.
 * Handles connection, navigation, waiting, screenshots.
 */
import { chromium } from 'playwright';
import { spawn, execFileSync } from 'child_process';
import { statSync, mkdirSync, existsSync as fsExistsSync, writeFileSync, readFileSync, rmSync, readdirSync } from 'fs';
import { dirname, resolve as pathResolve, join as pathJoin, basename, extname } from 'path';
import { tmpdir } from 'os';
import { fileURLToPath, pathToFileURL } from 'url';
import {
  readSectionsScript, readTabsScript, readCommandsScript,
  readFormScript, navigateSectionScript, openCommandScript,
  findClickTargetScript, findFieldButtonScript, readSubmenuScript,
  resolveFieldsScript, getFormStateScript,
  detectFormScript, readTableScript, checkErrorsScript,
  switchTabScript, resolveGridScript
} from './dom.mjs';

// Project root: 4 levels up from .claude/skills/web-test/scripts/browser.mjs
const __fn_browser = fileURLToPath(import.meta.url);
const projectRoot = pathResolve(dirname(__fn_browser), '..', '..', '..', '..');

/** Resolve a user-provided path relative to the project root (not cwd). */
const resolveProjectPath = (p) => pathResolve(projectRoot, p);

let browser = null;
let page = null;
let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
let seanceId = null;
let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
let lastCaptions = []; // captions from the last completed recording (for addNarration)
let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
let highlightMode = false;

const LOAD_TIMEOUT = 60000;
const INIT_TIMEOUT = 60000;
const ACTION_WAIT = 2000;   // fallback minimum wait

/** Normalize ё→е and \u00a0→space for fuzzy matching. */
const normYo = s => s.replace(/ё/gi, 'е').replace(/\u00a0/g, ' ');
const MAX_WAIT = 10000;     // max wait for stability
const POLL_INTERVAL = 200;  // polling interval
const STABLE_CYCLES = 3;    // consecutive stable cycles needed

// 1C browser extension ID (stable across versions, defined by key in manifest.json)
const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect

/**
 * Find the 1C browser extension in Chrome/Edge user profiles.
 * Returns the path to the latest version, or null if not found.
 * Can be overridden via extensionPath in .v8-project.json.
 */
function findExtension(overridePath) {
  if (overridePath) {
    try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
    return null;
  }
  const localAppData = process.env.LOCALAPPDATA;
  if (!localAppData) return null;
  const browsers = [
    pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
    pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
  ];
  for (const userData of browsers) {
    try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
    let profiles;
    try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
    for (const profile of profiles) {
      const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
      try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
      let versions;
      try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
      if (versions.length > 0) {
        const best = pathJoin(extDir, versions[versions.length - 1]);
        try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
      }
    }
  }
  return null;
}

/** Check if browser is connected and page is usable. */
export function isConnected() {
  if (!browser || !page || page.isClosed()) return false;
  // launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
  if (typeof browser.isConnected === 'function') return browser.isConnected();
  // For persistent context, check via context's browser()
  return browser.browser()?.isConnected() ?? false;
}

/**
 * Open browser and navigate to 1C web client URL.
 * Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
 */
export async function connect(url, { extensionPath } = {}) {
  if (isConnected()) {
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
  } else {
    const extPath = findExtension(extensionPath);
    if (extPath) {
      // Launch with 1C browser extension via persistent context
      persistentUserDataDir = pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now());
      mkdirSync(persistentUserDataDir, { recursive: true });
      const context = await chromium.launchPersistentContext(persistentUserDataDir, {
        headless: false,
        args: [
          '--start-maximized',
          '--disable-extensions-except=' + extPath,
          '--load-extension=' + extPath,
        ],
        viewport: null,
        permissions: ['clipboard-read', 'clipboard-write'],
      });
      browser = context; // persistent context IS the browser
      page = context.pages()[0] || await context.newPage();
    } else {
      // Fallback: launch without extension
      browser = await chromium.launch({ headless: false, args: ['--start-maximized'] });
      const context = await browser.newContext({
        viewport: null,
        permissions: ['clipboard-read', 'clipboard-write'],
      });
      page = await context.newPage();
    }

    // Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
    page.on('dialog', dialog => dialog.accept().catch(() => {}));

    // Capture seanceId from network requests for graceful logout
    sessionPrefix = null;
    seanceId = null;
    page.on('request', req => {
      if (seanceId) return;
      const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
      if (m) { sessionPrefix = m[1]; seanceId = m[2]; }
    });

    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
  }

  // Wait for 1C to initialize — detect by section panel appearance
  try {
    await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
  } catch {
    // Fallback: wait fixed time if selector doesn't appear (e.g. login page)
    await page.waitForTimeout(5000);
  }

  // Try to close startup modals (Путеводитель etc.)
  await closeModals();

  return await getPageState();
}

/**
 * Gracefully terminate the 1C session and close the browser.
 * Sends POST /e1cib/logout to release the license before closing.
 */
export async function disconnect() {
  // Auto-stop recording if active (prevents orphaned ffmpeg)
  if (recorder) {
    try { await stopRecording(); } catch {}
  }

  if (browser) {
    // Graceful logout — release the 1C license
    if (page && !page.isClosed() && seanceId && sessionPrefix) {
      try {
        const logoutUrl = `${sessionPrefix}/e1cib/logout?seanceId=${seanceId}`;
        await page.evaluate(async (url) => {
          await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: '{"root":{}}'
          });
        }, logoutUrl);
        await page.waitForTimeout(1000);
      } catch {}
    }
    await browser.close().catch(() => {});
    browser = null;
    page = null;
    sessionPrefix = null;
    seanceId = null;
    // Clean up persistent user data dir
    if (persistentUserDataDir) {
      try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
      persistentUserDataDir = null;
    }
  }
}

/**
 * Attach to a running browser server via CDP WebSocket.
 * Sets module state so all functions (getFormState, clickElement, etc.) work.
 */
export async function attach(wsEndpoint, session = {}) {
  if (isConnected()) return;
  browser = await chromium.connect(wsEndpoint);
  const ctx = browser.contexts()[0];
  page = ctx?.pages()[0];
  if (!page) throw new Error('No page found in browser');
  sessionPrefix = session.sessionPrefix || null;
  seanceId = session.seanceId || null;
}

/**
 * Detach from browser without closing it.
 * Returns session state for persistence.
 */
export function detach() {
  const session = { sessionPrefix, seanceId };
  browser = null;
  page = null;
  sessionPrefix = null;
  seanceId = null;
  return session;
}

/** Get current session state (for saving between reconnections). */
export function getSession() {
  return { sessionPrefix, seanceId };
}

/**
 * Close startup modals and guide tabs.
 * Strategy: Escape → click default buttons → close extra tabs → repeat.
 */
async function closeModals() {
  for (let attempt = 0; attempt < 5; attempt++) {
    // 1. Press Escape to dismiss any popup/modal
    await page.keyboard.press('Escape');
    await page.waitForTimeout(1000);

    // 2. Try clicking default "Закрыть"/"OK" buttons
    const clicked = await page.evaluate(`(() => {
      const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
      for (const btn of btns) {
        const text = (btn.innerText?.trim() || '').toLowerCase();
        if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) {
          btn.click();
          return text;
        }
      }
      return null;
    })()`);
    if (clicked) { await page.waitForTimeout(1000); continue; }

    // 3. Close extra tabs (Путеводитель etc.) via openedClose button
    const tabClosed = await page.evaluate(`(() => {
      const btn = document.querySelector('.openedClose');
      if (btn && btn.offsetWidth > 0) { btn.click(); return true; }
      return false;
    })()`);
    if (tabClosed) { await page.waitForTimeout(1000); continue; }

    // Nothing to close — done
    break;
  }
}

/**
 * Smart wait: poll until DOM is stable and no loading indicators are visible.
 * Checks: form number change, loading indicators, DOM stability.
 * @param {number|null} previousFormNum — form number before the action (null = don't check)
 */
async function waitForStable(previousFormNum = null) {
  let stableCount = 0;
  let lastSnapshot = '';
  const start = Date.now();

  while (Date.now() - start < MAX_WAIT) {
    await page.waitForTimeout(POLL_INTERVAL);

    // Check for loading indicators
    const status = await page.evaluate(`(() => {
      const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
      const isLoading = loading && loading.offsetWidth > 0;
      const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
      return { isLoading, formCount };
    })()`);

    if (status.isLoading) {
      stableCount = 0;
      continue;
    }

    // Check DOM stability by comparing element count snapshot
    const snapshot = String(status.formCount);
    if (snapshot === lastSnapshot) {
      stableCount++;
    } else {
      stableCount = 0;
      lastSnapshot = snapshot;
    }

    // If form was expected to change, ensure it did
    if (previousFormNum !== null && stableCount === 1) {
      const currentForm = await page.evaluate(detectFormScript());
      if (currentForm !== previousFormNum) {
        // Form changed — still wait for stability
      }
    }

    if (stableCount >= STABLE_CYCLES) return;
  }
  // Fallback: max wait reached
}

/**
 * Start monitoring network activity via CDP.
 * Must be called BEFORE the click so it captures all server requests.
 * Returns a monitor object with waitDone() and cleanup() methods.
 */
async function startNetworkMonitor() {
  const client = await page.context().newCDPSession(page);
  await client.send('Network.enable');

  let pending = 0;
  let total = 0;
  let lastZeroTime = null;
  const DEBOUNCE = 300;

  client.on('Network.requestWillBeSent', () => {
    pending++;
    total++;
    lastZeroTime = null;
  });
  client.on('Network.loadingFinished', () => {
    if (--pending === 0) lastZeroTime = Date.now();
  });
  client.on('Network.loadingFailed', () => {
    if (--pending === 0) lastZeroTime = Date.now();
  });

  return {
    /** Wait until all network requests complete (300ms debounce) or UI element appears. */
    async waitDone(timeout = 10000) {
      const start = Date.now();
      while (Date.now() - start < timeout) {
        await page.waitForTimeout(50);

        // Check for UI elements (modal, balloon, confirm)
        const ui = await page.evaluate(`(() => {
          const modal = document.querySelector('#modalSurface:not([style*="display: none"])');
          const balloon = document.querySelector('.balloon');
          const confirm = document.querySelector('.confirm');
          return !!(modal || balloon || confirm);
        })()`);
        if (ui) return;

        // CDP debounce: pending===0 held for DEBOUNCE ms
        if (total > 0 && pending === 0 && lastZeroTime !== null) {
          if (Date.now() - lastZeroTime >= DEBOUNCE) return;
        }
      }
    },
    /** Detach CDP session. Always call this when done. */
    async cleanup() {
      await client.send('Network.disable').catch(() => {});
      await client.detach().catch(() => {});
    }
  };
}

/**
 * Poll until a JS expression returns truthy, or timeout (ms) expires.
 * Resolves early — typically within 100-300ms instead of fixed delays.
 */
async function waitForCondition(evalScript, timeout = 2000) {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    const result = await page.evaluate(evalScript);
    if (result) return result;
    await page.waitForTimeout(100);
  }
  return null;
}

/**
 * Check for validation errors / diagnostics after an action.
 * Detects: inline balloon tooltip, messages panel, modal error dialog.
 * Returns { balloon, messages[], modal } or null.
 */
async function checkForErrors() {
  return await page.evaluate(checkErrorsScript());
}

/**
 * Dismiss pending error modal if present (single OK button dialog).
 * Called at the start of action functions so that a leftover error modal
 * from a previous operation doesn't block the next action.
 * Does NOT dismiss confirmations (Да/Нет — require user decision).
 * Returns the dismissed error object or null.
 */
async function dismissPendingErrors() {
  // Close leftover platform dialogs first (About, Support Info, Error Report)
  // These block all interaction via modalSurface and are invisible to 1C form detection
  try {
    const pd = await _detectPlatformDialogs();
    if (pd.length) await _closePlatformDialogs();
  } catch { /* OK */ }
  const err = await checkForErrors();
  if (!err?.modal) return null;
  try {
    // Target pressDefault within the modal's form container specifically
    const formNum = err.modal.formNum;
    const sel = formNum != null
      ? `#form${formNum}_container a.press.pressDefault`
      : 'a.press.pressDefault';
    const btn = await page.$(sel);
    if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); }
  } catch { /* OK */ }
  await waitForStable();
  return err;
}

/**
 * Detect open platform-level dialogs (About, Support Info, Error Report).
 * Returns array of { type, title? } for each detected dialog, or empty array.
 */
async function _detectPlatformDialogs() {
  return await page.evaluate(() => {
    const result = [];
    // "О программе" dialog
    const about = document.getElementById('aboutContainer');
    if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' });
    // "Информация для технической поддержки" (inside a ps*win with errJournalInput)
    const errJ = document.getElementById('errJournalInput');
    if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' });
    // "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer
    if (!result.length) {
      document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => {
        if (w.offsetWidth === 0 || w.offsetHeight === 0) return;
        // Skip the main app window (ps*win that contains the 1C forms)
        if (w.querySelector('[id^="form"][id$="_container"]')) return;
        // Check title text
        const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]');
        const title = titleEl?.textContent?.trim() || '';
        if (title) result.push({ type: 'platformWindow', title });
      });
    }
    return result;
  });
}

/**
 * Close any platform-level dialogs that may be left open (about, support info, error report).
 * These are NOT 1C forms — they are platform UI overlays invisible to getFormState().
 * Each close is wrapped in try/catch to avoid cascading failures.
 */
async function _closePlatformDialogs() {
  await page.evaluate(() => {
    // "Подробный текст ошибки" OK button (inside error report detail view)
    // It's a cloud window with its own OK button — look for visible pressDefault in small ps*win
    const psWins = document.querySelectorAll('[id^="ps"][id$="win"]');
    for (const w of psWins) {
      if (w.offsetWidth === 0) continue;
      // Check if this is a small dialog (error detail, about, support info)
      const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
      if (closeBtn && closeBtn.offsetWidth > 0) {
        try { closeBtn.click(); } catch {}
      }
    }
    // "Информация для технической поддержки" — extOkBtn
    const extOk = document.getElementById('extOkBtn');
    if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {}
    // "О программе" — aboutOkButton
    const aboutOk = document.getElementById('aboutOkButton');
    if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {}
  });
  await page.waitForTimeout(300);
}

/**
 * Parse raw error stack text into structured entries.
 * Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea.
 * Returns { raw, timestamp?, entries: [{location, code}] }
 */
function _parseErrorStack(raw) {
  if (!raw) return null;
  const result = { raw, entries: [] };
  // Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS)
  const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m);
  if (tsMatch) result.timestamp = tsMatch[1];
  // Extract {Module.Path(lineNum)}: code entries
  const entryRe = /\{([^}]+)\}:\s*(.+)/g;
  let m;
  while ((m = entryRe.exec(raw)) !== null) {
    result.entries.push({ location: m[1].trim(), code: m[2].trim() });
  }
  return result.entries.length > 0 ? result : null;
}

/**
 * Fetch error call stack from the 1C platform UI.
 * Uses two strategies:
 *   Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea
 *   Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput
 *
 * Always closes the error modal and any platform dialogs it opened.
 * Returns parsed stack object or null on failure.
 *
 * @param {number} formNum - form number of the error modal (e.g. 6 for form6_)
 * @param {boolean} hasReport - true if OpenReport link is available
 */
export async function fetchErrorStack(formNum, hasReport) {
  try {
    // Platform exception modals are initially unstable — they redraw within ~1s.
    // The initial state may lack the OpenReport link. Re-check after a short delay.
    if (!hasReport) {
      await page.waitForTimeout(1500);
      hasReport = await page.evaluate((fn) => {
        const el = document.getElementById('form' + fn + '_OpenReport#text');
        return !!(el && el.offsetWidth > 2 && el.textContent.trim());
      }, formNum);
    }
    if (hasReport) return await _fetchStackViaReport(formNum);
    return await _fetchStackViaHamburger(formNum);
  } catch {
    return null;
  } finally {
    // Ensure all platform dialogs are closed
    try { await _closePlatformDialogs(); } catch {}
    // Ensure the error modal itself is closed
    try {
      const sel = formNum != null
        ? `#form${formNum}_container a.press.pressDefault`
        : 'a.press.pressDefault';
      const btn = await page.$(sel);
      if (btn) await btn.click({ force: true });
      await page.waitForTimeout(300);
    } catch {}
  }
}

/**
 * Path 1: Fetch stack via OpenReport link (for platform exceptions).
 * The error modal must still be open with a visible "Сформировать отчет об ошибке" link.
 */
async function _fetchStackViaReport(formNum) {
  // 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks)
  const coords = await page.evaluate((fn) => {
    const el = document.getElementById('form' + fn + '_OpenReport#text');
    if (!el || el.offsetWidth <= 2) return null;
    const rect = el.getBoundingClientRect();
    return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
  }, formNum);
  if (!coords) return null;

  await page.mouse.click(coords.x, coords.y);

  // 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link
  let found = false;
  for (let i = 0; i < 20; i++) {
    await page.waitForTimeout(500);
    found = await page.evaluate(() => {
      const links = document.querySelectorAll('a, [class*="hyper"], span');
      for (const el of links) {
        if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
      }
      return false;
    });
    if (found) break;
  }
  if (!found) return null;

  // 3. Click "подробный текст ошибки"
  await page.getByText('подробный текст ошибки').click();
  await page.waitForTimeout(2000);

  // 4. Read the textarea with detailed error text (find the largest visible textarea)
  const raw = await page.evaluate(() => {
    let best = null;
    document.querySelectorAll('textarea').forEach(ta => {
      if (ta.offsetWidth > 0 && ta.value.length > 0) {
        if (!best || ta.value.length > best.value.length) best = ta;
      }
    });
    return best?.value || null;
  });

  // 5. Close "Подробный текст ошибки" dialog (click its OK button)
  try {
    const okBtn = await page.evaluate(() => {
      // Find the OK button in the topmost small cloud window
      const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
        .filter(w => w.offsetWidth > 0)
        .sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
      for (const w of psWins) {
        const ok = w.querySelector('button.webBtn, .pressDefault');
        if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
      }
      return false;
    });
    await page.waitForTimeout(300);
  } catch {}

  // 6. Close "Отчет об ошибке" dialog (click its × close button)
  try {
    await page.evaluate(() => {
      const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
        .filter(w => w.offsetWidth > 0);
      for (const w of psWins) {
        const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
        if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
      }
    });
    await page.waitForTimeout(300);
  } catch {}

  return _parseErrorStack(raw);
}

/**
 * Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки".
 * Works for all error types including simple ВызватьИсключение.
 * The error modal is closed first to allow access to the hamburger menu.
 */
async function _fetchStackViaHamburger(formNum) {
  // 1. Close the error modal first
  try {
    const sel = formNum != null
      ? `#form${formNum}_container a.press.pressDefault`
      : 'a.press.pressDefault';
    const btn = await page.$(sel);
    if (btn) await btn.click({ force: true });
    await page.waitForTimeout(500);
  } catch {}

  // 2. Click hamburger menu
  await page.click('#captionbarMore', { timeout: 5000 });
  await page.waitForTimeout(1000);

  // 3. Click "О программе..."
  await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 });
  await page.waitForTimeout(2000);

  // 4. Click "Информация для технической поддержки"
  await page.click('#aboutHyperLink', { timeout: 5000 });

  // 5. Wait for errJournalInput to appear and be filled
  let raw = null;
  for (let i = 0; i < 20; i++) {
    await page.waitForTimeout(500);
    raw = await page.evaluate(() => {
      const el = document.getElementById('errJournalInput');
      return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null;
    });
    if (raw) break;
  }
  if (!raw) return null;

  // 6. Parse first error block (most recent — before first separator)
  const separator = / - - - - /;
  const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw;
  // Find the "Ошибки:" section
  const errIdx = raw.indexOf('Ошибки:');
  let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw;
  // Take first block (before first separator line)
  const lines = errorText.split('\n');
  const firstBlockLines = [];
  let inBlock = false;
  for (const line of lines) {
    if (separator.test(line)) {
      if (inBlock) break; // end of first block
      inBlock = true;
      continue;
    }
    if (inBlock) firstBlockLines.push(line);
  }
  const firstBlock = firstBlockLines.join('\n').trim();

  // 7. Close support info and about dialogs (done in finally via _closePlatformDialogs)
  return _parseErrorStack(firstBlock || errorText);
}

/** Get the raw Playwright page object (for advanced scripting in skill mode). */
export function getPage() {
  ensureConnected();
  return page;
}

/**
 * Get current page state: active section, tabs.
 * Combined into a single evaluate call.
 */
export async function getPageState() {
  ensureConnected();
  const { sections, tabs } = await page.evaluate(`({
    sections: ${readSectionsScript()},
    tabs: ${readTabsScript()}
  })`);
  const activeSection = sections.find(s => s.active)?.name || null;
  const activeTab = tabs.find(t => t.active)?.name || null;
  return { activeSection, activeTab, sections, tabs };
}

/** Read section panel + commands in a single evaluate call. */
export async function getSections() {
  ensureConnected();
  const { sections, commands } = await page.evaluate(`({
    sections: ${readSectionsScript()},
    commands: ${readCommandsScript()}
  })`);
  const activeSection = sections.find(s => s.active)?.name || null;
  return { activeSection, sections, commands };
}

/** Navigate to a section by name. Returns new state with commands. */
export async function navigateSection(name) {
  ensureConnected();
  await dismissPendingErrors();
  if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
  const result = await page.evaluate(navigateSectionScript(name));
  if (result?.error) {
    const avail = result.available?.filter(Boolean);
    if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`);
    throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`);
  }

  await waitForStable();
  const { sections, commands } = await page.evaluate(`({
    sections: ${readSectionsScript()},
    commands: ${readCommandsScript()}
  })`);
  return { navigated: result, sections, commands };
}

/** Read commands of the current section. */
export async function getCommands() {
  ensureConnected();
  return await page.evaluate(readCommandsScript());
}

/** Open a command from function panel by name. Returns new form state. */
export async function openCommand(name) {
  ensureConnected();
  await dismissPendingErrors();
  if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
  const formBefore = await page.evaluate(detectFormScript());
  const result = await page.evaluate(openCommandScript(name));
  if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);

  await waitForStable(formBefore);
  const state = await getFormState();
  const err = await checkForErrors();
  if (err) state.errors = err;
  return state;
}

/** Switch to an open tab by name (fuzzy match). Returns updated form state. */
export async function switchTab(name) {
  ensureConnected();
  const result = await page.evaluate(switchTabScript(name));
  if (result?.error) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
  await waitForStable();
  return await getFormState();
}

// English → Russian metadata type mapping for e1cib navigation links
const E1CIB_TYPE_MAP = {
  'catalog': 'Справочник', 'catalogs': 'Справочник',
  'document': 'Документ', 'documents': 'Документ',
  'commonmodule': 'ОбщийМодуль',
  'enum': 'Перечисление', 'enums': 'Перечисление',
  'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка',
  'report': 'Отчет', 'reports': 'Отчет',
  'accumulationregister': 'РегистрНакопления',
  'informationregister': 'РегистрСведений',
  'accountingregister': 'РегистрБухгалтерии',
  'calculationregister': 'РегистрРасчета',
  'chartofaccounts': 'ПланСчетов',
  'chartofcharacteristictypes': 'ПланВидовХарактеристик',
  'chartofcalculationtypes': 'ПланВидовРасчета',
  'businessprocess': 'БизнесПроцесс',
  'task': 'Задача',
  'exchangeplan': 'ПланОбмена',
  'constant': 'Константа',
};

// Types that open via e1cib/app/ (reports and data processors have their own app forms)
const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']);

function normalizeE1cibUrl(url) {
  // Already a full e1cib link
  if (url.startsWith('e1cib/')) return url;
  // "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix
  const dot = url.indexOf('.');
  if (dot > 0) {
    const typePart = url.substring(0, dot);
    const namePart = url.substring(dot + 1);
    const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart;
    const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list';
    return `${prefix}/${ruType}.${namePart}`;
  }
  return `e1cib/list/${url}`;
}

/**
 * Open an external data processor or report (EPF/ERF) via File → Open menu.
 * Handles the security confirmation dialog on first open.
 * @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd)
 * @returns {Promise<object>} form state of the opened processor/report
 */
export async function openFile(filePath) {
  ensureConnected();
  await dismissPendingErrors();
  const absPath = resolveProjectPath(filePath.replace(/\\/g, '/'));

  const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    const formBefore = await page.evaluate(detectFormScript());

    // 1. Ctrl+O opens 1C's "Выбор файлов" dialog
    await page.keyboard.press('Control+o');

    // 2. Wait for the file selection dialog
    const dialogOk = await waitForCondition(`(() => {
      const ok = document.querySelector('#fileSelectDialogOk');
      return ok && ok.offsetWidth > 0 ? true : false;
    })()`, 3000);
    if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)");

    // 3. Click "выберите с диска" to trigger the native OS file picker
    let fileChooser;
    try {
      [fileChooser] = await Promise.all([
        page.waitForEvent('filechooser', { timeout: 5000 }),
        page.click('a.underline.pointer'),
      ]);
    } catch (e) {
      // Try closing the dialog before throwing
      await page.keyboard.press('Escape');
      throw new Error(`File chooser did not appear: ${e.message}`);
    }

    // 4. Set the file path and click OK
    await fileChooser.setFiles(absPath);
    await page.waitForTimeout(500);
    await page.click('#fileSelectDialogOk');
    await waitForStable(formBefore);

    // 5. Check for security dialog
    const err = await checkForErrors();
    if (err?.confirmation) {
      // Security confirmation — click the positive button (Продолжить/Да/OK)
      const positiveBtn = err.confirmation.buttons.find(b =>
        /продолжить|да|ok|yes|открыть/i.test(b)
      ) || err.confirmation.buttons[0];
      if (positiveBtn) {
        const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`);
        for (const b of btns) {
          const txt = (await b.textContent())?.trim();
          if (txt === positiveBtn) { await b.click(); break; }
        }
        await waitForStable(formBefore);
      }
      // After confirmation, check if EPF form appeared or a follow-up dialog showed.
      // Check form change FIRST — avoids confusing a small EPF form with a modal dialog.
      const formAfter = await page.evaluate(detectFormScript());
      if (formAfter != null && formAfter !== formBefore) {
        // New form appeared — but is it the EPF or an informational dialog?
        // Informational "re-open" dialogs are tiny (< 20 elements).
        const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`);
        if (elCount < 20) {
          // Likely an info dialog — check and dismiss
          const err2 = await checkForErrors();
          if (err2?.modal) {
            await dismissPendingErrors();
            await waitForStable(formBefore);
            continue; // retry open cycle
          }
        }
        // It's the real EPF form
        const state = await getFormState();
        state.opened = { file: absPath, attempt: attempt + 1 };
        return state;
      }
      // Form didn't appear — retry
      continue;
    }

    // No security dialog — check if form appeared
    if (err?.modal) {
      throw new Error(`Error opening file: ${err.modal.message}`);
    }
    const formAfter = await page.evaluate(detectFormScript());
    if (formAfter != null && formAfter !== formBefore) {
      const state = await getFormState();
      state.opened = { file: absPath, attempt: attempt + 1 };
      return state;
    }
  }

  throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`);
}

/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */
export async function navigateLink(url) {
  ensureConnected();
  await dismissPendingErrors();
  const link = normalizeE1cibUrl(url);
  const formBefore = await page.evaluate(detectFormScript());

  // Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
  await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(link)})`);
  await page.keyboard.press('Shift+F11');
  await waitForStable();

  // Click "Перейти" in the navigation dialog
  const dialog = await page.evaluate(detectFormScript());
  if (dialog != null && dialog !== formBefore) {
    const btns = await page.$$(`#form${dialog}_container a.press`);
    for (const b of btns) {
      const txt = (await b.textContent())?.trim();
      if (txt === 'Перейти') { await b.click(); break; }
    }
  }

  await waitForStable(formBefore);
  const state = await getFormState();
  const err = await checkForErrors();
  if (err) state.errors = err;
  return state;
}

/** Read current form state. Single evaluate call via combined script. */
export async function getFormState() {
  ensureConnected();
  const state = await page.evaluate(getFormStateScript());
  const err = await checkForErrors();
  if (err) {
    state.errors = err;
    if (err.confirmation) {
      state.confirmation = err.confirmation;
      state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
    }
  }
  // Detect platform-level dialogs (About, Support Info, Error Report)
  // These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
  const pd = await _detectPlatformDialogs();
  if (pd.length) state.platformDialogs = pd;
  return state;
}

/** Read structured table data with pagination. Returns columns, rows, total count. */
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
  ensureConnected();
  const formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error('readTable: no form found');
  let gridSelector;
  if (table) {
    const resolved = await page.evaluate(resolveGridScript(formNum, table));
    if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
    gridSelector = resolved.gridSelector;
  }
  return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
}

// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) ---

/**
 * Scan spreadsheet iframes for the current form and collect all cells.
 * Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> }
 * where frameIndex is the Playwright frames[] index (1-based, 0 = main).
 */
async function scanSpreadsheetCells(formNum) {
  const iframeIndices = await page.evaluate(`(() => {
    const prefix = 'form${formNum ?? 0}_';
    const allIframes = [...document.querySelectorAll('iframe')];
    const indices = [];
    for (let i = 0; i < allIframes.length; i++) {
      const f = allIframes[i];
      if (f.offsetWidth < 100) continue;
      let el = f.parentElement, found = false;
      for (let d = 0; el && d < 30; d++, el = el.parentElement) {
        if (el.id && el.id.startsWith(prefix)) { found = true; break; }
      }
      if (found) indices.push(i);
    }
    return indices;
  })()`);

  const frames = page.frames();
  const allCells = new Map();
  const frameMap = new Map(); // key 'r_c' → Playwright frame index

  for (const iframeIdx of iframeIndices) {
    const frameIndex = iframeIdx + 1;
    const frame = frames[frameIndex];
    if (!frame) continue;
    try {
      const cells = await frame.evaluate(`(() => {
        const cells = [];
        document.querySelectorAll('div[x]').forEach(d => {
          const span = d.querySelector('span');
          const text = span?.textContent?.trim() || '';
          if (!text) return;
          const rowDiv = d.parentElement;
          const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null;
          const col = d.getAttribute('x');
          if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text });
        });
        return cells;
      })()`);
      for (const cell of cells) {
        const key = `${cell.r}_${cell.c}`;
        if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) {
          allCells.set(key, cell);
          frameMap.set(key, frameIndex);
        }
      }
    } catch { /* skip inaccessible frames */ }
  }
  return { allCells, frameMap };
}

/**
 * Build structured mapping from raw cells: headers, column map, data/totals row indices.
 * Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap }
 * or null if header detection fails.
 */
function buildSpreadsheetMapping(allCells) {
  const rowMap = new Map();
  let maxCol = 0;
  for (const cell of allCells.values()) {
    if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
    rowMap.get(cell.r).set(cell.c, cell.t);
    if (cell.c > maxCol) maxCol = cell.c;
  }

  const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
  const rows = sortedRows.map(r => {
    const cm = rowMap.get(r);
    const arr = [];
    for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
    return arr;
  });

  const hasNumber = (row) => row.some(c => /^[\d\s\u00a0]/.test(c) && /\d/.test(c));
  const nonEmpty = (row) => row.filter(c => c !== '').length;

  // Find first data row (first row with numbers)
  let firstDataIdx = rows.length;
  for (let i = 0; i < rows.length; i++) {
    if (hasNumber(rows[i])) { firstDataIdx = i; break; }
  }

  // Find header rows
  let detailIdx = -1;
  for (let i = firstDataIdx - 1; i >= 0; i--) {
    if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; }
  }
  if (detailIdx === -1) return null; // no headers detected

  let groupIdx = -1;
  if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1;

  const detailRow = rows[detailIdx];
  const groupRow = groupIdx >= 0 ? rows[groupIdx] : null;

  // Detect optional third header level above group row (bounds carry-forward)
  let superRow = null;
  if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) {
    superRow = rows[groupIdx - 1];
  }

  // Build column names (group + detail merge)
  const groupFilled = new Array(maxCol + 1).fill('');
  if (groupRow) {
    let cur = '';
    for (let c = 0; c <= maxCol; c++) {
      if (groupRow[c]) {
        cur = groupRow[c];
      } else if (superRow && superRow[c]) {
        // New top-level header starts here — stop carry-forward
        cur = '';
      }
      groupFilled[c] = cur;
    }
  }

  const detailCounts = {};
  for (let c = 0; c <= maxCol; c++) {
    const n = detailRow[c];
    if (n) detailCounts[n] = (detailCounts[n] || 0) + 1;
  }

  const colNames = [];
  for (let c = 0; c <= maxCol; c++) {
    const detail = detailRow[c];
    const group = groupFilled[c];
    if (detail) {
      const needPrefix = group && group !== detail && (detailCounts[detail] > 1 || (groupRow && groupRow[c] === ''));
      colNames.push(needPrefix ? `${group} / ${detail}` : detail);
    } else if (group) {
      colNames.push(group);
    } else if (superRow && superRow[c]) {
      colNames.push(superRow[c]);
    } else {
      colNames.push(null);
    }
  }

  // Column name → physical column index
  const colMap = new Map();
  for (let c = 0; c < colNames.length; c++) {
    if (colNames[c]) colMap.set(colNames[c], c);
  }

  // Classify data rows: separate data indices and totals index
  const dataRowIndices = []; // indices into rows[] array
  let totalsRowIdx = -1;
  for (let i = firstDataIdx; i < rows.length; i++) {
    if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
    const first = rows[i][0]?.trim().toLowerCase();
    if (first === 'итого' || first === 'всего') {
      totalsRowIdx = i;
    } else {
      dataRowIndices.push(i);
    }
  }

  return {
    rows, sortedRows, maxCol, colNames, colMap,
    headerRowIdx: detailIdx, groupRowIdx: groupIdx,
    dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx,
    rowMap, hasNumber, nonEmpty,
  };
}

/**
 * Scroll SpreadsheetDocument to make a cell visible using arrow keys.
 * Uses native platform scroll — keeps headers, data, and scrollbar synchronized.
 *
 * How it works:
 * 1. Check target cell visibility via Playwright boundingBox (page-level coords).
 * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay.
 *    This is the same native click that clickSpreadsheetCell uses — it gives keyboard
 *    focus to the spreadsheet and keeps headers/data/scrollbar in sync.
 *    (frame.locator().click() bypasses overlay → desyncs frozen headers;
 *     page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.)
 * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport.
 *
 * @param {Frame} frame - Playwright Frame containing the spreadsheet cells
 * @param {number} physRow - physical row (y attribute) in the frame
 * @param {number} physCol - physical column (x attribute) in the frame
 * @param {Locator} cellLoc - Playwright locator for the target cell (from caller)
 */
async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) {
  const pageVw = await page.evaluate('window.innerWidth');
  // Get iframe bounds — the actual visible region on page.
  // The iframe may extend behind the section panel on the left, so cells with
  // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel.
  const frameElm = await frame.frameElement();
  const frameBox = await frameElm.boundingBox();
  const visLeft = frameBox ? frameBox.x : 0;
  const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw;

  const getBox = async () => {
    try { return await cellLoc.boundingBox({ timeout: 500 }); }
    catch { return null; }
  };
  const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight;

  let box = await getBox();
  if (!box) return; // cell not in DOM
  if (isFullyVisible(box)) return;

  const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft';

  // Find a fully-visible cell to click for focus.
  // Prefer cells in the target row (scrollable area), fall back to any row.
  const targetRowSel = `div[y="${physRow}"] div[x]`;
  const anyRowSel = 'div[x]';
  let focusClicked = false;
  for (const sel of [targetRowSel, anyRowSel]) {
    const locs = frame.locator(sel);
    const count = await locs.count();
    const candidates = [];
    for (let ci = 0; ci < count; ci++) {
      const b = await locs.nth(ci).boundingBox();
      if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) {
        candidates.push({ ci, box: b });
      }
    }
    if (candidates.length === 0) continue;
    candidates.sort((a, b) => a.box.x - b.box.x);
    // ArrowRight → rightmost fully-visible (each press scrolls right immediately)
    // ArrowLeft  → leftmost fully-visible  (each press scrolls left immediately)
    const pick = direction === 'ArrowRight'
      ? candidates[candidates.length - 1]
      : candidates[0];
    // Native click through overlay — gives keyboard focus + no header desync.
    await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2);
    await page.waitForTimeout(100);
    focusClicked = true;
    break;
  }
  if (!focusClicked) return; // no visible cells — can't scroll

  // Arrow keys until cell is fully visible or we detect no progress.
  const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?)
  let prevCx = box.x + box.width / 2;
  let staleCount = 0;
  for (let i = 0; i < 100; i++) {
    await page.keyboard.press(direction);
    await page.waitForTimeout(50);
    box = await getBox();
    if (!box) break;
    if (isFullyVisible(box)) break;
    const cx = box.x + box.width / 2;
    if (Math.abs(cx - prevCx) >= 1) {
      staleCount = 0;
    } else {
      staleCount++;
      if (staleCount >= MAX_STALE) break;
    }
    prevCx = cx;
  }
  await page.waitForTimeout(200);
}

/**
 * Click a cell in SpreadsheetDocument by logical coordinates.
 * target: { row: number|'totals'|{colName: value}, column: string }
 * Internal helper — called from clickElement when first arg is an object.
 */
async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) {
  ensureConnected();
  const formNum = await page.evaluate(detectFormScript());
  const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
  if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.');

  const mapping = buildSpreadsheetMapping(allCells);
  if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.');

  const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping;

  // Resolve column
  const colName = target.column;
  if (!colMap.has(colName)) {
    const available = colNames.filter(n => n);
    throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`);
  }
  const physCol = colMap.get(colName);

  // Resolve row → index into rows[] array
  let rowIdx;
  const row = target.row;
  if (row === 'totals') {
    if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.');
    rowIdx = totalsRowIdx;
  } else if (typeof row === 'number') {
    if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`);
    rowIdx = dataRowIndices[row];
  } else if (typeof row === 'object') {
    // Filter: { colName: value } — find first data row where column matches
    const filterEntries = Object.entries(row);
    const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
    rowIdx = dataRowIndices.find(i => {
      return filterEntries.every(([fCol, fVal]) => {
        const fColIdx = colMap.get(fCol);
        if (fColIdx == null) return false;
        const cellText = norm(rows[i][fColIdx]);
        const search = norm(fVal);
        return cellText === search || cellText.includes(search);
      });
    });
    if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`);
  } else {
    throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.');
  }

  // Map rows[] index → physical row number
  const physRow = sortedRows[rowIdx];
  const cellKey = `${physRow}_${physCol}`;
  const frameIndex = frameMap.get(cellKey);
  if (!frameIndex) {
    // Cell exists in mapping but might be empty — try clicking anyway
    throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`);
  }

  // Get bounding box and click via page.mouse (bypasses mxlCurrBody overlay)
  const frame = page.frames()[frameIndex];
  // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs.
  const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first();
  // Scroll cell into view using arrow keys — the only reliable way to scroll
  // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar.
  await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv);
  const box = await cellDiv.boundingBox();
  if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`);

  const x = box.x + box.width / 2;
  const y = box.y + box.height / 2;
  const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
  if (modKey) await page.keyboard.down(modKey);
  if (dbl) {
    await page.mouse.dblclick(x, y);
  } else {
    await page.mouse.click(x, y);
  }
  if (modKey) await page.keyboard.up(modKey);

  await waitForStable();
  const state = await getFormState();
  state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) };
  return state;
}

/**
 * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement).
 * Returns { frameIndex, physRow, physCol, box } or null if not found.
 */
async function findSpreadsheetCellByText(formNum, searchText) {
  const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
  if (allCells.size === 0) return null;

  const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
  const target = norm(searchText);

  // Exact match first, then includes
  let found = null;
  for (const [key, cell] of allCells) {
    if (norm(cell.t) === target) { found = { key, cell }; break; }
  }
  if (!found) {
    for (const [key, cell] of allCells) {
      if (norm(cell.t).includes(target)) { found = { key, cell }; break; }
    }
  }
  if (!found) return null;

  const frameIndex = frameMap.get(found.key);
  if (!frameIndex) return null;

  const frame = page.frames()[frameIndex];
  // Scroll cell into view using native arrow-key mechanism
  const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first();
  await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv);
  const box = await cellDiv.boundingBox();
  if (!box) return null;

  return { frameIndex, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box };
}

/**
 * Read report output (SpreadsheetDocumentField) rendered in iframes.
 * 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes.
 * Each cell is a div[x] inside a row div[y], text content in <span>.
 *
 * Returns structured data:
 *   { title, headers, data: [{col: val}], totals: {col: val}, total }
 * If header detection fails, falls back to { rows: string[][], total }.
 */
export async function readSpreadsheet() {
  ensureConnected();
  const formNum = await page.evaluate(detectFormScript());

  const { allCells } = await scanSpreadsheetCells(formNum);

  if (allCells.size === 0) throw new Error('readSpreadsheet: no SpreadsheetDocument found. Report may not be generated yet.');

  const mapping = buildSpreadsheetMapping(allCells);
  if (!mapping) {
    // Fallback: return raw rows
    const rowMap = new Map();
    let maxCol = 0;
    for (const cell of allCells.values()) {
      if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
      rowMap.get(cell.r).set(cell.c, cell.t);
      if (cell.c > maxCol) maxCol = cell.c;
    }
    const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
    const rows = sortedRows.map(r => {
      const cm = rowMap.get(r);
      const arr = [];
      for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
      return arr;
    });
    return { rows, total: rows.length };
  }

  const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, hasNumber, nonEmpty } = mapping;

  // Convert data rows to objects
  const data = [];
  let totals = null;
  const toObj = (row) => {
    const obj = {};
    for (let c = 0; c < colNames.length; c++) {
      if (colNames[c] && row[c]) obj[colNames[c]] = row[c];
    }
    return obj;
  };

  for (let i = dataStartIdx; i < rows.length; i++) {
    if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
    const first = rows[i][0]?.trim().toLowerCase();
    if (first === 'итого' || first === 'всего') {
      totals = toObj(rows[i]);
    } else {
      data.push(toObj(rows[i]));
    }
  }

  // Meta: title, params, filters from rows before header
  const metaEnd = groupRowIdx >= 0 ? groupRowIdx : headerRowIdx;
  let title = '';
  const meta = [];
  for (let i = 0; i < metaEnd; i++) {
    const parts = rows[i].filter(c => c);
    if (!parts.length) continue;
    if (!title) { title = parts.join(' '); continue; }
    meta.push(parts.join(' '));
  }

  return {
    title: title || undefined,
    meta: meta.length ? meta : undefined,
    headers: colNames.filter(n => n),
    data,
    totals: totals || undefined,
    total: data.length,
  };
}

/**
 * Scan visible grid rows for a text match (exact → startsWith → includes).
 * Returns center coords of the matched row, or null if not found.
 * When searchLower is empty, returns coords of the first row (fallback).
 */
async function scanGridRows(formNum, searchLower) {
  return page.evaluate(`(() => {
    const p = 'form${formNum}_';
    const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
    if (!grid) return null;
    const body = grid.querySelector('.gridBody');
    if (!body) return null;
    const lines = [...body.querySelectorAll('.gridLine')];
    if (!lines.length) return { rowCount: 0 };
    const searchLower = ${JSON.stringify(searchLower || '')};
    let sel = null;
    if (searchLower) {
      const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
      const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) }));
      sel = rowData.find(r => r.text === searchLower)?.el
        || rowData.find(r => r.text.startsWith(searchLower))?.el
        || rowData.find(r => r.text.includes(searchLower))?.el;
    } else {
      sel = lines[0]; // empty search → first row
    }
    if (!sel) return null;
    const r = sel.getBoundingClientRect();
    return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
  })()`);
}

/**
 * Select a row in a selection form via click + Enter, verify it closed.
 * Uses click + Enter instead of dblclick because dblclick toggles
 * expand/collapse in tree-style selection forms.
 * Returns { field, ok: true, method: 'form' } on success,
 * or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row).
 */
async function dblclickAndVerify(coords, selFormNum, fieldName) {
  // Click to highlight the row, then Enter to confirm selection.
  // This works for both flat grids and tree forms (dblclick would
  // toggle expand/collapse on tree group rows).
  await page.mouse.click(coords.x, coords.y);
  await page.waitForTimeout(200);
  await page.keyboard.press('Enter');
  await waitForStable(selFormNum);

  // Verify selection form closed
  const stillOpen = await page.evaluate(`(() => {
    const p = 'form${selFormNum}_';
    return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
  })()`);
  if (stillOpen) {
    // Enter didn't select — item is likely a non-selectable group.
    // Don't Escape here — let the caller decide (may want to try another row).
    return { field: fieldName, ok: false, reason: 'still_open' };
  }

  // Check for 1C error modals after selection
  const err = await page.evaluate(checkErrorsScript());
  if (err?.modal) {
    try {
      const btn = await page.$('a.press.pressDefault');
      if (btn) { await btn.click(); await page.waitForTimeout(500); }
    } catch { /* OK */ }
  }
  return { field: fieldName, ok: true, method: 'form' };
}

/**
 * Inline advanced search on a selection form via Alt+F.
 * Does NOT click any column — FieldSelector auto-populates with main representation.
 * Switches to "по части строки" (CompareType#1) to avoid composite type issues.
 * Does not throw — returns silently on failure.
 */
async function advancedSearchInline(formNum, text) {
  try {
    // 1. Open advanced search via Alt+F
    await page.keyboard.press('Alt+f');
    await page.waitForTimeout(2000);

    const dialogForm = await page.evaluate(detectFormScript());
    if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog

    // 2. Switch to "по части строки" (CompareType#1)
    const radioClicked = await page.evaluate(`(() => {
      const p = 'form${dialogForm}_';
      const el = document.getElementById(p + 'CompareType#1#radio');
      if (!el || el.offsetWidth === 0) return false;
      if (el.classList.contains('select')) return true; // already selected
      const r = el.getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
    })()`);
    if (radioClicked && typeof radioClicked === 'object') {
      await page.mouse.click(radioClicked.x, radioClicked.y);
      await page.waitForTimeout(300);
    }

    // 3. Fill Pattern field via clipboard paste
    const patternId = await page.evaluate(`(() => {
      const p = 'form${dialogForm}_';
      const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
        .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
      return el ? el.id : null;
    })()`);
    if (!patternId) {
      await page.keyboard.press('Escape');
      await page.waitForTimeout(300);
      return;
    }
    await page.click(`[id="${patternId}"]`);
    await page.waitForTimeout(200);
    await page.keyboard.press('Control+A');
    await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
    await page.keyboard.press('Control+V');
    await page.waitForTimeout(300);

    // 4. Click "Найти"
    const findBtn = await page.evaluate(`(() => {
      const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
      const btn = btns.find(el => el.innerText?.trim() === 'Найти');
      if (!btn) return null;
      const r = btn.getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
    })()`);
    if (findBtn) {
      await page.mouse.click(findBtn.x, findBtn.y);
      await page.waitForTimeout(2000);
    }

    // 5. Close advanced search dialog
    for (let attempt = 0; attempt < 3; attempt++) {
      const dialogVisible = await page.evaluate(`(() => {
        const p = 'form${dialogForm}_';
        return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
      })()`);
      if (!dialogVisible) break;
      await page.keyboard.press('Escape');
      await page.waitForTimeout(500);
    }
    await waitForStable(formNum);
  } catch { /* silently fail — caller will re-scan and handle not_found */ }
}

/**
 * Pick a value from an opened selection form.
 *
 * Strategy (escalating):
 *   1. Scan visible rows for text match (exact → startsWith → includes)
 *   2. Simple search (search input + Enter) → re-scan
 *   3. Advanced search (Alt+F, "по части строки") → re-scan
 *   4. Not found → Escape → error
 *
 * For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan.
 * For empty search: pick first visible row.
 *
 * @param {number} selFormNum - selection form number
 * @param {string} fieldName - field being filled (for error messages)
 * @param {string|Object} search - string for simple search, or { field: value } for per-field search
 * @param {number} origFormNum - original form number (to verify we returned)
 * @returns {{ field, ok, method }} or {{ field, error, message }}
 */
async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) {
  const searchText = typeof search === 'string'
    ? search : (search ? Object.values(search).join(' ') : '');
  const searchLower = normYo((searchText || '').toLowerCase());

  // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group).
  let hadUnselectableMatch = false;
  async function trySelect(row) {
    const r = await dblclickAndVerify(row, selFormNum, fieldName);
    if (r.ok) return r;
    hadUnselectableMatch = true; // found match but couldn't select (group row)
    return null; // form still open, try next step
  }

  // Step 1: Scan visible rows (no filtering)
  if (searchLower) {
    const row = await scanGridRows(selFormNum, searchLower);
    if (row?.x) {
      const r = await trySelect(row);
      if (r) return r;
    }
  }

  // Step 2: Simple search via search input (directly on the known form, avoids filterList form-detection)
  if (typeof search === 'string' && searchLower) {
    const searchInputId = await page.evaluate(`(() => {
      const p = 'form${selFormNum}_';
      const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
        .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
      return el ? el.id : null;
    })()`);
    if (searchInputId) {
      try {
        await page.click(`[id="${searchInputId}"]`);
        await page.waitForTimeout(200);
        await page.keyboard.press('Control+A');
        await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(searchText))})`);
        await page.keyboard.press('Control+V');
        await page.waitForTimeout(300);
        await page.keyboard.press('Enter');
        await waitForStable(selFormNum);
      } catch { /* proceed to advanced search */ }
      const row = await scanGridRows(selFormNum, searchLower);
      if (row?.x) {
        const r = await trySelect(row);
        if (r) return r;
      }
    }
  }

  // Step 3: Advanced search
  if (typeof search === 'object' && search) {
    // Per-field advanced search via filterList(val, {field})
    for (const [fld, val] of Object.entries(search)) {
      try { await filterList(String(val), { field: fld }); } catch { /* proceed */ }
    }
  } else if (searchLower) {
    // Inline advanced search (Alt+F, "по части строки")
    await advancedSearchInline(selFormNum, searchText);
  }
  if (searchLower) {
    const row = await scanGridRows(selFormNum, searchLower);
    if (row?.x) {
      const r = await trySelect(row);
      if (r) return r;
    }
  }

  // Step 4: Empty search → pick first row; otherwise not found
  if (!searchLower) {
    const row = await scanGridRows(selFormNum, '');
    if (row?.x) {
      const r = await trySelect(row);
      if (r) return r;
    }
  }

  await page.keyboard.press('Escape');
  await waitForStable();
  const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search);
  if (hadUnselectableMatch) {
    return { field: fieldName, error: 'not_selectable',
      message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' };
  }
  return { field: fieldName, error: 'not_found',
    message: 'No matches in selection form for ' + searchDesc };
}

/**
 * Detect whether a form is a type selection dialog ("Выбор типа данных").
 * Type dialogs appear when selecting a value for a composite-type field.
 *
 * Detection signals (any one is sufficient):
 * - form{N}_OK element exists (selection forms use "Выбрать", not "OK")
 * - form{N}_ValueList grid exists (specific to type/value list dialogs)
 * - Window title contains "Выбор типа" (title attr on .toplineBoxTitle)
 */
async function isTypeDialog(formNum) {
  return page.evaluate(`(() => {
    const p = 'form' + ${formNum} + '_';
    const hasOK = !!document.getElementById(p + 'OK');
    const hasValueList = !!document.getElementById(p + 'ValueList');
    const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
      .some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
    return hasOK || hasValueList || hasTitle;
  })()`);
}

/**
 * Select a type from the type selection dialog ("Выбор типа данных")
 * using Ctrl+F search. The dialog has a virtual grid (~5 visible rows),
 * so Ctrl+F is the only reliable way to find a type.
 *
 * Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) →
 * verify selected row matches → Enter (OK)
 *
 * @param {number} formNum - type dialog form number
 * @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт")
 * @throws {Error} if type not found
 */
async function pickFromTypeDialog(formNum, typeName) {
  // The type dialog is a modal ValueList grid. Uses Ctrl+F "Найти" (Find) dialog
  // to search in the virtual grid (only ~5 rows visible, scrolling unreliable).
  //
  // Key constraints discovered during testing:
  // - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true})
  //   which punches through the modal overlay to the form underneath
  // - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog)
  // - Buttons: use page.click({force:true}), NOT evaluate(() => el.click())
  //   because evaluate click doesn't trigger 1C's event chain properly
  // - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти"
  // - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open

  // 1. Focus the grid via evaluate (does NOT punch through modal like page.click)
  await page.evaluate(`(() => {
    const grid = document.getElementById('form${formNum}_ValueList');
    if (!grid) return;
    const body = grid.querySelector('.gridBody');
    if (body) body.focus(); else grid.focus();
  })()`);
  await page.waitForTimeout(500);

  // 2. Ctrl+F to open "Найти" dialog
  await page.keyboard.press('Control+f');
  await page.waitForTimeout(1000);

  // 3. Paste search text (focus is on "Что искать" field)
  await page.keyboard.press('Control+a');
  await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(typeName)})`);
  await page.keyboard.press('Control+v');
  await page.waitForTimeout(300);

  // 4. Find the "Найти" dialog form number (it's > formNum)
  const findFormNum = await page.evaluate(`(() => {
    for (let n = ${formNum} + 1; n < ${formNum} + 20; n++) {
      const btn = document.getElementById('form' + n + '_Find');
      if (btn && btn.offsetWidth > 0) return n;
    }
    return null;
  })()`);

  if (findFormNum === null) {
    await page.keyboard.press('Escape');
    await waitForStable();
    throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection');
  }

  // 5. Click "Найти" via page.click({force:true}) — evaluate click doesn't trigger 1C events
  await page.click(`#form${findFormNum}_Find`, { force: true });
  await page.waitForTimeout(3000);

  // 6. Read ALL visible grid rows and check how many match the search text.
  // After Ctrl+F the grid scrolls to the found row — nearby matching rows are visible too.
  // The "Найти" dialog may auto-close after search, so don't rely on clicking "Найти" again.
  const gridCheck = await page.evaluate(`(() => {
    const grid = document.getElementById('form${formNum}_ValueList');
    if (!grid) return { visible: [], selected: null };
    const body = grid.querySelector('.gridBody');
    if (!body) return { visible: [], selected: null };
    const lines = body.querySelectorAll('.gridLine');
    const visible = [];
    let selected = null;
    for (const line of lines) {
      const text = (line.innerText || '').trim().replace(/\\u00a0/g, ' ');
      if (!text) continue;
      visible.push(text);
      if (line.classList.contains('select') || line.classList.contains('selRow')) selected = text;
    }
    return { visible, selected };
  })()`);

  const typeNorm = normYo(typeName.toLowerCase());
  const matching = (gridCheck.visible || []).filter(t => normYo(t.toLowerCase()).includes(typeNorm));

  if (matching.length === 0) {
    for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
    await waitForStable();
    throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` +
      `. Visible: ${(gridCheck.visible || []).join(', ')}`);
  }

  if (matching.length > 1) {
    for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
    await waitForStable();
    throw new Error(`selectValue: multiple types match "${typeName}": ${matching.map(m => '"' + m + '"').join(', ')}. Specify a more precise type name`);
  }

  // 7. Click OK on type dialog via page.click({force:true}) — bypasses "Найти" modal
  await page.click(`#form${formNum}_OK`, { force: true });
  await page.waitForTimeout(ACTION_WAIT);
}

/**
 * Fill a reference field via clipboard paste + 1C autocomplete.
 *
 * Strategy:
 *   1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors)
 *   2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete)
 *   3. Check editDropDown for autocomplete results → click match or Tab to resolve
 *   4. Verify result: resolved → ok, not found → clear + error
 *
 * Clipboard paste was chosen because:
 *   - Ctrl+V produces trusted browser events that 1C respects for autocomplete
 *   - page.fill() + synthetic keydown/keyup only triggers hints, not real search
 *   - keyboard.type() garbles Cyrillic on some fields
 *
 * @returns {{ field, ok?, method?, error?, value?, message?, available? }}
 */
async function fillReferenceField(selector, fieldName, value, formNum) {
  const text = String(value);
  const escapedSel = selector.replace(/'/g, "\\'");

  // Helper: detect new forms opened above the current one
  async function detectNewForm() {
    return page.evaluate(`(() => {
      const forms = {};
      document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => {
        if (el.offsetWidth === 0) return;
        const m = el.id.match(/^form(\\d+)_/);
        if (m) forms[m[1]] = true;
      });
      const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
      return nums.length > 0 ? Math.max(...nums) : null;
    })()`);
  }

  // Helper: clear the field using Shift+F4 (native 1C mechanism)
  async function clearField() {
    try {
      await page.click(selector, { timeout: 3000 });
      await page.keyboard.press('Shift+F4');
      await page.waitForTimeout(300);
      await page.keyboard.press('Tab');
      await page.waitForTimeout(300);
    } catch { /* OK */ }
  }

  // Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке")
  async function checkNotInListCloud() {
    return page.evaluate(`(() => {
      const divs = document.querySelectorAll('div');
      for (const el of divs) {
        if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
        const style = getComputedStyle(el);
        if (style.position !== 'absolute' && style.position !== 'fixed') continue;
        const z = parseInt(style.zIndex) || 0;
        if (z < 100) continue;
        if ((el.innerText || '').includes('нет в списке')) return true;
      }
      return false;
    })()`);
  }

  // 0. Dismiss any leftover error modal from a previous operation
  await dismissPendingErrors();

  // 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields
  //     and also for reference fields that show a dropdown.
  const inputId = selector.match(/\[id="(.+)"\]/)?.[1];
  // DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB)
  const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB';
  const dlbSelector = `[id="${dlbId}"]`;
  try {
    const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`);
    if (dlbVisible) {
      await page.click(dlbSelector);
      await page.waitForTimeout(1000);
      const eddState = await page.evaluate(`(() => {
        const edd = document.getElementById('editDropDown');
        if (!edd || edd.offsetWidth === 0) return { visible: false };
        const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
        return {
          visible: true,
          items: eddTexts.map(el => {
            const r = el.getBoundingClientRect();
            return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
          })
        };
      })()`);
      if (eddState.visible && eddState.items?.length > 0) {
        const target = normYo(text.toLowerCase());
        const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
        let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target);
        if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
        if (!match) match = candidates.find(i => {
          const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
          return name.includes(target) || target.includes(name);
        });
        if (match) {
          await page.mouse.click(match.x, match.y);
          await waitForStable();
          await dismissPendingErrors();
          return { field: fieldName, ok: true, method: 'dropdown',
            value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
        }
        // No match in DLB dropdown — close and fall through to paste approach
        await page.keyboard.press('Escape');
        await page.waitForTimeout(300);
      } else if (eddState.visible) {
        // DLB opened a hint popup (no .eddText items) — close it before proceeding
        await page.keyboard.press('Escape');
        await page.waitForTimeout(300);
      }
    }
  } catch { /* DLB approach failed — fall through to paste */ }

  // 1. Focus (handle surface/modal overlay from previous interaction)
  try {
    await page.click(selector);
  } catch (e) {
    if (e.message.includes('intercepts pointer events')) {
      // Try force click first (no side effects), then Escape as fallback
      try {
        await page.click(selector, { force: true });
      } catch (e2) {
        if (e2.message.includes('intercepts pointer events')) {
          await dismissPendingErrors();
          await page.keyboard.press('Escape');
          await page.waitForTimeout(500);
          await page.click(selector);
        } else throw e2;
      }
    } else throw e;
  }

  // 2. If field already has a value, clear using Shift+F4 (native 1C mechanism).
  //    This is needed for reference fields — Shift+F4 properly clears the ref link.
  const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
  if (currentVal) {
    await page.keyboard.press('Shift+F4');
    await page.waitForTimeout(500);
    await page.keyboard.press('Tab');
    await page.waitForTimeout(500);
    // Refocus
    await page.click(selector);
  }

  // 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete)
  await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
  await page.keyboard.press('Control+V');
  await page.waitForTimeout(2000);

  // 4. Check editDropDown for autocomplete suggestions
  const eddState = await page.evaluate(`(() => {
    const edd = document.getElementById('editDropDown');
    if (!edd || edd.offsetWidth === 0) return { visible: false };
    const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
    return {
      visible: true,
      items: eddTexts.map(el => {
        const r = el.getBoundingClientRect();
        return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
      })
    };
  })()`);

  if (eddState.visible && eddState.items?.length > 0) {
    const target = normYo(text.toLowerCase());
    // Separate real matches from "Создать:" items
    const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));

    if (candidates.length > 0) {
      // Find best match (items have format "Name (Code)" — match against name part)
      let match = candidates.find(i => {
        const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
        return name === target;
      });
      if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
      if (!match) match = candidates.find(i => {
        const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
        return name.includes(target) || target.includes(name);
      });

      if (match) {
        await page.mouse.click(match.x, match.y);
        await waitForStable();
        await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async
        return { field: fieldName, ok: true, method: 'dropdown',
          value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
      }
      // Candidates exist but none match — report them
      await page.keyboard.press('Escape');
      await page.waitForTimeout(300);
      await clearField();
      return { field: fieldName, error: 'not_matched',
        available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
    }

    // Only "Создать:" items — no existing matches
    await page.keyboard.press('Escape');
    await page.waitForTimeout(300);
    await clearField();
    return { field: fieldName, error: 'not_found',
      message: 'No existing values match "' + text + '"' };
  }

  // 4b. No edd — check for "not in list" cloud that may have appeared during paste
  if (await checkNotInListCloud()) {
    await page.keyboard.press('Escape');
    await page.waitForTimeout(300);
    await clearField();
    return { field: fieldName, error: 'not_found',
      message: 'Value "' + text + '" not found (not in list)' };
  }

  // 5. No edd at all — press Tab to trigger direct resolve
  await page.keyboard.press('Tab');
  await waitForStable();
  await dismissPendingErrors();

  // 5x. Check for "not in list" cloud popup after Tab
  if (await checkNotInListCloud()) {
    await page.keyboard.press('Escape');
    await page.waitForTimeout(300);
    await clearField();
    return { field: fieldName, error: 'not_found',
      message: 'Value "' + text + '" not found (not in list)' };
  }

  // 5a. New form opened? (creation form = value not found)
  const newForm = await detectNewForm();
  if (newForm !== null) {
    await page.keyboard.press('Escape');
    await waitForStable();
    await clearField();
    return { field: fieldName, error: 'not_found',
      message: 'Value "' + text + '" not found' };
  }

  // 5b. Dropdown after Tab?
  const popup = await page.evaluate(readSubmenuScript());
  if (Array.isArray(popup) && popup.length > 0) {
    const realItems = popup.filter(i => !i.name.startsWith('Создать'));
    await page.keyboard.press('Escape');
    await page.waitForTimeout(300);
    await clearField();
    if (realItems.length > 0) {
      return { field: fieldName, error: 'ambiguous',
        message: 'Multiple matches for "' + text + '"',
        available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
    }
    return { field: fieldName, error: 'not_found',
      message: 'Value "' + text + '" not found' };
  }

  // 5c. Check final value
  const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
  if (!finalVal) {
    // 6. Last resort: try F4 to open selection form and pick from there
    try {
      await page.click(selector);
      await page.waitForTimeout(300);
    } catch { /* OK — field may be unfocused */ }
    await page.keyboard.press('F4');
    await page.waitForTimeout(ACTION_WAIT);

    const selFormNum = await detectNewForm();
    if (selFormNum !== null) {
      const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum);
      if (pickResult.ok) return pickResult;
      // pickFromSelectionForm already closed the form on error
    }

    return { field: fieldName, error: 'not_found',
      message: 'Value "' + text + '" not found (field is empty)' };
  }

  return { field: fieldName, ok: true, method: 'typeahead', value: finalVal };
}


/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
export async function fillFields(fields) {
  ensureConnected();
  await dismissPendingErrors();
  const formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error('fillFields: no form found');

  // Resolve field names to element IDs
  const resolved = await page.evaluate(resolveFieldsScript(formNum, fields));
  const results = [];

  for (const r of resolved) {
    if (r.error) {
      results.push(r);
      continue;
    }
    // Auto-highlight the field input before filling
    if (highlightMode && r.inputId) {
      try {
        await page.evaluate(({ id }) => {
          const target = document.getElementById(id);
          if (!target) return;
          let div = document.getElementById('__web_test_highlight');
          if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
          const r = target.getBoundingClientRect();
          div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
        }, { id: r.inputId });
        await page.waitForTimeout(500);
        await unhighlight();
      } catch {}
    }
    try {
      // Auto-enable DCS checkbox if resolved via label
      if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
        await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
        await waitForStable();
      }
      const selector = `[id="${r.inputId}"]`;
      // Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
      const rawValue = fields[r.field];
      const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
      if (isEmpty && !r.isCheckbox && !r.isRadio) {
        await page.click(selector);
        await page.waitForTimeout(200);
        await page.keyboard.press('Shift+F4');
        await page.waitForTimeout(300);
        await page.keyboard.press('Tab');
        await waitForStable();
        results.push({ field: r.field, ok: true, value: '', method: 'clear' });
        continue;
      }
      if (r.isCheckbox) {
        // Checkbox: compare desired with current, toggle if mismatch
        const desired = String(fields[r.field]).toLowerCase();
        const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired);
        if (wantChecked !== r.checked) {
          await page.click(selector);
          await waitForStable();
        }
        results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' });
      } else if (r.isRadio) {
        // Radio button: find option by label (fuzzy match) and click it
        const desired = normYo(String(fields[r.field]).toLowerCase());
        const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
          || r.options.find(o => normYo(o.label.toLowerCase()).includes(desired));
        if (opt) {
          // Option 0 = base element (no suffix), options 1+ = #N#radio
          const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`;
          await page.click(`[id="${radioId}"]`);
          await waitForStable();
          results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' });
        } else {
          results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) });
        }
      } else if (r.hasSelect) {
        // Combobox/reference with DLB: DLB-first, then paste fallback
        const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
        results.push(refResult);
      } else if (r.hasPick && r.isDate) {
        // Date/time field with calendar CB — use paste (calendar is not a selection form)
        await page.click(selector);
        await page.waitForTimeout(200);
        await page.keyboard.press('Control+A');
        await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(fields[r.field]))})`);
        await page.keyboard.press('Control+V');
        await page.waitForTimeout(300);
        await page.keyboard.press('Tab');
        await waitForStable();
        results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
      } else if (r.hasPick) {
        // Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
        const svResult = await selectValue(r.field, String(fields[r.field]));
        if (svResult?.error) {
          results.push({ field: r.field, error: svResult.error, message: svResult.message });
        } else {
          results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
        }
      } else {
        // Plain field: clipboard paste + Tab to commit
        // page.fill() sets DOM value but doesn't trigger 1C input events;
        // clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly.
        await page.click(selector);
        await page.waitForTimeout(200);
        await page.keyboard.press('Control+A');
        await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(fields[r.field]))})`);
        await page.keyboard.press('Control+V');
        await page.waitForTimeout(300);
        await page.keyboard.press('Tab');
        await waitForStable();
        results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
      }
    } catch (e) {
      results.push({ field: r.field, error: e.message });
    }
    if (highlightMode) try { await unhighlight(); } catch {}
  }

  const formData = await page.evaluate(readFormScript(formNum));
  const failed = results.filter(r => r.error);
  if (failed.length > 0) {
    const details = failed.map(f => `  ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
    throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
  }
  return { filled: results, form: formData };
}

/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
export async function fillField(name, value) {
  return fillFields({ [name]: value });
}

/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
 *  First argument can also be an object { row, column } to click a SpreadsheetDocument cell. */
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) {
  ensureConnected();
  // Dispatch to spreadsheet cell handler when first arg is { row, column }
  if (typeof text === 'object' && text !== null && text.column != null) {
    await dismissPendingErrors();
    return clickSpreadsheetCell(text, { dblclick, modifier });
  }
  await dismissPendingErrors();
  if (highlightMode) try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
  let netMonitor = null;
  try {

  // First check if there's a confirmation dialog — click matching button
  const pending = await checkForErrors();
  if (pending?.confirmation) {
    const btnResult = await page.evaluate(`(() => {
      const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || '';
      const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
      const target = ny(${JSON.stringify(text.toLowerCase())});
      const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0);
      let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target);
      if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target));
      if (best) {
        const r = best.getBoundingClientRect();
        return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
      }
      return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) };
    })()`);
    if (btnResult?.error) throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`);
    await page.mouse.click(btnResult.x, btnResult.y);
    await waitForStable();
    const state = await getFormState();
    state.clicked = { kind: 'confirmation', name: btnResult.name };
    return state;
  }

  // Check if there's an open popup — if so, try to click inside it
  const popupItems = await page.evaluate(readSubmenuScript());
  if (Array.isArray(popupItems) && popupItems.length > 0) {
    const target = normYo(text.toLowerCase());
    let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
    if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
    if (found) {
      // submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu
      if (found.kind === 'submenuArrow') {
        // page.hover(selector) is more reliable than page.mouse.move(x,y) —
        // some submenu groups don't expand with plain mouse.move
        if (found.id) {
          await page.hover(`[id="${found.id}"]`);
        } else {
          await page.mouse.move(found.x, found.y);
        }
        await page.waitForTimeout(ACTION_WAIT);
        const nestedItems = await page.evaluate(readSubmenuScript());
        const state = await getFormState();
        state.clicked = { kind: 'submenuArrow', name: found.name };
        if (Array.isArray(nestedItems)) {
          state.submenu = nestedItems.map(i => i.name);
          state.hint = 'Call web_click again with a submenu item name to select it';
        }
        return state;
      }
      // Regular submenu/dropdown items — trusted events required.
      // Use mouse.click(x,y) when in viewport; use :visible selector for clipped items
      // (same ID can exist hidden in parent cloud AND visible in nested cloud).
      const vpHeight = await page.evaluate('window.innerHeight');
      if (found.x && found.y && found.y > 0 && found.y < vpHeight) {
        await page.mouse.click(found.x, found.y);
      } else if (found.id) {
        await page.click(`[id="${found.id}"]:visible`);
      } else if (found.x && found.y) {
        await page.mouse.click(found.x, found.y);
      }
      await waitForStable();
      const state = await getFormState();
      state.clicked = { kind: 'popupItem', name: found.name };
      const err = await checkForErrors();
      if (err) state.errors = err;
      return state;
    }
    // No match in popup — fall through to form elements
  }

  let formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error(`clickElement: no form found`);

  // Pre-resolve grid when table is specified
  let gridSelector;
  if (table) {
    const resolved = await page.evaluate(resolveGridScript(formNum, table));
    if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
    gridSelector = resolved.gridSelector;
  }

  // Find the target element ID
  let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));

  // Retry: if not found, a modal form may still be loading (e.g. after F4).
  // Wait up to 2s for a new form to appear and re-detect.
  if (target?.error) {
    for (let retry = 0; retry < 4; retry++) {
      await page.waitForTimeout(500);
      const newForm = await page.evaluate(detectFormScript());
      if (newForm !== null && newForm !== formNum) {
        formNum = newForm;
        target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
        if (!target?.error) break;
      }
    }
  }
  // Fallback: search spreadsheet iframes for text match before giving up
  if (target?.error) {
    const ssCell = await findSpreadsheetCellByText(formNum, text);
    if (ssCell) {
      const cx = ssCell.box.x + ssCell.box.width / 2;
      const cy = ssCell.box.y + ssCell.box.height / 2;
      const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
      if (modKey) await page.keyboard.down(modKey);
      if (dblclick) await page.mouse.dblclick(cx, cy);
      else await page.mouse.click(cx, cy);
      if (modKey) await page.keyboard.up(modKey);
      await waitForStable();
      const state = await getFormState();
      state.clicked = { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) };
      return state;
    }
    throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
  }

  // Helper: click with optional modifier key (Ctrl/Shift for multi-select)
  const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
  async function modClick(x, y) {
    if (modKey) await page.keyboard.down(modKey);
    await page.mouse.click(x, y);
    if (modKey) await page.keyboard.up(modKey);
  }
  async function modDblClick(x, y) {
    if (modKey) await page.keyboard.down(modKey);
    await page.mouse.dblclick(x, y);
    if (modKey) await page.keyboard.up(modKey);
  }

  // Grid row targets — use coordinate click (single or double)
  if (target.kind === 'gridGroup' || target.kind === 'gridParent') {
    if (expand != null || toggle) {
      // Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV)
      // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click
      const levelIconInfo = await page.evaluate(`(() => {
        const p = ${JSON.stringify(`form${formNum}_`)};
        const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
        const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
        const body = grid?.querySelector('.gridBody');
        if (!body) return null;
        const targetY = ${target.y};
        const lines = [...body.querySelectorAll('.gridLine')];
        for (const line of lines) {
          const lr = line.getBoundingClientRect();
          if (targetY < lr.top || targetY > lr.bottom) continue;
          const icon = line.querySelector('.gridListH, .gridListV');
          if (icon) {
            const r = icon.getBoundingClientRect();
            const isExpanded = !!icon.classList.contains('gridListV');
            return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
          }
        }
        return null;
      })()`);
      const shouldClick = toggle || !levelIconInfo
        || (expand === true && !levelIconInfo.isExpanded)
        || (expand === false && levelIconInfo.isExpanded);
      if (shouldClick) {
        if (levelIconInfo) {
          await modClick(levelIconInfo.x, levelIconInfo.y);
        } else {
          // Fallback: dblclick (standard hierarchy navigation)
          await modDblClick(target.x, target.y);
        }
      }
      await waitForStable(formNum);
      const state = await getFormState();
      state.clicked = { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) };
      state.hint = shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.';
      return state;
    }
    // Default: dblclick to enter group / go up to parent
    await modDblClick(target.x, target.y);
    await waitForStable(formNum);
    const state = await getFormState();
    state.clicked = { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) };
    return state;
  }
  if (target.kind === 'gridTreeNode') {
    if (expand != null || toggle) {
      // Expand/collapse tree node — click the tree icon [tree="true"]
      // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click
      const treeIconInfo = await page.evaluate(`(() => {
        const p = ${JSON.stringify(`form${formNum}_`)};
        const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
        const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
        const body = grid?.querySelector('.gridBody');
        if (!body) return null;
        const targetY = ${target.y};
        const lines = [...body.querySelectorAll('.gridLine')];
        for (const line of lines) {
          const lr = line.getBoundingClientRect();
          if (targetY < lr.top || targetY > lr.bottom) continue;
          const treeIcon = line.querySelector('.gridBoxImg [tree="true"]');
          if (treeIcon) {
            const r = treeIcon.getBoundingClientRect();
            const bg = treeIcon.style.backgroundImage || '';
            const isExpanded = bg.includes('gx=0');
            return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
          }
        }
        return null;
      })()`);
      const shouldClick = toggle || !treeIconInfo
        || (expand === true && !treeIconInfo.isExpanded)
        || (expand === false && treeIconInfo.isExpanded);
      if (shouldClick) {
        if (treeIconInfo) {
          await modClick(treeIconInfo.x, treeIconInfo.y);
        } else {
          // Fallback: dblclick on row (works for trees without clickable +/- icons)
          await modDblClick(target.x, target.y);
        }
      }
      await waitForStable(formNum);
      const state = await getFormState();
      state.clicked = { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) };
      state.hint = shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.';
      return state;
    }
    // Default: select row (click text, no expand/collapse)
    await modClick(target.x, target.y);
    await waitForStable(formNum);
    const state = await getFormState();
    state.clicked = { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) };
    state.hint = 'Row selected. Use { expand: true } to expand/collapse.';
    return state;
  }
  if (target.kind === 'gridRow') {
    if (dblclick) {
      await modDblClick(target.x, target.y);
      await waitForStable();
      const state = await getFormState();
      state.clicked = { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) };
      return state;
    }
    await modClick(target.x, target.y);
    await waitForStable();
    const state = await getFormState();
    state.clicked = { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) };
    return state;
  }

  // Start CDP network monitor BEFORE the click for buttons —
  // so we capture all server requests triggered by the click.
  if (target.kind === 'button') {
    try { netMonitor = await startNetworkMonitor(); } catch {}
  }

  // Tabs without ID — use coordinate click to avoid global [data-content] ambiguity
  if (target.kind === 'tab' && !target.id && target.x && target.y) {
    await page.mouse.click(target.x, target.y);
  } else {
    const selector = `[id="${target.id}"]`;
    // Use Playwright click for proper mousedown/mouseup events
    try {
      await page.click(selector, { timeout: 5000 });
    } catch (clickErr) {
      if (clickErr.message.includes('intercepts pointer events')) {
        // Surface overlay intercepts — try force click first (no side effects),
        // then Escape + retry as fallback (Escape can trigger save dialogs on forms)
        try {
          await page.click(selector, { force: true, timeout: 5000 });
        } catch (clickErr2) {
          if (clickErr2.message.includes('intercepts pointer events')) {
            await page.keyboard.press('Escape');
            await page.waitForTimeout(500);
            await page.click(selector, { timeout: 5000 });
          } else {
            throw clickErr2;
          }
        }
      } else {
        throw clickErr;
      }
    }
  }

  // If submenu button — read popup items and return them as hints
  if (target.kind === 'submenu') {
    await page.waitForTimeout(ACTION_WAIT);
    const submenuItems = await page.evaluate(readSubmenuScript());
    const state = await getFormState();
    state.clicked = { kind: 'submenu', name: target.name };
    if (Array.isArray(submenuItems)) {
      state.submenu = submenuItems.map(i => i.name);
      state.hint = 'Call web_click again with a submenu item name to select it';
    }
    return state;
  }

  await waitForStable(formNum);

  // Check if the click opened a popup/submenu (split buttons like "Создать на основании")
  const openedPopup = await page.evaluate(readSubmenuScript());
  if (Array.isArray(openedPopup) && openedPopup.length > 0) {
    const state = await getFormState();
    state.clicked = { kind: 'submenu', name: target.name };
    state.submenu = openedPopup.map(i => i.name);
    state.hint = 'Call web_click again with a submenu item name to select it';
    return state;
  }

  // For buttons that trigger server-side operations (post, write, etc.),
  // the DOM may stabilize BEFORE the server response arrives.
  // Use waitForSelector to detect error modal — this doesn't block the JS event loop.
  // Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected.
  if (target.kind === 'button') {
    const postForm = await page.evaluate(detectFormScript());
    if (postForm === formNum) {
      const inGridEdit = await page.evaluate(`(() => {
        const f = document.activeElement;
        if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
        let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
        return false;
      })()`);
      if (!inGridEdit && netMonitor) {
        // Form didn't change — server might still be processing.
        // CDP monitor was started before click — wait for all requests to complete
        // (300ms debounce) or for a modal/balloon/confirm to appear.
        await netMonitor.waitDone(timeout);
        await waitForStable();
      }
    }
  }

  // Form may have changed — re-detect
  const state = await getFormState();
  state.clicked = { kind: target.kind, name: target.name };
  const err = await checkForErrors();
  if (err) {
    state.errors = err;
    if (err.confirmation) {
      state.confirmation = err.confirmation;
      state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
    }
  }
  return state;

  } finally {
    if (netMonitor) try { await netMonitor.cleanup(); } catch {}
    if (highlightMode) try { await unhighlight(); } catch {}
  }
}

/**
 * Close the current form/dialog via Escape.
 * @param {Object} [opts]
 * @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
 *   true  → click "Да" (save and close)
 *   false → click "Нет" (discard and close)
 *   undefined → return confirmation as hint for caller to decide
 */
export async function closeForm({ save } = {}) {
  ensureConnected();
  await dismissPendingErrors();
  // If platform dialogs are open, close them instead of pressing Escape
  const pd = await _detectPlatformDialogs();
  if (pd.length) {
    await _closePlatformDialogs();
    await page.waitForTimeout(300);
    const state = await getFormState();
    state.closed = true;
    state.closedPlatformDialogs = pd;
    return state;
  }
  const beforeForm = await page.evaluate(detectFormScript());
  await page.keyboard.press('Escape');
  await waitForStable(beforeForm);
  const state = await getFormState();
  const err = await checkForErrors();
  if (err?.confirmation) {
    if (save === true || save === false) {
      const label = save ? 'Да' : 'Нет';
      const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
      const btns = await page.$$(btnSel);
      for (const b of btns) {
        const txt = (await b.textContent()).trim();
        if (txt === label) {
          if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
          await b.click({ force: true });
          await waitForStable(beforeForm);
          break;
        }
      }
      const afterState = await getFormState();
      afterState.closed = afterState.form !== beforeForm;
      return afterState;
    }
    state.confirmation = err.confirmation;
    state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
    return state;
  }
  state.closed = state.form !== beforeForm;
  return state;
}

/**
 * Select a value from a reference field (compound operation).
 * Handles three patterns:
 *   A) DLB opens an inline dropdown popup — click matching item
 *   B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form
 *   C) DLB opens a separate selection form directly — search + dblclick in grid
 */
export async function selectValue(fieldName, searchText, { type } = {}) {
  ensureConnected();
  await dismissPendingErrors();
  const formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error(`selectValue: no form found`);

  // 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields)
  let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB'));
  if (btn?.error === 'button_not_found') {
    btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB'));
  }
  if (btn?.error) return btn;
  if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {}
  try {

  // === CLEAR FIELD if searchText is empty/null ===
  if (!searchText && searchText !== 0) {
    const inputId = await page.evaluate(`(() => {
      const p = 'form${formNum}_';
      const name = ${JSON.stringify(btn.fieldName)};
      const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
      return el ? el.id : null;
    })()`);
    if (inputId) {
      await page.click(`[id="${inputId}"]`);
      await page.waitForTimeout(200);
      await page.keyboard.press('Shift+F4');
      await page.waitForTimeout(300);
      await page.keyboard.press('Tab');
      await waitForStable();
    }
    if (highlightMode) try { await unhighlight(); } catch {}
    const formData = await getFormState();
    return { ...formData, selected: { field: fieldName, search: null, method: 'clear' } };
  }

  // === COMPOSITE TYPE HANDLING ===
  // When `type` is specified, clear the field first to reset cached type,
  // then open type selection dialog, pick the type, then pick the value.
  if (type) {
    // Find and focus the field input
    const inputId = await page.evaluate(`(() => {
      const p = 'form${formNum}_';
      const name = ${JSON.stringify(btn.fieldName)};
      const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
      return el ? el.id : null;
    })()`);
    if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`);

    // Clear cached type + value with Shift+F4
    await page.click(`[id="${inputId}"]`);
    await page.waitForTimeout(300);
    await page.keyboard.press('Shift+F4');
    await page.waitForTimeout(500);

    // Re-focus and press F4 to open type selection dialog
    await page.click(`[id="${inputId}"]`);
    await page.waitForTimeout(300);
    await page.keyboard.press('F4');
    await page.waitForTimeout(ACTION_WAIT);
    await waitForStable(formNum);

    const newFormNum = await detectNewForm();
    if (newFormNum === null) {
      throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`);
    }

    if (await isTypeDialog(newFormNum)) {
      // Pick type from the dialog
      await pickFromTypeDialog(newFormNum, type);
      await waitForStable(newFormNum);

      // After type selection, the actual selection form should open
      const selFormNum = await detectSelectionForm();
      if (selFormNum === null) {
        throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`);
      }

      const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
      const state = await getFormState();
      state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' };
      if (pickResult.error) state.selected.error = pickResult.error;
      if (pickResult.message) state.selected.message = pickResult.message;
      const err = await checkForErrors();
      if (err) state.errors = err;
      return state;
    } else {
      // Not a type dialog — field is not composite type, proceed with normal selection
      const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum);
      const state = await getFormState();
      state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
      if (pickResult.error) state.selected.error = pickResult.error;
      if (pickResult.message) state.selected.message = pickResult.message;
      const err = await checkForErrors();
      if (err) state.errors = err;
      return state;
    }
  }
  // === END COMPOSITE TYPE HANDLING ===

  // Auto-enable DCS checkbox if resolved via label
  if (btn.dcsCheckbox) {
    const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`;
    const isChecked = await page.$eval(cbSel, el =>
      el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'));
    if (!isChecked) { await page.click(cbSel); await waitForStable(); }
  }

  // Helper: detect selection form (form number > formNum)
  async function detectSelectionForm() {
    return page.evaluate(`(() => {
      const forms = {};
      document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => {
        if (el.offsetWidth === 0) return;
        const m = el.id.match(/^form(\\d+)_/);
        if (m) forms[m[1]] = true;
      });
      const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
      return nums.length > 0 ? Math.max(...nums) : null;
    })()`);
  }

  // Helper: detect any new form (broader than detectSelectionForm — also finds type dialogs
  // whose a.press buttons have empty IDs). Looks for any visible element with id="form{N}_*".
  async function detectNewForm() {
    return page.evaluate(`(() => {
      const forms = {};
      document.querySelectorAll('[id]').forEach(el => {
        if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
        const m = el.id.match(/^form(\\d+)_/);
        if (m) forms[m[1]] = true;
      });
      const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
      return nums.length > 0 ? Math.max(...nums) : null;
    })()`);
  }

  // Helper: open selection form and pick value
  async function openFormAndPick() {
    await waitForStable(formNum);
    const selFormNum = await detectSelectionForm();
    if (selFormNum !== null) {
      const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
      const state = await getFormState();
      state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
      if (pickResult.error) state.selected.error = pickResult.error;
      if (pickResult.message) state.selected.message = pickResult.message;
      const err = await checkForErrors();
      if (err) state.errors = err;
      return state;
    }
    return null;
  }

  // Helper: click EDD item via evaluate (bypasses div.surface overlay from DLB)
  // page.mouse.click() doesn't work here — surface intercepts pointer events.
  // Dispatching mousedown directly on the element avoids this.
  async function clickEddItem(itemName) {
    return page.evaluate(`(() => {
      const edd = document.getElementById('editDropDown');
      if (!edd || edd.offsetWidth === 0) return null;
      const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
      const target = ny(${JSON.stringify(itemName.toLowerCase())});
      const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
      function clickEl(el) {
        const r = el.getBoundingClientRect();
        const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
        el.dispatchEvent(new MouseEvent('mousedown', opts));
        el.dispatchEvent(new MouseEvent('mouseup', opts));
        el.dispatchEvent(new MouseEvent('click', opts));
        return el.innerText.trim();
      }
      // Pass 1: exact match (prefer over partial)
      for (const el of items) {
        const t = ny((el.innerText?.trim() || '').toLowerCase());
        if (t === target) return clickEl(el);
        const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
        if (stripped === target) return clickEl(el);
      }
      // Pass 2: partial match
      for (const el of items) {
        const t = ny((el.innerText?.trim() || '').toLowerCase());
        if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
      }
      return null;
    })()`);
  }

  // Helper: click "Показать все" in EDD footer via evaluate
  async function clickShowAll() {
    return page.evaluate(`(() => {
      const edd = document.getElementById('editDropDown');
      if (!edd || edd.offsetWidth === 0) return false;
      let el = edd.querySelector('.eddBottom .hyperlink');
      if (!el || el.offsetWidth === 0) {
        const candidates = [...edd.querySelectorAll('span, div, a')]
          .filter(e => e.offsetWidth > 0 && e.children.length === 0);
        el = candidates.find(e => {
          const t = (e.innerText?.trim() || '').toLowerCase();
          return t === 'показать все' || t === 'show all';
        });
      }
      if (!el) return false;
      const r = el.getBoundingClientRect();
      const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
      el.dispatchEvent(new MouseEvent('mousedown', opts));
      el.dispatchEvent(new MouseEvent('mouseup', opts));
      el.dispatchEvent(new MouseEvent('click', opts));
      return true;
    })()`);
  }

  // 2. Click DLB (handle funcPanel / surface overlay intercept)
  const dlbSel = `[id="${btn.buttonId}"]`;
  try {
    await page.click(dlbSel, { timeout: 5000 });
  } catch (dlbErr) {
    if (dlbErr.message.includes('intercepts pointer events')) {
      try {
        await page.click(dlbSel, { force: true, timeout: 5000 });
      } catch (dlbErr2) {
        if (dlbErr2.message.includes('intercepts pointer events')) {
          await page.keyboard.press('Escape');
          await page.waitForTimeout(500);
          await page.click(dlbSel, { timeout: 5000 });
        } else throw dlbErr2;
      }
    } else throw dlbErr;
  }
  await page.waitForTimeout(ACTION_WAIT);

  // 3A. Check if a dropdown popup appeared (inline quick selection)
  const popupItems = await page.evaluate(readSubmenuScript());
  if (Array.isArray(popupItems) && popupItems.length > 0) {
    const regularItems = popupItems.filter(i => i.kind !== 'showAll');
    const showAllItem = popupItems.find(i => i.kind === 'showAll');

    if (searchText) {
      const target = normYo(searchText.toLowerCase());
      // Try to find match among regular dropdown items
      let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target);
      if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target));
      if (!match) match = regularItems.find(i => {
        const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
        return name === target || name.includes(target) || target.includes(name);
      });

      if (match) {
        // Click via evaluate to bypass div.surface overlay
        await clickEddItem(match.name);
        await waitForStable();
        const state = await getFormState();
        state.selected = { field: btn.fieldName, search: searchText, method: 'dropdown' };
        const err = await checkForErrors();
        if (err) state.errors = err;
        return state;
      }

      // No match in dropdown — try "Показать все" to open selection form
      if (showAllItem) {
        await clickShowAll();
        const formResult = await openFormAndPick();
        if (formResult) return formResult;
      }

      // No "Показать все" — close dropdown, try F4
      await page.keyboard.press('Escape');
      await page.waitForTimeout(500);

      // Focus the field input and press F4 to open selection form
      const inputId = await page.evaluate(`(() => {
        const p = 'form${formNum}_';
        const name = ${JSON.stringify(btn.fieldName)};
        const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
        return el ? el.id : null;
      })()`);
      if (inputId) {
        await page.click(`[id="${inputId}"]`);
        await page.waitForTimeout(300);
      }
      await page.keyboard.press('F4');
      await page.waitForTimeout(ACTION_WAIT);

      const formResult = await openFormAndPick();
      if (formResult) return formResult;

      // Still nothing — report available items from original dropdown
      throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`);
    }

    // No search text — click first regular item
    if (regularItems.length > 0) {
      await clickEddItem(regularItems[0].name);
      await waitForStable();
      const state = await getFormState();
      state.selected = { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' };
      const err = await checkForErrors();
      if (err) state.errors = err;
      return state;
    }
  }

  // 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs)
  const selFormNum = await detectNewForm();
  if (selFormNum !== null) {
    // Auto-detect type selection dialog when `type` was not specified
    if (await isTypeDialog(selFormNum)) {
      await page.keyboard.press('Escape');
      await waitForStable();
      throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`);
    }
    const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
    const state = await getFormState();
    state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
    if (pickResult.error) state.selected.error = pickResult.error;
    if (pickResult.message) state.selected.message = pickResult.message;
    const err = await checkForErrors();
    if (err) state.errors = err;
    return state;
  }

  // 3C. Neither popup nor form — try F4 as last resort
  await page.keyboard.press('Escape');
  await page.waitForTimeout(300);

  const inputId = await page.evaluate(`(() => {
    const p = 'form${formNum}_';
    const name = ${JSON.stringify(btn.fieldName)};
    const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
    return el ? el.id : null;
  })()`);
  if (inputId) {
    await page.click(`[id="${inputId}"]`);
    await page.waitForTimeout(300);
  }
  await page.keyboard.press('F4');
  await page.waitForTimeout(ACTION_WAIT);

  const formResult = await openFormAndPick();
  if (formResult) return formResult;

  throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`);

  } finally { if (highlightMode) try { await unhighlight(); } catch {} }
}

/**
 * Fill cells in the current table row via Tab navigation.
 * Grid cells are only accessible sequentially (Tab) — no random access.
 *
 * After "Добавить", 1C enters inline edit mode on the first cell.
 * All inputs in the row are created hidden (offsetWidth=0); only the active one is visible.
 * Tab moves through cells in a fixed order determined by the form configuration.
 *
 * @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура")
 * @param {Object} [options]
 * @param {string} [options.tab] - Switch to this form tab before operating
 * @param {boolean} [options.add] - Click "Добавить" to create a new row first
 * @returns {{ filled[], notFilled[]?, form }}
 */
export async function fillTableRow(fields, { tab, add, row, table } = {}) {
  ensureConnected();
  await dismissPendingErrors();
  const formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error('fillTableRow: no form found');

  // Pre-resolve grid when table is specified
  let gridSelector;
  if (table) {
    const resolved = await page.evaluate(resolveGridScript(formNum, table));
    if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
    gridSelector = resolved.gridSelector;
  }

  try {
  // 1. Switch tab if requested
  if (tab) {
    await clickElement(tab);
  }

  // 2. Add new row if requested
  let addedRowIdx = -1;
  if (add) {
    // Count rows before add — new row will be appended at this index
    addedRowIdx = await page.evaluate(`(() => {
      const grid = ${gridSelector
        ? `document.querySelector(${JSON.stringify(gridSelector)})`
        : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
      const body = grid?.querySelector('.gridBody');
      return body ? body.querySelectorAll('.gridLine').length : 0;
    })()`);
    await clickElement('Добавить', { table });
    // Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
    for (let aw = 0; aw < 6; aw++) {
      await page.waitForTimeout(150);
      const ready = await page.evaluate(`(() => {
        const f = document.activeElement;
        if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
        let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
        return false;
      })()`);
      if (ready) break;
    }
  }

  // 2b. Enter edit mode on existing row by dblclick
  if (row != null) {
    // Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right
    const sortedKeys = await page.evaluate(`(() => {
      const grid = ${gridSelector
        ? `document.querySelector(${JSON.stringify(gridSelector)})`
        : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
      if (!grid) return null;
      const head = grid.querySelector('.gridHead');
      if (!head) return null;
      const headLine = head.querySelector('.gridLine') || head;
      const cols = [];
      [...headLine.children].forEach(box => {
        if (box.offsetWidth === 0) return;
        const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
        const ci = parseInt(box.getAttribute('colindex') || '-1');
        if (t) cols.push({ text: t, colindex: ci });
      });
      const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))};
      const mapped = keys.map(k => {
        const exact = cols.find(c => c.text === k);
        if (exact) return { key: k, colindex: exact.colindex };
        const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
        return { key: k, colindex: inc ? inc.colindex : 999 };
      });
      mapped.sort((a, b) => a.colindex - b.colindex);
      return mapped.map(m => m.key);
    })()`);
    if (sortedKeys) {
      // Rebuild fields in sorted order
      const sortedFields = {};
      for (const kl of sortedKeys) {
        const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl);
        if (origKey) sortedFields[origKey] = fields[origKey];
      }
      // Add any keys not matched in header (preserve original order for those)
      for (const k of Object.keys(fields)) {
        if (!(k in sortedFields)) sortedFields[k] = fields[k];
      }
      fields = sortedFields;
    }

    const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()));
    const cellCoords = await page.evaluate(`(() => {
      const grid = ${gridSelector
        ? `document.querySelector(${JSON.stringify(gridSelector)})`
        : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
      if (!grid) return { error: 'no_grid' };
      const head = grid.querySelector('.gridHead');
      const body = grid.querySelector('.gridBody');
      if (!head || !body) return { error: 'no_grid_body' };

      // Read column headers to find target colindex
      const headLine = head.querySelector('.gridLine') || head;
      const cols = [];
      [...headLine.children].forEach(box => {
        if (box.offsetWidth === 0) return;
        const t = box.querySelector('.gridBoxText');
        const ci = box.getAttribute('colindex');
        cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
      });

      const keys = ${fieldKeys};
      let targetColindex = null;
      for (const key of keys) {
        const exact = cols.find(c => c.text === key);
        if (exact) { targetColindex = exact.colindex; break; }
        const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
        if (inc) { targetColindex = inc.colindex; break; }
      }

      const rows = [...body.querySelectorAll('.gridLine')];
      if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
      const line = rows[${row}];

      // Find body cell by colindex (reliable across merged headers)
      let box = null;
      if (targetColindex != null) {
        box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
      }
      // Fallback: second visible box (skip checkbox/N column)
      if (!box) {
        const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
        box = boxes.length > 1 ? boxes[1] : boxes[0];
      }
      if (!box) return { error: 'no_cell' };
      // Scroll into view if off-screen
      box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
      const cell = box.querySelector('.gridBoxText') || box;
      const r = cell.getBoundingClientRect();
      const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
    })()`);

    if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);

    // Skip if cell already contains the desired value (single-field optimization)
    const firstKey0 = Object.keys(fields)[0];
    const rawFirstVal = fields[firstKey0];
    const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === ''
      ? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal));
    let firstFieldSkipped = false;
    if (cellCoords.currentText && firstVal0 &&
        cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) {
      firstFieldSkipped = true;
      if (Object.keys(fields).length === 1) {
        return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }];
      }
    }

    // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse).
    // Then escalate: dblclick → F4 if needed.
    await page.mouse.click(cellCoords.x, cellCoords.y);

    // Clear cell via Shift+F4 if value is empty
    if (firstVal0 === '') {
      await page.waitForTimeout(500);
      // Check if click opened a selection form — close it first
      let openedForm = await page.evaluate(`(() => {
        const forms = {};
        document.querySelectorAll('[id]').forEach(el => {
          if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
          const m = el.id.match(/^form(\\d+)_/);
          if (m) forms[m[1]] = true;
        });
        const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
        return nums.length > 0 ? Math.max(...nums) : null;
      })()`);
      if (openedForm !== null) {
        await page.keyboard.press('Escape');
        await page.waitForTimeout(500);
      } else {
        // No form opened — need to enter edit mode first (dblclick), then close any form that opens
        await page.mouse.dblclick(cellCoords.x, cellCoords.y);
        await page.waitForTimeout(500);
        openedForm = await page.evaluate(`(() => {
          const forms = {};
          document.querySelectorAll('[id]').forEach(el => {
            if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
            const m = el.id.match(/^form(\\d+)_/);
            if (m) forms[m[1]] = true;
          });
          const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
          return nums.length > 0 ? Math.max(...nums) : null;
        })()`);
        if (openedForm !== null) {
          await page.keyboard.press('Escape');
          await page.waitForTimeout(500);
        }
      }
      await page.keyboard.press('Shift+F4');
      await page.waitForTimeout(300);
      const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }];
      // If more fields remain, process them on the same row
      const remaining = { ...fields };
      delete remaining[firstKey0];
      if (Object.keys(remaining).length > 0) {
        const more = await fillTableRow(remaining, { row, table });
        if (Array.isArray(more)) results.push(...more);
        else if (more?.filled) results.push(...more.filled);
      }
      const formData = await getFormState();
      return { filled: results, form: formData };
    }

    // Check if clicked cell is a checkbox (toggle-on-click, no edit mode)
    const checkboxInfo = await page.evaluate(`(() => {
      const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y});
      const cell = el?.closest('.gridBox');
      if (!cell) return null;
      const chk = cell.querySelector('.checkbox');
      if (!chk) return null;
      const r = chk.getBoundingClientRect();
      return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
    })()`);
    if (checkboxInfo !== null) {
      // Checkbox cell found — click directly on the checkbox icon (not cell center)
      const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim());
      if (checkboxInfo.checked !== desired) {
        await page.mouse.click(checkboxInfo.x, checkboxInfo.y);
        await page.waitForTimeout(300);
      }
      const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }];
      await waitForStable(formNum);
      // If more fields remain, process them on the same row
      const remaining = { ...fields };
      delete remaining[firstKey0];
      if (Object.keys(remaining).length > 0) {
        const more = await fillTableRow(remaining, { row, table });
        results.push(...more);
      }
      return results;
    }

    let inEdit = false;
    let directEditForm = null;
    for (let dw = 0; dw < 4; dw++) {
      await page.waitForTimeout(150);
      inEdit = await page.evaluate(`(() => {
        const f = document.activeElement;
        return f && f.tagName === 'INPUT';
      })()`);
      if (inEdit) break;
      directEditForm = await page.evaluate(`(() => {
        const forms = {};
        document.querySelectorAll('[id]').forEach(el => {
          if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
          const m = el.id.match(/^form(\\d+)_/);
          if (m) forms[m[1]] = true;
        });
        const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
        return nums.length > 0 ? Math.max(...nums) : null;
      })()`);
      if (directEditForm !== null) break;
    }
    // Click didn't enter edit — try dblclick (works for flat grids)
    if (!inEdit && directEditForm === null) {
      await page.mouse.dblclick(cellCoords.x, cellCoords.y);
      for (let dw = 0; dw < 4; dw++) {
        await page.waitForTimeout(150);
        inEdit = await page.evaluate(`(() => {
          const f = document.activeElement;
          return f && f.tagName === 'INPUT';
        })()`);
        if (inEdit) break;
        directEditForm = await page.evaluate(`(() => {
          const forms = {};
          document.querySelectorAll('[id]').forEach(el => {
            if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
            const m = el.id.match(/^form(\\d+)_/);
            if (m) forms[m[1]] = true;
          });
          const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
          return nums.length > 0 ? Math.max(...nums) : null;
        })()`);
        if (directEditForm !== null) break;
      }
    }
    // Still nothing — try F4 (opens selection for direct-edit cells)
    if (!inEdit && directEditForm === null) {
      await page.keyboard.press('F4');
      for (let fw = 0; fw < 8; fw++) {
        await page.waitForTimeout(200);
        inEdit = await page.evaluate(`(() => {
          const f = document.activeElement;
          return f && f.tagName === 'INPUT';
        })()`);
        if (inEdit) break;
        directEditForm = await page.evaluate(`(() => {
          const forms = {};
          document.querySelectorAll('[id]').forEach(el => {
            if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
            const m = el.id.match(/^form(\\d+)_/);
            if (m) forms[m[1]] = true;
          });
          const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
          return nums.length > 0 ? Math.max(...nums) : null;
        })()`);
        if (directEditForm !== null) break;
      }
    }

    // When click entered INPUT mode but no selection form yet — try F4 only for tree grids
    // (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop)
    if (inEdit && directEditForm === null) {
      const isTreeGrid = await page.evaluate(`(() => {
        const grid = ${gridSelector
          ? `document.querySelector(${JSON.stringify(gridSelector)})`
          : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
        return grid ? !!grid.querySelector('.gridBoxTree') : false;
      })()`);
      if (isTreeGrid) {
        await page.keyboard.press('F4');
        for (let fw = 0; fw < 8; fw++) {
          await page.waitForTimeout(200);
          directEditForm = await page.evaluate(`(() => {
            const forms = {};
            document.querySelectorAll('[id]').forEach(el => {
              if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
              const m = el.id.match(/^form(\\d+)_/);
              if (m) forms[m[1]] = true;
            });
            const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
            return nums.length > 0 ? Math.max(...nums) : null;
          })()`);
          if (directEditForm !== null) break;
        }
        // If F4 didn't open a selection form, fall through to Tab loop
      }
    }

    // Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing).
    // Handle each field by picking from selection form, then dblclick next cell.
    if (directEditForm !== null) {
      const pending = new Map();
      for (const [key, val] of Object.entries(fields)) {
        if (val && typeof val === 'object' && 'value' in val) {
          pending.set(key, { value: String(val.value), type: val.type || null, filled: false });
        } else {
          pending.set(key, { value: String(val), type: null, filled: false });
        }
      }
      const results = [];

      // Helper: handle type dialog + pick from selection form
      async function directEditPick(openedForm, key, info) {
        let selForm = openedForm;
        // Check if opened form is a type selection dialog (composite type field)
        if (await isTypeDialog(selForm)) {
          if (info.type) {
            await pickFromTypeDialog(selForm, info.type);
            await waitForStable(selForm);
            // After type selection, detect the actual selection form
            selForm = await page.evaluate(`(() => {
              const forms = {};
              document.querySelectorAll('[id]').forEach(el => {
                if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
                const m = el.id.match(/^form(\\d+)_/);
                if (m) forms[m[1]] = true;
              });
              const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
              return nums.length > 0 ? Math.max(...nums) : null;
            })()`);
            if (selForm === null) {
              return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
            }
          } else {
            // No type specified — close type dialog and report error
            await page.keyboard.press('Escape');
            await page.waitForTimeout(300);
            return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` };
          }
        }
        const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
        return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message };
      }

      // First field: selection form is already open from the dblclick above
      const firstKey = Object.keys(fields)[0];
      const firstInfo = pending.get(firstKey);
      if (firstFieldSkipped) {
        firstInfo.filled = true;
        results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText });
        // Close the selection form that opened from the click
        await page.keyboard.press('Escape');
        await waitForStable(formNum);
      } else {
        const pickResult = await directEditPick(directEditForm, firstKey, firstInfo);
        firstInfo.filled = true;
        results.push(pickResult);
      }

      // Remaining fields: dblclick on each column cell individually
      for (const [key, info] of pending) {
        if (info.filled) continue;
        // Find column for this key and dblclick on it
        const nextCoords = await page.evaluate(`(() => {
          const grid = ${gridSelector
            ? `document.querySelector(${JSON.stringify(gridSelector)})`
            : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
          if (!grid) return null;
          const head = grid.querySelector('.gridHead');
          const body = grid.querySelector('.gridBody');
          if (!head || !body) return null;
          const headLine = head.querySelector('.gridLine') || head;
          const cols = [];
          [...headLine.children].forEach(box => {
            if (box.offsetWidth === 0) return;
            const t = box.querySelector('.gridBoxText');
            const ci = box.getAttribute('colindex');
            cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
          });
          const kl = ${JSON.stringify(key.toLowerCase())};
          const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
          let targetColindex = null;
          const exact = cols.find(c => c.text === kl);
          if (exact) targetColindex = exact.colindex;
          else {
            const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
              || c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
            if (inc) targetColindex = inc.colindex;
          }
          if (targetColindex == null) return null;
          const rows = [...body.querySelectorAll('.gridLine')];
          if (${row} >= rows.length) return null;
          const line = rows[${row}];
          const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
          if (!box) return null;
          box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
          const cell = box.querySelector('.gridBoxText') || box;
          const r = cell.getBoundingClientRect();
          const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
          return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
        })()`);
        if (!nextCoords) {
          info.filled = true;
          results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` });
          continue;
        }
        // Skip if cell already contains the desired value
        if (nextCoords.currentText && info.value &&
            nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) {
          info.filled = true;
          results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText });
          continue;
        }
        await page.mouse.dblclick(nextCoords.x, nextCoords.y);
        await page.waitForTimeout(300);
        // Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator
        const inInputAfterDblclick = await page.evaluate(`(() => {
          const f = document.activeElement;
          if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
          let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
          return false;
        })()`);
        // Also check if a selection form already appeared
        let selForm = await page.evaluate(`(() => {
          const forms = {};
          document.querySelectorAll('[id]').forEach(el => {
            if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
            const m = el.id.match(/^form(\\d+)_/);
            if (m) forms[m[1]] = true;
          });
          const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
          return nums.length > 0 ? Math.max(...nums) : null;
        })()`);
        if (selForm === null && inInputAfterDblclick) {
          // Plain text/numeric field — fill via clipboard paste
          await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(info.value)})`);
          await page.keyboard.press('Control+a');
          await page.keyboard.press('Control+v');
          await page.waitForTimeout(400);
          // Dismiss EDD autocomplete if it appeared
          const hasEdd = await page.evaluate(`(() => {
            const edd = document.getElementById('editDropDown');
            return edd && edd.offsetWidth > 0;
          })()`);
          if (hasEdd) {
            await page.keyboard.press('Escape');
            await page.waitForTimeout(200);
          }
          info.filled = true;
          results.push({ field: key, ok: true, method: 'paste' });
          continue;
        }
        // Poll for selection form (with F4 fallback if dblclick didn't open it)
        if (selForm === null) {
          for (let attempt = 0; attempt < 2 && selForm === null; attempt++) {
            if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback
            for (let sw = 0; sw < 6; sw++) {
              await page.waitForTimeout(200);
              selForm = await page.evaluate(`(() => {
                const forms = {};
                document.querySelectorAll('[id]').forEach(el => {
                  if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
                  const m = el.id.match(/^form(\\d+)_/);
                  if (m) forms[m[1]] = true;
                });
                const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
                return nums.length > 0 ? Math.max(...nums) : null;
              })()`);
              if (selForm !== null) break;
            }
          }
        }
        if (selForm === null) {
          info.filled = true;
          results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` });
          continue;
        }
        const pr = await directEditPick(selForm, key, info);
        info.filled = true;
        results.push(pr);
      }
      // Commit the edit: click on a different row (Escape cancels in tree grids).
      // Find the first visible row that is NOT the edited row and click it.
      const commitCoords = await page.evaluate(`(() => {
        const grid = ${gridSelector
          ? `document.querySelector(${JSON.stringify(gridSelector)})`
          : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
        if (!grid) return null;
        const body = grid.querySelector('.gridBody');
        if (!body) return null;
        const rows = [...body.querySelectorAll('.gridLine')];
        const otherIdx = ${row} === 0 ? 1 : 0;
        const other = rows[otherIdx];
        if (!other) return null;
        const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
        const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
        if (!box) return null;
        const r = box.getBoundingClientRect();
        return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
      })()`);
      if (commitCoords) {
        await page.mouse.click(commitCoords.x, commitCoords.y);
      } else {
        await page.keyboard.press('Escape');
      }
      await waitForStable(formNum);
      return results;
    }

    if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`);
  } else {
    // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent)
    const editCheck = await page.evaluate(`(() => {
      const f = document.activeElement;
      if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
      let node = f;
      while (node) {
        if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
        node = node.parentElement;
      }
      return { inEdit: false, hint: 'input not inside grid' };
    })()`);

    if (!editCheck.inEdit) {
      throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.');
    }
  }

  // 4. Prepare pending fields for fuzzy matching
  const pending = new Map();
  for (const [key, val] of Object.entries(fields)) {
    if (val === null || val === undefined || val === '') {
      pending.set(key, { value: '', type: null, filled: false });
    } else if (val && typeof val === 'object' && 'value' in val) {
      const innerVal = val.value;
      pending.set(key, {
        value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal),
        type: val.type || null, filled: false
      });
    } else {
      pending.set(key, { value: String(val), type: null, filled: false });
    }
  }

  const results = [];
  const MAX_ITER = 40;
  let prevCellId = null;
  let nonInputCount = 0;
  let firstCellId = null;

  for (let iter = 0; iter < MAX_ITER; iter++) {
    // Read focused element (INPUT or TEXTAREA inside grid = editable cell)
    const cell = await page.evaluate(`(() => {
      const f = document.activeElement;
      if (!f) return { tag: 'none' };
      if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
        const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
        if (inGrid) {
          let headerText = '';
          let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
          if (grid) {
            const fr = f.getBoundingClientRect();
            const head = grid.querySelector('.gridHead');
            const hl = head?.querySelector('.gridLine') || head;
            if (hl) for (const h of hl.children) {
              if (h.offsetWidth === 0) continue;
              const hr = h.getBoundingClientRect();
              if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
                const t = h.querySelector('.gridBoxText');
                headerText = (t || h).innerText?.trim() || '';
                break;
              }
            }
          }
          return {
            tag: 'INPUT', id: f.id,
            fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
            headerText
          };
        }
      }
      return { tag: f.tagName || 'none' };
    })()`);

    if (cell.tag !== 'INPUT' || !cell.fullName) {
      // Not in an editable grid cell — Tab past (ERP has DIV focus between cells)
      nonInputCount++;
      // If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows
      const onlyCheckboxLeft = [...pending.values()].every(p => p.filled ||
        ['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim()));
      if (nonInputCount > 3 || onlyCheckboxLeft) break;
      await page.keyboard.press('Tab');
      await page.waitForTimeout(300);
      continue;
    }
    nonInputCount = 0;

    // Track first cell to detect wrap-around (Tab looped back to row start)
    if (firstCellId === null) firstCellId = cell.id;
    else if (cell.id === firstCellId) break; // wrapped around — all cells visited

    // Stuck detection: same cell twice in a row → force Tab
    if (cell.id === prevCellId) {
      await page.keyboard.press('Tab');
      await page.waitForTimeout(500);
      prevCellId = null;
      continue;
    }
    prevCellId = cell.id;

    // Fuzzy match cell name to user field: exact → suffix → includes → no-space includes
    const cellLower = cell.fullName.toLowerCase();
    let matchedKey = null;
    for (const [key, info] of pending) {
      if (info.filled) continue;
      const kl = key.toLowerCase();
      if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) {
        matchedKey = key;
        break;
      }
      // CamelCase cell names have no spaces/dashes — try matching without spaces and dashes
      const klNoSpace = kl.replace(/[\s\-]+/g, '');
      if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) {
        matchedKey = key;
        break;
      }
    }

    // Fallback: match by column header text (handles metadata typos in cell id)
    if (!matchedKey && cell.headerText) {
      const htLower = cell.headerText.toLowerCase();
      for (const [key, info] of pending) {
        if (info.filled) continue;
        const kl = key.toLowerCase();
        if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) {
          matchedKey = key;
          break;
        }
      }
    }

    if (!matchedKey) {
      // Skip this cell
      await page.keyboard.press('Tab');
      await page.waitForTimeout(300);
      continue;
    }

    const info = pending.get(matchedKey);
    const text = info.value;

    // Clear cell if value is empty (Shift+F4 = native 1C clear)
    if (text === '') {
      await page.keyboard.press('Shift+F4');
      await page.waitForTimeout(300);
      info.filled = true;
      results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' });
      if ([...pending.values()].every(p => p.filled)) break;
      await page.keyboard.press('Tab');
      await page.waitForTimeout(500);
      continue;
    }

    // If user specified a type, always clear and use type selection flow
    if (info.type) {
      await page.keyboard.press('Shift+F4');  // Clear cell to reset any inherited type
      await page.waitForTimeout(300);
      await page.keyboard.press('F4');
      // Poll for type dialog form to appear
      let typeForm = null;
      for (let tw = 0; tw < 6; tw++) {
        await page.waitForTimeout(200);
        typeForm = await page.evaluate(`(() => {
          const forms = {};
          document.querySelectorAll('[id]').forEach(el => {
            if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
            const m = el.id.match(/^form(\\d+)_/);
            if (m) forms[m[1]] = true;
          });
          const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
          return nums.length > 0 ? Math.max(...nums) : null;
        })()`);
        if (typeForm !== null) break;
      }
      if (typeForm !== null && await isTypeDialog(typeForm)) {
        await pickFromTypeDialog(typeForm, info.type);
        await waitForStable(typeForm);
        // After type selection, check if a selection form opened (ref types)
        const selForm = await page.evaluate(`(() => {
          const forms = {};
          document.querySelectorAll('[id]').forEach(el => {
            if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
            const m = el.id.match(/^form(\\d+)_/);
            if (m) forms[m[1]] = true;
          });
          const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
          return nums.length > 0 ? Math.max(...nums) : null;
        })()`);
        if (selForm === null) {
          // Primitive type — poll for calculator/calendar popup or settle on INPUT
          let hasPopup = null;
          for (let pw = 0; pw < 5; pw++) {
            await page.waitForTimeout(200);
            hasPopup = await page.evaluate(`(() => {
              const calc = document.querySelector('.calculate');
              if (calc && calc.offsetWidth > 0) return 'calculator';
              const cal = document.querySelector('.frameCalendar');
              if (cal && cal.offsetWidth > 0) return 'calendar';
              return null;
            })()`);
            if (hasPopup) break;
          }
          if (hasPopup) {
            await page.keyboard.press('Escape');
            // Poll for popup to disappear
            for (let dw = 0; dw < 4; dw++) {
              await page.waitForTimeout(150);
              const gone = await page.evaluate(`(() => {
                const calc = document.querySelector('.calculate');
                if (calc && calc.offsetWidth > 0) return false;
                const cal = document.querySelector('.frameCalendar');
                if (cal && cal.offsetWidth > 0) return false;
                return true;
              })()`);
              if (gone) break;
            }
          }
          // Ensure we are in an editable INPUT for this cell
          const inInput = await page.evaluate(`(() => {
            const f = document.activeElement;
            return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
          })()`);
          if (!inInput) {
            const cellRect = await page.evaluate(`(() => {
              const el = document.getElementById(${JSON.stringify(cell.id)});
              if (!el) return null;
              const r = el.getBoundingClientRect();
              return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
            })()`);
            if (cellRect) {
              await page.mouse.dblclick(cellRect.x, cellRect.y);
              // Poll for INPUT focus
              for (let fw = 0; fw < 4; fw++) {
                await page.waitForTimeout(150);
                const ok = await page.evaluate(`(() => {
                  const f = document.activeElement;
                  return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
                })()`);
                if (ok) break;
              }
            }
          }
          await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
          await page.keyboard.press('Control+a');
          await page.keyboard.press('Control+v');
          await page.waitForTimeout(400);
          await page.keyboard.press('Tab');
          await page.waitForTimeout(300);
          info.filled = true;
          results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
          continue;
        }
        const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
        info.filled = true;
        results.push(pickResult.ok
          ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
          : { field: matchedKey, cell: cell.fullName,
              error: pickResult.error, message: pickResult.message });
        continue;
      }
      // F4 opened something but not a type dialog — close and report
      if (typeForm !== null) {
        await page.keyboard.press('Escape');
        await page.waitForTimeout(300);
      }
      info.filled = true;
      results.push({ field: matchedKey, cell: cell.fullName,
        error: 'type_dialog_failed',
        message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` });
      await page.keyboard.press('Tab');
      await page.waitForTimeout(500);
      continue;
    }

    // === Fill this cell: clipboard paste (trusted event) ===
    await page.keyboard.press('Control+A');
    await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
    await page.keyboard.press('Control+V');
    await page.waitForTimeout(1500);

    // Check if paste was rejected (composite-type cell blocks text input until type is selected)
    const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`);
    if (!inputAfterPaste && text) {
      // No type specified — can't fill this composite-type cell
      info.filled = true;
      results.push({ field: matchedKey, cell: cell.fullName,
        error: 'type_required',
        message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` });
      await page.keyboard.press('Tab');
      await page.waitForTimeout(500);
      continue;
    }

    // Check for EDD autocomplete (indicates reference field)
    const eddItems = await page.evaluate(`(() => {
      const edd = document.getElementById('editDropDown');
      if (!edd || edd.offsetWidth === 0) return null;
      return [...edd.querySelectorAll('.eddText')]
        .filter(el => el.offsetWidth > 0)
        .map(el => el.innerText?.trim() || '');
    })()`);

    if (eddItems && eddItems.length > 0) {
      // Reference field with autocomplete — click best match
      // Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...)
      // but keep standalone enum values like "Создать" (no space/colon after)
      const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i));

      if (realItems.length > 0) {
        const tgt = normYo(text.toLowerCase());
        let pick = realItems.find(i =>
          normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt);
        if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt));
        if (!pick) pick = realItems[0];

        // Click EDD item via dispatchEvent (bypasses div.surface overlay)
        const pickLower = pick.toLowerCase();
        await page.evaluate(`(() => {
          const edd = document.getElementById('editDropDown');
          if (!edd) return;
          for (const el of edd.querySelectorAll('.eddText')) {
            if (el.offsetWidth === 0) continue;
            if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) {
              const r = el.getBoundingClientRect();
              const opts = { bubbles:true, cancelable:true,
                clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
              el.dispatchEvent(new MouseEvent('mousedown', opts));
              el.dispatchEvent(new MouseEvent('mouseup', opts));
              el.dispatchEvent(new MouseEvent('click', opts));
              return;
            }
          }
        })()`);
        await waitForStable();
        info.filled = true;
        results.push({ field: matchedKey, cell: cell.fullName, ok: true,
          method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') });
      } else {
        // Only "Создать:" items — value not found in autocomplete
        await page.keyboard.press('Escape');
        await page.waitForTimeout(300);
        info.filled = true;
        results.push({ field: matchedKey, cell: cell.fullName,
          error: 'not_found', message: `No match for "${text}"` });
      }

      // Done? If so, don't Tab (avoids creating a new row after last cell)
      if ([...pending.values()].every(p => p.filled)) break;
      // Tab to move to next cell
      await page.keyboard.press('Tab');
      await page.waitForTimeout(500);
      continue;
    }

    // No EDD — press Tab to commit the value
    await page.keyboard.press('Tab');
    await page.waitForTimeout(1000);

    // Check for "нет в списке" cloud popup (reference field, value not found)
    const notInList = await page.evaluate(`(() => {
      for (const el of document.querySelectorAll('div')) {
        if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
        const s = getComputedStyle(el);
        if (s.position !== 'absolute' && s.position !== 'fixed') continue;
        if ((parseInt(s.zIndex) || 0) < 100) continue;
        if ((el.innerText || '').includes('нет в списке')) return true;
      }
      return false;
    })()`);

    if (notInList) {
      // Cloud has "Показать все" link — try to open selection form via it
      const clickedShowAll = await page.evaluate(`(() => {
        for (const el of document.querySelectorAll('div')) {
          if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
          const s = getComputedStyle(el);
          if (s.position !== 'absolute' && s.position !== 'fixed') continue;
          if ((parseInt(s.zIndex) || 0) < 100) continue;
          if (!(el.innerText || '').includes('нет в списке')) continue;
          // Found the cloud — look for "Показать все" hyperlink inside
          const links = [...el.querySelectorAll('a, span, div')]
            .filter(e => e.offsetWidth > 0 && e.children.length === 0);
          const showAll = links.find(e => {
            const t = (e.innerText?.trim() || '').toLowerCase();
            return t === 'показать все' || t === 'show all';
          });
          if (showAll) {
            const r = showAll.getBoundingClientRect();
            const opts = { bubbles:true, cancelable:true,
              clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
            showAll.dispatchEvent(new MouseEvent('mousedown', opts));
            showAll.dispatchEvent(new MouseEvent('mouseup', opts));
            showAll.dispatchEvent(new MouseEvent('click', opts));
            return true;
          }
          return false;
        }
        return false;
      })()`);

      if (clickedShowAll) {
        await waitForStable(formNum);
        // Check if selection form opened
        const selForm = await page.evaluate(`(() => {
          const forms = {};
          document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => {
            if (el.offsetWidth === 0) return;
            const m = el.id.match(/^form(\\d+)_/);
            if (m) forms[m[1]] = true;
          });
          const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
          return nums.length > 0 ? Math.max(...nums) : null;
        })()`);

        if (selForm !== null) {
          const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
          info.filled = true;
          if (pickResult.ok) {
            results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' });
            continue;
          }
          // Not found in selection form — fall through to clear + skip
          results.push({ field: matchedKey, cell: cell.fullName,
            error: pickResult.error, message: pickResult.message });
        } else {
          info.filled = true;
          results.push({ field: matchedKey, cell: cell.fullName,
            error: 'not_found', message: `Value "${text}" not in list` });
        }
      } else {
        info.filled = true;
        results.push({ field: matchedKey, cell: cell.fullName,
          error: 'not_found', message: `Value "${text}" not in list` });
      }

      // 1C won't let us Tab away from an invalid ref value.
      // Must clear the field first, then Tab to move on.
      // Escape dismisses the cloud; Ctrl+A + Delete clears the text.
      await page.keyboard.press('Escape');
      await page.waitForTimeout(300);
      await page.keyboard.press('Control+A');
      await page.keyboard.press('Delete');
      await page.waitForTimeout(300);
      await page.keyboard.press('Tab');
      await page.waitForTimeout(500);
      continue;
    }

    // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs)
    const newForm = await page.evaluate(`(() => {
      const forms = {};
      document.querySelectorAll('[id]').forEach(el => {
        if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
        const m = el.id.match(/^form(\\d+)_/);
        if (m) forms[m[1]] = true;
      });
      const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
      return nums.length > 0 ? Math.max(...nums) : null;
    })()`);

    if (newForm !== null) {
      if (await isTypeDialog(newForm)) {
        // Composite-type cell — need type to proceed
        if (info.type) {
          await pickFromTypeDialog(newForm, info.type);
          await waitForStable(newForm);
          // After type selection, the actual selection form should open
          const selForm = await page.evaluate(`(() => {
            const forms = {};
            document.querySelectorAll('[id]').forEach(el => {
              if (el.offsetWidth === 0 && el.offsetHeight === 0) return;
              const m = el.id.match(/^form(\\d+)_/);
              if (m) forms[m[1]] = true;
            });
            const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum});
            return nums.length > 0 ? Math.max(...nums) : null;
          })()`);
          if (selForm === null) {
            // Primitive type — poll for calculator/calendar popup or settle on INPUT
            let hasPopup = null;
            for (let pw = 0; pw < 5; pw++) {
              await page.waitForTimeout(200);
              hasPopup = await page.evaluate(`(() => {
                const calc = document.querySelector('.calculate');
                if (calc && calc.offsetWidth > 0) return 'calculator';
                const cal = document.querySelector('.frameCalendar');
                if (cal && cal.offsetWidth > 0) return 'calendar';
                return null;
              })()`);
              if (hasPopup) break;
            }
            if (hasPopup) {
              await page.keyboard.press('Escape');
              for (let dw = 0; dw < 4; dw++) {
                await page.waitForTimeout(150);
                const gone = await page.evaluate(`(() => {
                  const calc = document.querySelector('.calculate');
                  if (calc && calc.offsetWidth > 0) return false;
                  const cal = document.querySelector('.frameCalendar');
                  if (cal && cal.offsetWidth > 0) return false;
                  return true;
                })()`);
                if (gone) break;
              }
            }
            const inInput = await page.evaluate(`(() => {
              const f = document.activeElement;
              return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
            })()`);
            if (!inInput) {
              const cellRect = await page.evaluate(`(() => {
                const el = document.getElementById(${JSON.stringify(cell.id)});
                if (!el) return null;
                const r = el.getBoundingClientRect();
                return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
              })()`);
              if (cellRect) {
                await page.mouse.dblclick(cellRect.x, cellRect.y);
                for (let fw = 0; fw < 4; fw++) {
                  await page.waitForTimeout(150);
                  const ok = await page.evaluate(`(() => {
                    const f = document.activeElement;
                    return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA');
                  })()`);
                  if (ok) break;
                }
              }
            }
            await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(text)})`);
            await page.keyboard.press('Control+a');
            await page.keyboard.press('Control+v');
            await page.waitForTimeout(400);
            await page.keyboard.press('Tab');
            await page.waitForTimeout(300);
            info.filled = true;
            results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
            continue;
          }
          const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
          info.filled = true;
          results.push(pickResult.ok
            ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
            : { field: matchedKey, cell: cell.fullName,
                error: pickResult.error, message: pickResult.message });
          continue;
        } else {
          // No type specified — close dialog, clear cell, report error
          await page.keyboard.press('Escape');
          await page.waitForTimeout(300);
          await page.keyboard.press('Control+A');
          await page.keyboard.press('Delete');
          await page.waitForTimeout(300);
          await page.keyboard.press('Tab');
          await page.waitForTimeout(500);
          info.filled = true;
          results.push({ field: matchedKey, cell: cell.fullName,
            error: 'type_required',
            message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` });
          continue;
        }
      }
      // Not a type dialog — normal selection form
      const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum);
      info.filled = true;
      results.push(pickResult.ok
        ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
        : { field: matchedKey, cell: cell.fullName,
            error: pickResult.error, message: pickResult.message });
      continue;
    }

    // Plain field — value committed via Tab
    info.filled = true;
    results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' });

    // All done?
    if ([...pending.values()].every(p => p.filled)) break;
    // Tab already pressed — we're on next cell
  }

  // Commit the new row: click on the grid header to exit edit mode.
  // Clicking a different data row would re-enter edit mode on that row.
  // Without this commit click, the row stays in "uncommitted add" state
  // and a subsequent Escape (e.g. from closeForm) would cancel the entire row.
  const commitTarget = await page.evaluate(`(() => {
    const grid = ${gridSelector
      ? `document.querySelector(${JSON.stringify(gridSelector)})`
      : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
    if (!grid) return null;
    const head = grid.querySelector('.gridHead');
    if (head) {
      const r = head.getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
    }
    return null;
  })()`);
  if (commitTarget) {
    await page.mouse.click(commitTarget.x, commitTarget.y);
    await page.waitForTimeout(500);
  } else {
    // Fallback: Tab out of the last cell to commit the row
    await page.keyboard.press('Tab');
    await page.waitForTimeout(500);
  }

  // Dismiss any leftover error modals
  const err = await checkForErrors();
  if (err?.modal) {
    try {
      const btn = await page.$('a.press.pressDefault');
      if (btn) { await btn.click(); await page.waitForTimeout(500); }
    } catch { /* OK */ }
  }

  const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key);

  // Retry unfilled checkbox fields via direct click (Tab skips checkbox cells)
  if (notFilled.length > 0) {
    const checkboxFields = {};
    for (const key of notFilled) {
      const val = String(pending.get(key).value).toLowerCase().trim();
      if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) {
        checkboxFields[key] = pending.get(key).value;
      }
    }
    if (Object.keys(checkboxFields).length > 0) {
      // Use row index: addedRowIdx (from add mode) or fallback to selected row
      const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => {
        const grid = ${gridSelector
          ? `document.querySelector(${JSON.stringify(gridSelector)})`
          : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
        if (!grid) return -1;
        const body = grid.querySelector('.gridBody');
        if (!body) return -1;
        const lines = [...body.querySelectorAll('.gridLine')];
        const sel = lines.findIndex(l => l.classList.contains('selected'));
        return sel >= 0 ? sel : lines.length - 1;
      })()`)
      );
      if (currentRow >= 0) {
        const more = await fillTableRow(checkboxFields, { row: currentRow, table });
        if (Array.isArray(more)) {
          results.push(...more);
        } else if (more?.filled) {
          results.push(...more.filled);
        }
        for (const key of Object.keys(checkboxFields)) {
          const idx = notFilled.indexOf(key);
          if (idx >= 0) notFilled.splice(idx, 1);
        }
      }
    }
  }

  const formData = await getFormState();
  const result = { filled: results };
  if (notFilled.length > 0) result.notFilled = notFilled;
  result.form = formData;
  return result;

  } catch (e) {
    if (e.message.startsWith('fillTableRow:')) throw e;
    throw new Error(`fillTableRow: ${e.message}`);
  }
}

/**
 * Delete a row from the current table part.
 * Single click to select the row, then Delete key to remove it.
 *
 * @param {number} row - 0-based row index to delete
 * @param {Object} [options]
 * @param {string} [options.tab] - Switch to this form tab before operating
 * @returns {{ deleted, rowsBefore, rowsAfter, form }}
 */
export async function deleteTableRow(row, { tab, table } = {}) {
  ensureConnected();
  await dismissPendingErrors();
  const formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error('deleteTableRow: no form found');

  // Pre-resolve grid when table is specified
  let gridSelector;
  if (table) {
    const resolved = await page.evaluate(resolveGridScript(formNum, table));
    if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
    gridSelector = resolved.gridSelector;
  }

  // 1. Switch tab if requested
  if (tab) {
    await clickElement(tab);
    await page.waitForTimeout(500);
  }

  // 2. Find the target row and click to select it
  const cellCoords = await page.evaluate(`(() => {
    const grid = ${gridSelector
      ? `document.querySelector(${JSON.stringify(gridSelector)})`
      : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
    if (!grid) return { error: 'no_grid' };
    const body = grid.querySelector('.gridBody');
    if (!body) return { error: 'no_grid_body' };
    const rows = [...body.querySelectorAll('.gridLine')];
    if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
    const line = rows[${row}];
    // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
    const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
    // Skip first column (row number / checkbox) — pick second visible box
    const box = boxes.length > 1 ? boxes[1] : boxes[0];
    if (!box) return { error: 'no_cell' };
    const cell = box.querySelector('.gridBoxText') || box;
    const r = cell.getBoundingClientRect();
    return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
  })()`);

  if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);

  const rowsBefore = cellCoords.total;

  // Single click to select the row
  await page.mouse.click(cellCoords.x, cellCoords.y);
  await page.waitForTimeout(300);

  // 3. Press Delete to remove the row
  await page.keyboard.press('Delete');
  await waitForStable();

  // 4. Count rows after deletion
  const rowsAfter = await page.evaluate(`(() => {
    const grid = ${gridSelector
      ? `document.querySelector(${JSON.stringify(gridSelector)})`
      : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`};
    if (!grid) return 0;
    const body = grid.querySelector('.gridBody');
    return body ? body.querySelectorAll('.gridLine').length : 0;
  })()`);

  const formData = await getFormState();
  return { deleted: row, rowsBefore, rowsAfter, form: formData };
}

/**
 * Filter the current list by field value, or search via search bar.
 *
 * Without field: simple search via the search bar (filters by all columns, no badge).
 * With field: advanced search — clicks target column cell to auto-populate FieldSelector,
 * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge.
 * Handles text, reference (with Tab autocomplete), and date fields automatically.
 * Multiple filters can be chained by calling filterList multiple times.
 *
 * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016")
 * @param {object} [opts]
 * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата")
 * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact)
 */
export async function filterList(text, { field, exact } = {}) {
  ensureConnected();
  await dismissPendingErrors();
  const formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error('filterList: no form found');

  if (!field) {
    // --- Simple search: fill search input + Enter ---
    const searchId = await page.evaluate(`(() => {
      const p = 'form${formNum}_';
      const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
        .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
      return el ? el.id : null;
    })()`);

    if (searchId) {
      await page.click(`[id="${searchId}"]`);
      await page.waitForTimeout(200);
      await page.keyboard.press('Control+A');
      await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
      await page.keyboard.press('Control+V');
      await page.waitForTimeout(300);
      await page.keyboard.press('Enter');
      await waitForStable(formNum);

      const state = await getFormState();
      state.filtered = { type: 'search', text };
      return state;
    }

    // No search input — Ctrl+F opens advanced search on such forms.
    // Click first grid cell then fall through to advanced search path below.
    const firstCell = await page.evaluate(`(() => {
      const p = 'form${formNum}_';
      const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
        .find(g => g.offsetWidth > 0);
      if (!grid) return null;
      const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
      if (!rows.length) return null;
      const cells = [...rows[0].querySelectorAll('.gridBox')];
      if (!cells.length) return null;
      const r = cells[0].getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
    })()`);
    if (!firstCell) throw new Error('filterList: no search input and no grid found on this form');
    await page.mouse.click(firstCell.x, firstCell.y);
    await page.waitForTimeout(300);
    field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected)
  }

  // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти ---
  // Clicking a cell in the target column makes it active, so when Alt+F opens the
  // advanced search dialog, FieldSelector is auto-populated with the correct field name.
  // This avoids changing FieldSelector programmatically (which can cause errors).
  const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim());

  // 1. Click a cell in the target column to activate it (auto-populates FieldSelector).
  //    If the column isn't visible in the grid, click any cell and use DLB fallback later.
  let needDlb = false;
  const gridEl = await page.evaluate(`(() => {
    const p = 'form${formNum}_';
    const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
      .find(g => g.offsetWidth > 0);
    if (!grid) return { error: 'no_grid' };
    const targetField = ${JSON.stringify(field)};
    const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
    let colIndex = -1;
    let startsWithIdx = -1;
    let includesIdx = -1;
    for (let i = 0; i < headers.length; i++) {
      const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
      if (!t) continue;
      const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
      const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
      if (tl === fl) { colIndex = i; break; }
      if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
      else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
    }
    if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
    const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
    if (!rows.length) return { error: 'no_rows' };
    if (colIndex < 0) {
      // Column not in grid — click first cell of first row, will use DLB to change field
      const cells = [...rows[0].querySelectorAll('.gridBox')];
      if (!cells.length) return { error: 'no_cells' };
      const r = cells[0].getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
    }
    const cells = [...rows[0].querySelectorAll('.gridBox')];
    if (colIndex >= cells.length) return { error: 'cell_not_found' };
    const r = cells[colIndex].getBoundingClientRect();
    return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
  })()`);
  if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`);
  needDlb = !!gridEl.needDlb;
  await page.mouse.click(gridEl.x, gridEl.y);
  await page.waitForTimeout(500);

  // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu)
  await page.keyboard.press('Alt+f');
  await page.waitForTimeout(2000);

  let dialogForm = await page.evaluate(detectFormScript());
  if (dialogForm === formNum) {
    // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск
    await clickElement('Еще');
    await page.waitForTimeout(500);
    const menu = await page.evaluate(readSubmenuScript());
    const searchItem = Array.isArray(menu) && menu.find(i =>
      i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск'));
    if (!searchItem) {
      await page.keyboard.press('Escape');
      throw new Error('filterList: advanced search dialog could not be opened');
    }
    await page.mouse.click(searchItem.x, searchItem.y);
    await page.waitForTimeout(2000);
    dialogForm = await page.evaluate(detectFormScript());
    if (dialogForm === formNum) {
      throw new Error('filterList: advanced search dialog did not open');
    }
  }

  // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown
  //     Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field)
  if (needDlb && field) {
    const fsInfo = await page.evaluate(`(() => {
      const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
      const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
        .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
      const dlb = document.getElementById(p + 'FieldSelector_DLB');
      return {
        current: fsInput?.value?.trim() || '',
        dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
        dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
      };
    })()`);

    if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) {
      await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY);
      await page.waitForTimeout(1500);

      const ddResult = await page.evaluate(`(() => {
        const edd = document.getElementById('editDropDown');
        if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
        const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
        const target = ny(${JSON.stringify(field.toLowerCase())});
        const items = [...edd.querySelectorAll('div')].filter(el =>
          el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
        const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
          || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
        if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
        const r = match.getBoundingClientRect();
        return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
      })()`);

      if (ddResult.error) {
        await page.keyboard.press('Escape');
        await page.waitForTimeout(500);
        await page.keyboard.press('Escape');
        await page.waitForTimeout(500);
        throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`);
      }
      await page.mouse.click(ddResult.x, ddResult.y);
      await page.waitForTimeout(3000);
    }
  }

  // 3. Read dialog state and fill Pattern
  //    Detect field type by Pattern's sibling buttons:
  //    - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value)
  //    - iDLB on Pattern → reference field (paste + Tab for autocomplete)
  //    - neither → plain text field (just paste)
  const dialogInfo = await page.evaluate(`(() => {
    const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
    const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
      .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
    const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
      .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
    const ptLabel = ptInput?.closest('label');
    const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
    const isDate = btns.some(c => c.includes('iCalendB'));
    const isRef = !isDate && btns.some(c => c.includes('iDLB'));
    return {
      fieldSelector: fsInput?.value?.trim() || '',
      patternValue: ptInput?.value?.trim() || '',
      patternId: ptInput?.id || '',
      isDate,
      isRef
    };
  })()`);

  if (dialogInfo.isDate) {
    // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste)
    if (isDateValue && dialogInfo.patternValue !== text.trim()) {
      await page.click(`[id="${dialogInfo.patternId}"]`);
      await page.waitForTimeout(200);
      await page.keyboard.press('Home');
      await page.waitForTimeout(100);
      await page.keyboard.press('Shift+End');
      await page.waitForTimeout(100);
      await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
      await page.keyboard.press('Control+V');
      await page.waitForTimeout(500);
    }
  } else {
    // Text or reference field: fill Pattern via clipboard paste
    await page.click(`[id="${dialogInfo.patternId}"]`);
    await page.waitForTimeout(200);
    await page.keyboard.press('Control+A');
    await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
    await page.keyboard.press('Control+V');
    await page.waitForTimeout(300);

    if (dialogInfo.isRef) {
      // Reference field: Tab triggers autocomplete to resolve text → reference value
      await page.keyboard.press('Tab');
      await page.waitForTimeout(2000);
    }
  }

  // 3b. Switch CompareType if exact match requested (text fields only).
  //    Date/number: always exact, CompareType disabled. Reference: default exact (selects ref).
  if (exact && !dialogInfo.isDate && !dialogInfo.isRef) {
    const exactRadio = await page.evaluate(`(() => {
      const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
      // Check if CompareType group is disabled (dates, numbers)
      const group = document.getElementById(p + 'CompareType');
      if (group && group.classList.contains('disabled')) return { already: true };
      const el = document.getElementById(p + 'CompareType#2#radio');
      if (!el || el.offsetWidth === 0) return null;
      if (el.classList.contains('select')) return { already: true };
      const r = el.getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
    })()`);
    if (exactRadio && !exactRadio.already) {
      await page.mouse.click(exactRadio.x, exactRadio.y);
      await page.waitForTimeout(300);
    }
  }

  // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked)
  const findBtnCoords = await page.evaluate(`(() => {
    const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
    const btn = btns.find(el => el.innerText?.trim() === 'Найти');
    if (!btn) return null;
    const r = btn.getBoundingClientRect();
    return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
  })()`);
  if (findBtnCoords) {
    await page.mouse.click(findBtnCoords.x, findBtnCoords.y);
  } else {
    await clickElement('Найти');
  }
  await page.waitForTimeout(2000);

  // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
  //    Check the specific dialog form — not generic modalSurface — to avoid closing parent modals
  //    (e.g. a selection form that opened this advanced search).
  for (let attempt = 0; attempt < 3; attempt++) {
    const dialogVisible = await page.evaluate(`(() => {
      const p = 'form${dialogForm}_';
      return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
    })()`);
    if (!dialogVisible) break;
    await page.keyboard.press('Escape');
    await page.waitForTimeout(500);
  }
  await waitForStable(formNum);

  const state = await getFormState();
  state.filtered = { type: 'advanced', field, text, exact: !!exact };
  return state;
}

/**
 * Remove active filters/search from the current list.
 *
 * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field).
 * With field: clicks the × button on the specific filter badge (selective removal).
 *
 * @param {object} [opts]
 * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×)
 */
export async function unfilterList({ field } = {}) {
  ensureConnected();
  await dismissPendingErrors();
  const formNum = await page.evaluate(detectFormScript());
  if (formNum === null) throw new Error('unfilterList: no form found');

  if (field) {
    // --- Selective: click × on specific filter badge ---
    const closeBtn = await page.evaluate(`(() => {
      const p = 'form${formNum}_';
      const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
      const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
      const target = ny(${JSON.stringify(field.toLowerCase())});
      const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
      for (const item of items) {
        const titleEl = item.querySelector('.trainName');
        const title = ny(norm(titleEl?.innerText).toLowerCase());
        if (title === target || title.includes(target)) {
          const close = item.querySelector('.trainClose');
          if (close) {
            const r = close.getBoundingClientRect();
            return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
          }
        }
      }
      const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
      return { error: 'not_found', available };
    })()`);

    if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`);
    await page.mouse.click(closeBtn.x, closeBtn.y);
    await waitForStable(formNum);

    const state = await getFormState();
    state.unfiltered = { field: closeBtn.field };
    return state;
  }

  // --- Clear ALL filters ---

  // 1. Remove all advanced filter badges (.trainItem × buttons)
  for (let attempt = 0; attempt < 20; attempt++) {
    const badge = await page.evaluate(`(() => {
      const p = 'form${formNum}_';
      const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
        .find(el => el.offsetWidth > 0);
      if (!item) return null;
      const close = item.querySelector('.trainClose');
      if (!close) return null;
      const r = close.getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
    })()`);
    if (!badge) break;
    await page.mouse.click(badge.x, badge.y);
    await waitForStable(formNum);
  }

  // 2. Cancel active search via Ctrl+Q
  await page.keyboard.press('Control+q');
  await waitForStable(formNum);

  // 3. Clear simple search field if it has a value
  const searchInfo = await page.evaluate(`(() => {
    const p = 'form${formNum}_';
    const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
      .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
    return el ? { id: el.id, value: el.value || '' } : null;
  })()`);

  if (searchInfo?.value) {
    await page.click(`[id="${searchInfo.id}"]`);
    await page.waitForTimeout(200);
    await page.keyboard.press('Control+A');
    await page.keyboard.press('Delete');
    await page.keyboard.press('Enter');
    await waitForStable(formNum);
  }

  const state = await getFormState();
  state.unfiltered = true;
  return state;
}

/** Take a screenshot. Returns PNG buffer. */
export async function screenshot() {
  ensureConnected();
  return await page.screenshot({ type: 'png' });
}

/** Wait for a specified number of seconds. */
export async function wait(seconds) {
  ensureConnected();
  let ms = seconds * 1000;
  // Credit system: if showCaption already waited for TTS, subtract that time
  if (recorder && recorder.captionCredit) {
    const elapsed = Date.now() - recorder.captionCredit.at;
    const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
    ms = Math.max(0, ms - credit);
    recorder.captionCredit = null;
  }
  if (ms > 0) {
    // During recording, split long waits into chunks and flush frames
    // to keep video timeline in sync (CDP may not send frames for static pages)
    if (recorder?._flushFrames && ms > 1000) {
      let remaining = ms;
      while (remaining > 0) {
        const chunk = Math.min(remaining, 1000);
        await page.waitForTimeout(chunk);
        remaining -= chunk;
        recorder._flushFrames();
      }
    } else {
      await page.waitForTimeout(ms);
    }
  }
  return await getFormState();
}

// ============================================================
// Video recording — CDP screencast + ffmpeg
// ============================================================

/** Check if video recording is active. */
export function isRecording() {
  return recorder !== null;
}

/**
 * Start video recording via CDP screencast + ffmpeg.
 * Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
 * @param {string} outputPath — output .mp4 file path
 * @param {object} [opts]
 * @param {number} [opts.fps=25] — target framerate
 * @param {number} [opts.quality=80] — JPEG quality (1-100)
 * @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
 */
export async function startRecording(outputPath, opts = {}) {
  ensureConnected();
  if (recorder) {
    if (opts.force) {
      try { await stopRecording(); } catch {}
    } else {
      throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
    }
  }
  lastCaptions = [];
  lastRecordingDuration = null;

  const fps = opts.fps || 25;
  const quality = opts.quality || 80;
  const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);

  // Ensure output directory exists
  const resolvedPath = resolveProjectPath(outputPath);
  mkdirSync(dirname(resolvedPath), { recursive: true });

  // Create CDP session for screencast
  const cdp = await page.context().newCDPSession(page);

  // Spawn ffmpeg process
  const ffmpeg = spawn(ffmpegPath, [
    '-y',                          // overwrite output
    '-f', 'image2pipe',            // input: piped images
    '-framerate', String(fps),     // input framerate
    '-i', '-',                     // read from stdin
    '-c:v', 'libx264',            // H.264 codec
    '-preset', 'fast',             // good quality/speed balance
    '-crf', '23',                  // default quality (good for screen content)
    '-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
    '-pix_fmt', 'yuv420p',        // broad compatibility
    '-color_range', 'tv',          // limited range (16-235) — standard for H.264 players
    '-movflags', '+faststart',     // web-friendly MP4
    resolvedPath
  ], { stdio: ['pipe', 'ignore', 'pipe'] });

  let ffmpegError = '';
  ffmpeg.stderr.on('data', d => { ffmpegError += d.toString(); });
  ffmpeg.on('error', err => { ffmpegError += err.message; });

  // Listen for screencast frames and pipe to ffmpeg
  // CDP sends frames only on screen changes, so we duplicate frames
  // to fill gaps and maintain real-time playback speed
  const frameDuration = 1000 / fps;
  let lastFrameTime = null;
  let lastFrameBuf = null;

  cdp.on('Page.screencastFrame', async ({ data, sessionId }) => {
    const buf = Buffer.from(data, 'base64');
    const now = Date.now();

    if (!ffmpeg.stdin.destroyed) {
      let framesWritten = 0;
      if (lastFrameTime && lastFrameBuf) {
        // Fill the gap with duplicates of the previous frame
        const gap = now - lastFrameTime;
        const dupes = Math.round(gap / frameDuration) - 1;
        for (let i = 0; i < dupes && i < fps * 30; i++) {
          ffmpeg.stdin.write(lastFrameBuf);
          framesWritten++;
        }
      }
      ffmpeg.stdin.write(buf);
      framesWritten++;
      // Track actual video timeline position (accounts for frame duplication)
      if (recorder) recorder.videoTimeMs += framesWritten * frameDuration;
    }

    lastFrameTime = now;
    lastFrameBuf = buf;
    try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
  });

  // Start the screencast
  await cdp.send('Page.startScreencast', {
    format: 'jpeg',
    quality,
    everyNthFrame: 1
  });

  // Expose a frame-writing helper on the recorder object.
  // During static periods (e.g. smart TTS pauses), CDP may not send screencast
  // frames. Call _flushFrames() to fill the gap with duplicates of the last frame,
  // keeping video timeline in sync with wall-clock time.
  const _flushFrames = () => {
    if (!lastFrameBuf || !lastFrameTime || ffmpeg.stdin.destroyed) return;
    const now = Date.now();
    const gap = now - lastFrameTime;
    const dupes = Math.round(gap / frameDuration);
    for (let i = 0; i < dupes; i++) {
      ffmpeg.stdin.write(lastFrameBuf);
      if (recorder) recorder.videoTimeMs += frameDuration;
    }
    if (dupes > 0) lastFrameTime = now;
  };

  const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
  recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0, _flushFrames, speechRate };
  // Redirect stderr accumulation to the recorder object
  ffmpeg.stderr.removeAllListeners('data');
  ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
}

/**
 * Stop video recording. Finalizes the MP4 file.
 * @returns {{ file: string, duration: number, size: number }}
 */
export async function stopRecording() {
  if (!recorder) return { file: null, duration: 0, size: 0 };

  const { cdp, ffmpeg, startTime, outputPath } = recorder;

  // Final frame flush: write remaining frames to cover the gap since the last screencast frame
  if (recorder._flushFrames) recorder._flushFrames();

  // Stop CDP screencast
  try { await cdp.send('Page.stopScreencast'); } catch {}
  try { await cdp.detach(); } catch {}

  // Close ffmpeg stdin and wait for encoding to finish
  await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      ffmpeg.kill('SIGKILL');
      reject(new Error('ffmpeg timed out after 30s'));
    }, 30000);

    ffmpeg.on('close', (code) => {
      clearTimeout(timeout);
      if (code === 0) resolve();
      else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
    });
    ffmpeg.on('error', (err) => {
      clearTimeout(timeout);
      reject(err);
    });

    ffmpeg.stdin.end();
  });

  const duration = (Date.now() - startTime) / 1000;
  const stats = statSync(outputPath);

  // Preserve captions for addNarration()
  lastCaptions = recorder.captions || [];
  lastRecordingDuration = duration;
  if (lastCaptions.length) {
    const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
    const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
    writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
  }

  recorder = null;

  return {
    file: outputPath,
    duration: Math.round(duration * 10) / 10,
    size: stats.size,
    captions: lastCaptions.length
  };
}

/**
 * Show a text caption overlay on the page (visible in recording).
 * Calling again updates the text without creating a new element.
 * @param {string} text — caption text
 * @param {object} [opts]
 * @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
 * @param {number} [opts.fontSize=24] — font size in pixels
 * @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
 * @param {string} [opts.color='#fff'] — text color
 * @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text,
 *   pass a string for custom narration, or false to skip narration for this caption.
 */
export async function showCaption(text, opts = {}) {
  ensureConnected();

  // Collect caption for TTS narration if recording
  let smartWaitMs = 0;
  if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) {
    const speech = typeof opts.speech === 'string' ? opts.speech : text;
    // Use video timeline position (accounts for frame duplication) instead of wall-clock
    recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
    // Estimate TTS duration and wait so the video has enough screen time for voiceover
    smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
  }
  const position = opts.position || 'bottom';
  const fontSize = opts.fontSize || 24;
  const bg = opts.background || 'rgba(0,0,0,0.7)';
  const color = opts.color || '#fff';

  await page.evaluate(({ text, position, fontSize, bg, color }) => {
    let el = document.getElementById('__web_test_caption');
    if (!el) {
      el = document.createElement('div');
      el.id = '__web_test_caption';
      el.style.cssText = `
        position: fixed; left: 0; right: 0; z-index: 99999;
        text-align: center; padding: 12px 24px;
        font-family: Arial, sans-serif; pointer-events: none;
      `;
      document.body.appendChild(el);
    }
    el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
    el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
    el.style.fontSize = fontSize + 'px';
    el.style.background = bg;
    el.style.color = color;
    el.textContent = text;
  }, { text, position, fontSize, bg, color });

  // Smart TTS wait: pause for estimated speech duration so video has enough screen time.
  // Split into chunks and flush frames periodically — CDP doesn't send screencast frames
  // for static pages, so we must write duplicate frames to keep video timeline in sync.
  if (smartWaitMs > 0) {
    let remaining = smartWaitMs;
    while (remaining > 0) {
      const chunk = Math.min(remaining, 1000);
      await page.waitForTimeout(chunk);
      remaining -= chunk;
      if (recorder?._flushFrames) recorder._flushFrames();
    }
    recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
  }
}

/** Remove the caption overlay from the page. */
export async function hideCaption() {
  ensureConnected();
  await page.evaluate(() => {
    const el = document.getElementById('__web_test_caption');
    if (el) el.remove();
  });
}

/**
 * Get captions collected during the current or last recording.
 * @returns {Array<{text: string, speech: string, time: number}>}
 */
export function getCaptions() {
  if (recorder) return [...recorder.captions];
  return [...lastCaptions];
}

/**
 * Add TTS narration to a recorded video.
 * Generates speech from captions and merges audio with the video.
 * @param {string} videoPath — path to the recorded MP4 file
 * @param {object} [opts]
 * @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
 * @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai'
 * @param {string} [opts.voice] — voice name (provider-specific)
 * @param {string} [opts.apiKey] — API key (for openai provider)
 * @param {string} [opts.apiUrl] — API endpoint (for openai provider)
 * @param {string} [opts.model] — model name (for openai provider, default: 'tts-1')
 * @param {string} [opts.ffmpegPath] — path to ffmpeg binary
 * @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4)
 * @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
 */
export async function addNarration(videoPath, opts = {}) {
  if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
  videoPath = resolveProjectPath(videoPath);
  const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
  const ttsProvider = getTtsProvider(opts.provider || 'edge');
  const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };

  // Resolve captions: explicit > lastCaptions > .captions.json
  let captions = opts.captions;
  let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
  let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
  if (!captions || !captions.length) {
    if (lastCaptions.length) {
      captions = [...lastCaptions];
      recordingDuration = lastRecordingDuration;
      // Runtime captions always use video timestamps (set in showCaption)
    }
  }
  if (!captions || !captions.length) {
    const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
    if (fsExistsSync(captionsJsonPath)) {
      const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
      // Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
      if (Array.isArray(raw)) {
        captions = raw;
        videoTimestamps = false;
      } else {
        captions = raw.captions;
        videoTimestamps = !!raw.videoTimestamps;
        recordingDuration = raw.recordingDuration || null;
      }
    }
  }
  if (!captions || !captions.length) {
    throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
  }

  const videoDuration = getAudioDuration(videoPath, ffmpegPath);

  // Legacy fallback: scale wall-clock timestamps to video duration
  // (only for old captions without videoTimestamps flag)
  if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
    const timeScale = videoDuration / recordingDuration;
    if (Math.abs(timeScale - 1) > 0.005) {
      captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
    }
  }

  // Output path
  const ext = extname(videoPath);
  const base = videoPath.slice(0, -ext.length);
  const outputPath = opts.outputPath || `${base}-narrated${ext}`;

  // Temp directory
  const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
  mkdirSync(tempDir, { recursive: true });

  const warnings = [];

  try {
    // Phase 1: Generate TTS audio for each caption
    const ttsFiles = [];
    const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
    for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
      const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
      const promises = batch.map(async (cap, batchIdx) => {
        const idx = batchStart + batchIdx;
        const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
        const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
        try {
          await ttsProvider(cap.speech, ttsFile, capTtsOpts);
        } catch (err) {
          // Retry once
          try {
            await ttsProvider(cap.speech, ttsFile, capTtsOpts);
          } catch (retryErr) {
            warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
            // Generate 1s silence as placeholder
            generateSilence(ttsFile, 1, ffmpegPath);
          }
        }
        return ttsFile;
      });
      const results = await Promise.all(promises);
      ttsFiles.push(...results);
    }

    // Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
    // This avoids MP3 frame quantization drift from silence-file concatenation
    const ffmpegInputs = [];
    const filterParts = [];
    const mixLabels = [];

    for (let i = 0; i < captions.length; i++) {
      const captionTimeMs = Math.round(captions[i].time);
      const ttsFile = ttsFiles[i];
      const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);

      ffmpegInputs.push('-i', ttsFile);
      const filters = [];

      // Speed up TTS slightly if it's longer than gap to next caption (max 1.3x)
      if (i < captions.length - 1) {
        const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
        if (ttsDuration > maxDuration && maxDuration > 0.1) {
          const tempo = ttsDuration / maxDuration;
          if (tempo <= 1.3) {
            filters.push(`atempo=${tempo.toFixed(4)}`);
          } else {
            // Too fast — let audio overlap instead of distorting
            warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
          }
        }
      }

      // Delay to exact caption timestamp (milliseconds)
      if (captionTimeMs > 0) {
        filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
      }

      const label = `a${i}`;
      mixLabels.push(`[${label}]`);
      // Input indices are shifted by 1 because silence reference is input [0]
      filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
    }

    // Generate a silence reference track as input [0] so amix runs for full video duration
    const silencePath = pathJoin(tempDir, 'silence.mp3');
    generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);

    const filterComplex = filterParts.join(';') + ';' +
      `[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;

    const narrationPath = pathJoin(tempDir, 'narration.mp3');
    execFileSync(ffmpegPath, [
      '-y', '-i', silencePath, ...ffmpegInputs,
      '-filter_complex', filterComplex,
      '-t', String(Math.ceil(videoDuration)),
      '-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
    ], { stdio: 'pipe', timeout: 120000 });

    // Phase 4: Merge video + narration audio
    execFileSync(ffmpegPath, [
      '-y', '-i', videoPath, '-i', narrationPath,
      '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
      '-map', '0:v:0', '-map', '1:a:0',
      '-t', String(Math.ceil(videoDuration)),
      '-movflags', '+faststart', outputPath,
    ], { stdio: 'pipe', timeout: 120000 });

    const stats = statSync(outputPath);
    const duration = getAudioDuration(outputPath, ffmpegPath);

    const result = {
      file: outputPath,
      duration: Math.round(duration * 10) / 10,
      size: stats.size,
      captions: captions.length,
    };
    if (warnings.length) result.warnings = warnings;
    return result;

  } finally {
    // Cleanup temp directory
    try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
  }
}

/**
 * Show a full-screen title slide overlay (for video recordings).
 * Repeated calls update the content. Use hideTitleSlide() to remove.
 * @param {string} text  Title text (\n → line break)
 * @param {object} [opts]
 * @param {string} [opts.subtitle]    Smaller text below the title
 * @param {string} [opts.background]  CSS background (default: dark gradient)
 * @param {string} [opts.color]       Text color (default: '#fff')
 * @param {number} [opts.fontSize]    Title font size in px (default: 36)
 */
export async function showTitleSlide(text, opts = {}) {
  ensureConnected();
  const {
    subtitle = '',
    background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
    color = '#fff',
    fontSize = 36,
    speech,
  } = opts;

  // Collect caption for TTS narration if recording
  let smartWaitMs = 0;
  if (recorder && speech && speech !== false) {
    const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
    if (captionText) {
      recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
      smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
    }
  }

  await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
    let div = document.getElementById('__web_test_title');
    if (!div) {
      div = document.createElement('div');
      div.id = '__web_test_title';
      document.body.appendChild(div);
    }
    div.style.cssText = [
      'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
      `background:${background}`,
      'display:flex', 'align-items:center', 'justify-content:center',
      'z-index:999999', 'pointer-events:none',
    ].join(';');
    // Remove other overlays to prevent flash between slides
    const img = document.getElementById('__web_test_image');
    if (img) img.remove();
    const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>');
    let html = `<div style="font-size:${fontSize}px;font-weight:600;line-height:1.4;">${esc(text)}</div>`;
    if (subtitle) {
      html += `<div style="font-size:${Math.round(fontSize * 0.5)}px;margin-top:16px;opacity:0.7;">${esc(subtitle)}</div>`;
    }
    div.innerHTML = `<div style="text-align:center;max-width:70%;color:${color};font-family:'Segoe UI',Arial,sans-serif;">${html}</div>`;
  }, { text, subtitle, background, color, fontSize });

  // Smart TTS wait (same pattern as showCaption/showImage)
  if (smartWaitMs > 0) {
    let remaining = smartWaitMs;
    while (remaining > 0) {
      const chunk = Math.min(remaining, 1000);
      await page.waitForTimeout(chunk);
      remaining -= chunk;
      if (recorder?._flushFrames) recorder._flushFrames();
    }
    recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
  }
}

/** Remove the title slide overlay. */
export async function hideTitleSlide() {
  ensureConnected();
  await page.evaluate(() => {
    const el = document.getElementById('__web_test_title');
    if (el) el.remove();
  });
}

/**
 * Show a full-screen image overlay (e.g. presentation slide screenshot).
 * Reads the image file, base64-encodes it, and renders as a fixed overlay
 * on the page — captured by CDP screencast automatically.
 *
 * Style presets:
 *   - 'blur'  (default) — blurred+dimmed copy as background, image centered with shadow
 *   - 'dark'  — dark background (#2a2a2a) with shadow
 *   - 'light' — white background with shadow
 *   - 'full'  — image covers entire screen, no padding/shadow
 *
 * Custom background overrides the preset (e.g. background: '#003366').
 *
 * @param {string} imagePath — path to the image file (PNG, JPG, etc.)
 * @param {object} [opts]
 * @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
 * @param {string} [opts.background] — custom background color/gradient (overrides style preset)
 * @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
 * @param {string|false} [opts.speech] — TTS narration text while image is shown.
 *   Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
 */
export async function showImage(imagePath, opts = {}) {
  ensureConnected();
  const style = opts.style || 'blur';
  const speech = opts.speech;

  // Style presets
  const presets = {
    blur:  { bg: '#222',    fit: 'contain', shadow: true,  blur: true  },
    dark:  { bg: '#2a2a2a', fit: 'contain', shadow: true,  blur: false },
    light: { bg: '#ffffff', fit: 'contain', shadow: true,  blur: false },
    full:  { bg: '#000',    fit: 'contain', shadow: false, blur: false },
  };
  const preset = presets[style] || presets.blur;

  const bg      = opts.background || preset.bg;
  const fit     = preset.fit;
  const shadow  = opts.shadow !== undefined ? opts.shadow : preset.shadow;
  const useBlur = opts.background ? false : preset.blur;

  // Read image and base64-encode
  const absPath = resolveProjectPath(imagePath);
  if (!fsExistsSync(absPath)) {
    throw new Error(`showImage: file not found: ${absPath}`);
  }
  const buf = readFileSync(absPath);
  const ext = extname(absPath).toLowerCase().replace('.', '');
  const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
    : ext === 'png' ? 'image/png'
    : ext === 'gif' ? 'image/gif'
    : ext === 'webp' ? 'image/webp'
    : ext === 'svg' ? 'image/svg+xml'
    : 'image/png';
  const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;

  // Collect caption for TTS narration if recording
  let smartWaitMs = 0;
  if (recorder && speech && speech !== false) {
    const captionText = typeof speech === 'string' ? speech : '';
    if (captionText) {
      recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
      smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
    }
  }

  // Padding: full style uses 100%, others use 92% for breathing room
  const isFull = style === 'full';
  const maxSize = isFull ? '100%' : '92%';

  await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
    let div = document.getElementById('__web_test_image');
    if (!div) {
      div = document.createElement('div');
      div.id = '__web_test_image';
      document.body.appendChild(div);
    }
    // Remove other overlays to prevent flash between slides
    const title = document.getElementById('__web_test_title');
    if (title) title.remove();

    div.style.cssText = [
      'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
      `background:${bg}`,
      'display:flex', 'align-items:center', 'justify-content:center',
      'z-index:999999', 'pointer-events:none', 'overflow:hidden'
    ].join(';');

    let html = '';

    // Blurred background layer: the same image stretched to cover, blurred and dimmed
    if (useBlur) {
      html += `<img src="${dataUrl}" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;filter:blur(30px) brightness(0.5);transform:scale(1.1);" />`;
    }

    // Main image
    const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
    const sizeCss = isFull
      ? `width:100%;height:100%;object-fit:${fit};`
      : `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
    html += `<img src="${dataUrl}" style="position:relative;${sizeCss}${shadowCss}" />`;

    div.innerHTML = html;
  }, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });

  // Smart TTS wait (same pattern as showCaption)
  if (smartWaitMs > 0) {
    let remaining = smartWaitMs;
    while (remaining > 0) {
      const chunk = Math.min(remaining, 1000);
      await page.waitForTimeout(chunk);
      remaining -= chunk;
      if (recorder?._flushFrames) recorder._flushFrames();
    }
    recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
  }
}

/** Remove the image overlay. */
export async function hideImage() {
  ensureConnected();
  await page.evaluate(() => {
    const el = document.getElementById('__web_test_image');
    if (el) el.remove();
  });
}

/**
 * Highlight an element on the page (visual accent for video recordings).
 * Uses overlay div for visibility (not clipped by overflow:hidden), with
 * requestAnimationFrame tracking so it follows layout shifts (async banners etc).
 * @param {string} text  Element text/label (fuzzy match, same as clickElement/fillFields)
 * @param {object} [opts]
 * @param {string} [opts.color]    Outline color (default: '#e74c3c')
 * @param {number} [opts.padding]  Extra padding around element (default: 4)
 */
export async function highlight(text, opts = {}) {
  ensureConnected();
  const { color = '#e74c3c', padding = 4, table } = opts;

  // Remove previous highlight first
  await unhighlight();

  let elId = null;

  // 0. Open submenu/popup — highest priority (submenu overlays the form,
  // so form search would match grid rows behind the popup)
  const popupItems = await page.evaluate(readSubmenuScript());
  if (Array.isArray(popupItems) && popupItems.length > 0) {
    const target = normYo(text.toLowerCase());
    let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
    if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
    if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
    if (found) {
      // 1C duplicates IDs in clouds — getElementById returns the hidden copy.
      // Use elementFromPoint to find the visible element and get its actual rect.
      await page.evaluate(({ x, y, color, padding }) => {
        const el = document.elementFromPoint(x, y);
        if (!el) return;
        const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
        const r = block.getBoundingClientRect();
        let div = document.getElementById('__web_test_highlight');
        if (!div) {
          div = document.createElement('div');
          div.id = '__web_test_highlight';
          document.body.appendChild(div);
        }
        div.style.cssText = [
          'position:fixed', 'pointer-events:none', 'z-index:999998',
          `top:${r.y - padding}px`, `left:${r.x - padding}px`,
          `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
          `outline:3px solid ${color}`, 'border-radius:4px',
          `box-shadow:0 0 16px ${color}80`,
        ].join(';');
      }, { x: found.x, y: found.y, color, padding });
      return; // overlay placed, done
    }
  }

  // 1. Visible commands on the function panel (cmd_XXX_txt elements)
  // Must be checked BEFORE form search: when the section content panel
  // is showing, the form behind it is hidden but detectFormScript still
  // finds it, and form buttons match before commands.
  if (!elId) {
    elId = await page.evaluate(`(() => {
      const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
      const target = ${JSON.stringify(normYo(text.toLowerCase()))};
      const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
      if (cmds.length === 0) return null;
      let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
      if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
      if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
      return el ? el.id : null;
    })()`);
  }

  // 1b. Command group headers on the function panel (eAccentColor labels).
  //     Match header text, then highlight the header + commands below it
  //     until the next spacer/header/end.
  if (!elId) {
    const groupDone = await page.evaluate(({ target, color, padding }) => {
      const container = document.querySelector('#funcPanel_container');
      if (!container) return false;
      const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
      const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
      if (!headers.length) return false;

      let headerEl = headers.find(h => norm(h.textContent) === target);
      if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
      if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
      if (!headerEl) return false;

      // Collect header + following cmd siblings until next spacer/header
      const parent = headerEl.parentElement;
      const children = [...parent.children];
      const startIdx = children.indexOf(headerEl);
      const groupEls = [headerEl];
      for (let i = startIdx + 1; i < children.length; i++) {
        const el = children[i];
        if (el.classList.contains('eAccentColor')) break;
        if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
        groupEls.push(el);
      }

      // Bounding box
      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
      for (const el of groupEls) {
        const r = el.getBoundingClientRect();
        if (r.width === 0 && r.height === 0) continue;
        minX = Math.min(minX, r.left);  minY = Math.min(minY, r.top);
        maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
      }
      if (minX === Infinity) return false;

      let div = document.getElementById('__web_test_highlight');
      if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
      div.style.cssText = [
        'position:fixed', 'pointer-events:none', 'z-index:999998',
        `top:${minY - padding}px`, `left:${minX - padding}px`,
        `width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
        `outline:3px solid ${color}`, 'border-radius:4px',
        `box-shadow:0 0 16px ${color}80`,
      ].join(';');
      return true;
    }, { target: normYo(text.toLowerCase()), color, padding });
    if (groupDone) return;
  }

  // 2. Form groups/panels — checked BEFORE buttons/fields because group names
  //    often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
  //    panel and a command bar element). Includes _container and _div elements
  //    but skips logicGroupContainer (Representation=None, height=0).
  if (!elId) {
    const formNum = await page.evaluate(detectFormScript());
    if (formNum !== null) {
      elId = await page.evaluate(`(() => {
        const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
        const target = ${JSON.stringify(normYo(text.toLowerCase()))};
        const p = 'form' + ${formNum} + '_';
        // Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
        const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
          .filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
        const items = groups.map(el => {
          const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
          const titleEl = document.getElementById(p + idName + '#title_text')
            || document.getElementById(p + idName + '_title_text');
          const label = norm(titleEl?.innerText || '').toLowerCase();
          const name = norm(idName).toLowerCase();
          const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
          return { id: el.id, name, label, big };
        });
        let found = items.find(i => i.label === target);
        if (!found) found = items.find(i => i.name === target);
        // Fuzzy match: only large groups (min 100x50) to avoid matching command bars
        if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
        if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
        return found ? found.id : null;
      })()`);
    }
  }

  // 3. Form-scoped search (buttons, links, fields, grid rows)
  if (!elId) {
    const formNum = await page.evaluate(detectFormScript());
    if (formNum !== null) {
      // 3a. Try button/link/tab/gridRow via findClickTargetScript
      let gridSelector;
      if (table) {
        const resolved = await page.evaluate(resolveGridScript(formNum, table));
        if (!resolved.error) gridSelector = resolved.gridSelector;
      }
      const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
      if (target && !target.error) {
        if (target.id) {
          elId = target.id;
        } else if (target.x && target.y) {
          // Grid row — find the gridLine element and tag it
          elId = await page.evaluate(`(() => {
            const p = ${JSON.stringify(`form${formNum}_`)};
            const grid = document.querySelector('[id^="' + p + '"].grid');
            if (!grid) return null;
            const body = grid.querySelector('.gridBody');
            if (!body) return null;
            const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
            const target = ${JSON.stringify(normYo(text.toLowerCase()))};
            for (const line of body.querySelectorAll('.gridLine')) {
              const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
              const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
              if (rowText.includes(target)) {
                if (!line.id) line.id = '__wt_hl_tmp';
                return line.id;
              }
            }
            return null;
          })()`);
        }
      }

      // 3b. If not found as button — try as field via resolveFieldsScript
      if (!elId) {
        const dummyFields = { [text]: '' };
        const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
        if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
          elId = resolved[0].inputId;
        }
      }
    }
  }

  // 4. Fallback: sections (sidebar navigation)
  if (!elId) {
    elId = await page.evaluate(`(() => {
      const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
      const target = ${JSON.stringify(normYo(text.toLowerCase()))};
      const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
      let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
      if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
      if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
      return el ? el.id : null;
    })()`);
  }

  if (!elId) {
    // Collect available elements to help the caller fix the name
    const available = await page.evaluate(`(() => {
      const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
      const result = {};
      // Commands
      const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
      if (cmds.length) result.commands = cmds;
      // Command group headers
      const fp = document.querySelector('#funcPanel_container');
      if (fp) {
        const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
        if (gh.length) result.commandGroups = gh;
      }
      // Sections
      const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
      if (secs.length) result.sections = secs;
      // Form elements
      ${(() => {
        // Detect form inline to avoid extra evaluate round-trip
        return `
        const forms = {};
        document.querySelectorAll('[id^="form"]').forEach(el => {
          const m = el.id.match(/^form(\\d+)_/);
          if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
        });
        let formNum = null, maxCount = 0;
        for (const [n, c] of Object.entries(forms)) {
          if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
        }
        if (formNum !== null) {
          const p = 'form' + formNum + '_';
          // Groups (_container or _div, skip logicGroupContainer, min 100x50)
          const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
            .filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
            .map(el => {
              const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
              const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
              return norm(titleEl?.innerText || '') || idName;
            }).filter(Boolean);
          if (groups.length) result.groups = groups;
          // Buttons/links
          const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
            .filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
          if (btns.length) result.buttons = [...new Set(btns)];
        }`;
      })()}
      return result;
    })()`);
    const parts = [];
    for (const [cat, items] of Object.entries(available)) {
      parts.push(`  ${cat}: ${items.join(', ')}`);
    }
    const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
    throw new Error(`highlight: "${text}" not found${hint}`);
  }

  // Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
  await page.evaluate(({ elId, color, padding }) => {
    const target = document.getElementById(elId);
    if (!target) return;
    let div = document.getElementById('__web_test_highlight');
    if (!div) {
      div = document.createElement('div');
      div.id = '__web_test_highlight';
      document.body.appendChild(div);
    }
    function sync() {
      const r = target.getBoundingClientRect();
      div.style.cssText = [
        'position:fixed', 'pointer-events:none', 'z-index:999998',
        `top:${r.y - padding}px`, `left:${r.x - padding}px`,
        `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
        `outline:3px solid ${color}`, 'border-radius:4px',
        `box-shadow:0 0 16px ${color}80`,
      ].join(';');
    }
    sync();
    // Track position changes via rAF
    function tick() {
      if (!document.getElementById('__web_test_highlight')) return; // stopped
      sync();
      requestAnimationFrame(tick);
    }
    requestAnimationFrame(tick);
  }, { elId, color, padding });
}

/** Remove the highlight overlay. */
export async function unhighlight() {
  ensureConnected();
  await page.evaluate(() => {
    const el = document.getElementById('__web_test_highlight');
    if (el) el.remove(); // also stops rAF loop (id check)
    // Clean up temp ID from grid rows
    const tmp = document.getElementById('__wt_hl_tmp');
    if (tmp) tmp.removeAttribute('id');
  });
}

/**
 * Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
 * automatically highlight the target element before acting.
 * @param {boolean} on  true to enable, false to disable
 */
export function setHighlight(on) {
  highlightMode = !!on;
}

/** @returns {boolean} Whether auto-highlight mode is active. */
export function isHighlightMode() {
  return highlightMode;
}

// ============================================================
// Private helpers
// ============================================================

/** Resolve ffmpeg binary path. */
function resolveFfmpeg(explicit) {
  // 1. Explicit path
  if (explicit) {
    try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
    catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
  }

  // 2. FFMPEG_PATH env var
  const envPath = process.env.FFMPEG_PATH;
  if (envPath) {
    try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
    catch { /* fall through */ }
  }

  // 3. System PATH
  try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
  catch { /* fall through */ }

  // 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
  const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
  if (fsExistsSync(localPath)) {
    try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
    catch { /* fall through */ }
  }

  // 5. Error with instructions
  throw new Error(
    'ffmpeg not found. Install it:\n' +
    '  - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
    '  - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
    '  - Or pass ffmpegPath option to startRecording()'
  );
}

// ── TTS providers ──────────────────────────────────────────────────────────

/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
let _edgeTtsModule = null;
async function resolveEdgeTts() {
  if (_edgeTtsModule) return _edgeTtsModule;

  // 1. Global/project-level install (standard Node resolution)
  try {
    _edgeTtsModule = await import('node-edge-tts');
    return _edgeTtsModule;
  } catch { /* fall through */ }

  // 2. tools/tts/ relative to project root
  const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
  if (fsExistsSync(localPath)) {
    try {
      _edgeTtsModule = await import(pathToFileURL(localPath).href);
      return _edgeTtsModule;
    } catch { /* fall through */ }
  }

  // 3. Error with instructions
  throw new Error(
    'node-edge-tts not found. Install it:\n' +
    '  - npm install --prefix tools/tts node-edge-tts\n' +
    '  - or: npm install node-edge-tts (global/project-level)'
  );
}

/**
 * Edge TTS provider (free, no API key). Uses node-edge-tts package.
 * @param {string} text — text to synthesize
 * @param {string} outputPath — path for the output mp3 file
 * @param {object} opts — { voice }
 */
async function edgeTtsProvider(text, outputPath, opts = {}) {
  const { EdgeTTS } = await resolveEdgeTts();
  const voice = opts.voice || 'ru-RU-DmitryNeural';
  const tts = new EdgeTTS({ voice });
  await Promise.race([
    tts.ttsPromise(text, outputPath),
    new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
  ]);
}

/**
 * OpenAI-compatible TTS provider. Requires apiKey.
 * @param {string} text — text to synthesize
 * @param {string} outputPath — path for the output mp3 file
 * @param {object} opts — { apiKey, apiUrl, voice, model }
 */
async function openaiTtsProvider(text, outputPath, opts = {}) {
  const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
  if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
  const resp = await fetch(apiUrl, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: opts.model || 'tts-1',
      input: text,
      voice: opts.voice || 'alloy',
      response_format: 'mp3',
    }),
  });
  if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
  const buf = Buffer.from(await resp.arrayBuffer());
  writeFileSync(outputPath, buf);
}

/**
 * ElevenLabs TTS provider. Requires apiKey.
 * @param {string} text — text to synthesize
 * @param {string} outputPath — path for the output mp3 file
 * @param {object} opts — { apiKey, apiUrl, voice, model }
 */
async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
  const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
  const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
  if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
  const resp = await fetch(apiUrl, {
    method: 'POST',
    headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text,
      model_id: opts.model || 'eleven_multilingual_v2',
    }),
  });
  if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
  const buf = Buffer.from(await resp.arrayBuffer());
  writeFileSync(outputPath, buf);
}

/** Get TTS provider function by name. */
function getTtsProvider(name) {
  switch (name) {
    case 'openai': return openaiTtsProvider;
    case 'elevenlabs': return elevenlabsTtsProvider;
    case 'edge': default: return edgeTtsProvider;
  }
}

// ── TTS audio helpers ──────────────────────────────────────────────────────

/**
 * Get audio duration in seconds using ffprobe.
 * @param {string} filePath — path to audio file
 * @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it)
 * @returns {number} duration in seconds
 */
function getAudioDuration(filePath, ffmpegPath) {
  const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
  const out = execFileSync(ffprobePath, [
    '-v', 'error', '-show_entries', 'format=duration',
    '-of', 'default=noprint_wrappers=1:nokey=1', filePath,
  ], { encoding: 'utf8', timeout: 10000 }).trim();
  return parseFloat(out) || 0;
}

/**
 * Generate a silence mp3 file of given duration.
 * @param {string} outputPath — path for the output mp3 file
 * @param {number} seconds — duration in seconds
 * @param {string} ffmpegPath — path to ffmpeg binary
 */
function generateSilence(outputPath, seconds, ffmpegPath) {
  execFileSync(ffmpegPath, [
    '-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
    '-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
  ], { stdio: 'pipe', timeout: 10000 });
}

function ensureConnected() {
  if (!isConnected()) {
    throw new Error('Browser not connected. Call web_connect first.');
  }
}
