/**
 * VL Component Test Tool — Playwright-powered testing of compiled VL apps
 *
 * VL 3.7 injects data-vl-id="componentId" onto every named component's root HTML element.
 * Use data-vl-id for precise, fast element targeting:
 *   - vlid:loginBtn          → [data-vl-id="loginBtn"]   (cleanest shorthand — PREFERRED)
 *   - [data-vl-id="loginBtn"]                            (full CSS selector)
 *
 * Selector strategies (in priority order):
 *   0. vlid:xxx              — VL component ID shorthand (PREFERRED)
 *   1. text=, placeholder=   — Playwright native prefixes
 *   2. [data-vl-id="xxx"]    — CSS data-vl-id selector
 *   3. [instance-id="xxx"]   — legacy fallback
 *   4. CSS (#id, .class, tag, [attr])
 *   5. label:XXX             — find input/button near a label text
 *   6. Bare string           — try data-vl-id, then text match
 *
 * VL ih5 framework input handling:
 *   - Playwright fill() and keyboard.type() DO NOT work — ih5 ignores them
 *   - Must use JS native value setter + dispatchEvent('input'/'change') to bind values
 *
 * Actions:
 *   open       — open a VL preview URL in the test browser
 *   click      — click element by vlid/text/CSS
 *   fill       — fill an input using ih5-compatible JS setter
 *   select     — select from VL custom dropdown
 *   assert     — assert element visible/hidden/text
 *   snapshot   — get a compact DOM snapshot (shows vlid)
 *   screenshot — take screenshot of current state
 *   listIds    — list all VL components with their data-vl-id / vlid:xxx selectors
 *   hover      — hover over element (triggers tooltips, dropdown menus)
 *   scroll     — scroll window or a specific container element
 *   drag       — drag element to target or by x/y offset (sortable lists, sliders)
 *   wait       — wait for element to appear, or fixed duration
 *   evaluate   — run arbitrary JS in the page context
 *   run        — run a test case (array of steps)
 *   close      — close the test browser
 */
import { chromium } from 'playwright';
import path from 'path';
import fs from 'fs';
import os from 'os';

let _browser = null;
let _page = null;
let _aborted = false;

function log(tag, msg) {
  console.log(`[VLTest] [${tag}] ${msg}`);
}

/**
 * Read the ih5bearer cookie from the project config.
 * Checks both the config passed at tool creation and the project.json on disk.
 */
function getCookie(config) {
  // 1. From tool config (set via Settings in UI)
  if (config?.cookie) return config.cookie;
  // 2. From project.json on disk (may have been updated after tool creation)
  try {
    const workDir = config?.workDir || process.cwd();
    const profilePath = path.join(workDir, '.vl-code', 'project.json');
    if (fs.existsSync(profilePath)) {
      const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
      if (profile.cookie) return profile.cookie;
    }
  } catch {}
  return '';
}

async function ensureBrowser(headless = true, config = {}) {
  if (_browser && _page) {
    try { await _page.evaluate(() => true); return _page; } catch {}
  }
  if (_browser) { try { await _browser.close(); } catch {} }

  _browser = await chromium.launch({ headless, args: ['--no-sandbox', '--disable-gpu'] });

  // Inject ih5bearer cookie for VL preview authentication
  const cookie = getCookie(config);
  const contextOptions = {
    viewport: { width: 1440, height: 900 },
    ignoreHTTPSErrors: true,
  };

  const ctx = await _browser.newContext(contextOptions);

  // Add ih5bearer cookie to all VL domains BEFORE any page navigation
  if (cookie) {
    const domains = [
      { domain: '.visuallogic.ai', url: 'https://v4pre.visuallogic.ai' },
      { domain: '.visuallogic.ai', url: 'https://editor.visuallogic.ai' },
    ];
    const cookies = domains.map(d => ({
      name: 'ih5bearer',
      value: cookie,
      domain: d.domain,
      path: '/',
      httpOnly: false,
      secure: true,
      sameSite: 'None',
    }));
    await ctx.addCookies(cookies);
    log('Browser', `Injected ih5bearer cookie (${cookie.slice(0, 20)}...) for .visuallogic.ai`);
  } else {
    log('Browser', 'WARNING: No ih5bearer cookie found — VL preview pages may fail to load');
  }

  _page = await ctx.newPage();

  // Auto-dismiss native dialogs (VL SysUI.showModal)
  _page.on('dialog', async (dialog) => {
    try {
      if (dialog.type() === 'confirm') await dialog.accept();
      else if (dialog.type() === 'prompt') await dialog.accept('');
      else await dialog.dismiss();
    } catch {}
  });

  return _page;
}

