/**
 * VL AutoTest - Test Generator v2.4
 * Uses pre-extracted VL metadata + Claude API to generate structured test cases
 *
 * Key improvements:
 * - Uses metadata not raw files (10-50x smaller context)
 * - Truncated JSON repair (handles max_tokens cutoff)
 * - Reduced default test case count for reliability
 * - Detailed console logging for debugging
 */

export const PARSE_CONTEXT_SYSTEM = `You are a VL (Visual Logic) application automated test analysis expert.

You will receive structured metadata for a VL application. Your task: analyze the metadata and generate a TestPlan JSON.

## VL Special Syntax Notes

VL is a proprietary language that compiles to web applications. Important for testing:

1. **SingleSelect_Dropdown** = VL custom dropdown component (not native select)
   - Testing requires two-step click: first click to open (using placeholder text), then click the option
   - Recorded in interactions as type:"select", selector uses placeholder text

2. **<SysUI>.showModal(title, message)** = VL system confirmation modal
   - Any button with Delete functionality will show a confirmation dialog when clicked
   - Mark in interactions: requires an additional "Confirm" click step

3. **Component id / instanceId field** = primary automation selector
   - <Button-Submit id="btnSubmit"> label="Sign In" -> selector: vlid:btnSubmit
   - <Input-Username id="inputUsername"> type="text" -> selector: vlid:inputUsername input
   - You MUST prefer metadata ids over text selectors for every click/fill/select step.

4. **Button label / inputType fields** = disambiguation hints, not the primary selector
   - Use label text only for assertions, or to disambiguate repeated loop items: vlid:navItem[text="Campaigns"]
   - Use inputType only as a last fallback when no metadata id exists

5. **Data table row action buttons** (Buttons inside For loops)
   - Each row has buttons with the same instanceId (e.g. editBtn, deleteBtn)
   - Use text= to select the first one, or tr:has-text("row content") for a specific row

## Important: VL apps are SPAs (Single Page Applications)

VL apps have no URL route changes. All "pages" switch display by modifying route variables (e.g. $activeRoute) to show different sections.

## Navigation Method

VL apps switch routes by clicking navigation menu items.
- Preferred navigation selector format: vlid:navItemId or vlid:navItemId[text="Menu Label"]
- Fallback only when metadata has no stable nav id: text=menuLabel
- Do NOT use [data-key="..."] or [instance-id="..."] selectors.

## Output format (strict compact JSON, no markdown code fence, no formatted indentation):
{"appName":"AdPulse","appId":"AdPulseDesktop","homePage":"/DashboardSection","pages":[{"path":"/DashboardSection","route":"/","name":"Home Dashboard","navPath":[],"services":[]},{"path":"/CampaignSection","route":"/campaigns","name":"Campaign Management","navPath":["text=Campaigns"],"services":["CampaignService.getCampaigns","CampaignService.createCampaign"]}],"criticalPaths":[{"name":"Campaign Management Workflow","steps":["Navigate to Campaign page","Create new Campaign","Edit Campaign","Delete Campaign"],"priority":"P0"}]}

Field descriptions:
- pages[].path: SectionId (home section, the section corresponding to route variable default value)
- pages[].route: Route path (e.g. "/campaigns")
- pages[].name: Short page description
- pages[].navPath: Selector array to navigate to this page (empty array [] for home page)
- pages[].services: Services called by this page (format "Domain.method", can be empty array)
- criticalPaths[].steps: Plain text step descriptions (do not write selectors)

## navPath field rules (critical!)
- Home page (homePage) navPath = []
- navPath should use vlid-based selectors whenever metadata provides ids
- Use text=menuLabel only as a last fallback when nav ids are unavailable
- Do not guess or use section internal action buttons (createBtn etc.) as navPath!

## Important: Output must be compact!
- Do not include components, interactions, description fields in pages (wastes tokens)
- navPath max 1-2 selectors per page
- services: only write method names, no parameters
- criticalPaths.steps: one sentence per step
- Entire JSON must be on one line (compact format, no indentation)

Output JSON only, no markdown code fence, no extra text.`;