/**
 * Resolve a VL selector to a Playwright locator.
 *
 * VL 3.7 injects data-vl-id="xxx" onto the root HTML element of every named component.
 * Use vlid:xxx (shorthand) or [data-vl-id="xxx"] as the primary targeting method.
 *
 * Strategy order:
 *   0. vlid:xxx              — VL component ID shorthand → [data-vl-id="xxx"] (PREFERRED)
 *   1. Playwright native prefixes (text=, role=, placeholder=)
 *   2. [data-vl-id="xxx"]    — direct CSS attribute selector
 *   3. [instance-id="xxx"]   — legacy fallback (older compiled output)
 *   4. label:XXX             — find input/button near a text label
 *   5. CSS selector (#id, .class, [attr], tag)
 *   6. Bare camelCase string — try data-vl-id, then text
 *   7. Placeholder / label / button-role / text fallbacks
 */
async function resolveSelector(page, selector) {
  if (!selector) return null;

  // Strategy 0: vlid: shorthand → [data-vl-id="xxx"] (PREFERRED for VL 3.5 apps)
  if (selector.startsWith('vlid:')) {
    const id = selector.slice(5).trim();
    // Primary: data-vl-id (VL 3.5), fallback: instance-id (legacy)
    try {
      const loc = page.locator(`[data-vl-id="${id}"]`).first();
      if (await loc.count() > 0) return loc;
    } catch {}
    return page.locator(`[data-vl-id="${id}"], [instance-id="${id}"]`).first();
  }

  // Strategy 1: Playwright native prefixes
  if (selector.startsWith('text=')) return page.getByText(selector.slice(5)).first();
  if (selector.startsWith('role=')) return page.locator(selector).first();
  if (selector.startsWith('placeholder=')) return page.getByPlaceholder(selector.slice(12)).first();

  // Strategy 1b: Playwright CSS extended selectors
  if (selector.includes(':has-text(') || selector.includes(':has(') || selector.includes(' >> ')) {
    return page.locator(selector).first();
  }

  // Strategy 2: [data-vl-id="xxx"] — VL 3.7 primary attribute
  const vlIdMatch = selector.match(/\[data-vl-id=["']?([^"'\]]+)["']?\]/);
  if (vlIdMatch) {
    try {
      const loc = page.locator(selector).first();
      if (await loc.count() > 0) return loc;
    } catch {}
    return page.locator(`[data-vl-id="${vlIdMatch[1]}"]`).first();
  }

  // Strategy 3: [instance-id="xxx"] — legacy selector (still supported as fallback)
  const iidMatch = selector.match(/\[instance-id=["']?([^"'\]]+)["']?\]/);
  if (iidMatch) {
    const compId = iidMatch[1];
    // Try data-vl-id first (upgraded projects), then instance-id (legacy)
    try {
      const loc = page.locator(`[data-vl-id="${compId}"]`).first();
      if (await loc.count() > 0) return loc;
    } catch {}
    try {
      const loc = page.locator(selector).first();
      if (await loc.count() > 0) return loc;
    } catch {}
    return page.locator(`[instance-id="${compId}"]`).first();
  }

  // Strategy 4: Label proximity — "label:Username" finds the input near the label text "Username"
  const labelMatch = selector.match(/^label:(.+)$/);
  if (labelMatch) {
    return await resolveLabelProximity(page, labelMatch[1]);
  }

  // Strategy 5: CSS selector (#id, .class, [attr], tag)
  if (/^[#.\[]/.test(selector)) {
    return page.locator(selector).first();
  }

  // Strategy 6: Bare camelCase string — try as data-vl-id first, then text
  if (/^[a-zA-Z]/.test(selector) && !selector.includes(' ')) {
    try {
      const loc = page.locator(`[data-vl-id="${selector}"], [instance-id="${selector}"]`).first();
      if (await loc.count() > 0) return loc;
    } catch {}
  }

  // Strategy 6: Placeholder match
  try {
    const phLoc = page.getByPlaceholder(selector, { exact: false });
    if (await phLoc.count() > 0) return phLoc.first();
  } catch {}

  // Strategy 7: Label match
  try {
    const lblLoc = page.getByLabel(selector, { exact: false });
    if (await lblLoc.count() > 0) return lblLoc.first();
  } catch {}

  // Strategy 8: Button role match
  try {
    const btnLoc = page.getByRole('button', { name: selector });
    if (await btnLoc.count() > 0) return btnLoc.first();
  } catch {}

  // Final: text match
  return page.getByText(selector, { exact: false }).first();
}

/**
 * Label proximity: find an interactive element (input/textarea/button) near a label.
 * VL renders: <div>Label text</div><input ...> as siblings inside a container div.
 */
async function resolveLabelProximity(page, labelText) {
  // Strategy A: sibling — parent has label text div + input as children
  try {
    const loc = page.locator(`div:has(> div:text("${labelText}")) > input, div:has(> div:text("${labelText}")) > textarea`).first();
    if (await loc.count() > 0) {
      log('Selector', `  ✓ Found input near label "${labelText}" via sibling`);
      return loc;
    }
  } catch {}

  // Strategy B: parent container — :has-text on wrapper, then find input inside
  try {
    const loc = page.locator(`div:has-text("${labelText}") input, div:has-text("${labelText}") textarea`).first();
    if (await loc.count() > 0) {
      log('Selector', `  ✓ Found input near label "${labelText}" via parent:has-text`);
      return loc;
    }
  } catch {}

  // Strategy C: just get any element with this text
  return page.getByText(labelText, { exact: false }).first();
}

/**
 * Fill an input using ih5-compatible approach.
 *
 * VL ih5 framework ignores Playwright fill() and keyboard.type().
 * The only reliable method: JS native value setter + dispatch input/change events.
 *
 * Strategy order:
 *   1. ih5 JS setter (primary — works for all VL compiled inputs)
 *   2. Playwright fill (fallback for non-VL pages)
 *   3. Child input fill (wrapper div penetration)
 *   4. Click + keyboard type (last resort)
 */
async function vlFill(page, locator, value, timeout = 8000) {
  await locator.waitFor({ state: 'visible', timeout: Math.min(timeout, 6000) });

  // Resolve the actual <input> or <textarea> element
  let inputLocator = locator;
  const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => '');

  // If locator points to a wrapper div, find the input inside
  if (tag !== 'input' && tag !== 'textarea') {
    const child = locator.locator('input, textarea, [contenteditable="true"]').first();
    if (await child.count() > 0) {
      inputLocator = child;
    }
  }

  // Strategy 1: ih5 JS native setter + events (primary for VL apps)
  try {
    const success = await inputLocator.evaluate((el, val) => {
      const tagName = el.tagName.toLowerCase();
      if (tagName !== 'input' && tagName !== 'textarea') return false;
      const nativeSetter = Object.getOwnPropertyDescriptor(
        tagName === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype,
        'value'
      )?.set;
      if (!nativeSetter) return false;
      // Focus the element first
      el.focus();
      // Set value via native setter (bypasses React/ih5 controlled component)
      nativeSetter.call(el, val);
      // Dispatch events that ih5 framework listens to
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
      return true;
    }, value || '');
    if (success) {
      log('Fill', `ih5 JS setter succeeded for "${value}"`);
      return 'ih5 setter';
    }
  } catch (e) {
    log('Fill', `ih5 setter failed: ${e.message}`);
  }

  // Strategy 2: standard Playwright fill (non-VL pages)
  try {
    await inputLocator.fill(value || '');
    return 'direct fill';
  } catch (e1) {
    // Strategy 3: force fill
    try {
      await inputLocator.fill(value || '', { force: true, timeout: 5000 });
      return 'force fill';
    } catch {}
    // Strategy 4: click + keyboard type
    try {
      await inputLocator.click({ force: true, timeout: 3000 }).catch(() => {});
      await page.keyboard.type(value || '', { delay: 30 });
      return 'keyboard type';
    } catch {}
    throw e1;
  }
}