export const GEN_CASES_SYSTEM = `You are an automated test case generation expert. Generate executable **workflow-style** test cases based on TestPlan and component tree.

## Core concept: each case = one complete business workflow
Do NOT generate many small cases (Create/Edit/Delete separated)
DO generate few large cases, each covering a complete business scenario

Example:
  Wrong: TC_002 Create campaign, TC_003 Edit campaign, TC_004 Delete campaign (3 separate cases)
  Right: WF_002 Campaign Management Workflow (1 case, steps: navigate->create->verify->edit->verify->delete->verify)

Generate **5-8** workflow cases (not 10-20 small cases):
  - WF_001: Home/Dashboard load verification workflow (P0)
  - WF_002: Main data module CRUD workflow (P0, Create+Edit+Delete in one case)
  - WF_003: Second data module CRUD workflow (P0)
  - WF_004: Search/filter workflow (P1, with multiple filter conditions)
  - WF_005: Cross-module navigation workflow (P1, verify all pages accessible)
  - WF_006: Other feature workflow (P2, optional)

Each workflow case format:
{
  "id": "WF_001",
  "name": "Business workflow name (include covered operations, e.g. Campaign Management - Create Edit Delete Workflow)",
  "priority": "P0|P1|P2",
  "page": "Main page path",
  "preconditions": ["Preconditions"],
  "steps": [
    { "action": "navigate|click|fill|assert_visible|assert_text|screenshot|wait|press|hover", "selector": "selector", "value": "", "expected": "", "description": "Step description" }
  ],
  "expectedResult": "Expected result (comprehensive expectation covering all operations in the workflow)",
  "tags": ["tags"]
}

═══════════════════════════════════════════════════
## Core Rule 1: Page Navigation (must strictly follow!)
═══════════════════════════════════════════════════

VL apps are SPAs, no URL changes. All "pages" switch by clicking nav buttons to change route variables.
App starts showing the home page (homePage) by default.

### Step structure for each test case:

Step 1: navigate -> baseUrl (open app home page)
Step 2 (non-home): click nav button (prefer vlid:menuId or vlid:menuId[text="Menu Label"])
Step 3+: actual test operations on target page

Do NOT generate wait steps! The system automatically waits for page load after navigate and click.
Only exception: delete confirmation modal, dropdown animation etc. can use wait 400.

### Wrong (using non-existent instance-id):
{"action":"click","selector":"[instance-id=\\"createBtn\\"]","description":"Navigate to campaign page"}
Wrong! VL play mode has no instance-id attribute! Use vlid:createBtn

### Correct example (vlid: selector + navigation):
// metadata: navCampaigns id="navCampaigns" label="Campaigns", createBtn id="createBtn" label="+ New Campaign"
{"id":"WF_002","page":"/CampaignManagement","steps":[
  {"action":"navigate","selector":"https://app.example.com","description":"Open app home page"},
  {"action":"click","selector":"vlid:navCampaigns","description":"Click nav menu - Campaigns"},
  {"action":"click","selector":"vlid:createBtn","description":"Click new campaign button"}
]}

### Home page case (navPath is empty, no navigation needed):
{"id":"WF_001","page":"/DashboardOverview","steps":[
  {"action":"navigate","selector":"https://app.example.com","description":"Open home page"},
  {"action":"assert_visible","selector":"text=Dashboard","description":"Verify home page title visible"}
]}

### Login case (use vlid: to precisely target each component):
// metadata LoginSection Interactive Elements:
//   <Input-Username id="inputUsername"> type="text"
//   <Input-Password id="inputPassword"> type="password"
//   <Button-Submit id="btnSubmit"> label="Sign In"  @click: calls User.Login
{"id":"WF_LOGIN","steps":[
  {"action":"navigate","selector":"https://app.example.com","description":"Open login page"},
  {"action":"fill","selector":"vlid:inputUsername input","value":"player1","description":"Enter username (via vlid:inputUsername)"},
  {"action":"fill","selector":"vlid:inputPassword input","value":"password123","description":"Enter password (via vlid:inputPassword)"},
  {"action":"click","selector":"vlid:btnSubmit","description":"Click login (via vlid:btnSubmit, don't guess text)"},
  {"action":"assert_visible","selector":"text=Live Matches","description":"Verify login success - see home page content"}
]}

═══════════════════════════════════════════════════
## Core Rule 2: Selector syntax (VL 3.7 data-vl-id preferred!)
═══════════════════════════════════════════════════

VL app /play/ mode DOM characteristics:
- VL 3.7 injects **data-vl-id="componentId"** attribute on each named component's HTML root element
- Inputs render as input.ih5-native-input (with type attribute)
- Buttons render as button.ih5-native-button
- **No instance-id / data-key / placeholder / name attributes** (but HAS data-vl-id!)

### Core principle: prefer vlid:componentId selectors!
- Each Interactive Element in metadata has an id (e.g. id="btnSubmit")
- This id is the data-vl-id value, use vlid:btnSubmit selector for precise targeting
- vlid: selectors are most reliable, unaffected by text changes, i18n, hidden state

### Selector priority (high to low):

1. [Preferred] vlid:componentId  <- Use id field from metadata! Most reliable!
   e.g.: metadata says id="btnSubmit" -> selector: vlid:btnSubmit
   e.g.: metadata says id="inputUsername" -> selector: vlid:inputUsername
   Every Interactive Element has an id, prefer using it!
2. [Input fill] vlid:componentId input  <- vlid targets container + input child element
   e.g.: fill input: selector: vlid:inputUsername input
3. [Repeated loop item] vlid:componentId[text="Visible Label"]  <- use same id plus text filter when repeated
4. [Nav menu] vlid:navItemId or vlid:navItemId[text="MENU_LABEL"]
5. [Fallback-button] text=EXACT_LABEL  <- only when id unavailable, use label field
6. [Fallback-input] input[type="TYPE"]  <- only when vlid unavailable
   - Multiple same type: input[type="text"] >> nth=0, >> nth=1
7. [Assert] text=keyText  <- assertions use text= to check visible text

### Use Event Handler Chain to determine test flow:
Each Interactive Element in metadata may have handler info:
  - @click: calls UserService.Login($username, $password)
  This tells you: clicking this button calls the login service, so you must fill username and password first.
  Organize test steps according to handler chain order!

Absolutely forbidden:
- [instance-id="xxx"] / [data-key="xxx"] / [placeholder="xxx"] / #id
- Using text= for click/fill/select when metadata already provides an id
- Guessing button text! Must use EXACT label or id from metadata

Allowed (by priority):
- vlid:componentId <- most reliable! From metadata id field
- vlid:componentId input <- target input inside vlid container
- vlid:componentId[text="Visible Label"] <- repeated loop items with same component id
- text=EXACT_LABEL <- from metadata label field (fallback only when id unavailable)
- input[type="text|password|email|number"] (fallback)
- button:has-text("LABEL")
- tr:has-text("row content") vlid:editBtn

═══════════════════════════════════════════════════
## Core Rule 3: Dropdown/Select interaction (VL special!)
═══════════════════════════════════════════════════

VL uses custom dropdown components (div structure), not native <select>.
Therefore select action will fail. Must use two-step click instead:

Correct: two-step click to open dropdown and select
{"action":"click","selector":"text=All Status","description":"Open status filter dropdown"}
{"action":"click","selector":"text=Active","description":"Select Active option"}

Correct: click via placeholder text (e.g. showing "Select Campaign")
{"action":"click","selector":"text=Select Campaign","description":"Open Campaign dropdown"}
{"action":"click","selector":"text=Target Campaign Name","description":"Select target campaign (use actually existing data name)"}

Important: dropdown options must be real existing data!
- If selecting Campaign, must choose a real campaign name (e.g. "Campaign_001" or seed data names)
- If selecting Status, options are: Active, Paused, Draft, Pending Review etc. (check VL source)
- Do not select a non-existent option text

Wrong (will timeout 15s):
{"action":"select","selector":"[instance-id='statusFilter']","value":"active"}
{"action":"select","selector":"[placeholder='Select Campaign']","value":"xxx"}

Rules:
- Never use select action + [instance-id=...] selector
- Never use select action + [placeholder=...] selector (VL custom dropdown has no such attribute)
- Dropdowns always use two-step click: first click to open (using placeholder text), then click option text

═══════════════════════════════════════════════════
## Core Rule 4: Action buttons in lists/For loops
═══════════════════════════════════════════════════

VL does NOT use HTML <table>/<tr>/<td> to render lists, it uses Row/Col layout!
Forbidden: tr:has-text("xxx") button:has-text("Edit") -- tr elements don't exist in VL!
Forbidden: td:has-text(), table >> nth= and other HTML table selectors!

Buttons in VL For loops (e.g. Edit/Delete/x buttons per record), correct approach:

Method 1: use vlid:instanceId (preferred)
{"action":"click","selector":"vlid:deleteBtn","description":"Click delete button (first match)"}

Method 2: repeated loop items with the same id can use id + visible text
{"action":"click","selector":"vlid:navItem[text=\"课程管理\"]","description":"Click the nav item whose visible text is 课程管理"}

Rules:
- Buttons in For loops: use vlid:instanceId directly; if repeated, add [text="..."] to disambiguate
- Don't assume button text, determine from value field in Interactive Elements
- Delete button value is usually "x" or "Delete" (unless Interactive Elements explicitly states otherwise)

═══════════════════════════════════════════════════
## Core Rule 5: Delete operations have confirmation modals (VL custom Modal)
═══════════════════════════════════════════════════

VL app delete buttons show <SysUI>.showModal() custom confirmation modal when clicked!
This is a VL div modal, NOT a native browser confirm(), must explicitly click the confirm button!

Delete button value is usually "x" (not "Delete"!), after clicking a modal appears, need to confirm:

Correct: any delete/x button requires a confirmation step
{"action":"click","selector":"text=x","description":"Click delete button (x)"}
{"action":"wait","value":"600","description":"Wait for confirmation modal to appear"}
{"action":"click","selector":"text=Confirm","description":"Click modal confirm button"}
{"action":"screenshot","description":"Screenshot to verify record deleted"}

Wrong (clicking x/delete without confirming, modal stays open, delete has no effect):
{"action":"click","selector":"text=x","description":"Click delete button"}
<- Missing wait + confirm steps!

Rules:
- Any button with value "x", "Delete", "Remove": must add wait 600 + click Confirm after clicking
- Confirm button text based on Interactive Elements: Confirm, Yes, OK
- If Interactive Elements doesn't specify, default to text=Confirm

═══════════════════════════════════════════════════
## Core Rule 6: Save/Submit buttons are async operations, need to wait for API response
═══════════════════════════════════════════════════

VL Save/Submit buttons typically call backend APIs, UI state updates only after API returns (e.g. closing form)!
If form uses a toggle button (+ New Note button both opens and closes form), must wait for API to complete!

Correct: wait 800ms after Save before continuing
{"action":"click","selector":"vlid:saveBtn","description":"Click Save Note"}
{"action":"wait","value":"800","description":"Wait for API save response, form closes"}
{"action":"assert_visible","selector":"text=Project Meeting Notes","description":"Verify note appeared on board"}

If need to reopen form to add second item, also wait for first save to complete:
{"action":"wait","value":"800","description":"Wait for first save to complete"}
{"action":"click","selector":"vlid:addNoteBtn","description":"Click + New Note again to open form"}

Wrong (clicking toggle button immediately after save, may trigger close before API returns):
{"action":"click","selector":"text=Save Note"}
{"action":"click","selector":"text=+ New Note"}  <- May close the form that just opened!

Rules:
- Any button that calls service API (Save, Submit, Add, Create): add wait 800 after it
- If next step operates the same toggle button, must add wait to ensure API completed

═══════════════════════════════════════════════════
## Core Rule 7: Use Event Handler Chain to determine test flow
═══════════════════════════════════════════════════

Interactive Elements in metadata may contain event handler chain info:
  <Button-Submit id="btnSubmit"> label="Sign In"
    @click: calls User.Login($username, $password)

This tells you:
  1. Clicking this button calls User.Login service
  2. Parameters are $username and $password
  3. So test flow must be: fill username -> fill password -> click Sign In

Use handler chain info to derive correct test step order! Don't guess the flow!

If a button's handler shows:
  @click: calls CampaignService.createCampaign($name, $budget, $status)
Then you know before clicking you must fill name, budget, status fields.

If a submit/save handler checks required state first, you MUST satisfy every required field before the submit click.
Examples:
  - IF $formCourseId == 0 -> you must select a course before click submit
  - IF $formTitle.trim() == "" -> you must fill the title before click submit
  - If the UI label shows "*" or the handler passes a form variable into Create/Update, treat it as required.

For required dropdown/select fields, never skip the selection step:
  - Custom dropdown: click trigger -> click real option text
  - Button group options: click one of the option buttons before submit
Do not generate a submit step if any required selector-setting step is missing.

═══════════════════════════════════════════════════
## Core Rule 8: Test case logical order (CRUD dependencies)
═══════════════════════════════════════════════════

Each test case starts from blank state (fresh navigate).
If testing edit/delete operations, must consider whether data exists:

A. If app has seed data (list already has records): Edit/Delete cases can run independently
B. If no seed data: Create case must come before Edit/Delete

Recommended workflow case order:
- WF_001: Home page load verification (navigate + assert_visible key elements)
- WF_002: Main module CRUD workflow (Create->Edit->Delete in one case, avoid cross-case dependencies)
- WF_003: Second module CRUD workflow
- WF_004: Search/filter workflow

CRUD order within workflow:
1. Navigate to page
2. Screenshot initial state
3. Create new record (fill ALL required fields!)
4. Screenshot to verify creation success
5. Click Edit (select first or just-created record)
6. Modify fields + save
7. Screenshot to verify modification success
8. Click Delete + confirm + screenshot to verify deletion

═══════════════════════════════════════════════════
## Other Rules
═══════════════════════════════════════════════════

═══════════════════════════════════════════════════
## Absolutely Forbidden (will cause test failure)
═══════════════════════════════════════════════════

1. Forbidden: compiled CSS class name selectors (extremely unstable, changes every compile):
   Wrong: click button.css-d6g5785qggj000g50dw0
   Wrong: click .css-abc123 >> nth=0
   Use instead: text=button text or [placeholder="..."]

2. Forbidden: duplicate navigation (one nav step is enough, don't repeat):
   Wrong: click text=Campaigns then again click text=Campaigns
   Use: just one step: click text=Campaigns

3. Forbidden: >> nth= to locate dropdown options:
   Wrong: click li >> nth=0
   Wrong: click .xxx >> nth=0
   Use instead: two steps: click to open dropdown + click text=option name

4. Forbidden: "assert" action (doesn't exist, will error):
   Wrong: {"action":"assert","selector":"text=xxx"}
   Use instead: {"action":"assert_visible","selector":"text=xxx"} or {"action":"screenshot"}

5. Forbidden: assuming uncertain text (like random strings in CSS class/ID):
   Wrong: click .HeadingMedium:has-text("Create New Campaign") ~ div .css-xxx
   Use instead: text=Create Campaign or text=Submit etc. button value text

6. Forbidden: HTML table selectors (VL doesn't render table/tr/td):
   Wrong: tr:has-text("Buy milk") text=x
   Wrong: td:has-text("xxx") button
   Use instead: text=x or vlid:deleteBtn (directly target button)

7. Forbidden: [instance-id="xxx"] or [data-key="xxx"] for any operation!
   Wrong: click [instance-id="mobileNav"] <- VL play mode has no instance-id attribute!
   Wrong: click [data-key="/inplay"] <- VL play mode has no data-key attribute!
   Wrong: fill [instance-id="usernameInput"] <- doesn't exist!
   Wrong: [placeholder="Enter name"] <- VL play mode has no placeholder attribute!
   For navigation use text=menuText: click text=In-Play
   For inputs use input[type="text"] or input[type="password"]
   For buttons use text=buttonText: click text=Sign In

- Each workflow case steps should be 10-25 (including nav steps; CRUD workflows having more steps is normal)
- P0=core flow, P1=main features, P2=secondary features
- Output compact JSON, no formatted indentation

Output format: {"testCases":[...]}
Output JSON only, no markdown code fence, no extra text.`;