export function createVLComponentTestTool(config) {
  const ssDir = path.join(os.homedir(), '.vl-code', 'screenshots');

  const tool = {
    description: `Test VL components in compiled preview apps using Playwright.
VL 3.7 injects data-vl-id="componentId" on every named component. Use vlid:xxx as the primary selector (e.g. vlid:loginBtn, vlid:submitButton).
Full CSS: [data-vl-id="loginBtn"]. Bare text: text=Login. Label proximity: label:Username.
Fill uses ih5-compatible JS native setter (Playwright fill/keyboard.type don't work on VL inputs).
Run listIds first to discover all vlid:xxx selectors on the current page.
Actions: open, click, fill, select, assert, snapshot, screenshot, listIds, evaluate, hover, scroll, drag, wait, run, close`,
    parameters: {
      type: 'object',
      properties: {
        action: {
          type: 'string',
          enum: ['open', 'click', 'fill', 'select', 'assert', 'snapshot', 'screenshot', 'listIds', 'evaluate', 'hover', 'scroll', 'drag', 'wait', 'run', 'close'],
          description: 'Test action to perform',
        },
        url: { type: 'string', description: 'Preview URL to open (for "open" action)' },
        selector: {
          type: 'string',
          description: 'Selector: vlid:componentId (PREFERRED — VL 3.7 data-vl-id), [data-vl-id="xxx"], text=Label, label:LabelText, placeholder=..., CSS selector, or bare text',
        },
        value: { type: 'string', description: 'Value for fill/select, or expected text for assert' },
        assertType: { type: 'string', enum: ['visible', 'hidden', 'text', 'count'], description: 'Assertion type (default: visible)' },
        name: { type: 'string', description: 'Screenshot filename (without ext)' },
        expression: { type: 'string', description: 'JS expression to evaluate in page context (for "evaluate" action)' },
        steps: {
          type: 'array',
          description: 'Array of test steps for "run" action: [{action, selector, value, description}]',
          items: {
            type: 'object',
            properties: {
              action: { type: 'string' },
              selector: { type: 'string' },
              value: { type: 'string' },
              assertType: { type: 'string' },
              expression: { type: 'string' },
              description: { type: 'string' },
            },
          },
        },
        target: { type: 'string', description: 'Target selector for drag action (where to drop)' },
        x: { type: 'number', description: 'X offset for scroll (pixels) or drag delta' },
        y: { type: 'number', description: 'Y offset for scroll (pixels) or drag delta' },
        timeout: { type: 'number', description: 'Timeout in ms for wait action (default: 3000)' },
        headless: { type: 'boolean', description: 'Run browser in headless mode (default: true)' },
      },
      required: ['action'],
    },
    execute: async (input) => {
      // Only reset abort flag for top-level calls, NOT recursive calls from 'run'
      if (!input._internal) _aborted = false;
      try {
        // For close action, don't need browser
        if (input.action === 'close') {
          if (_browser) { await _browser.close().catch(() => {}); _browser = null; _page = null; }
          return { closed: true };
        }

        const page = await ensureBrowser(input.headless !== false, config);

        switch (input.action) {
          case 'open': {
            if (!input.url) return { error: 'url required' };
            await page.goto(input.url, { waitUntil: 'domcontentloaded', timeout: 15000 });
            // Wait for VL SPA to render
            try {
              await page.waitForLoadState('networkidle', { timeout: 10000 });
            } catch {}
            try {
              await page.waitForFunction(() =>
                document.querySelectorAll('button, a, input, [role="button"]').length > 2,
                { timeout: 5000 }
              );
            } catch { await page.waitForTimeout(500); }
            // Count elements — data-vl-id is the primary targeting method (VL 3.5)
            const counts = await page.evaluate(() => ({
              vlIds: document.querySelectorAll('[data-vl-id]').length,
              instanceIds: document.querySelectorAll('[instance-id]').length, // legacy
              dataKeys: document.querySelectorAll('[data-key]').length,
              inputs: document.querySelectorAll('input, textarea').length,
              buttons: document.querySelectorAll('button, [role="button"]').length,
              total: document.querySelectorAll('*').length,
            }));
            return {
              opened: input.url,
              title: await page.title(),
              elements: counts,
              idTargeting: counts.vlIds > 0
                ? `✓ ${counts.vlIds} data-vl-id elements — use vlid:xxx selectors`
                : counts.instanceIds > 0
                ? `⚠ ${counts.instanceIds} legacy instance-id elements (no data-vl-id found)`
                : '⚠ No VL component IDs found — check if page loaded correctly',
            };
          }

          case 'click': {
            if (!input.selector) return { error: 'selector required' };
            const loc = await resolveSelector(page, input.selector);
            if (!loc) return { error: `Selector not resolved: ${input.selector}` };
            try {
              await loc.waitFor({ state: 'visible', timeout: 6000 });
              await loc.scrollIntoViewIfNeeded().catch(() => {});
              await loc.click({ timeout: 8000 });
              await page.waitForTimeout(300);
              return { clicked: input.selector };
            } catch (e1) {
              // Force click fallback
              try {
                await loc.click({ force: true, timeout: 3000 });
                await page.waitForTimeout(300);
                return { clicked: input.selector, strategy: 'force' };
              } catch (e2) {
                return { error: `Click failed: ${e1.message}`, selector: input.selector };
              }
            }
          }

          case 'fill': {
            if (!input.selector) return { error: 'selector required' };
            const loc = await resolveSelector(page, input.selector);
            if (!loc) return { error: `Selector not resolved: ${input.selector}` };
            const strategy = await vlFill(page, loc, input.value || '');
            await page.waitForTimeout(200);
            return { filled: input.selector, value: input.value, strategy };
          }

          case 'select': {
            if (!input.selector || !input.value) return { error: 'selector and value required' };
            const loc = await resolveSelector(page, input.selector);
            if (!loc) return { error: `Selector not resolved: ${input.selector}` };
            // VL dropdowns: click to open → click option text
            try {
              await loc.click({ timeout: 5000 });
              await page.waitForTimeout(500);
              // Try multiple strategies to find the option
              const optionSelectors = [
                page.locator('[role="option"]').filter({ hasText: input.value }),
                page.getByRole('option', { name: input.value }),
                page.locator('li').filter({ hasText: input.value }),
                page.locator('[class*="option"]').filter({ hasText: input.value }),
                page.getByText(input.value, { exact: true }),
                page.getByText(input.value, { exact: false }),
              ];
              for (const optLoc of optionSelectors) {
                try {
                  if (await optLoc.count() > 0) {
                    await optLoc.first().click({ timeout: 3000 });
                    await page.waitForTimeout(300);
                    return { selected: input.value, from: input.selector };
                  }
                } catch {}
              }
              return { error: `Option "${input.value}" not found in dropdown` };
            } catch (e) {
              return { error: `Select failed: ${e.message}` };
            }
          }

          case 'assert': {
            if (!input.selector) return { error: 'selector required' };
            const loc = await resolveSelector(page, input.selector);
            const type = input.assertType || 'visible';
            try {
              switch (type) {
                case 'visible':
                  await loc.waitFor({ state: 'visible', timeout: 5000 });
                  return { assert: 'visible', pass: true, selector: input.selector };
                case 'hidden':
                  await loc.waitFor({ state: 'hidden', timeout: 5000 });
                  return { assert: 'hidden', pass: true, selector: input.selector };
                case 'text': {
                  const text = await loc.textContent({ timeout: 5000 });
                  const pass = text?.includes(input.value || '');
                  return { assert: 'text', pass, expected: input.value, actual: text?.substring(0, 200) };
                }
                case 'count': {
                  const count = await loc.count();
                  return { assert: 'count', count, selector: input.selector };
                }
              }
            } catch (e) {
              return { assert: type, pass: false, error: e.message, selector: input.selector };
            }
          }

          case 'snapshot': {
            const snapshot = await page.evaluate((maxD) => {
              const SKIP = new Set(['script', 'style', 'link', 'meta', 'noscript', 'svg', 'path']);
              const walk = (el, d = 0) => {
                if (d > maxD) return '';
                const tag = el.tagName?.toLowerCase() || '';
                if (SKIP.has(tag)) return '';
                // data-vl-id (VL 3.5) preferred; instance-id as legacy fallback
                const iid = el.getAttribute?.('data-vl-id') || el.getAttribute?.('instance-id') || '';
                const dk = el.getAttribute?.('data-key') || '';
                const ph = el.getAttribute?.('placeholder') || '';
                const role = el.getAttribute?.('role') || '';
                const type = el.getAttribute?.('type') || '';
                const val = (tag === 'input' || tag === 'textarea') ? (el.value || '').slice(0, 40) : '';
                let text = '';
                for (const n of el.childNodes) {
                  if (n.nodeType === 3 && n.textContent.trim()) { text = n.textContent.trim().slice(0, 60); break; }
                }
                let children = '';
                for (const c of el.children || []) children += walk(c, d + 1);
                const hasInfo = iid || dk || ph || role || text || val || tag === 'input' || tag === 'textarea' || tag === 'button' || tag === 'a' || tag === 'img';
                if (tag === 'div' && !hasInfo && el.children.length <= 1 && d > 3) return children;
                let line = '  '.repeat(Math.min(d, 12)) + `<${tag}`;
                if (iid) line += ` vlid="${iid}"`;
                if (dk) line += ` dk="${dk}"`;
                if (type) line += ` type="${type}"`;
                if (ph) line += ` ph="${ph}"`;
                if (role) line += ` role="${role}"`;
                if (val) line += ` val="${val}"`;
                line += '>';
                if (text) line += ` ${text}`;
                return line + '\n' + children;
              };
              return walk(document.body);
            }, 25);
            return { snapshot: snapshot.substring(0, 8000), truncated: snapshot.length > 8000 };
          }

          case 'screenshot': {
            if (!fs.existsSync(ssDir)) fs.mkdirSync(ssDir, { recursive: true });
            const fname = (input.name || `vltest_${Date.now()}`).replace(/[^a-zA-Z0-9_-]/g, '_') + '.png';
            const fpath = path.join(ssDir, fname);
            await page.screenshot({ path: fpath, fullPage: false });
            return { path: fpath, size: `${(fs.statSync(fpath).size / 1024).toFixed(1)}KB` };
          }

          case 'listIds': {
            // VL 3.7 injects data-vl-id onto every named component's root HTML element.
            // Primary output: all [data-vl-id] elements. Secondary: bare inputs/buttons without a VL ID ancestor.
            const elements = await page.evaluate(() => {
              const result = [];

              // Helper: get VL ID from element (data-vl-id preferred, instance-id as legacy fallback)
              function getVlId(el) {
                return el.getAttribute('data-vl-id') || el.getAttribute('instance-id') || null;
              }

              // PRIMARY: collect all VL-identified elements (data-vl-id or legacy instance-id)
              const vlEls = new Set([
                ...document.querySelectorAll('[data-vl-id]'),
                ...document.querySelectorAll('[instance-id]'),
              ]);
              vlEls.forEach(el => {
                const iid = getVlId(el);
                if (!iid) return;
                const tag = el.tagName.toLowerCase();
                const type = el.getAttribute('type') || '';
                const placeholder = el.getAttribute('placeholder') || '';
                const value = (tag === 'input' || tag === 'textarea') ? (el.value || '').slice(0, 40) : '';
                const text = el.textContent?.trim().slice(0, 50) || '';
                const visible = el.offsetParent !== null || getComputedStyle(el).position === 'fixed';
                const isLegacy = !el.hasAttribute('data-vl-id');
                result.push({
                  kind: 'vl-component',
                  iid,
                  tag,
                  type: type || undefined,
                  placeholder: placeholder || undefined,
                  value: value || undefined,
                  text: text || undefined,
                  visible,
                  legacy: isLegacy || undefined,
                  selector: `vlid:${iid}`,
                });
              });

              // SECONDARY: inputs/buttons with no VL ID ancestor (bare HTML elements)
              document.querySelectorAll('input, textarea, button, [role="button"]').forEach((el, i) => {
                let node = el;
                while (node) {
                  if (node.getAttribute?.('data-vl-id') || node.getAttribute?.('instance-id')) return;
                  node = node.parentElement;
                }
                const tag = el.tagName.toLowerCase();
                const type = el.getAttribute('type') || '';
                const placeholder = el.getAttribute('placeholder') || '';
                const text = el.textContent?.trim().slice(0, 40) || '';
                const visible = el.offsetParent !== null;
                result.push({
                  kind: 'bare',
                  tag,
                  type: type || undefined,
                  placeholder: placeholder || undefined,
                  text: text || undefined,
                  visible,
                  selector: placeholder ? `placeholder=${placeholder}` : text ? `text=${text}` : `${tag} >> nth=${i}`,
                });
              });

              return result;
            });

            const vlComponents = elements.filter(e => e.kind === 'vl-component');
            return {
              count: elements.length,
              vlComponents: vlComponents.length,
              hasVlIds: vlComponents.length > 0,
              elements: elements.slice(0, 150),
            };
          }

          case 'hover': {
            if (!input.selector) return { error: 'selector required' };
            const loc = await resolveSelector(page, input.selector);
            if (!loc) return { error: `Selector not resolved: ${input.selector}` };
            try {
              await loc.waitFor({ state: 'visible', timeout: 6000 });
              await loc.scrollIntoViewIfNeeded().catch(() => {});
              await loc.hover({ timeout: 5000 });
              await page.waitForTimeout(300);
              return { hovered: input.selector };
            } catch (e) {
              return { error: `Hover failed: ${e.message}`, selector: input.selector };
            }
          }

          case 'scroll': {
            // scroll the page or a specific container
            // selector: element to scroll inside (optional — omit for window scroll)
            // x/y: scroll offset in pixels (default: 0/300)
            const scrollX = input.x || 0;
            const scrollY = input.y || 300;
            if (input.selector) {
              const loc = await resolveSelector(page, input.selector);
              if (!loc) return { error: `Selector not resolved: ${input.selector}` };
              try {
                await loc.evaluate((el, [sx, sy]) => el.scrollBy(sx, sy), [scrollX, scrollY]);
                await page.waitForTimeout(200);
                return { scrolled: input.selector, x: scrollX, y: scrollY };
              } catch (e) {
                return { error: `Scroll failed: ${e.message}` };
              }
            } else {
              await page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [scrollX, scrollY]);
              await page.waitForTimeout(200);
              return { scrolled: 'window', x: scrollX, y: scrollY };
            }
          }

          case 'drag': {
            // Drag source element to target element (for sortable lists, kanban cards, etc.)
            // selector: source element (vlid:xxx)
            // target: destination element (vlid:xxx) OR use x/y for pixel offset drag
            if (!input.selector) return { error: 'selector required (source element)' };
            const srcLoc = await resolveSelector(page, input.selector);
            if (!srcLoc) return { error: `Source not resolved: ${input.selector}` };

            try {
              await srcLoc.waitFor({ state: 'visible', timeout: 6000 });

              if (input.target) {
                // Drag to target element
                const dstLoc = await resolveSelector(page, input.target);
                if (!dstLoc) return { error: `Target not resolved: ${input.target}` };
                await dstLoc.waitFor({ state: 'visible', timeout: 6000 });
                // Playwright dragTo — works for HTML5 drag-and-drop
                await srcLoc.dragTo(dstLoc, { timeout: 8000 });
                await page.waitForTimeout(400);
                return { dragged: input.selector, to: input.target };
              } else if (input.x !== undefined || input.y !== undefined) {
                // Drag by pixel offset (for resizable panels, sliders)
                const box = await srcLoc.boundingBox();
                if (!box) return { error: 'Could not get bounding box for drag source' };
                const startX = box.x + box.width / 2;
                const startY = box.y + box.height / 2;
                await page.mouse.move(startX, startY);
                await page.mouse.down();
                await page.mouse.move(startX + (input.x || 0), startY + (input.y || 0), { steps: 10 });
                await page.mouse.up();
                await page.waitForTimeout(400);
                return { dragged: input.selector, deltaX: input.x || 0, deltaY: input.y || 0 };
              } else {
                return { error: 'drag requires either target selector or x/y offset' };
              }
            } catch (e) {
              return { error: `Drag failed: ${e.message}`, selector: input.selector };
            }
          }

          case 'wait': {
            // Wait for element to appear, or just wait a fixed duration
            if (input.selector) {
              const ms = input.timeout || 5000;
              try {
                const loc = await resolveSelector(page, input.selector);
                await loc.waitFor({ state: 'visible', timeout: ms });
                return { waited: input.selector, ms };
              } catch {
                return { timeout: true, selector: input.selector, ms: input.timeout || 5000 };
              }
            } else {
              const ms = Math.min(input.timeout || 1000, 10000);
              await page.waitForTimeout(ms);
              return { waited: `${ms}ms` };
            }
          }

          case 'evaluate': {
            if (!input.expression) return { error: 'expression required' };
            try {
              const result = await page.evaluate(input.expression);
              return { result };
            } catch (e) {
              return { error: `evaluate failed: ${e.message}` };
            }
          }

          case 'run': {
            if (!input.steps?.length) return { error: 'steps array required' };
            const results = [];
            for (let i = 0; i < input.steps.length; i++) {
              // Check abort between steps
              if (_aborted) {
                results.push({ index: i, action: input.steps[i].action, status: 'aborted', error: 'Test aborted by user' });
                break;
              }
              const step = input.steps[i];
              const startMs = Date.now();
              try {
                // Recursive call — mark as _internal to preserve _aborted flag
                const stepResult = await tool.execute({ ...step, headless: input.headless, _internal: true });
                // Re-check abort after step execution (abort may have fired during the step)
                if (_aborted) {
                  results.push({ index: i, action: step.action, status: 'aborted', error: 'Test aborted by user' });
                  break;
                }
                const stepEntry = {
                  index: i, action: step.action, selector: step.selector,
                  status: stepResult.error ? 'failed' : 'passed',
                  result: stepResult, durationMs: Date.now() - startMs,
                  description: step.description || '',
                };
                // Auto-screenshot after each step (skip if step IS a screenshot)
                if (step.action !== 'screenshot' && step.action !== 'close' && _page && !_aborted) {
                  try {
                    if (!fs.existsSync(ssDir)) fs.mkdirSync(ssDir, { recursive: true });
                    const ssName = `step_${i}_${Date.now()}`;
                    await _page.screenshot({ path: path.join(ssDir, ssName + '.png'), fullPage: false });
                    stepEntry.screenshot = ssName;
                  } catch {}
                }
                results.push(stepEntry);
                if (stepResult.error) {
                  log('Run', `Step ${i + 1} FAILED: ${stepResult.error}`);
                  break; // Stop on first failure
                }
              } catch (e) {
                // If aborted, page.close() will cause errors — treat as abort, not failure
                if (_aborted) {
                  results.push({ index: i, action: step.action, status: 'aborted', error: 'Test aborted by user' });
                  break;
                }
                results.push({
                  index: i, action: step.action, selector: step.selector,
                  status: 'failed', error: e.message, durationMs: Date.now() - startMs,
                });
                break;
              }
            }
            const passed = results.filter(r => r.status === 'passed').length;
            const abortedCount = results.filter(r => r.status === 'aborted').length;
            const screenshots = results.filter(r => r.screenshot).map(r => r.screenshot);
            return {
              total: input.steps.length, executed: results.length,
              passed, failed: results.length - passed - abortedCount,
              aborted: abortedCount > 0,
              pass: !_aborted && passed === results.length,
              steps: results,
              screenshots,
            };
          }

          default:
            return { error: `Unknown action: ${input.action}` };
        }
      } catch (err) {
        // If aborted, return clean abort message instead of raw error
        if (_aborted) return { error: 'Test aborted by user', aborted: true };
        return { error: `VLComponentTest error: ${err.message}` };
      }
    },
    // Abort running test — closes browser context to interrupt ALL pending Playwright operations
    abort: async () => {
      _aborted = true;
      log('abort', 'Test aborted by user — closing browser');
      // Close the entire browser (not just page) to immediately kill all pending operations.
      // Closing only the page can leave dangling promises; closing the browser terminates everything.
      const browser = _browser;
      _page = null;
      _browser = null;
      if (browser) {
        try { await browser.close(); } catch {}
      }
    },
  };
  return tool;
}

export async function closeTestBrowser() {
  if (_browser) { try { await _browser.close(); } catch {} _browser = null; _page = null; }
}