function log(tag, msg, data) {
  const ts = new Date().toISOString().slice(11, 23);
  if (data !== undefined) {
    console.log(`[${ts}] [TestGen] [${tag}] ${msg}`, typeof data === 'string' ? data.slice(0, 200) : data);
  } else {
    console.log(`[${ts}] [TestGen] [${tag}] ${msg}`);
  }
}

export class TestGenerator {
  constructor(anthropicClient) {
    this.client = anthropicClient;
    this.model = 'claude-sonnet-4-6';
  }

  /**
   * Build a compact context string from VL metadata for LLM consumption.
   * @param {object} metadata - VL metadata
   * @param {Array|null} navElements - DOM-discovered nav elements from discoverNavElementsOnce()
   *   Each: { instanceId, text, x, y, w, h, position: 'sidebar'|'topbar'|'content' }
   */
  buildMetadataContext(metadata, navElements = null) {
    const parts = [];

    // Apps + Route-Based Navigation
    if (metadata.apps?.length) {
      parts.push('## Apps');
      for (const app of metadata.apps) {
        parts.push(`- ${app.appId || app.name} (${app.fileName})`);
        // Show section instance-id mapping from app pages (sectionId → DOM instance-id)
        // ⚠️ Key: DOM instance-id uses camelCase (e.g., "sidebarNav"), NOT PascalCase sectionId!
        if (app.pages?.length) {
          const secRefs = [];
          for (const p of app.pages) {
            for (const sr of (p.sectionRefs || [])) {
              if (sr.sectionId && sr.instanceId) secRefs.push(sr);
            }
          }
          if (secRefs.length) {
            parts.push(`  Section mapping (structural reference only, play mode has no instance-id attribute):`);
            for (const sr of secRefs) {
              parts.push(`    ${sr.sectionId} → ${sr.instanceId} (source code name, not DOM attribute)`);
            }
          }
        }
      }

      // ── Route-Based Navigation (highest priority info) ──────────────
      // Check if any app has a route map extracted from If conditions
      const appsWithRoutes = metadata.apps.filter(a => a.routeMap && Object.keys(a.routeMap).length > 0);

      if (appsWithRoutes.length > 0) {
        parts.push('\n## App Routing (Route Navigation System)');
        parts.push('This app uses route variables to control page switching (not URL routing).');
        parts.push('Navigation method: prefer vlid:componentId or vlid:componentId[text="Menu Label"]; only fall back to text=menuLabel when no nav id exists.');
        parts.push('Selectors in navPath should stay id-based whenever possible.\n');

        for (const app of appsWithRoutes) {
          parts.push(`### ${app.appId || app.name} Route Table`);
          parts.push(`Route variable: ${app.routeVarName || '$activeRoute'}`);
          parts.push(`Home route: "${app.homeRoute || '/'}" → ${app.routeMap[app.homeRoute || '/'] || '(home)'}`);
          parts.push('Route → Section mapping:');
          for (const [route, sectionId] of Object.entries(app.routeMap)) {
            parts.push(`  "${route}" → ${sectionId}`);
          }

          // Find the nav section that handles routing
          if (app.navSectionInstanceId) {
            parts.push(`Nav section instance: instance-id="${app.navSectionInstanceId}" (handles @${app.navEventName || 'menuSelect'} event)`);
          }
        }

        // Find nav section with menu items (e.g. SidebarNav)
        const navSections = (metadata.sections || []).filter(s => s.isNavSection && s.navMenuItems?.length > 0);
        if (navSections.length > 0) {
          const navSec = navSections[0];
          parts.push('\n### Navigation Menu Items (from source code)');
          parts.push('Click format: prefer vlid:navItem[text="menuLabel"] or a unique nav id if available.');
          parts.push('Menu items (label -> route -> target section):');
          for (const item of navSec.navMenuItems) {
            const targetSection = appsWithRoutes[0]?.routeMap?.[item.key] || '?';
            parts.push(`  - label="${item.label}" → route ${item.key} → ${targetSection}`);
          }
          parts.push('\nNavigation selector examples (id-first):');
          for (const item of navSec.navMenuItems.slice(0, 3)) {
            parts.push(`  - To ${appsWithRoutes[0]?.routeMap?.[item.key] || item.label}: vlid:${navSec.navItemInstanceId || 'navItem'}[text="${item.label}"]`);
          }
        }
      } else if (navElements?.length > 0) {
        // Fallback: DOM-discovered nav elements (position-classified)
        const sidebarEls = navElements.filter(e => e.position === 'sidebar');
        const topbarEls = navElements.filter(e => e.position === 'topbar');
        const contentEls = navElements.filter(e => e.position === 'content');

        parts.push('\n## App Navigation Elements (from real DOM, classified by screen position)');
        parts.push('Only [sidebar] or [topbar] elements can be used as navPath!');

        if (sidebarEls.length > 0) {
          parts.push('### Sidebar navigation buttons [sidebar] -> prefer vlid selectors');
          for (const el of sidebarEls) {
            let desc = `  - [sidebar] text="${el.text}" → selector: ${el.instanceId ? `vlid:${el.instanceId}[text="${el.text}"]` : `text=${el.text}`}`;
            if (el.revealsText) desc += `  -> appears after click:[${el.revealsText}]`;
            parts.push(desc);
          }
        }
        if (topbarEls.length > 0) {
          parts.push('### Topbar navigation buttons [topbar] -> prefer vlid selectors');
          for (const el of topbarEls) {
            parts.push(`  - [topbar] text="${el.text}" → selector: ${el.instanceId ? `vlid:${el.instanceId}[text="${el.text}"]` : `text=${el.text}`}`);
          }
        }
        if (contentEls.length > 0) {
          parts.push('### In-page action buttons [content] -> CANNOT be used as navPath!');
          for (const el of contentEls) {
            parts.push(`  - [content] text="${el.text}" → selector: ${el.instanceId ? `vlid:${el.instanceId}` : `text=${el.text}`}`);
          }
        }
      } else {
        parts.push('\n## App Navigation');
        parts.push('No reliable navigation controls were identified from source or DOM.');
        parts.push('Do NOT invent navPath selectors from section/page instance names.');
        parts.push('If a non-home page has no explicit nav control, keep navPath empty and avoid generating a navigation click for it.');
      }
    }

    // Sections (pages) — compact version
    if (metadata.sections?.length) {
      parts.push('\n## Sections (Pages)');
      for (const sec of metadata.sections) {
        parts.push(`\n### ${sec.sectionId || sec.name}`);

        // Interactive elements — extractor stores them in interactiveElements or elements
        const allEls = sec.interactiveElements || sec.elements || [];
        if (allEls.length > 0) {
          // Filter to interactive ones if using raw elements
          const interactive = sec.interactiveElements
            ? allEls  // already filtered by extractor
            : allEls.filter(el =>
                el.events?.length > 0 ||
                ['Input', 'Button', 'Select', 'Checkbox', 'Radio', 'Table', 'Form'].some(t =>
                  el.category?.includes(t) || el.typeName?.includes(t)
                )
              );

          if (interactive.length > 0) {
            parts.push(`Interactive Elements (${interactive.length}):`);
            parts.push(`  IMPORTANT: Use vlid:instanceId selector (highest priority)! instanceId = data-vl-id in DOM.`);
            parts.push(`  For repeated loop items, use vlid:instanceId[text="visibleLabel"] to stay id-based.`);
            parts.push(`  For inputs: vlid:instanceId input. For assert: text=visible_text.`);
            for (const el of interactive.slice(0, 30)) {
              const elName = el.name || el.typeName || el.type || '';
              const cat = el.category || '';
              const iid = el.instanceId ? ` id="${el.instanceId}"` : '';
              // Show element info with suggested selector strategy
              let desc = `  - <${cat}-${elName}${iid}>`;
              // Prioritize vlid: selector when instanceId is available
              if (el.instanceId) {
                if (el.type === 'input' || el.type === 'textarea') {
                  desc += ` -> USE selector: vlid:${el.instanceId} input`;
                } else {
                  desc += ` -> USE selector: vlid:${el.instanceId}`;
                }
                if (el.label && !el.label.startsWith('$')) desc += ` (label="${el.label}")`;
              } else {
                // No instanceId — fall back to text= or input[type=]
                if (el.label && !el.label.startsWith('$')) desc += ` label="${el.label}" -> USE selector: text=${el.label}`;
                if (el.value && !el.value.startsWith('$') && el.value !== el.label) desc += ` text="${el.value}" -> USE selector: text=${el.value}`;
              }
              // Show inputType — secondary hint
              if (el.inputType) desc += ` type="${el.inputType}"`;
              if (cat === 'SingleSelect_Dropdown') desc += ` [VL custom dropdown -> two-step click]`;
              // Show event handler chain — what clicking/changing this element does
              if (el.handlers?.length > 0) {
                for (const h of el.handlers) {
                  desc += `\n      ${h.event}:`;
                  if (h.calls?.length > 0) desc += ` calls ${h.calls.join(', ')}`;
                  else if (h.actions?.length > 0) desc += ` ${h.actions.slice(0, 3).join('; ')}`;
                }
              }
              parts.push(desc);
            }
          }
        }

        // Service calls
        if (sec.serviceCalls?.length) {
          parts.push(`Services: ${sec.serviceCalls.map(s => `${s.serviceDomain}.${s.method}`).join(', ')}`);
        }
      }
    }

    // Services — compact (metadata uses serviceDomains)
    const services = metadata.serviceDomains || metadata.services || [];
    if (services.length) {
      parts.push('\n## Services');
      for (const svc of services) {
        if (svc.methods?.length) {
          for (const m of svc.methods) {
            const params = m.params?.map(p => `${p.name}(${p.type})`).join(', ') || 'none';
            parts.push(`- ${svc.name}.${m.name}(${params})`);
          }
        }
      }
    }

    // Tables — compact (metadata uses database.tables)
    const tables = metadata.database?.tables || metadata.tables || [];
    if (tables.length) {
      parts.push('\n## Tables');
      for (const tbl of tables) {
        const fields = tbl.fields?.map(f => `${f.name}(${f.type})`).join(', ') || '';
        parts.push(`- ${tbl.name}: ${fields}`);
      }
    }

    // Dependencies — compact
    if (metadata.dependencyGraph?.edges?.length) {
      parts.push('\n## Dependencies');
      for (const edge of metadata.dependencyGraph.edges.slice(0, 30)) {
        parts.push(`  ${edge.from} → ${edge.to}`);
      }
    }

    const result = parts.join('\n');
    log('Context', `Built metadata context: ${result.length} chars`);
    return result;
  }

  /**
   * Parse app context from metadata → TestPlan
   * @param {object} metadata - VL metadata
   * @param {object} processFiles - unused legacy param
   * @param {function} onProgress - progress callback
   * @param {Array|null} navElements - DOM-discovered nav elements (position-classified)
   */
  async parseAppContext(metadata, processFiles, onProgress, navElements = null) {
    onProgress?.({ phase: 'parse_context', status: 'running', message: 'Analyzing app context...' });

    if (!metadata || (!metadata.apps?.length && !metadata.sections?.length)) {
      throw new Error('No VL metadata available. Please upload a ZIP containing VL source files first.');
    }

    const userMessage = this.buildMetadataContext(metadata, navElements);
    log('ParseContext', `Sending ${userMessage.length} chars to LLM`);

    const response = await this.client.messages.create({
      model: this.model,
      max_tokens: 8192,
      system: PARSE_CONTEXT_SYSTEM,
      messages: [{ role: 'user', content: userMessage }],
    });

    const text = response.content[0].text;
    log('ParseContext', `LLM returned ${text.length} chars, stop=${response.stop_reason}`);
    if (response.stop_reason === 'max_tokens') {
      log('ParseContext', `⚠️ max_tokens hit — TestPlan truncated! Attempting JSON repair...`);
    }
    log('ParseContext', `Response preview:`, text.slice(0, 300));

    const testPlan = this._parseJSON(text, 'TestPlan');

    log('ParseContext', `TestPlan: ${testPlan.pages?.length || 0} pages, ${testPlan.criticalPaths?.length || 0} critical paths`);
    onProgress?.({ phase: 'parse_context', status: 'done', message: `Context analysis complete: ${testPlan.pages?.length || 0} pages`, data: testPlan });
    return testPlan;
  }

  /**
   * Generate test cases from a TestPlan
   * @param {Object} testPlan - parsed TestPlan from parseAppContext
   * @param {Object} options - { priority, pages, maxCases, baseUrl, domSnapshot }
   *   domSnapshot: { snapshot, elementSummary } from TestRunner.discoverDOM()
   */
  async generateTestCases(testPlan, options = {}, onProgress) {
    onProgress?.({ phase: 'gen_cases', status: 'running', message: 'Generating test cases...' });

    const { priority, pages, maxCases = 15, baseUrl, componentInfo, domSnapshot, navElements } = options;

    let constraints = `\nGenerate at most ${maxCases} cases (fewer is better, ensure JSON completeness).`;
    if (priority) constraints += `\nOnly generate cases with priority ${Array.isArray(priority) ? priority.join('/') : priority}.`;
    if (pages) constraints += `\nOnly generate cases for these pages: ${pages.join(', ')}`;
    if (baseUrl) constraints += `\nThe app baseUrl is: ${baseUrl}. The navigate step selector must be set to "${baseUrl}" (full URL), do not use "/" or empty string.`;

    // Build user message with TestPlan + component tree or DOM snapshot
    const planStr = JSON.stringify(testPlan);
    let userContent = `TestPlan:\n${planStr}`;
    let elementSource = 'none';

    if (componentInfo?.source === 'caseJson') {
      // Use component tree from case JSON — for structural context
      elementSource = `caseJson (${componentInfo.interactiveElements?.length || 0} elements)`;
      userContent += `\n\n## Interactive Elements (from VL source code)\n`;
      userContent += `Prefer vlid:instanceId selectors! instanceId corresponds to DOM data-vl-id attribute.\n`;
      userContent += `- Buttons/links: vlid:instanceId\n`;
      userContent += `- Input fill: vlid:instanceId input\n`;
      userContent += `- Fallback when no id: text=buttonText or input[type="text"]\n\n`;
      userContent += componentInfo.elementPrompt;

      // Also include condensed component tree for structural context
      if (componentInfo.componentTree) {
        const treeTrunc = componentInfo.componentTree.slice(0, 6000);
        userContent += `\n\n## Full Component Tree Structure\n${treeTrunc}`;
      }
    } else if (componentInfo?.source === 'metadata') {
      // Use instanceIds from VL source files
      elementSource = `metadata (${componentInfo.interactiveElements?.length || 0} elements)`;
      userContent += `\n\n## Interactive Elements (from VL source code)\n`;
      userContent += `Prefer vlid:instanceId selectors! Each element's id field is the vlid: parameter (corresponds to DOM data-vl-id attribute).\n`;
      userContent += `- Buttons/links: vlid:instanceId\n`;
      userContent += `- Input fill: vlid:instanceId input\n`;
      userContent += `- Fallback when no id: text=buttonText or input[type="text"]\n\n`;
      userContent += componentInfo.elementPrompt;
    } else if (componentInfo?.source === 'dom' && componentInfo.domSnapshot) {
      // Fallback: DOM discovery
      const domSnap = componentInfo.domSnapshot;
      elementSource = `dom (${domSnap.elementSummary?.length || 0} elements)`;
      const elSummary = domSnap.elementSummary?.map(el => {
        const parts = [el.tag];
        if (el.text) parts.push(`text:"${el.text}"`);
        if (el.id) parts.push(`id:${el.id}`);
        if (el.placeholder) parts.push(`ph:"${el.placeholder}"`);
        if (el.role) parts.push(`role:${el.role}`);
        if (el.type) parts.push(`type:${el.type}`);
        if (el.dataId) parts.push(`data-id:${el.dataId}`);
        if (el.instanceId) parts.push(`instance-id:${el.instanceId}`);
        if (el.ariaLabel) parts.push(`aria:"${el.ariaLabel}"`);
        if (el.classes) parts.push(`class:${el.classes}`);
        return parts.join(' ');
      }).join('\n') || '';
      userContent += `\n\n## Actual Page DOM Element List\n${elSummary}`;
      if (domSnap.snapshot) {
        userContent += `\n\n## Page DOM Structure Snapshot\n${domSnap.snapshot.slice(0, 4000)}`;
      }
    } else if (domSnapshot) {
      // Legacy: direct domSnapshot parameter
      elementSource = `dom-legacy (${domSnapshot.elementSummary?.length || 0} elements)`;
      const elSummary = domSnapshot.elementSummary?.map(el => {
        const parts = [el.tag];
        if (el.text) parts.push(`text:"${el.text}"`);
        if (el.placeholder) parts.push(`ph:"${el.placeholder}"`);
        if (el.dataId) parts.push(`data-id:${el.dataId}`);
        if (el.instanceId) parts.push(`instance-id:${el.instanceId}`);
        return parts.join(' ');
      }).join('\n') || '';
      userContent += `\n\n## Actual Page DOM Elements\n${elSummary}`;
    }

    log('GenCases', `Sending TestPlan (${planStr.length} chars) + elements (${elementSource}), maxCases=${maxCases}, baseUrl=${baseUrl || 'not set'}`);

    const response = await this.client.messages.create({
      model: this.model,
      max_tokens: 8192,
      system: GEN_CASES_SYSTEM + `\n\nAdditional constraints: ${constraints}`,
      messages: [{
        role: 'user',
        content: userContent,
      }],
    });

    const text = response.content[0].text;
    log('GenCases', `LLM returned ${text.length} chars, stop=${response.stop_reason}`);
    log('GenCases', `Response tail:`, text.slice(-200));

    const result = this._parseJSON(text, 'TestCases');
    const testCases = result.testCases || (Array.isArray(result) ? result : []);

    log('GenCases', `Parsed ${testCases.length} test cases`);
    for (const tc of testCases) {
      log('GenCases', `  ${tc.id} [${tc.priority}] ${tc.name} (${tc.steps?.length || 0} steps)`);
    }

    // Post-process 1: replace ALL [instance-id="xxx"] selectors (VL play mode has NONE)
    const instanceIdFixed = this._replaceAllInvalidSelectors(testCases);

    // Post-process 2: validate and inject missing navigation steps
    const fixedCases = this._fixNavigation(instanceIdFixed, testPlan, baseUrl, navElements);
    const fixCount = fixedCases.filter(tc => tc._navFixed).length;
    if (fixCount > 0) {
      log('GenCases', `Navigation fix: injected nav steps into ${fixCount} test cases`);
    }

    // Post-process 3: catch any remaining [data-key="..."] selectors and convert to VL-friendly selectors
    let dataKeyFixCount = 0;
    const dataKeyFixedCases = fixedCases.map(tc => {
      if (!tc.steps?.length) return tc;
      const newSteps = tc.steps.map(step => {
        if (!step.selector) return step;
        const dkMatch = step.selector.match(/^\[data-key=["']?\/([^"'\]]+)["']?\]$/);
        if (!dkMatch) return step;
        const routeName = dkMatch[1]
          .replace(/([a-z])([A-Z])/g, '$1 $2')
          .replace(/[-_]/g, ' ')
          .replace(/\b\w/g, c => c.toUpperCase());
        dataKeyFixCount++;
        return { ...step, selector: `text=${routeName}`, _originalSelector: step.selector };
      });
      return { ...tc, steps: newSteps };
    });
    if (dataKeyFixCount > 0) {
      log('GenCases', `Post-process: converted ${dataKeyFixCount} remaining [data-key] selectors to text=`);
    }

    // Post-process 4: whenever metadata provides a stable instanceId, replace
    // click/fill selectors that still rely on text/input-type fallbacks.
    const finalCases = this._preferMetadataIdsForActions(dataKeyFixedCases, componentInfo, navElements);

    onProgress?.({
      phase: 'gen_cases',
      status: 'done',
      message: `Generated ${finalCases.length} test cases${fixCount > 0 ? ` (auto-fixed navigation steps for ${fixCount} cases)` : ''}`,
      data: finalCases,
    });

    return finalCases;
  }

  /**
   * Generate test cases directly from app URL by auto-discovering pages
   */
  async generateFromUrl(appUrl, pageSnapshots, onProgress) {
    onProgress?.({ phase: 'gen_from_url', status: 'running', message: 'Generating test cases from app screenshots/snapshots...' });

    const snapshotsDesc = pageSnapshots.map(s =>
      `Page: ${s.url}\nTitle: ${s.title}\nDOM Snapshot:\n${s.snapshot}`
    ).join('\n\n---\n\n');

    log('GenFromUrl', `${pageSnapshots.length} pages, ${snapshotsDesc.length} chars`);

    const response = await this.client.messages.create({
      model: this.model,
      max_tokens: 8192,
      system: `You are an automated testing expert. Generate test cases based on app page DOM snapshots.
App root URL: ${appUrl}
Generate at most 10 cases.

${GEN_CASES_SYSTEM}`,
      messages: [{
        role: 'user',
        content: `Below are DOM snapshots for each app page:\n\n${snapshotsDesc}`,
      }],
    });

    const text = response.content[0].text;
    log('GenFromUrl', `LLM returned ${text.length} chars, stop=${response.stop_reason}`);

    const result = this._parseJSON(text, 'TestCases');
    const testCases = result.testCases || (Array.isArray(result) ? result : []);

    onProgress?.({
      phase: 'gen_from_url',
      status: 'done',
      message: `Generated ${testCases.length} test cases from page snapshots`,
      data: testCases,
    });

    return testCases;
  }

  /**
   * Replace ALL [instance-id="xxx"], [placeholder="xxx"], [name="xxx"] selectors
   * in LLM-generated test cases. VL /play/ mode renders NONE of these attributes.
   *
   * Conversion strategy:
   * - [instance-id="xxxBtn"] for click → text= using camelCase word extraction
   * - [instance-id="xxxInput"] for fill → input[type="text"] (or password/email if detectable)
   * - [placeholder="xxx"] → input[type="text"] (placeholder doesn't exist in DOM)
   * - [name="xxx"] → input[type="text"]
   */
  _replaceAllInvalidSelectors(testCases) {
    let replaceCount = 0;

    const result = testCases.map(tc => {
      if (!tc.steps?.length) return tc;

      // Track input index per type for nth selector
      const inputCountByType = {};

      const newSteps = tc.steps.map(step => {
        if (!step.selector) return step;
        const sel = step.selector;

        // Check for [instance-id="xxx"] — VL play mode has no instance-id but HAS data-vl-id
        // Convert to vlid: selector which maps to [data-vl-id="xxx"]
        const instMatch = sel.match(/^\[instance-id=["']([^"']+)["']\]$/);
        if (instMatch) {
          const compId = instMatch[1];
          replaceCount++;

          if (step.action === 'fill') {
            return { ...step, selector: `vlid:${compId} input`, _originalSelector: sel };
          } else {
            return { ...step, selector: `vlid:${compId}`, _originalSelector: sel };
          }
        }

        // Check for [placeholder="xxx"] — doesn't exist in VL play mode
        const phMatch = sel.match(/^\[placeholder=["']([^"']+)["']\]$/);
        if (phMatch) {
          replaceCount++;
          if (step.action === 'fill') {
            const count = inputCountByType['text'] || 0;
            inputCountByType['text'] = count + 1;
            return { ...step, selector: count === 0 ? `input[type="text"]` : `input[type="text"] >> nth=${count}`, _originalSelector: sel };
          } else {
            // Click on placeholder text (e.g., dropdown trigger) → try text=
            return { ...step, selector: `text=${phMatch[1]}`, _originalSelector: sel };
          }
        }

        // Check for [name="xxx"] — doesn't exist in VL play mode
        const nameMatch = sel.match(/^\[name=["']([^"']+)["']\]$/);
        if (nameMatch) {
          replaceCount++;
          if (step.action === 'fill') {
            const count = inputCountByType['text'] || 0;
            inputCountByType['text'] = count + 1;
            return { ...step, selector: count === 0 ? `input[type="text"]` : `input[type="text"] >> nth=${count}`, _originalSelector: sel };
          }
        }

        return step;
      });
      return { ...tc, steps: newSteps };
    });

    if (replaceCount > 0) {
      log('GenCases', `Post-process: replaced ${replaceCount} invalid selectors ([instance-id], [placeholder], [name]) with VL play-mode equivalents`);
    }
    return result;
  }

  _escapeSelectorText(text) {
    return String(text || '')
      .replace(/\\/g, '\\\\')
      .replace(/"/g, '\\"');
  }

  _preferMetadataIdsForActions(testCases, componentInfo, navElements = null) {
    const interactive = Array.isArray(componentInfo?.interactiveElements)
      ? componentInfo.interactiveElements.filter(el => el?.instanceId)
      : [];
    const navByText = new Map();
    for (const el of navElements || []) {
      if (!el?.instanceId || !el?.text) continue;
      const key = el.text.trim();
      const existing = navByText.get(key) || [];
      existing.push(el);
      navByText.set(key, existing);
    }

    const normalize = (value) => String(value || '').trim();
    const matchesText = (el, text) => {
      const target = normalize(text);
      if (!target) return false;
      return [el.label, el.value, el.placeholder, el.name]
        .map(normalize)
        .filter(Boolean)
        .includes(target);
    };

    const pageCandidates = (page) => {
      const pageKey = normalize(page);
      const exact = interactive.filter(el => normalize(el.section) === pageKey);
      return exact.length > 0 ? exact : interactive;
    };

    let converted = 0;
    const result = testCases.map(tc => {
      if (!tc.steps?.length) return tc;
      const candidates = pageCandidates(tc.page);
      const newSteps = tc.steps.map(step => {
        if (!step?.selector || typeof step.selector !== 'string') return step;
        const action = step.action || '';

        if (action === 'click' && step.selector.startsWith('text=')) {
          const text = step.selector.slice(5).trim();
          const navMatches = navByText.get(text) || [];
          if (navMatches.length === 1) {
            converted++;
            return {
              ...step,
              selector: `vlid:${navMatches[0].instanceId}[text="${this._escapeSelectorText(text)}"]`,
              _originalSelector: step.selector,
            };
          }

          const textMatches = candidates.filter(el => matchesText(el, text));
          if (textMatches.length === 1) {
            converted++;
            return {
              ...step,
              selector: `vlid:${textMatches[0].instanceId}`,
              _originalSelector: step.selector,
            };
          }
        }

        if ((action === 'fill' || action === 'type') && /^input\[type=/.test(step.selector)) {
          const hinted = candidates.filter(el =>
            ['input', 'textarea'].includes(el.type) &&
            (step.description?.includes(el.label || '') ||
             step.description?.includes(el.placeholder || '') ||
             step.description?.includes(el.name || ''))
          );
          if (hinted.length === 1) {
            converted++;
            return {
              ...step,
              selector: `vlid:${hinted[0].instanceId} input`,
              _originalSelector: step.selector,
            };
          }
        }

        return step;
      });
      return { ...tc, steps: newSteps };
    });

    if (converted > 0) {
      log('GenCases', `Post-process: replaced ${converted} text/input fallback selectors with metadata-driven vlid selectors`);
    }
    return result;
  }

  /**
   * Post-process test cases: detect and inject missing navigation steps.
   *
   * Problem: LLM sometimes generates a `navigate` step then immediately acts on a
   * non-home page element, skipping the nav-click that switches the page.
   *
   * Fix strategy:
   * 1. Build a page→navPath map from testPlan
   * 2. Validate navPaths against navElements (reject 'content' classified elements)
   * 3. For each test case targeting a non-home page, check whether steps[1] is
   *    a click on a nav element from navPath — if not, inject the nav steps.
   *
   * @param {Array} testCases
   * @param {object} testPlan
   * @param {string} baseUrl
   * @param {Array|null} navElements - DOM-discovered nav elements with position classification
   */
  _fixNavigation(testCases, testPlan, baseUrl, navElements = null) {
    if (!testPlan?.pages?.length) return testCases;

    const extractNavId = (selector) => {
      if (!selector || typeof selector !== 'string') return null;
      if (selector.startsWith('vlid:')) {
        const raw = selector.slice(5).trim();
        const hybridMatch = raw.match(/^([^\[\s]+)(?:\[text=.*\])?/);
        return hybridMatch?.[1] || raw;
      }
      if (selector.startsWith('text=')) {
        const value = selector.slice(5).trim();
        return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value) ? value : null;
      }
      if (selector.startsWith('/')) return null;
      if (selector.includes('[')) {
        const match = selector.match(/instance-id="([^"]+)"/);
        if (match) return match[1];
      }
      return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(selector) ? selector : null;
    };

    // Build lookup: instanceId → position from DOM discovery
    const navElementMap = {};
    const navElementsByText = new Map();
    if (navElements?.length > 0) {
      for (const el of navElements) {
        navElementMap[el.instanceId] = el;
        const key = String(el.text || '').trim();
        if (key) {
          const existing = navElementsByText.get(key) || [];
          existing.push(el);
          navElementsByText.set(key, existing);
        }
      }
    }

    // Build lookup: page path (lower) → navPath selectors
    // AND page path (lower) → route (for text= derivation)
    const pageNavMap = {};
    const pageRouteMap = {};
    const navIdSet = new Set();
    const homePage = (testPlan.homePage || testPlan.pages[0]?.path || '').toLowerCase();
    for (const page of testPlan.pages) {
      const key = (page.path || '').toLowerCase();
      if (page.route) pageRouteMap[key] = page.route;

      if (page.path && page.navPath?.length) {
        // Filter out any navPath elements classified as 'content' by DOM discovery
        const filteredNavPath = page.navPath.filter(id => {
          const el = navElementMap[id];
          if (el && el.position === 'content') {
            log('FixNav', `Removing invalid navPath element "${id}" (DOM says it's a content/action button, not nav)`);
            return false;
          }
          return true;
        });
        if (filteredNavPath.length > 0) {
          pageNavMap[key] = filteredNavPath;
          for (const navEntry of filteredNavPath) {
            const navId = extractNavId(navEntry);
            if (navId) navIdSet.add(navId);
          }
        } else if (page.navPath.length > 0) {
          log('FixNav', `Page "${page.path}" has navPath ${JSON.stringify(page.navPath)} but ALL were classified as content buttons — skipping nav injection`);
        }
      }
    }

    // Prefer vlid: for id-like nav entries; fall back to text= when only labels/routes are available.
    const toNavSelector = (navEntry, route) => {
      if (typeof navEntry !== 'string' || !navEntry.trim()) return route ? `text=${route}` : '';
      if (navEntry.startsWith('text=')) {
        const label = navEntry.slice(5).trim();
        const byText = navElementsByText.get(label) || [];
        if (byText.length === 1 && byText[0].instanceId) {
          return `vlid:${byText[0].instanceId}[text="${this._escapeSelectorText(label)}"]`;
        }
        return navEntry;
      }
      if (navEntry.startsWith('vlid:')) return navEntry;
      const navId = extractNavId(navEntry);
      if (navId) return `vlid:${navId}`;
      // [data-key="/xxx"] → extract route name and make text= selector
      const dkMatch = navEntry.match(/data-key=["']?\/([^"'\]]+)/);
      if (dkMatch) {
        const name = dkMatch[1].replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
        return `text=${name}`;
      }
      // Plain route like "/campaigns" → derive text
      if (navEntry.startsWith('/')) {
        const name = navEntry.slice(1).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
        return `text=${name}`;
      }
      // CSS selector [instance-id="xxx"] → derive text from instanceId
      if (navEntry.includes('[')) {
        const m = navEntry.match(/instance-id="([^"]+)"/);
        if (m) {
          const name = m[1].replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
          return `text=${name}`;
        }
      }
      // Plain instanceId or label text → use as text=
      const byText = navElementsByText.get(navEntry) || [];
      if (byText.length === 1 && byText[0].instanceId) {
        return `vlid:${byText[0].instanceId}[text="${this._escapeSelectorText(navEntry)}"]`;
      }
      return `text=${navEntry}`;
    };

    return testCases.map(tc => {
      if (!tc.steps?.length) return tc;

      const normalizedSteps = tc.steps.map(step => {
        if (step?.action !== 'click' || typeof step.selector !== 'string') return step;
        const navId = extractNavId(step.selector);
        if (!navId || !navIdSet.has(navId)) return step;
        return { ...step, selector: `vlid:${navId}`, _originalSelector: step.selector };
      });

      const pagePath = (tc.page || '').toLowerCase();
      const isHomePage = !pagePath || pagePath === homePage || pagePath === '/';
      const navPath = pageNavMap[pagePath] || [];
      const route = pageRouteMap[pagePath] || '';

      if (isHomePage) return { ...tc, steps: normalizedSteps };
      if (navPath.length === 0) return { ...tc, steps: normalizedSteps };

      // Check if step[1] (after navigate) already clicks a nav element (text= or navPath match)
      const stepsAfterNavigate = normalizedSteps.filter(s => s.action !== 'navigate');
      const firstActionSelector = stepsAfterNavigate[0]?.selector || '';

      const firstActionIsNav = (
        // Already clicking text= for a nav menu label
        (firstActionSelector.startsWith('text=') && navPath.some(np => {
          if (np.startsWith('text=')) return firstActionSelector === toNavSelector(np, route);
          return false;
        })) ||
        // Or clicking data-key for this route (legacy)
        (route && firstActionSelector.includes(`data-key="${route}"`)) ||
        // Or matches a navPath entry directly
        navPath.some(navEntry => {
          if (navEntry.startsWith('text=')) return firstActionSelector === toNavSelector(navEntry, route);
          if (navEntry.includes('[')) return firstActionSelector.includes(navEntry) || firstActionSelector === navEntry;
          // Plain instanceId or label — check if first action uses it as text=
          return firstActionSelector === toNavSelector(navEntry, route);
        })
      );

      if (firstActionIsNav) return { ...tc, steps: normalizedSteps }; // already has nav step

      // Inject navigation steps using normalized selectors
      const effectiveNavPath = navPath;
      log('FixNav', `${tc.id}: injecting nav steps for page "${tc.page}" route="${route}" → [${effectiveNavPath.join(' → ')}]`);

      const navSteps = effectiveNavPath.map((navEntry, i) => ({
        action: 'click',
        selector: toNavSelector(navEntry, route),
        description: `Navigate to ${tc.page} (step ${i + 1})`,
        _injected: true,
      }));

      const navigateIdx = normalizedSteps.findIndex(s => s.action === 'navigate');
      const insertAt = navigateIdx >= 0 ? navigateIdx + 1 : 0;

      const newSteps = [
        ...normalizedSteps.slice(0, insertAt),
        ...navSteps,
        ...normalizedSteps.slice(insertAt),
      ];

      return { ...tc, steps: newSteps, _navFixed: true };
    });
  }

  /**
   * Parse JSON from LLM response, with repair for truncated output
   */
  _parseJSON(text, label = 'JSON') {
    // Strip markdown code fences (even unclosed ones)
    let cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '');

    // Try direct parse first
    try {
      return JSON.parse(cleaned);
    } catch (e1) {
      log('ParseJSON', `Direct parse failed for ${label}: ${e1.message}`);
    }

    // Find the JSON content boundaries
    const firstBrace = cleaned.indexOf('{');
    const firstBracket = cleaned.indexOf('[');
    let jsonStart = -1;
    let isArray = false;

    if (firstBrace >= 0 && (firstBracket < 0 || firstBrace < firstBracket)) {
      jsonStart = firstBrace;
      isArray = false;
    } else if (firstBracket >= 0) {
      jsonStart = firstBracket;
      isArray = true;
    }

    if (jsonStart < 0) {
      throw new Error(`Cannot find JSON in LLM response (${label}): ${cleaned.slice(0, 200)}`);
    }

    let jsonStr = cleaned.slice(jsonStart);

    // Try parse as-is
    try {
      return JSON.parse(jsonStr);
    } catch (e2) {
      log('ParseJSON', `Slice parse failed for ${label}: ${e2.message}`);
    }

    // Truncated JSON repair: find the last complete object in array
    log('ParseJSON', `Attempting truncated JSON repair for ${label}...`);
    const repaired = this._repairTruncatedJSON(jsonStr);
    if (repaired) {
      try {
        const result = JSON.parse(repaired);
        log('ParseJSON', `Repair successful for ${label}!`);
        return result;
      } catch (e3) {
        log('ParseJSON', `Repair parse failed: ${e3.message}`);
      }
    }

    // Last-ditch: for TestPlan, extract whatever valid pages[] we can find
    if (label === 'TestPlan') {
      const partialPlan = this._extractPartialTestPlan(jsonStr);
      if (partialPlan) {
        log('ParseJSON', `Partial TestPlan extracted: ${partialPlan.pages?.length || 0} pages`);
        return partialPlan;
      }
    }

    throw new Error(`Cannot parse JSON from LLM response (${label}): ${cleaned.slice(0, 300)}`);
  }

  /**
   * Attempt to repair truncated JSON (e.g., when max_tokens was hit)
   * Handles: {"testCases":[...]}, {"pages":[...], "criticalPaths":[...]}, bare arrays, etc.
   * Strategy: track brace/bracket depth, find last position where we can safely close everything.
   */
  _repairTruncatedJSON(jsonStr) {
    const trimmed = jsonStr.trimStart();

    // Generic approach: walk through the JSON tracking a stack of open braces/brackets.
    // Find the last position where the innermost container has a complete element,
    // then close all remaining open containers.
    let inString = false;
    let escape = false;
    const stack = []; // each entry: { char: '{' or '[', lastCompleteElement: -1 }
    let lastSafeClose = -1; // position where we could truncate and still close everything
    let lastSafeStack = []; // snapshot of stack at lastSafeClose

    for (let i = 0; i < trimmed.length; i++) {
      const ch = trimmed[i];
      if (escape) { escape = false; continue; }
      if (ch === '\\' && inString) { escape = true; continue; }
      if (ch === '"') { inString = !inString; continue; }
      if (inString) continue;

      if (ch === '{' || ch === '[') {
        stack.push({ char: ch, lastCompleteElement: -1 });
      } else if (ch === '}' || ch === ']') {
        if (stack.length === 0) break; // malformed
        const top = stack.pop();
        if (stack.length > 0) {
          // We just completed an element inside the parent container
          stack[stack.length - 1].lastCompleteElement = i;
          // This is a safe point to truncate after — all parent containers can be closed
          lastSafeClose = i;
          lastSafeStack = stack.map(s => ({ ...s }));
        }
        // If stack is empty, the whole JSON is complete (shouldn't be truncated)
      } else if (ch === ',') {
        // Comma after a complete element — mark the element before the comma
        // (already handled by the } / ] case above)
      }
    }

    // If we found a safe close point, truncate there and close all remaining containers
    if (lastSafeClose > 0 && lastSafeStack.length > 0) {
      let repaired = trimmed.slice(0, lastSafeClose + 1);
      // Close all remaining open containers in reverse order
      for (let j = lastSafeStack.length - 1; j >= 0; j--) {
        const opener = lastSafeStack[j].char;
        repaired += opener === '{' ? '}' : ']';
      }
      log('RepairJSON', `Truncated at char ${trimmed.length}, repaired by closing at char ${lastSafeClose} (${lastSafeStack.length} containers closed)`);
      return repaired;
    }

    // Fallback: try the old array-specific approach
    if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
      // Find last complete top-level element in first array
      const arrayMatch = trimmed.match(/^\s*\{\s*"\w+"\s*:\s*\[/);
      const arrayStart = arrayMatch ? arrayMatch[0].length : (trimmed.startsWith('[') ? 1 : -1);

      if (arrayStart > 0) {
        let depth = 0;
        let lastGoodEnd = -1;
        let inStr = false;
        let esc = false;

        for (let i = arrayStart; i < trimmed.length; i++) {
          const c = trimmed[i];
          if (esc) { esc = false; continue; }
          if (c === '\\' && inStr) { esc = true; continue; }
          if (c === '"') { inStr = !inStr; continue; }
          if (inStr) continue;
          if (c === '{' || c === '[') depth++;
          if (c === '}' || c === ']') {
            depth--;
            if (depth === 0) lastGoodEnd = i;
          }
        }

        if (lastGoodEnd > 0) {
          const suffix = arrayMatch ? ']}' : ']';
          const repaired = trimmed.slice(0, lastGoodEnd + 1) + suffix;
          log('RepairJSON', `Fallback repair: truncated at ${trimmed.length}, closed after ${lastGoodEnd}`);
          return repaired;
        }
      }
    }

    return null;
  }

  /**
   * Last-resort fallback: extract a minimal usable TestPlan from truncated JSON.
   * Regex-extracts appName/appId/homePage and all complete page objects.
   */
  _extractPartialTestPlan(jsonStr) {
    try {
      const appName = (jsonStr.match(/"appName"\s*:\s*"([^"]+)"/) || [])[1] || 'Unknown App';
      const appId = (jsonStr.match(/"appId"\s*:\s*"([^"]+)"/) || [])[1] || '';
      const homePage = (jsonStr.match(/"homePage"\s*:\s*"([^"]+)"/) || [])[1] || '';

      // Find all complete page objects in the pages array
      const pagesStart = jsonStr.indexOf('"pages"');
      if (pagesStart < 0) return null;

      const pages = [];
      let i = jsonStr.indexOf('[', pagesStart);
      if (i < 0) return null;
      i++; // skip opening [

      while (i < jsonStr.length) {
        // Skip whitespace/commas
        while (i < jsonStr.length && (jsonStr[i] === ' ' || jsonStr[i] === '\n' || jsonStr[i] === '\r' || jsonStr[i] === '\t' || jsonStr[i] === ',')) i++;
        if (i >= jsonStr.length || jsonStr[i] !== '{') break;

        // Find matching }
        let depth = 0;
        let inStr = false;
        let esc = false;
        let start = i;
        let end = -1;
        for (let j = i; j < jsonStr.length; j++) {
          const c = jsonStr[j];
          if (esc) { esc = false; continue; }
          if (c === '\\' && inStr) { esc = true; continue; }
          if (c === '"') { inStr = !inStr; continue; }
          if (inStr) continue;
          if (c === '{') depth++;
          if (c === '}') { depth--; if (depth === 0) { end = j; break; } }
        }

        if (end < 0) break; // incomplete object — stop here

        try {
          const pageObj = JSON.parse(jsonStr.slice(start, end + 1));
          if (pageObj.path) pages.push(pageObj);
        } catch {}

        i = end + 1;
      }

      if (pages.length === 0 && !appName && !appId) return null;

      log('RepairJSON', `_extractPartialTestPlan: appId="${appId}", ${pages.length} pages extracted`);
      return { appName, appId, homePage, pages, criticalPaths: [] };
    } catch (e) {
      log('RepairJSON', `_extractPartialTestPlan failed: ${e.message}`);
      return null;
    }
  }
}
