"""
Agent 3: QA Playwright Code Generator — MCP Server

This agent owns all browser interaction.  The pipeline is a pure orchestrator
that passes credentials/URL; this server does the browser walk and returns a
verified context_map.

Tools exposed:
  capture_app_context      — navigate app, snapshot AX tree, return context_map
  generate_playwright_code — generate TS files from gherkin + context_map
  scaffold_locator_repository — generate self-healing infrastructure once per project
  get_generated_files      — retrieve cached TS file contents
  attach_code_to_work_item — attach generated files to an ADO work item
  validate_gherkin_steps   — parse and return unique steps by keyword
  pre_validate_cucumber_steps — validate step-def syntax before write_helix_files

Three layers of self-healing at TEST RUNTIME:
  1. Locator Healing    — selector chain + AI Vision
  2. Timing Healing     — network trace drift detection → auto-adjusted timeouts
  3. Visual Regression  — element screenshot diff against approved baseline

CI/CD Telemetry:
  HealingDashboard — HTTP server on :7890, engineers review/approve AI suggestions
  before they are permanently committed to the repository.
"""
from __future__ import annotations
import asyncio, json, logging, re, sys, os, uuid, socket, subprocess, time, threading, queue
from pathlib import Path
from dotenv import load_dotenv
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
from stlc_agents.shared.auth import get_auth_headers, get_signed_in_user
from stlc_agents.agent_playwright_generator.tools.ado_attach import attach_file_to_work_item as _attach_file
from stlc_agents.shared.cost_tracker import track
from stlc_agents.shared import heal_intel as _heal_intel

load_dotenv()
app = Server("qa-playwright-generator")

# ---------------------------------------------------------------------------
# File content cache — keeps large TS blobs out of conversation history.
# generate_playwright_code and scaffold_locator_repository store files here
# keyed by a short cache_key; get_generated_files retrieves them on demand.
#
# Files are persisted to disk so the cache survives MCP server restarts.
# Each entry is written as JSON under _CACHE_DIR/<cache_key>.json.
# ---------------------------------------------------------------------------
import tempfile as _tempfile

_CACHE_DIR = Path(_tempfile.gettempdir()) / "stlc_file_cache"
_CACHE_DIR.mkdir(parents=True, exist_ok=True)


def _cache_write(cache_key: str, files: dict) -> None:
    (_CACHE_DIR / f"{cache_key}.json").write_text(json.dumps(files))


def _cache_read(cache_key: str) -> dict | None:
    p = _CACHE_DIR / f"{cache_key}.json"
    if not p.exists():
        return None
    return json.loads(p.read_text())


def _cache_has(cache_key: str) -> bool:
    return (_CACHE_DIR / f"{cache_key}.json").exists()


# ---------------------------------------------------------------------------
# Pre-output validation helpers
# ---------------------------------------------------------------------------

def _validate_scaffold_output(result: dict) -> dict:
    """Validate scaffold_locator_repository output before returning to user.

    Checks:
      1. All expected infrastructure files are present.
      2. File contents are non-empty.
      3. Basic TS syntax (brace/paren balance) for each file.

    Returns { valid: bool, errors: list[str], warnings: list[str] }.
    """
    errors: list[str] = []
    warnings: list[str] = []

    files = result.get("files", {})
    if not files:
        errors.append("scaffold_locator_repository produced no files.")
        return {"valid": False, "errors": errors, "warnings": warnings}

    required_bases = {"LocatorHealer.ts", "LocatorRepository.ts"}
    found_bases = {Path(k).name for k in files}
    missing = required_bases - found_bases
    if missing:
        errors.append(f"Missing required infrastructure files: {sorted(missing)}")

    for file_key, content in files.items():
        if not content or not content.strip():
            errors.append(f"{file_key}: file content is empty.")
            continue
        # Brace/paren balance
        s = re.sub(r'`[^`]*`', '``', content)
        s = re.sub(r'"[^"\n]*"', '""', s)
        s = re.sub(r"'[^'\n]*'", "''", s)
        s = re.sub(r'//[^\n]*', '', s)
        s = re.sub(r'/\*.*?\*/', '', s, flags=re.DOTALL)
        brace_delta = s.count('{') - s.count('}')
        if brace_delta != 0:
            errors.append(f"{file_key}: unbalanced braces (delta={brace_delta:+d})")
        paren_delta = s.count('(') - s.count(')')
        if paren_delta != 0:
            errors.append(f"{file_key}: unbalanced parentheses (delta={paren_delta:+d})")

    return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}


def _validate_attach_inputs(files: list[dict]) -> dict:
    """Validate file payloads before attaching to ADO.

    Checks:
      1. Files list is non-empty.
      2. Every file has a non-empty file_name and content.
      3. No duplicate file names.
      4. File names use safe characters.

    Returns { valid: bool, errors: list[str], warnings: list[str] }.
    """
    errors: list[str] = []
    warnings: list[str] = []

    if not files:
        errors.append("No files to attach. Provide at least one file.")
        return {"valid": False, "errors": errors, "warnings": warnings}

    seen_names: set[str] = set()
    for i, f in enumerate(files, start=1):
        name = (f.get("file_name") or "").strip()
        content = (f.get("content") or "").strip()

        if not name:
            errors.append(f"File #{i}: file_name is empty or missing.")
        elif name in seen_names:
            errors.append(f"File #{i}: duplicate file_name '{name}'.")
        else:
            seen_names.add(name)

        if not content:
            errors.append(f"File #{i} ('{name}'): content is empty.")

        # Check for unsafe characters in filename
        if name and re.search(r'[<>:"|?*\\]', name):
            warnings.append(
                f"File #{i} ('{name}'): file name contains characters that may be "
                f"problematic on some file systems."
            )

    return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}

@app.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="capture_app_context",
            description=(
                "Navigate the live application using the Playwright MCP server, capture the "
                "AX accessibility tree via browser_snapshot, and return a verified context_map "
                "of locators.  Handles login flows automatically when app_username and "
                "app_password are supplied.  The returned context_map should be passed directly "
                "to generate_playwright_code so selectors come from the real DOM rather than "
                "Gherkin inference.\n\n"
                "When APP_BASE_URL is not supplied this tool returns an empty context_map and "
                "locator_source=\"gherkin-inferred\" — generation still proceeds."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "app_url": {
                        "type": "string",
                        "description": "Base URL of the application under test.",
                    },
                    "app_username": {
                        "type": "string",
                        "description": "Optional username for login flows.",
                    },
                    "app_password": {
                        "type": "string",
                        "description": "Optional password for login flows.",
                    },
                    "gherkin_content": {
                        "type": "string",
                        "description": (
                            "Optional Gherkin feature text.  When supplied the agent uses it "
                            "to decide which pages to navigate to (e.g. checkout flows)."
                        ),
                    },
                    "playwright_mcp_url": {
                        "type": "string",
                        "description": "Override the Playwright MCP server URL (default: http://localhost:8931/mcp).",
                    },
                },
                "required": [],
            },
        ),
        types.Tool(
            name="generate_playwright_code",
            description=(
                "Generate complete Playwright TypeScript code from a Gherkin .feature file. "
                "Produces: locators.ts (selector+intent+stability+visualIntent), "
                "{Page}Page.ts (LocatorHealer + TimingHealer + VisualIntentChecker), "
                "{feature}.steps.ts, {feature}.feature, cucumber-profile.js. "
                "All actions go through LocatorHealer (never raw page.click/fill). "
                "TimingHealer intercepts network traces per action. "
                "VisualIntentChecker screenshots elements at key assertions. "
                "Pass context_map from Playwright MCP browser_snapshot calls to use "
                "accessibility-tree-verified selectors instead of Gherkin inference. "
                "Pass helix_project_root to auto-merge with existing Helix-QA files "
                "(no overwrites, deduplication, conflict renaming)."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "gherkin_content": {"type": "string"},
                    "page_class_name": {"type": "string"},
                    "app_name": {"type": "string"},
                    "healing_strategy": {
                        "type": "string",
                        "enum": ["role-label-text-ai", "role-text-ai", "ai-first"],
                    },
                    "auth_hook": {
                        "type": "string",
                        "enum": ["microsoft-sso", "azure-keyvault-mfa", "none"],
                    },
                    "enable_visual_regression": {"type": "boolean"},
                    "enable_timing_healing": {"type": "boolean"},
                    "context_map": {
                        "type": "object",
                        "description": (
                            "Verified locator map built from Playwright MCP browser_snapshot calls. "
                            "Each key maps to { selector, intent, stability, visualIntent? }. "
                            "When provided, overrides Gherkin-inferred selectors. "
                            "Build by: browser_navigate → browser_snapshot → map AX nodes to entries "
                            "using stability rank: data-testid(100) > aria-role+name(90) > id(80) "
                            "> aria-label(70) > placeholder(60)."
                        ),
                    },
                    "app_url": {
                        "type": "string",
                        "description": (
                            "Optional base URL of the application under test "
                            "(e.g. 'https://account.stg.myamplify.io'). "
                            "When provided AND context_map is absent, Agent 3 will embed the URL "
                            "as a comment in locators.ts so it is available for manual AX-tree "
                            "snapshot runs. The URL is also echoed back in the result as app_url "
                            "for reference. Agent 3 does not navigate to it directly — use "
                            "browser_navigate + browser_snapshot via the Playwright MCP server "
                            "to build a verified context_map, then pass context_map to this tool."
                        ),
                    },
                    "helix_project_root": {
                        "type": "string",
                        "description": (
                            "Optional path to Helix-QA project root. When provided, generated code "
                            "is merged with existing files (append-only, deduplication, conflict renaming). "
                            "No overwrites ever occur. Locators with naming conflicts get unique names."
                        ),
                    },
                },
                "required": ["gherkin_content", "page_class_name"],
            },
        ),
        types.Tool(
            name="scaffold_locator_repository",
            description=(
                "Generate the complete self-healing infrastructure. Five files: "
                "LocatorHealer.ts, LocatorRepository.ts, "
                "TimingHealer.ts (network trace timing healing — observes API response times, "
                "auto-adjusts timeouts when endpoints drift beyond 20% of baseline), "
                "VisualIntentChecker.ts (screenshot-based visual regression at key assertions — "
                "detects elements that are found but look wrong), "
                "HealingDashboard.ts (HTTP server on :7890 — shows all pending AI suggestions "
                "from all three layers; engineers approve/reject before changes are committed). "
                "Call once per project."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "output_dir": {"type": "string"},
                    "enable_ai_vision": {"type": "boolean"},
                    "repository_path": {"type": "string"},
                    "dashboard_port": {"type": "integer"},
                    "enable_timing_healing": {"type": "boolean"},
                    "enable_visual_regression": {"type": "boolean"},
                    "ai_healing_provider": {
                        "type": "string",
                        "enum": ["anthropic", "copilot", "claude-code"],
                        "description": (
                            "AI Vision provider for strategy 6 healing. "
                            "anthropic: direct Anthropic API (requires AI_HEALING_API_KEY env var). "
                            "copilot: GitHub Copilot chat completions (uses GITHUB_TOKEN from Copilot extension or gh auth login). "
                            "claude-code: Claude Code local proxy (uses ANTHROPIC_API_KEY set automatically by the claude CLI session). "
                            "Can also be overridden at runtime via the AI_HEALING_PROVIDER env var without regenerating files. "
                            "Default: anthropic."
                        ),
                    },
                },
                "required": [],
            },
        ),
        types.Tool(
            name="attach_code_to_work_item",
            description=(
                "Attach generated TypeScript files to an ADO work item (Feature, PBI, or Bug). "
                "NEVER pass an Epic ID — attachments on Epics are invisible in normal ADO workflow "
                "views. If you have an Epic ID, look up the child Features and PBIs/Bugs in ADO "
                "and call this tool once per Feature or PBI/Bug work item ID."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "organization_url": {"type": "string"},
                    "project_name": {"type": "string"},
                    "work_item_id": {"type": "integer"},
                    "files": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "file_name": {"type": "string"},
                                "content": {"type": "string"},
                            },
                            "required": ["file_name", "content"],
                        },
                    },
                },
                "required": ["organization_url", "project_name", "work_item_id", "files"],
            },
        ),
        types.Tool(
            name="validate_gherkin_steps",
            description="Parse a .feature file and return all unique step strings grouped by keyword.",
            inputSchema={
                "type": "object",
                "properties": {"gherkin_content": {"type": "string"}},
                "required": ["gherkin_content"],
            },
        ),
        types.Tool(
            name="pre_validate_cucumber_steps",
            description=(
                "Validate Cucumber step syntax in TypeScript step-definition files BEFORE passing "
                "them to write_helix_files. Catches issues that cause the parenthesis-balance "
                "validator to fail and blocks write_helix_files submissions containing bad patterns.\n\n"
                "Checks performed:\n"
                "  1. Raw regex step patterns (/^…$/) with unescaped capture groups — these "
                "unbalance the paren counter. Prescribes Cucumber expression replacements.\n"
                "  2. Step patterns that mix Cucumber expressions and regex syntax.\n"
                "  3. Missing closing parenthesis or brace in step callback functions.\n"
                "  4. Duplicate step patterns within the same file.\n"
                "  5. Gherkin feature steps that have no matching step definition (unmatched steps).\n"
                "  6. Cross-file duplicate detection — compares generated step patterns against "
                "existing *.steps.ts files in the project (passed via existing_steps_ts_contents).\n"
                "     6a. Exact match → error: step already defined in another file; remove it.\n"
                "     6b. Same {param}-normalized form → warning: possible semantic duplicate with "
                "different placeholder label names; verify intent before keeping both.\n\n"
                "Returns { valid: bool, errors: list, warnings: list, suggestions: list } "
                "where suggestions provide ready-to-paste Cucumber expression replacements for "
                "every flagged raw-regex pattern. Fix all errors before calling write_helix_files."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "steps_ts_content": {
                        "type": "string",
                        "description": (
                            "Content of the generated *.steps.ts file to validate. "
                            "Retrieve it via get_generated_files(cache_key)['files']['<feature>.steps.ts']."
                        ),
                    },
                    "gherkin_content": {
                        "type": "string",
                        "description": (
                            "Optional: content of the .feature file. "
                            "When provided, enables unmatched-step detection (check 5)."
                        ),
                    },
                    "existing_steps_ts_contents": {
                        "type": "object",
                        "description": (
                            "Optional: map of relative file path → file content for every "
                            "existing *.steps.ts file already on disk in the Helix project "
                            "(e.g. {'src/test/steps/login.steps.ts': '<content>'}). "
                            "Read each file with qa-helix-writer:read_helix_file before calling "
                            "this tool. When provided, enables cross-file duplicate detection "
                            "(check 6). Omitting this check is the most common source of "
                            "redundant step definitions across files."
                        ),
                        "additionalProperties": {"type": "string"},
                    },
                },
                "required": ["steps_ts_content"],
            },
        ),
        types.Tool(
            name="get_generated_files",
            description=(
                "Retrieve the full TypeScript file contents that were generated by a previous "
                "call to generate_playwright_code or scaffold_locator_repository. "
                "Use the cache_key returned by those tools. Call this only when you are about "
                "to attach files to ADO (attach_code_to_work_item) or write them to Helix-QA "
                "(qa-helix-writer:write_helix_files). Do not call this just to inspect the output."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "cache_key": {
                        "type": "string",
                        "description": "The cache_key value returned by generate_playwright_code or scaffold_locator_repository.",
                    },
                },
                "required": ["cache_key"],
            },
        ),
    ]


@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    t0 = time.monotonic()
    try:
        if name == "capture_app_context":
            result = await asyncio.to_thread(
                _capture_app_context,
                arguments.get("app_url", os.getenv("APP_BASE_URL", "")),
                arguments.get("app_username", os.getenv("APP_USERNAME", "")),
                arguments.get("app_password", os.getenv("APP_PASSWORD", "")),
                arguments.get("gherkin_content", ""),
                arguments.get("playwright_mcp_url",
                              os.getenv("PLAYWRIGHT_MCP_URL", "http://localhost:8931/mcp")),
            )
        elif name == "generate_playwright_code":
            result = await asyncio.to_thread(
                _generate_playwright_code,
                arguments["gherkin_content"],
                arguments["page_class_name"],
                arguments.get("app_name", "app"),
                arguments.get("healing_strategy", "role-label-text-ai"),
                arguments.get("auth_hook", "microsoft-sso"),
                arguments.get("enable_visual_regression", True),
                arguments.get("enable_timing_healing", True),
                arguments.get("context_map"),
                arguments.get("helix_project_root"),
                arguments.get("app_url"),
            )
            # Cache files; return manifest so TS content stays out of conversation
            cache_key = uuid.uuid4().hex[:8]
            _files = result.pop("files")
            _cache_write(cache_key, _files)
            result["cache_key"] = cache_key
            result["files_manifest"] = list(_files.keys())
            result.pop("healing_layers", None)
            result.pop("scaffold_note", None)
            # Echo app_url so the caller knows which URL a live snapshot should target
            if arguments.get("app_url"):
                result["app_url"] = arguments["app_url"]
        elif name == "scaffold_locator_repository":
            result = await asyncio.to_thread(
                _scaffold_locator_repository,
                arguments.get("output_dir", "src/utils/locators"),
                arguments.get("enable_ai_vision", True),
                arguments.get("repository_path", "test-results/locator-repository.json"),
                arguments.get("dashboard_port", 7890),
                arguments.get("enable_timing_healing", True),
                arguments.get("enable_visual_regression", True),
                arguments.get("ai_healing_provider", "anthropic"),
            )
            # ── Pre-output validation (reads files before we cache them) ──
            result["_validation"] = _validate_scaffold_output(result)
            # Cache files; return manifest so TS content stays out of conversation
            cache_key = uuid.uuid4().hex[:8]
            _files = result.pop("files")
            _cache_write(cache_key, _files)
            result["cache_key"] = cache_key
            result["files_manifest"] = list(_files.keys())
            result.pop("healing_strategies", None)
            result.pop("healing_layers", None)
            result.pop("env_vars", None)
        elif name == "get_generated_files":
            cache_key = arguments["cache_key"]
            if not _cache_has(cache_key):
                result = {
                    "error": f"Cache key '{cache_key}' not found. "
                             "Re-run generate_playwright_code or scaffold_locator_repository.",
                }
            else:
                result = {"cache_key": cache_key, "files": _cache_read(cache_key)}
        elif name == "attach_code_to_work_item":
            # ── Pre-attach input validation ────────────────────────────────
            input_validation = _validate_attach_inputs(arguments.get("files", []))
            if not input_validation["valid"]:
                result = {
                    "error": "input_validation_failed",
                    "_validation": input_validation,
                    "message": (
                        "File inputs failed validation. Fix the errors below "
                        "and retry. No files were attached to ADO."
                    ),
                }
                return track(result, tool_name=name, server='qa-playwright-generator', t0=t0)

            # ── Epic guard — attachments on Epics are invisible in ADO workflow views ──
            wi_id = arguments["work_item_id"]
            try:
                import requests as _requests
                from stlc_agents.shared.auth import get_auth_headers as _gah
                _peek = await asyncio.to_thread(
                    lambda: _requests.get(
                        f"{arguments['organization_url'].rstrip('/')}/{arguments['project_name']}"
                        f"/_apis/wit/workitems/{wi_id}",
                        headers=_gah(),
                        params={"api-version": "7.1"},
                        timeout=15,
                    )
                )
                if _peek.ok:
                    _wi_type = _peek.json().get("fields", {}).get("System.WorkItemType", "")
                    if _wi_type == "Epic":
                        return [types.TextContent(type="text", text=json.dumps({
                            "error": "epic_not_supported",
                            "work_item_id": wi_id,
                            "work_item_type": "Epic",
                            "message": (
                                f"Work item {wi_id} is an Epic. Playwright TypeScript files "
                                "must NOT be attached directly to an Epic — attachments on Epics "
                                "are effectively invisible in normal ADO workflow views. "
                                "Use qa-gherkin-generator to walk the child "
                                "Features and PBIs/Bugs, then call attach_code_to_work_item once "
                                "per Feature or PBI/Bug work item ID."
                            ),
                        }, indent=2))]
            except Exception:
                pass  # If the peek fails, let the attach proceed and surface any real error naturally

            attached, failed = [], []
            for f in arguments["files"]:
                try:
                    r = await asyncio.to_thread(
                        _attach_file,
                        arguments["organization_url"], arguments["project_name"],
                        arguments["work_item_id"], f["file_name"], f["content"],
                        "Generated by QA Playwright Code Generator",
                    )
                    attached.append(r)
                except Exception as e:
                    failed.append({"file_name": f["file_name"], "error": str(e)})
            result = {
                "summary": {"requested": len(arguments["files"]),
                            "attached": len(attached), "failed": len(failed)},
                "attached": attached, "failed": failed,
                "_validation": {
                    "valid": len(failed) == 0,
                    "errors": [f["error"] for f in failed] if failed else [],
                    "warnings": input_validation.get("warnings", []),
                    "post_attach_check": {
                        "all_attached": len(attached) == len(arguments["files"]),
                        "failed_count": len(failed),
                    },
                },
            }
        elif name == "validate_gherkin_steps":
            result = await asyncio.to_thread(_parse_gherkin_steps, arguments["gherkin_content"])
        elif name == "pre_validate_cucumber_steps":
            result = await asyncio.to_thread(
                _pre_validate_cucumber_steps,
                arguments["steps_ts_content"],
                arguments.get("gherkin_content"),
                arguments.get("existing_steps_ts_contents"),
            )
        else:
            result = {"error": f"Unknown tool: {name}"}
        return track(result, tool_name=name, server='qa-playwright-generator', t0=t0)
    except Exception as exc:
        return [types.TextContent(type="text", text=json.dumps({"error": str(exc), "tool": name}, indent=2))]


# ============================================================================
# Core helpers
# ============================================================================

def _to_kebab(s: str) -> str:
    s = re.sub(r"([A-Z])", r"-\1", s).lower().strip("-")
    return re.sub(r"-+", "-", s)


def _parse_gherkin_steps(content: str) -> dict:
    keywords = ("Given", "When", "Then", "And", "But")
    steps: dict = {k: [] for k in keywords}
    for line in content.splitlines():
        stripped = line.strip()
        for kw in keywords:
            if stripped.startswith(kw + " "):
                text = stripped[len(kw):].strip()
                if text not in steps[kw]:
                    steps[kw].append(text)
                break
    all_steps = [f"{kw} {s}" for kw in keywords for s in steps[kw]]
    return {"by_keyword": steps, "all_steps": all_steps, "total_unique_steps": len(all_steps)}


# Cucumber expression parameter types — any of these in a step pattern means
# the step already uses Cucumber expressions correctly.
_CUCUMBER_EXPR_RE = re.compile(r"\{(?:string|int|float|word|bigdecimal|byte|short|long|double|[A-Za-z_]\w*)\}")

# Regex step pattern: Given(/^…$/, …) or When(/^…$/, …)
_RAW_REGEX_STEP_RE = re.compile(
    r"^(Given|When|Then)\s*\(\s*/\^([^$]*)\$\s*/[gimusy]*\s*,",
    re.MULTILINE,
)

# Capture groups inside a regex pattern that should become Cucumber expressions
_CAPTURE_GROUP_RE = re.compile(r"\((?!\?)")  # ( not followed by ? (i.e. not (?:) or (?=) etc.)

# Mapping from common regex fragments to Cucumber expression equivalents
_REGEX_TO_CUCUMBER: list[tuple[re.Pattern, str]] = [
    (re.compile(r'\(\.\+\)'),       "{string}"),
    (re.compile(r'\(\.\*\)'),       "{string}"),
    (re.compile(r'\(\\d\+\)'),      "{int}"),
    (re.compile(r'\(\\d\+\\.\\d\+\)'), "{float}"),
    (re.compile(r'\(\\w\+\)'),      "{word}"),
    (re.compile(r'\([^)]+\)'),      "{string}"),   # fallback for any other group
]


def _regex_pattern_to_cucumber(raw_pattern: str) -> str:
    """Best-effort conversion of a raw regex step pattern to a Cucumber expression."""
    result = raw_pattern
    for rx, replacement in _REGEX_TO_CUCUMBER:
        result = rx.sub(replacement, result, count=1)
        if result != raw_pattern:
            break
    # Replace all remaining capture groups with {string}
    result = _CAPTURE_GROUP_RE.sub("{string}", result)
    return result


def _normalize_step_pattern(pattern: str) -> str:
    """Collapse all Cucumber expression placeholder types to {param} for structural comparison."""
    normalized = re.sub(r"\{[^}]+\}", "{param}", pattern)
    return re.sub(r"\s+", " ", normalized).strip().lower()


def _extract_step_patterns_from_content(content: str) -> list[str]:
    """Return all Cucumber expression step patterns found in a *.steps.ts file."""
    patterns: list[str] = []
    for match in re.finditer(
        r"(?:Given|When|Then)\s*\(\s*(?:'([^']+)'|\"([^\"]+)\")",
        content,
    ):
        pat = match.group(1) or match.group(2) or ""
        if pat:
            patterns.append(pat)
    return patterns


def _pre_validate_cucumber_steps(
    steps_ts_content: str,
    gherkin_content: str | None,
    existing_steps_ts_contents: dict[str, str] | None = None,
) -> dict:
    """Validate Cucumber step syntax in a generated *.steps.ts file.

    Returns { valid: bool, errors: list[str], warnings: list[str], suggestions: list[dict] }.
    """
    errors: list[str] = []
    warnings: list[str] = []
    suggestions: list[dict] = []

    if not steps_ts_content or not steps_ts_content.strip():
        return {
            "valid": False,
            "errors": ["steps_ts_content is empty — nothing to validate."],
            "warnings": [],
            "suggestions": [],
        }

    lines = steps_ts_content.splitlines()

    # ── Check 1 & 2: raw regex patterns and mixed syntax ─────────────────
    seen_patterns: list[str] = []
    for match in _RAW_REGEX_STEP_RE.finditer(steps_ts_content):
        keyword, raw_pattern = match.group(1), match.group(2)
        capture_count = len(_CAPTURE_GROUP_RE.findall(raw_pattern))
        if capture_count > 0:
            cucumber_expr = _regex_pattern_to_cucumber(raw_pattern)
            errors.append(
                f"{keyword}(/^{raw_pattern}$/) uses a raw regex with {capture_count} "
                f"capture group(s). "
                f"The regex preprocessor in write_helix_files strips string literals before "
                f"counting parentheses, so these unmatched '(' cause the paren-balance check "
                f"to fail with delta={capture_count:+d}. "
                f"Fix: replace the regex with a Cucumber expression."
            )
            suggestions.append({
                "original": f"/^{raw_pattern}$/",
                "replacement": f"'{cucumber_expr}'",
                "keyword": keyword,
                "note": (
                    "Replace the /^…$/ regex with the quoted Cucumber expression. "
                    "Update the callback parameter types: string for {string}, number for {int}/{float}."
                ),
            })
        elif _CUCUMBER_EXPR_RE.search(raw_pattern):
            warnings.append(
                f"{keyword}(/^{raw_pattern}$/) mixes regex anchors with Cucumber expressions. "
                f"Use either a plain Cucumber expression string OR a pure regex — not both."
            )
        seen_patterns.append(raw_pattern)

    # ── Check 3: unbalanced braces / parens in step callbacks ────────────
    # Strip string literals and comments, then check balance per-block
    stripped = re.sub(r'`[^`]*`', '``', steps_ts_content)
    stripped = re.sub(r'"[^"\n]*"', '""', stripped)
    stripped = re.sub(r"'[^'\n]*'", "''", stripped)
    stripped = re.sub(r'//[^\n]*', '', stripped)
    stripped = re.sub(r'/\*.*?\*/', '', stripped, flags=re.DOTALL)
    # Skip regex literals (/^…$/) so they don't confuse the counter
    stripped = re.sub(r'/\^[^/]+\$/', '/REGEX/', stripped)

    brace_delta = stripped.count('{') - stripped.count('}')
    paren_delta = stripped.count('(') - stripped.count(')')
    if brace_delta != 0:
        errors.append(
            f"File-level unbalanced braces (delta={brace_delta:+d}) after stripping "
            f"string literals and comments. Check for unterminated arrow functions or class bodies."
        )
    if paren_delta != 0:
        # Only flag if no raw-regex errors already explain this
        if not any("capture group" in e for e in errors):
            errors.append(
                f"File-level unbalanced parentheses (delta={paren_delta:+d}) after stripping "
                f"string literals and comments. This will cause write_helix_files to reject the file. "
                f"Check for unclosed function calls or raw regex step patterns with capture groups."
            )

    # ── Check 4: duplicate step patterns ─────────────────────────────────
    all_patterns: list[str] = []
    for match in re.finditer(
        r"(?:Given|When|Then)\s*\(\s*(?:'([^']+)'|\"([^\"]+)\"|/\^([^$]+)\$/)",
        steps_ts_content,
    ):
        pattern = match.group(1) or match.group(2) or match.group(3) or ""
        if pattern in all_patterns:
            errors.append(f"Duplicate step pattern: '{pattern}' — defined more than once.")
        else:
            all_patterns.append(pattern)

    # ── Check 5: unmatched Gherkin steps (optional) ───────────────────────
    if gherkin_content:
        gherkin_parsed = _parse_gherkin_steps(gherkin_content)
        unmatched: list[str] = []
        for step_text in gherkin_parsed["all_steps"]:
            # Strip keyword prefix
            step_body = re.sub(r"^(?:Given|When|Then|And|But)\s+", "", step_text)
            matched = False
            for pat in all_patterns:
                try:
                    # Try Cucumber expression: replace {…} with .*
                    regex_pat = re.sub(r"\{[^}]+\}", ".*", pat)
                    if re.fullmatch(regex_pat, step_body, re.IGNORECASE):
                        matched = True
                        break
                except re.error:
                    pass
            if not matched:
                unmatched.append(step_body)
        if unmatched:
            for step in unmatched:
                warnings.append(
                    f"Gherkin step has no matching step definition: \"{step}\""
                )

    # ── Check 6: cross-file step pattern deduplication ────────────────────
    if existing_steps_ts_contents:
        # Build lookup: exact pattern → list of source filenames
        #               normalized pattern → list of (source_filename, original_pattern)
        existing_exact: dict[str, list[str]]                 = {}
        existing_normalized: dict[str, list[tuple[str, str]]] = {}
        for src_file, src_content in existing_steps_ts_contents.items():
            for existing_pat in _extract_step_patterns_from_content(src_content):
                existing_exact.setdefault(existing_pat, []).append(src_file)
                norm = _normalize_step_pattern(existing_pat)
                existing_normalized.setdefault(norm, []).append((src_file, existing_pat))

        for new_pat in all_patterns:
            if new_pat in existing_exact:
                # 6a — hard duplicate: identical pattern text in another file
                sources = ", ".join(f"'{f}'" for f in existing_exact[new_pat])
                errors.append(
                    f"Cross-file duplicate step pattern: '{new_pat}' is already defined in "
                    f"{sources}. Remove it from the generated file — Cucumber will pick up the "
                    f"existing definition automatically."
                )
            else:
                # 6b — structural duplicate: same {param}-normalised form, different text
                norm = _normalize_step_pattern(new_pat)
                if norm in existing_normalized:
                    for src_file, orig_pat in existing_normalized[norm]:
                        if orig_pat != new_pat:
                            warnings.append(
                                f"Possible semantic duplicate: new pattern '{new_pat}' has the "
                                f"same structure as '{orig_pat}' in '{src_file}' — both map to "
                                f"'{norm}' after placeholder normalization. "
                                f"If they test the same action, remove the new one and reuse the "
                                f"existing step wording instead."
                            )

    return {
        "valid": len(errors) == 0,
        "errors": errors,
        "warnings": warnings,
        "suggestions": suggestions,
        "step_patterns_found": len(all_patterns),
    }


def _extract_element_hints(gherkin: str) -> list:
    text = gherkin.lower()
    hints = []
    if "photo" in text or "image" in text or "crop" in text:
        hints += [
            ("profilePhoto", "profile-photo", "user profile photo image"),
            ("imageEditorModal", "image-editor-modal", "image crop editor modal"),
            ("cropHandle", "crop-handle", "image crop handle"),
            ("zoomSlider", "zoom-slider", "image zoom slider"),
            ("initialsAvatar", "initials-avatar", "user initials avatar fallback"),
        ]
    if "modal" in text or "dialog" in text:
        hints += [
            ("modalContainer", "modal-container", "modal or dialog container"),
            ("modalCloseBtn", "modal-close-btn", "modal close button"),
        ]
    if "filter" in text or "search" in text:
        hints += [
            ("searchInput", "search-input", "search or filter input"),
            ("filterDropdown", "filter-dropdown", "filter dropdown"),
        ]
    return hints


# ============================================================================
# Helix-QA Integration: Append-Only File Handling & Deduplication
# ============================================================================

def _file_exists(file_path: str) -> bool:
    """Check if file exists."""
    return Path(file_path).exists()


def _read_file(file_path: str) -> str:
    """Read file content. Return empty string if file does not exist."""
    if not _file_exists(file_path):
        return ""
    try:
        return Path(file_path).read_text(encoding="utf-8")
    except Exception:
        return ""


def _detect_group_subdir(helix_root: str | None, base_dir: str, kebab: str) -> str:
    """
    Detect a prefix-group folder in the user's project that matches this
    file's first kebab token. Mirrors the migration mapper's grouping rule
    so new generation lands NEXT TO the migrated peers instead of in a
    parallel flat dir.

    Args:
        helix_root: project root (e.g. /Users/me/Workspace/MyApp). When
            None or non-existent, returns "" (file goes flat).
        base_dir:   role container relative to root (e.g. "src/pages").
        kebab:      kebab-cased filename stem (e.g. "member-edit").

    Returns:
        The matching folder name (e.g. "member") when
        `<helix_root>/<base_dir>/<token>/` already exists, else "".

    Token derivation matches mapper._group_token:
        - First dash-separated segment of the kebab.
        - Trailing "s" stripped when token >= 4 chars and doesn't end in
          "ss" (so "members" → "member", "address" stays "address").
        - Tokens without a dash in the kebab stay unnormalised.
    """
    if not helix_root:
        return ""
    root = Path(helix_root)
    if not root.exists():
        return ""

    # Derive the candidate group token.
    first = kebab.split("-")[0] if "-" in kebab else kebab
    if "-" in kebab and len(first) >= 4 and first.endswith("s") and not first.endswith("ss"):
        token = first[:-1]
    else:
        token = first

    candidate = root / base_dir / token
    if candidate.is_dir():
        return token

    # Also accept the literal plural form if that's what the project chose
    # (rare but cheap to check) — e.g. project organised as `src/pages/members/`
    # rather than the singularised `members → member`.
    if token != first:
        plural_candidate = root / base_dir / first
        if plural_candidate.is_dir():
            return first

    return ""


def _update_pages_barrel(
    helix_root: str | None,
    page_class_name: str,
    page_rel_path: str,
    new_page_object: str,
) -> str | None:
    """
    Build the updated content for `src/pages/index.ts` after adding a new page.

    The barrel re-exports every page-object class so callers can do
    `import * as P from "@pages"` and reference `P.XxxPage` instead of N
    individual default imports. This helper appends ONE line — the new
    page's re-export — to whatever barrel already exists, deduplicating
    against an identical line so re-runs are safe.

    Args:
        helix_root:        project root (None → no barrel emitted)
        page_class_name:   e.g. "LoginPage"
        page_rel_path:     path under src/pages/ without extension,
                           e.g. "scweb/member/login.page" or "login.page".
        new_page_object:   the generated page-object source; used to detect
                           whether the class is `export default` or `export
                           class` so the re-export shape matches.

    Returns the new full barrel content, or None when `helix_root` isn't
    supplied (in which case the caller should skip emitting the barrel).
    """
    if not helix_root:
        return None

    # Detect export shape from the generated source.
    is_default = bool(
        re.search(rf"^export\s+default\s+class\s+{re.escape(page_class_name)}\b",
                  new_page_object, re.MULTILINE)
    )
    if is_default:
        new_line = f'export {{ default as {page_class_name} }} from "./{page_rel_path}";'
    else:
        new_line = f'export {{ {page_class_name} }} from "./{page_rel_path}";'

    # Read existing barrel (if any).
    barrel_path = Path(helix_root) / "src" / "pages" / "index.ts"
    existing = barrel_path.read_text(encoding="utf-8") if barrel_path.exists() else ""

    # Idempotent: the exact line is already there.
    if new_line in existing:
        return existing

    if not existing.strip():
        # Bootstrap a new barrel.
        return (
            "// Auto-generated barrel — re-exports every page-object class.\n"
            "// Callers: `import * as P from \"@pages\"` then `new P.XxxPage(...)`.\n"
            "\n"
            f"{new_line}\n"
        )

    # Append the new line, keeping the existing trailing newline shape.
    if not existing.endswith("\n"):
        existing += "\n"
    return existing + new_line + "\n"


def _parse_locators_from_file(ts_content: str) -> dict:
    """Extract existing locator keys and selectors from locators.ts file.
    
    Returns dict: {
        "keys": ["pageContainer", "submitBtn", ...],
        "locators": {
            "pageContainer": {"selector": "...", "intent": "...", "stability": ...},
            ...
        }
    }
    """
    keys = []
    locators_dict = {}
    
    # Match pattern: keyName: { selector: "...", intent: '...', ... }
    pattern = r'(\w+):\s*\{\s*selector:\s*"([^"]+)".*?intent:\s*[\'"]([^\'"]+)'
    
    for match in re.finditer(pattern, ts_content):
        key_name, selector, intent = match.groups()
        keys.append(key_name)
        locators_dict[key_name] = {
            "selector": selector,
            "intent": intent,
        }
    
    return {"keys": keys, "locators": locators_dict}


def _parse_page_methods_from_file(ts_content: str) -> dict:
    """Extract existing method names from page object .ts file.
    
    Returns dict: {
        "methods": ["navigate", "submitForm", "fillPrimaryInput", ...],
        "pattern": "async methodName(...): Promise<...>"
    }
    """
    methods = []
    
    # Match pattern: async methodName(...): Promise<...> {
    pattern = r'async\s+(\w+)\s*\('
    
    for match in re.finditer(pattern, ts_content):
        method_name = match.group(1)
        methods.append(method_name)
    
    return {"methods": methods}


def _parse_steps_from_file(ts_content: str) -> dict:
    """Extract existing step definition patterns from steps .ts file.
    
    Returns dict: {
        "step_patterns": ["given step text", "when step text", ...],
        "by_keyword": {"Given": [...], "When": [...], "Then": [...]}
    }
    """
    step_patterns = []
    by_keyword = {"Given": [], "When": [], "Then": []}
    
    # Match pattern: Given(/^step pattern$/,
    pattern = r'(Given|When|Then)\s*\(\s*/\^([^$]+)\$/'
    
    for match in re.finditer(pattern, ts_content):
        keyword, step_text = match.groups()
        # Unescape regex pattern
        step_text = step_text.replace(r'\ ', ' ').replace(r'\.', '.').replace(r'\+', '+')
        step_patterns.append(step_text)
        by_keyword.setdefault(keyword, []).append(step_text)
    
    return {"step_patterns": step_patterns, "by_keyword": by_keyword}


def _parse_scenarios_from_feature_file(feature_content: str) -> dict:
    """Extract existing scenario titles from feature file.
    
    Returns dict: {
        "scenario_titles": ["Scenario 1 Title", "Scenario 2 Title", ...],
        "scenario_count": N
    }
    """
    scenario_titles = []
    
    # Match pattern: Scenario: Scenario Title
    pattern = r'^\s*Scenario:\s+(.+)$'
    
    for line in feature_content.splitlines():
        match = re.match(pattern, line)
        if match:
            scenario_titles.append(match.group(1).strip())
    
    return {"scenario_titles": scenario_titles, "scenario_count": len(scenario_titles)}


def _parse_utils_locators_from_file(ts_content: str) -> dict:
    """Extract existing utility locator keys and selectors from src/utils/locators/*.ts files.
    
    Returns dict: {
        "keys": ["locator1", "locator2", ...],
        "locators": {
            "locator1": {"selector": "...", "intent": "..."},
            ...
        }
    }
    """
    keys = []
    locators_dict = {}
    
    # Match pattern: export const locatorName = { selector: "...", intent: '...' }
    pattern = r"export\s+const\s+(\w+)\s*=\s*\{[^}]*selector:\s*['\"]([^'\"]+)['\"][^}]*intent:\s*['\"]([^'\"]+)['\"]"
    
    for match in re.finditer(pattern, ts_content, re.DOTALL):
        key_name, selector, intent = match.groups()
        keys.append(key_name)
        locators_dict[key_name] = {
            "selector": selector,
            "intent": intent,
        }
    
    return {"keys": keys, "locators": locators_dict}


def _merge_utils_locators_ts(new_content: str, existing_content: str, file_name: str) -> str:
    """Merge new utility locators into existing file, avoiding duplicates.
    
    Args:
        new_content: New utility locator content
        existing_content: Existing file content
        file_name: Name of the utility locator file (for logging)
    
    Returns:
        merged content with new locators appended
    """
    if not existing_content:
        return new_content
    
    # Parse existing locators
    parsed = _parse_utils_locators_from_file(existing_content)
    existing_keys = parsed["keys"]
    
    # Parse new locators
    new_parsed = _parse_utils_locators_from_file(new_content)
    new_keys = new_parsed["keys"]
    
    # Filter out locators that already exist
    unique_keys = [k for k in new_keys if k not in existing_keys]
    
    if not unique_keys:
        return existing_content
    
    # Extract new unique locators from new_content and append
    new_entries = []
    for key_name in unique_keys:
        pattern = rf"export\s+const\s+{key_name}\s*=\s*{{[^}}]*}}"
        match = re.search(pattern, new_content, re.DOTALL)
        if match:
            new_entries.append(match.group(0))
    
    if new_entries:
        merged = existing_content + "\n\n" + "\n\n".join(new_entries)
        return merged
    
    return existing_content


def _generate_unique_locator_key(key: str, existing_keys: list) -> str:
    """Generate a unique locator key when a conflict occurs.
    
    Strategy: append _1, _2, etc. until a unique name is found.
    
    Example: If 'submitBtn' exists, generateunique 'submitBtn_1', 'submitBtn_2', etc.
    Helix-QA Integration: On conflict, the new locator gets the unique name (existing one is never modified).
    """
    candidate = key
    counter = 1
    while candidate in existing_keys:
        candidate = f"{key}_{counter}"
        counter += 1
    return candidate


def _append_to_file(file_path: str, content: str) -> None:
    """Append content to file, creating it if it does not exist."""
    path = Path(file_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    
    if path.exists():
        # Append with a newline separator
        existing = path.read_text(encoding="utf-8")
        path.write_text(existing + "\n" + content, encoding="utf-8")
    else:
        # Create new file
        path.write_text(content, encoding="utf-8")


def _merge_locators_ts(new_locators_section: str, existing_content: str, page_class: str) -> str:
    """Merge new locators into existing locators.ts, avoiding duplicates.
    
    Args:
        new_locators_section: The full new locators section (everything between export const ... and closing brace)
        existing_content: The existing file content
        page_class: The page class name (e.g., "HomePage")
    
    Returns:
        merged content with new locators appended to the export object
    """
    if not existing_content:
        return new_locators_section
    
    # Parse existing locator keys
    parsed = _parse_locators_from_file(existing_content)
    existing_keys = parsed["keys"]
    
    # Extract locators from new section using regex
    new_pattern = r'(\w+):\s*\{\s*selector:\s*"([^"]+)".*?intent:\s*[\'"]([^\'"]+)[\'"].*?\}'
    new_locators_dict = {}
    
    for match in re.finditer(new_pattern, new_locators_section):
        key_name, selector, intent = match.groups()
        stab_m = re.search(r'stability:\s*(\d+)', match.group(0))
        stability = int(stab_m.group(1)) if stab_m else 0
        new_locators_dict[key_name] = {"selector": selector, "intent": intent, "stability": stability}
    
    # Filter out existing keys
    unique_locators = {}
    locator_conflicts = {}
    
    for key_name, entries in new_locators_dict.items():
        if key_name not in existing_keys:
            unique_locators[key_name] = entries
        else:
            # Check if selector is the same
            if parsed["locators"].get(key_name, {}).get("selector") != entries["selector"]:
                # Conflict: same key, different selector
                new_key = _generate_unique_locator_key(key_name, existing_keys + list(unique_locators.keys()))
                locator_conflicts[new_key] = entries
    
    # If all locators already exist, return existing content
    if not unique_locators and not locator_conflicts:
        return existing_content
    
    # Append new unique locators to the existing content
    # Find the closing brace of the locators object
    match = re.search(r'}} as const;\s*$', existing_content, re.MULTILINE)
    if not match:
        return existing_content
    
    insert_pos = match.start()
    
    new_entries = []
    for key_name, entry in {**unique_locators, **locator_conflicts}.items():
        selector = entry["selector"]
        intent = entry["intent"]
        stability = entry.get("stability", 0)
        new_entries.append(f'  {key_name}: {{ selector: "{selector}", intent: \'{intent}\', stability: {stability} }},')
    
    if new_entries:
        merged = (
            existing_content[:insert_pos] +
            "\n" + "\n".join(new_entries) +
            "\n" +
            existing_content[insert_pos:]
        )
        return merged
    
    return existing_content


def _merge_page_object_ts(new_methods_section: str, existing_content: str, page_class: str, kebab: str) -> str:
    """Merge new methods into existing page object .ts file, avoiding duplicates."""
    if not existing_content:
        return new_methods_section
    
    parsed = _parse_page_methods_from_file(existing_content)
    existing_methods = parsed["methods"]
    
    # Extract method names from new section
    new_pattern = r'async\s+(\w+)\s*\('
    new_methods = []
    for match in re.finditer(new_pattern, new_methods_section):
        new_methods.append(match.group(1))
    
    # Find methods to add (not in existing)
    unique_methods = [m for m in new_methods if m not in existing_methods]
    
    if not unique_methods:
        return existing_content
    
    # Extract only the unique methods from the new section
    methods_to_add = []
    for method_name in unique_methods:
        # Find the method in the new section
        pattern = rf'async\s+{method_name}\s*\(.*?\n  \}}\n'
        match = re.search(pattern, new_methods_section, re.DOTALL)
        if match:
            methods_to_add.append(match.group(0))
    
    if methods_to_add:
        # Append to the existing content before the closing brace of the class
        merged = re.sub(
            r'(\n\})\s*$',
            '\n\n' + '\n\n'.join(methods_to_add) + r'\1',
            existing_content,
            flags=re.MULTILINE
        )
        return merged
    
    return existing_content


def _merge_steps_ts(new_steps_section: str, existing_content: str, page_class: str, kebab: str) -> str:
    """Merge new steps into existing steps .ts file, avoiding duplicates."""
    if not existing_content:
        return new_steps_section
    
    parsed = _parse_steps_from_file(existing_content)
    existing_patterns = parsed["step_patterns"]
    
    # Extract new step patterns
    new_pattern_list = []
    for line in new_steps_section.splitlines():
        match = re.search(r'/\^([^$]+)\$/', line)
        if match:
            step_text = match.group(1)
            new_pattern_list.append(step_text)
    
    # Filter out duplicates
    unique_steps = []
    for step_line in new_steps_section.splitlines():
        # Check if this is a Given/When/Then block
        if re.match(r'(Given|When|Then)\s*\(', step_line):
            # Find the step pattern
            match = re.search(r'/\^([^$]+)\$/', step_line)
            if match:
                step_text = match.group(1)
                if step_text not in existing_patterns:
                    unique_steps.append(step_line)
    
    if not unique_steps:
        return existing_content
    
    # Append before the last ";" if it exists, or before the "export default" or end of file
    merged = re.sub(
        r'(\);)\s*$',
        r'\1\n\n' + '\n\n'.join(unique_steps),
        existing_content,
        flags=re.MULTILINE
    )
    return merged


def _merge_feature_file(new_feature_content: str, existing_content: str) -> str:
    """Merge scenarios into existing feature file, avoiding duplicate scenario titles."""
    if not existing_content:
        return new_feature_content
    
    parsed = _parse_scenarios_from_feature_file(existing_content)
    existing_titles = parsed["scenario_titles"]
    
    # Extract new scenarios
    scenario_blocks = []
    current_scenario = []
    in_scenario = False
    
    for line in new_feature_content.splitlines():
        if re.match(r'^\s*Scenario:', line):
            if current_scenario:
                scenario_blocks.append('\n'.join(current_scenario))
            current_scenario = [line]
            in_scenario = True
        elif in_scenario:
            if line.strip() and not re.match(r'^\s*#', line):
                current_scenario.append(line)
            elif not line.strip():
                current_scenario.append(line)
    
    if current_scenario:
        scenario_blocks.append('\n'.join(current_scenario))
    
    # Filter out scenarios with existing titles
    unique_scenarios = []
    for scenario_block in scenario_blocks:
        match = re.search(r'Scenario:\s+(.+)$', scenario_block, re.MULTILINE)
        if match:
            title = match.group(1).strip()
            if title not in existing_titles:
                unique_scenarios.append(scenario_block)
    
    if not unique_scenarios:
        return existing_content
    
    # Append scenarios to the feature file
    merged = existing_content + "\n\n" + "\n\n".join(unique_scenarios)
    return merged


# ============================================================================
# capture_app_context
# ============================================================================

# ── Playwright MCP helpers (owned by Agent 3, not by the pipeline) ────────────

_PMCP_CALL_TIMEOUT    = 120
_PMCP_STARTUP_WAIT    = 8
_PMCP_CONNECT_TIMEOUT = 10


def _port_is_open(host: str, port: int, timeout: float = 1.0) -> bool:
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except (OSError, ConnectionRefusedError, socket.timeout):
        return False


def _parse_mcp_url_port(url: str):
    m = re.match(r"https?://([^/:]+):(\d+)", url)
    if m:
        return m.group(1), int(m.group(2))
    return "localhost", 8931


def _find_free_port(start: int = 8932, end: int = 8999) -> int:
    for port in range(start, end + 1):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            try:
                s.bind(("127.0.0.1", port))
                return port
            except OSError:
                continue
    raise RuntimeError(f"No free TCP port found in {start}–{end}")


class _PlaywrightMcp:
    """Minimal synchronous Playwright MCP HTTP/SSE client used only by this agent."""

    def __init__(self, base_url: str):
        self._base_url = base_url.rstrip("/")
        self._session_id: str | None = None
        self._id = 0
        self._proc: subprocess.Popen | None = None

    def ensure_running(self) -> None:
        import urllib.request, urllib.error
        host, port = _parse_mcp_url_port(self._base_url)
        if _port_is_open(host, port):
            self._init_session()
            return
        # not running — launch on a free port
        try:
            new_port = _find_free_port(port, 8999)
        except RuntimeError:
            new_port = port
        self._base_url = f"http://127.0.0.1:{new_port}/mcp"
        cmd = ["npx", "@playwright/mcp@latest", "--port", str(new_port), "--headless"]
        self._proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        deadline = time.monotonic() + _PMCP_STARTUP_WAIT
        while time.monotonic() < deadline:
            if _port_is_open("127.0.0.1", new_port, 0.5):
                break
            if self._proc.poll() is not None:
                raise RuntimeError(f"Playwright MCP npx exited during startup: {self._proc.stderr.read()[:300]}")
            time.sleep(0.5)
        self._init_session()

    def call(self, tool: str, arguments: dict) -> dict:
        _log = logging.getLogger("stlc.playwright_mcp")
        msg = self._build_rpc("tools/call", {"name": tool, "arguments": arguments})
        resp = self._post(msg)
        if "error" in resp:
            _log.error("Playwright MCP tool '%s' error: %s", tool, resp["error"])
            raise RuntimeError(f"Playwright MCP tool '{tool}' error: {resp['error']}")
        content = resp.get("result", {}).get("content", [])
        if not content:
            return {}
        parts = [b.get("text", "") for b in content if b.get("type") == "text"]
        text = "\n".join(parts)
        try:
            return json.loads(text)
        except json.JSONDecodeError:
            return {"text": text}

    def close(self) -> None:
        if self._proc:
            try:
                self._proc.terminate()
                self._proc.wait(timeout=5)
            except Exception:
                try:
                    self._proc.kill()
                except Exception:
                    pass
            finally:
                self._proc = None

    # ── internals ────────────────────────────────────────────────────────────

    def _init_session(self) -> None:
        msg = self._build_rpc("initialize", {
            "protocolVersion": "2024-11-05",
            "capabilities": {},
            "clientInfo": {"name": "qa-playwright-generator", "version": "1.0"},
        })
        self._post(msg)
        # fire-and-forget initialized notification
        try:
            self._notify("notifications/initialized")
        except Exception:
            pass

    def _notify(self, method: str) -> None:
        import urllib.request
        body = json.dumps({"jsonrpc": "2.0", "method": method, "params": {}}).encode()
        headers = {"Content-Type": "application/json"}
        if self._session_id:
            headers["mcp-session-id"] = self._session_id
        req = urllib.request.Request(self._base_url, data=body, headers=headers, method="POST")
        try:
            urllib.request.urlopen(req, timeout=5)
        except Exception:
            pass

    def _build_rpc(self, method: str, params: dict) -> str:
        self._id += 1
        return json.dumps({"jsonrpc": "2.0", "id": self._id, "method": method, "params": params})

    def _post(self, body: str) -> dict:
        import urllib.request, urllib.error
        headers: dict = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
        if self._session_id:
            headers["mcp-session-id"] = self._session_id
        req = urllib.request.Request(self._base_url, data=body.encode(), headers=headers, method="POST")
        try:
            resp = urllib.request.urlopen(req, timeout=_PMCP_CALL_TIMEOUT)
        except urllib.error.HTTPError as e:
            raise RuntimeError(f"Playwright MCP HTTP {e.code}: {e.read(200).decode(errors='replace')}")
        except urllib.error.URLError as e:
            raise RuntimeError(f"Playwright MCP connection error: {e.reason}")

        sid = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id")
        if sid and not self._session_id:
            self._session_id = sid

        content_type = resp.headers.get("Content-Type", "")
        raw = resp.read()

        if "text/event-stream" in content_type:
            return self._parse_sse(raw.decode(errors="replace")) or self._read_sse_get()

        try:
            data = json.loads(raw.decode(errors="replace"))
            if "result" in data or "error" in data:
                return data
        except json.JSONDecodeError:
            pass

        return self._read_sse_get()

    def _parse_sse(self, text: str) -> dict | None:
        for line in text.splitlines():
            if line.startswith("data:"):
                payload = line[5:].strip()
                if not payload or payload == "[DONE]":
                    continue
                try:
                    data = json.loads(payload)
                    if "result" in data or "error" in data:
                        return data
                except json.JSONDecodeError:
                    continue
        return None  # fall through to GET SSE stream

    def _read_sse_get(self) -> dict:
        import urllib.request, urllib.error
        headers: dict = {"Accept": "text/event-stream"}
        if self._session_id:
            headers["mcp-session-id"] = self._session_id
        req = urllib.request.Request(self._base_url, headers=headers, method="GET")
        try:
            stream = urllib.request.urlopen(req, timeout=_PMCP_CALL_TIMEOUT)
        except (urllib.error.URLError, urllib.error.HTTPError) as e:
            raise RuntimeError(f"Playwright MCP SSE GET failed: {e}")
        deadline = time.monotonic() + _PMCP_CALL_TIMEOUT
        buf = ""
        while time.monotonic() < deadline:
            chunk = stream.read(4096)
            if not chunk:
                break
            buf += chunk.decode(errors="replace")
            for line in buf.splitlines():
                if line.startswith("data:"):
                    payload = line[5:].strip()
                    if not payload or payload == "[DONE]":
                        continue
                    try:
                        data = json.loads(payload)
                        if "result" in data or "error" in data:
                            stream.close()
                            return data
                    except json.JSONDecodeError:
                        continue
        raise RuntimeError("Playwright MCP SSE stream ended without a JSON-RPC result")


# ── AX-tree snapshot helpers ──────────────────────────────────────────────────

_AX_REF_RE = re.compile(
    r"^\s*-\s+(?P<role>[\w-]+)(?:\s+\"(?P<name>[^\"]+)\")?\s+\[ref=(?P<ref>[^\]]+)\]"
)
_AX_TEXT_RE = re.compile(r"^\s*-\s+text:\s+(?P<text>.+)$")


def _snapshot_to_text(snapshot: dict) -> str:
    raw = ""
    for block in snapshot.get("content", []):
        if isinstance(block, dict) and block.get("type") == "text":
            raw += block.get("text", "")
    return raw or snapshot.get("text", "")


def _parse_ax_elements(snapshot: dict) -> list[dict]:
    elements: list[dict] = []
    current: dict | None = None
    for line in _snapshot_to_text(snapshot).splitlines():
        m = _AX_REF_RE.match(line)
        if m:
            current = {"role": m.group("role") or "", "name": (m.group("name") or "").strip(), "ref": m.group("ref") or ""}
            elements.append(current)
            continue
        tm = _AX_TEXT_RE.match(line)
        if tm and current is not None and not current.get("name"):
            current["name"] = tm.group("text").strip().strip('"')
    return elements


def _find_ref(snapshot: dict, *, role: str | None = None, name_patterns: list[str] | None = None) -> str | None:
    compiled = [re.compile(p, re.IGNORECASE) for p in (name_patterns or [])]
    for el in _parse_ax_elements(snapshot):
        if role and el.get("role") != role:
            continue
        name = el.get("name", "")
        if compiled and not any(p.search(name) for p in compiled):
            continue
        return el.get("ref")
    return None


def _find_refs_by_role(snapshot: dict, role: str) -> list[str]:
    """Return all refs with the given role, in document order."""
    return [el["ref"] for el in _parse_ax_elements(snapshot) if el.get("role") == role and el.get("ref")]


def _to_camel(s: str) -> str:
    words = re.split(r"[\s\-_]+", s.strip())
    if not words:
        return "element"
    return words[0].lower() + "".join(w.capitalize() for w in words[1:])


def _score_selector(sel: str, tag: str | None = None) -> int:
    """Stability score for a candidate selector, derived from the
    crowdsourced attribute-priority list in `heal_intel.json`.

    The score is `100 - 10 * rank_of_first_matched_attribute`, floored at 30
    when nothing in the allowlist matches. When the heal-intel artifact is
    missing or schema-mismatched, the `_FALLBACK` order keeps the historical
    behaviour (data-testid > id > aria-label > placeholder > default).

    `tag` lets the caller request a tag-specific order (e.g. inputs prefer
    `name` over `aria-label`). Omit for the generic `*` order.
    """
    order = _heal_intel.attribute_order_for(tag or "*")
    for idx, attr in enumerate(order):
        if _selector_uses_attribute(sel, attr):
            return max(30, 100 - idx * 10)
    return 30


def _selector_uses_attribute(sel: str, attr: str) -> bool:
    """Does `sel` reference `attr` by name (values are not inspected)."""
    if attr == "id":
        return sel.startswith("#") or sel.startswith("[id=") or "[id=" in sel
    if attr == "role+text":
        return ('[role="' in sel) or ("getByRole" in sel) or sel.startswith("role=")
    if attr == "href-shape":
        return "[href" in sel
    return f"[{attr}" in sel or f"{attr}=" in sel


# Matches Playwright MCP YAML AX-tree lines:
#   - button "Add to cart" [ref=e54] [cursor=pointer]
#   - link "Sauce Labs Backpack" [ref=e45] [cursor=pointer]
_AX_ROLE_LINE = re.compile(
    r"^\s*-\s+(?P<role>[\w-]+)\s+\"(?P<name>[^\"]+)\"\s+\[ref=(?P<ref>[^\]]+)\]"
)

# Interactable roles we want to capture as locators
_INTERACTABLE_ROLES = frozenset({
    "button", "link", "textbox", "combobox", "checkbox",
    "radio", "menuitem", "tab", "option", "searchbox", "input",
})


def _build_context_map_from_snapshot(snapshot: dict) -> dict:
    """
    Convert a Playwright MCP browser_snapshot into a context_map dict.

    Handles three snapshot formats:
      1. Structured tree/nodes dict (some MCP versions)
      2. YAML AX-tree text in content[].text (Playwright MCP ≥0.0.14 default)
      3. Raw HTML text fallback (data-testid / id attribute scan)
    """
    context_map: dict = {}
    seen: set = set()

    # ── Format 1: structured JSON tree ───────────────────────────────────────
    for node in (snapshot.get("tree") or snapshot.get("nodes") or []):
        if not isinstance(node, dict):
            continue
        role = node.get("role", "")
        name = node.get("name", "") or node.get("text", "")
        selector = node.get("selector") or node.get("css") or ""
        if not selector or not name:
            continue
        key = _to_camel(name)[:40]
        if key in seen:
            key = f"{key}_{len(seen)}"
        seen.add(key)
        # Map AX-tree role → likely HTML tag so attribute-order ranking can
        # be input-specific (inputs prefer `name`, buttons prefer `aria-label`).
        _tag_hint = {
            "textbox": "input", "searchbox": "input", "input": "input",
            "combobox": "select", "checkbox": "input", "radio": "input",
            "button": "button", "link": "a",
        }.get(role)
        stab = _score_selector(selector, tag=_tag_hint)
        # Widen the captured selector into a fallback union so the primary
        # survives minor markup drift (e.g. a class renamed between envs).
        widened = _widen_selector(selector, role=role, name=name)
        context_map[key] = {
            "selector":  widened,
            "intent":    f"{role} — {name}".strip(" —"),
            "stability": stab,
            **({"visualIntent": True} if stab >= 70 else {}),
        }
    if context_map:
        return context_map

    # ── Format 2: YAML AX-tree text (Playwright MCP ref= format) ─────────────
    # Build role+name → aria selector with stability 90 (high confidence from AX tree)
    raw = _snapshot_to_text(snapshot)
    for line in raw.splitlines():
        m = _AX_ROLE_LINE.match(line)
        if not m:
            continue
        role = m.group("role")
        name = m.group("name").strip()
        if not name or role not in _INTERACTABLE_ROLES:
            continue
        key = _to_camel(name)[:40]
        if key in seen:
            key = f"{key}_{len(seen)}"
        seen.add(key)
        # role+name aria selector — stable for interactable elements
        safe_name = name.replace('"', '\\"')
        primary  = f'[role="{role}"][name="{safe_name}"]'
        # Widen with `<tag>:has-text(...)` and `[aria-label*=...]` so
        # accessibility-name lookups don't break when role/name change.
        widened = _widen_selector(primary, role=role, name=name)
        context_map[key] = {
            "selector":    widened,
            "intent":      f"{role} — {name}",
            "stability":   90,
            "visualIntent": role in ("button", "link"),
        }
    if context_map:
        return context_map

    # ── Format 3: HTML text fallback — data-testid / id attribute scan ────────
    for m in re.finditer(r'data-test(?:id)?=["\']([^"\']+)["\']', raw):
        tid = m.group(1)
        key = _to_camel(tid.replace("-", " "))
        if key not in seen:
            seen.add(key)
            primary = f"[data-testid='{tid}']"
            context_map[key] = {
                "selector":     _widen_selector(primary),
                "intent":       tid.replace("-", " "),
                "stability":    100,
                "visualIntent": True,
            }
    for m in re.finditer(r'\bid=["\']([A-Za-z][A-Za-z0-9_\-]+)["\']', raw):
        eid = m.group(1)
        key = _to_camel(eid.replace("-", " "))
        if key not in seen:
            seen.add(key)
            primary = f"#{eid}"
            context_map[key] = {
                "selector":  _widen_selector(primary),
                "intent":    eid.replace("-", " "),
                "stability": 80,
            }
    return context_map


def _split_selector_union(sel: str) -> list[str]:
    """
    Split a CSS selector on top-level commas only (commas inside `[...]`,
    `(...)`, or quoted strings are part of a single selector, not separators).
    Empty / whitespace-only segments are filtered out.
    """
    out: list[str] = []
    depth = 0
    in_str: str | None = None
    buf: list[str] = []
    i = 0
    while i < len(sel):
        ch = sel[i]
        if in_str is not None:
            buf.append(ch)
            if ch == "\\" and i + 1 < len(sel):
                buf.append(sel[i + 1])
                i += 2
                continue
            if ch == in_str:
                in_str = None
            i += 1
            continue
        if ch in ("'", '"'):
            in_str = ch
            buf.append(ch)
            i += 1
            continue
        if ch in "([{":
            depth += 1
        elif ch in ")]}":
            depth -= 1
        if ch == "," and depth == 0:
            seg = "".join(buf).strip()
            if seg:
                out.append(seg)
            buf = []
        else:
            buf.append(ch)
        i += 1
    tail = "".join(buf).strip()
    if tail:
        out.append(tail)
    return out


def _widen_selector(selector: str, role: str = "", name: str = "") -> str:
    """
    Expand a single-attribute selector into a comma-separated fallback union
    so the migrated primary survives minor markup drift across environments
    (e.g. localhost vs qa4 vs uat). The original selector is always the
    leftmost segment so Playwright tries it first. Fallbacks are added based
    on what's identifying about the element:

      • `#someId`                    → also try `[id*="some-id-slug" i]` and
                                       role+name where available
      • `[data-testid='x-y']`        → also try the kebab→space variant in
                                       `[aria-label*=...]`
      • `[role="X"][name="Y"]`       → also try `:has-text("Y")` scoped to the
                                       likely HTML tag (button / a / etc.)
      • Anything else with a known role/name → append a `:has-text` fallback

    The widening is best-effort: when no useful fallback can be derived, the
    original selector is returned unchanged. The function is deterministic
    so the same input always produces the same output (so successive runs
    of the generator don't churn the locator file).
    """
    base = (selector or "").strip()
    if not base:
        return base
    parts: list[str] = _split_selector_union(base)
    seen: set[str] = set(parts)

    def _add(candidate: str) -> None:
        c = candidate.strip()
        if c and c not in seen:
            parts.append(c)
            seen.add(c)

    # ── id-based widening ───────────────────────────────────────────────
    # `#someId` → also accept `[id*="<kebab>" i]` so a styling pass that
    # renames `#someId` to `#someId-new` still matches.
    id_match = re.match(r"^#([A-Za-z][\w-]*)$", base)
    if id_match:
        id_val = id_match.group(1)
        slug = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", id_val).lower()
        if slug != id_val.lower():
            _add(f'[id*="{slug}" i]')
        # Always also accept the case-insensitive id contains-match.
        _add(f'[id*="{id_val}" i]')

    # ── class-based widening ────────────────────────────────────────────
    # `.foo-bar` → also accept `[class*="foo-bar" i]` and a data-testid
    # substring match. Pure class selectors are the most fragile primary
    # we emit — class names get renamed in build / theme passes — so the
    # union here is the single highest-impact widening.
    class_match = re.match(r"^\.([A-Za-z][\w-]+)$", base)
    if class_match:
        cls = class_match.group(1)
        _add(f'[class*="{cls}" i]')
        _add(f'[data-testid*="{cls}" i]')
        # Most kebab classes correspond to a semantic name; emit a partial
        # head-token match so e.g. `.chevron-collapse` also binds to
        # `.chevron-collapse-info` after a refactor.
        head = cls.split("-")[0]
        if head and head != cls:
            _add(f'[class*="{head}" i]')

    # ── open-only widening for dropdown / menu primaries ────────────────
    # `.dropdown-menu` (and similar `<menu>` / `<popover>` classes) match
    # EVERY menu on the page — most of them closed/hidden. A subsequent
    # `assertVisible` then times out waiting for one of the hidden matches.
    # Add open-state modifiers from the three major frameworks (Bootstrap 3
    # `.open`, Bootstrap 4/5 `.show`, ARIA `aria-expanded='true'`) plus a
    # `:visible` engine filter so the union prefers an actually-open menu.
    # This addresses the SCWeb tri-dot menu failure where every row had a
    # closed `.dropdown-menu` sibling.
    is_menu_class = class_match and (
        "menu" in class_match.group(1).lower()
        or "dropdown" in class_match.group(1).lower()
        or "popover" in class_match.group(1).lower()
    )
    if is_menu_class:
        cls = class_match.group(1)
        _add(f'.open .{cls}')
        _add(f'.{cls}.show')
        _add(f'.{cls}.open')
        _add(f"[aria-expanded='true'] ~ .{cls}")
        _add(f'.{cls}:visible')

    # ── data-testid widening ────────────────────────────────────────────
    testid_match = re.match(r"^\[data-testid=['\"]([^'\"]+)['\"]\]$", base)
    if testid_match:
        tid = testid_match.group(1)
        words = tid.replace("-", " ").replace("_", " ").strip()
        if words:
            _add(f'[aria-label*="{words}" i]')

    # ── role+name widening (Format 2: AX-tree YAML) ─────────────────────
    rn_match = re.match(
        r"^\[role=['\"]([^'\"]+)['\"]\]\[name=['\"]([^'\"]+)['\"]\]$",
        base,
    )
    if rn_match:
        rn_role = rn_match.group(1)
        rn_name = rn_match.group(2).replace('"', '\\"')
        tag_for_role = {
            "button": "button",
            "link": "a",
            "textbox": "input",
            "searchbox": "input",
            "checkbox": "input",
            "radio": "input",
            "combobox": "select",
            "tab": '[role="tab"]',
            "menuitem": '[role="menuitem"]',
            "option": '[role="option"]',
        }.get(rn_role)
        if tag_for_role:
            _add(f'{tag_for_role}:has-text("{rn_name}")')
        _add(f'[aria-label="{rn_name}" i]')
        _add(f'[aria-label*="{rn_name}" i]')

    # ── generic role/name fallback when caller supplies them ────────────
    if role and name:
        nm = name.replace('"', '\\"')
        tag_for_role = {
            "button": "button",
            "link": "a",
            "textbox": "input",
            "searchbox": "input",
            "checkbox": "input",
            "radio": "input",
            "combobox": "select",
        }.get(role)
        if tag_for_role:
            _add(f'{tag_for_role}:has-text("{nm}")')
        # Action affordances rendered as buttons in one app and as links in
        # another (and vice-versa) are a frequent cross-environment break.
        # When the captured role is `button` or `link`, emit BOTH so the
        # primary survives the rebrand. SCWeb editButton failed for exactly
        # this reason: localhost rendered a <button>, qa4 rendered an <a>.
        if role == "button":
            _add(f'a:has-text("{nm}"):not([href*="view"]):not([href*="delete"])')
            _add(f'[role="button"]:has-text("{nm}")')
        elif role == "link":
            _add(f'button:has-text("{nm}")')
            _add(f'[role="button"]:has-text("{nm}")')

    # ── value-input widening (Next / Submit / Save buttons) ──────────────
    # `input[type="submit"][value="Next"]` is a build-fragile primary —
    # frameworks swap submit inputs for <button type="submit"> all the time
    # and label text often gains a non-breaking space / case change. Emit
    # the case-insensitive variants plus the <button> and <a>/role=button
    # shapes for the same label. Addresses the SCWeb import nextButton.
    submit_match = re.match(
        r'''^input\[type=['"]submit['"]\]\[value=['"]([^'"]+)['"]\]$''',
        base,
    )
    if submit_match:
        label = submit_match.group(1).replace('"', '\\"')
        _add(f'input[type="submit"][value="{label}" i]')
        _add(f'button[type="submit"]:has-text("{label}")')
        _add(f'button:has-text("{label}")')
        _add(f'a.btn:has-text("{label}")')
        _add(f'[role="button"]:has-text("{label}")')

    # ── nth-child ↔ nth-of-type cross-fallback ──────────────────────────
    # These two pseudo-classes count differently:
    #   :nth-child(N)    — Nth among all sibling elements
    #   :nth-of-type(N)  — Nth among siblings of the same tag
    # Authors confuse them constantly. When a primary uses one shape,
    # emit the other as a fallback so a misnamed selector still resolves.
    nth_child_pattern = re.compile(r":nth-child\((\d+|odd|even)\)")
    nth_of_type_pattern = re.compile(r":nth-of-type\((\d+|odd|even)\)")
    if nth_child_pattern.search(base):
        alt = nth_child_pattern.sub(lambda m: f":nth-of-type({m.group(1)})", base)
        _add(alt)
    if nth_of_type_pattern.search(base):
        alt = nth_of_type_pattern.sub(lambda m: f":nth-child({m.group(1)})", base)
        _add(alt)

    return ", ".join(parts)


def _merge_maps(*maps: dict) -> dict:
    """
    Combine context maps from multiple snapshots into one. When the same key
    is captured from more than one source, **union** their selectors into a
    comma-separated CSS list instead of dropping the lower-stability one —
    that way the primary remains robust against differences between
    environments (a class present on localhost but renamed on qa4 still has
    the AX-tree role+name fallback to bind to). Stability is taken as the
    max across contributors so the locator file's ranking still reflects
    the strongest signal we saw.
    """
    merged: dict = {}
    for cmap in maps:
        for k, v in (cmap or {}).items():
            if not isinstance(v, dict):
                continue
            if k not in merged:
                merged[k] = dict(v)
                continue
            existing = merged[k]
            existing_sel = (existing.get("selector") or "").strip()
            new_sel      = (v.get("selector")        or "").strip()
            if existing_sel and new_sel and existing_sel != new_sel:
                # Union, deduped, original-first.
                segs = _split_selector_union(existing_sel)
                seen = set(segs)
                for cand in _split_selector_union(new_sel):
                    if cand not in seen:
                        segs.append(cand)
                        seen.add(cand)
                existing["selector"] = ", ".join(segs)
            elif new_sel and not existing_sel:
                existing["selector"] = new_sel
            # Stability = max so the locator file still shows the strongest
            # signal we have; intent retained from the higher-stability hit.
            existing["stability"] = max(
                int(existing.get("stability", 0) or 0),
                int(v.get("stability", 0) or 0),
            )
            if v.get("stability", 0) > int(existing.get("stability", 0) or 0):
                if v.get("intent"):
                    existing["intent"] = v["intent"]
            if v.get("visualIntent") and not existing.get("visualIntent"):
                existing["visualIntent"] = True
    return merged


# ── Login flow detection / execution ─────────────────────────────────────────

def _needs_login(app_url: str, username: str, password: str) -> bool:
    return bool(username and password)


def _do_login(pmcp: _PlaywrightMcp, app_url: str, username: str, password: str) -> dict:
    """Navigate to app_url, fill login form, click submit, return post-login snapshot."""
    _log = logging.getLogger("stlc.playwright_mcp")
    pmcp.call("browser_navigate", {"url": app_url.rstrip("/")})

    # Snapshot AFTER navigate to get fresh refs — Playwright MCP requires ref= to target
    # a specific element; the element= field is only a human-readable permission label.
    login_snap = pmcp.call("browser_snapshot", {})
    _log.info("Pre-login snapshot (first 600):\n%s", _snapshot_to_text(login_snap)[:600])

    user_ref = _find_ref(login_snap, role="textbox", name_patterns=[r"user(?:name)?"])
    pass_ref = _find_ref(login_snap, role="textbox", name_patterns=[r"pass(?:word)?"])
    btn_ref  = _find_ref(login_snap, role="button",  name_patterns=[r"login|sign.?in|submit"])
    _log.info("Login refs: user=%s pass=%s btn=%s", user_ref, pass_ref, btn_ref)

    if not user_ref or not pass_ref:
        raise RuntimeError(
            f"Login form not found on page (user_ref={user_ref!r}, pass_ref={pass_ref!r}). "
            "Check APP_BASE_URL and that Playwright MCP can reach the app."
        )

    pmcp.call("browser_fill_form", {"fields": [
        {"name": "Username", "type": "textbox", "ref": user_ref, "value": username},
        {"name": "Password", "type": "textbox", "ref": pass_ref, "value": password},
    ]})

    if btn_ref:
        pmcp.call("browser_click", {"element": "Login button", "ref": btn_ref})
    else:
        pmcp.call("browser_click", {"element": "Login button"})

    post_snap = pmcp.call("browser_snapshot", {})
    _log.info("Post-login snapshot (first 400):\n%s", _snapshot_to_text(post_snap)[:400])

    # Login failed if the username input is still present on the page
    if _find_ref(post_snap, role="textbox", name_patterns=[r"user(?:name)?|email"]):
        raise RuntimeError(
            "Login failed — still on login page after submit. "
            "Check APP_USERNAME, APP_PASSWORD, and that Playwright MCP can reach the app."
        )
    return post_snap


# ── Gherkin-driven multi-step capture ────────────────────────────────────────

# Interaction verbs we look for in When/And Gherkin steps
_INTERACTION_VERBS = re.compile(
    r"^(?:click|tap|press|open|select|expand|toggle|choose|navigate\s+to|go\s+to)\b",
    re.IGNORECASE,
)

# Strip leading "I " or keyword prefix before matching verb
_STEP_PREFIX = re.compile(
    r"^(?:Given|When|Then|And|But)\s+(?:I\s+)?",
    re.IGNORECASE,
)

# Trailing noise words that follow the real target name
_TRAILING_NOISE = re.compile(
    r"\s+(?:button|link|tab|icon|item|option|element|toggle|"
    r"in\s+the\s+.+|from\s+the\s+.+|on\s+the\s+.+)\s*$",
    re.IGNORECASE,
)

# Filler words between the verb and the target
_VERB_FILLER = re.compile(
    r"^(?:click|tap|press|open|select|expand|toggle|choose|navigate\s+to|go\s+to)\s+"
    r"(?:on\s+)?(?:the\s+|a\s+|an\s+)?",
    re.IGNORECASE,
)


def _extract_interaction_target(step_body: str) -> str | None:
    """
    Return the interaction target from a Gherkin step body, or None if the step
    does not describe an interaction.

    Examples:
      "click the hamburger menu"          → "hamburger menu"
      "open the About link in the sidebar"→ "About"
      "tap the Submit button"             → "Submit"
      "I am logged in as a user"          → None  (no interaction verb)
    """
    body = _STEP_PREFIX.sub("", step_body).strip()
    if not _INTERACTION_VERBS.match(body):
        return None
    target = _VERB_FILLER.sub("", body).strip()
    target = _TRAILING_NOISE.sub("", target).strip().strip("\"'")
    return target if len(target) >= 2 else None


def _find_element_ref(snapshot: dict, target: str) -> str | None:
    """
    Find a clickable element ref whose accessible name fuzzy-matches target.

    Strategy: build a regex from the meaningful words in target and search
    through interactive roles in priority order, falling back to any role.
    """
    words = [w for w in re.split(r"\s+", target.lower()) if len(w) >= 3]
    if not words:
        return None
    pattern = "|".join(re.escape(w) for w in words)
    for role in ("button", "link", "menuitem", "tab", "option", None):
        ref = _find_ref(snapshot, role=role, name_patterns=[pattern])
        if ref:
            return ref
    return None


def _capture_gherkin_driven(
    pmcp: _PlaywrightMcp,
    app_url: str,
    username: str,
    password: str,
    gherkin: str,
) -> dict:
    """
    Gherkin-driven capture that works for any application.

    Algorithm:
      1. Login (if credentials) or navigate to app_url → snapshot landing page.
      2. Parse each When/And/Given/Then step for interaction verbs (click, open,
         select, expand, toggle, …).
      3. For each interaction: find the target element in the current snapshot,
         click it, wait for the DOM to settle, snapshot the new page state.
      4. Return the merged context_map from all captured states.

    Steps where the target element cannot be found in the current snapshot are
    skipped with a warning — this handles cases where an element is only revealed
    after a prior interaction.
    """
    _log = logging.getLogger("stlc.playwright_mcp")
    all_maps: list[dict] = []

    # Phase 1 — reach the starting page
    if _needs_login(app_url, username, password):
        current_snap = _do_login(pmcp, app_url, username, password)
    else:
        pmcp.call("browser_navigate", {"url": app_url})
        current_snap = pmcp.call("browser_snapshot", {})

    landing_map = _build_context_map_from_snapshot(current_snap)
    all_maps.append(landing_map)
    _log.info("gherkin capture: landing page elements=%s", list(landing_map.keys())[:10])

    if not gherkin:
        return _merge_maps(*all_maps)

    # Phase 2 — replay interaction steps
    steps = _parse_gherkin_steps(gherkin)
    for step_text in steps.get("all_steps", []):
        target = _extract_interaction_target(step_text)
        if not target:
            continue

        _log.info("gherkin capture: looking for '%s'", target)
        ref = _find_element_ref(current_snap, target)
        if not ref:
            _log.info("gherkin capture: '%s' not found in snapshot — skipping", target)
            continue

        try:
            pmcp.call("browser_click", {"element": target, "ref": ref})
            time.sleep(0.4)  # let DOM/animation settle
            new_snap = pmcp.call("browser_snapshot", {})
            new_map = _build_context_map_from_snapshot(new_snap)
            if new_map:
                all_maps.append(new_map)
                current_snap = new_snap
                _log.info(
                    "gherkin capture: after '%s' → %d elements (total states=%d)",
                    target, len(new_map), len(all_maps),
                )
        except Exception as exc:
            _log.warning("gherkin capture: interaction '%s' failed: %s", target, exc)

    return _merge_maps(*all_maps)


# ── Public implementation ─────────────────────────────────────────────────────

def _capture_app_context(
    app_url: str,
    app_username: str,
    app_password: str,
    gherkin_content: str,
    playwright_mcp_url: str,
) -> dict:
    """
    Navigate the live application and return a verified context_map.

    Uses Gherkin-driven capture: replays the interaction steps described in the
    Gherkin to visit every relevant page state.  Works for any application —
    no app-specific flow knowledge required.

    Owns the full browser lifecycle: start Playwright MCP if needed, run the
    capture, shut down.
    """
    _log = logging.getLogger("stlc.playwright_mcp")
    _log.info(
        "capture_app_context: url=%s username=%s password=%s mcp=%s",
        app_url, bool(app_username), bool(app_password), playwright_mcp_url,
    )
    if not app_url:
        return {
            "context_map": {},
            "locator_source": "gherkin-inferred",
            "note": "app_url not provided — skipping browser capture",
        }

    pmcp = _PlaywrightMcp(playwright_mcp_url)
    try:
        pmcp.ensure_running()
        context_map = _capture_gherkin_driven(
            pmcp, app_url, app_username, app_password, gherkin_content,
        )
        locator_source = "playwright-mcp-verified" if context_map else "gherkin-inferred"
        return {
            "context_map":    context_map,
            "locator_source": locator_source,
            "locator_count":  len(context_map),
        }

    except Exception as exc:
        _log.error("capture_app_context failed: %s", exc, exc_info=True)
        return {
            "context_map":    {},
            "locator_source": "gherkin-inferred",
            "error":          str(exc),
            "abort":          True,
            "note":           "Browser capture failed. STOP — do NOT call generate_playwright_code. Fix the error above, ensure the Playwright MCP server is reachable, then retry capture_app_context.",
        }
    finally:
        pmcp.close()


# ============================================================================
# generate_playwright_code
# ============================================================================

def _generate_playwright_code(
    gherkin, page_class, app_name, healing_strategy,
    auth_hook, enable_visual_regression, enable_timing_healing,
    context_map=None, helix_project_root=None, app_url=None,
):
    kebab     = _to_kebab(page_class)
    camel     = page_class[0].lower() + page_class[1:]
    all_steps = _parse_gherkin_steps(gherkin)["all_steps"]
    feature_title = page_class
    for line in gherkin.splitlines():
        if line.strip().startswith("Feature:"):
            feature_title = line.strip()[8:].strip()
            break

    locator_source = "playwright-mcp-verified" if context_map else "gherkin-inferred"

    # Generate new content
    new_locators = _gen_locators(page_class, kebab, gherkin, context_map)

    # If app_url is provided and no live context_map, prepend a snapshot hint comment
    # so engineers know the URL to navigate to when running AX-tree capture manually.
    if app_url and not context_map:
        snapshot_hint = (
            f"// APP URL: {app_url}\n"
            f"// Locator source: gherkin-inferred (no context_map provided).\n"
            f"// To replace with verified selectors, run:\n"
            f"//   browser_navigate('{app_url}') → browser_snapshot() → pass result as context_map\n"
        )
        new_locators = snapshot_hint + new_locators
    new_step_defs, derived_methods = _gen_step_defs(
        page_class, kebab, camel, all_steps, auth_hook)
    new_page_object = _gen_page_object(
        page_class, kebab, camel, gherkin, healing_strategy,
        enable_visual_regression, enable_timing_healing,
        derived_methods=derived_methods,
        context_map=context_map,
        app_url=app_url)
    new_feature = _gen_feature_file(gherkin, kebab, page_class)

    # Normalize feature file: auto-quote unquoted string parameters so they
    # match the generated step-definition regex patterns.
    new_feature, normalization_warnings = _normalize_feature_steps(new_feature, new_step_defs)

    # Detect prefix-grouped subfolders in the target project (matches the
    # migration agent's mapper-side grouping). When the user's project has
    # `src/pages/<token>/`, `src/test/steps/<token>/`, etc. already in place
    # for this kebab's first token, the new file is emitted INTO that group
    # folder instead of cluttering the parent dir.
    pages_group     = _detect_group_subdir(helix_project_root, "src/pages", kebab)
    steps_group     = _detect_group_subdir(helix_project_root, "src/test/steps", kebab)
    features_group  = _detect_group_subdir(helix_project_root, "src/test/features", kebab)
    locators_group  = _detect_group_subdir(helix_project_root, "src/locators", kebab)

    # Handle Helix-QA project integration: read existing files and merge
    if helix_project_root:
        helix_root = Path(helix_project_root)

        # Locators merge
        locators_path = str(helix_root / "src" / "locators" / locators_group / f"{kebab}.locators.ts")
        existing_locators = _read_file(locators_path)
        new_locators = _merge_locators_ts(new_locators, existing_locators, page_class)

        # Page object merge
        page_path = str(helix_root / "src" / "pages" / pages_group / f"{kebab}.page.ts")
        existing_page = _read_file(page_path)
        new_page_object = _merge_page_object_ts(new_page_object, existing_page, page_class, kebab)

        # Steps merge
        steps_path = str(helix_root / "src" / "test" / "steps" / steps_group / f"{kebab}.steps.ts")
        existing_steps = _read_file(steps_path)
        new_step_defs = _merge_steps_ts(new_step_defs, existing_steps, page_class, kebab)

        # Feature file merge
        feature_path = str(helix_root / "src" / "test" / "features" / features_group / f"{kebab}.feature")
        existing_feature = _read_file(feature_path)
        new_feature = _merge_feature_file(new_feature, existing_feature)

    # When the detector returned a group name, prefix it with a slash so the
    # final path interpolates cleanly. Empty string → file lands flat at the
    # parent level (the legacy default for projects without any grouping).
    def _slash(g: str) -> str:
        return f"{g}/" if g else ""

    files = {
        f"src/locators/{_slash(locators_group)}{kebab}.locators.ts":   new_locators,
        f"src/pages/{_slash(pages_group)}{kebab}.page.ts":             new_page_object,
        f"src/test/steps/{_slash(steps_group)}{kebab}.steps.ts":       new_step_defs,
        f"src/test/features/{_slash(features_group)}{kebab}.feature":  new_feature,
        "config/cucumber.js (add this profile)":  _gen_cucumber_profile(kebab, app_name, auth_hook),
    }

    # Append the new page-object export to the pages barrel (`src/pages/index.ts`).
    # Idempotent — when the line already exists, the barrel is left unchanged.
    barrel_content = _update_pages_barrel(
        helix_project_root,
        page_class_name=page_class,
        page_rel_path=f"{_slash(pages_group)}{kebab}.page",
        new_page_object=new_page_object,
    )
    if barrel_content is not None:
        files["src/pages/index.ts"] = barrel_content

    layers = ["LocatorHealer (selector → role → label → text → AI Vision)"]
    if enable_timing_healing:
        layers.append("TimingHealer (network trace drift → auto-adjusted timeouts → HealingDashboard)")
    if enable_visual_regression:
        layers.append("VisualIntentChecker (element screenshot diff against baseline → HealingDashboard)")

    hint = (
        "context_map was provided — locators.ts uses accessibility-tree-verified selectors."
        if context_map else
        "No context_map provided — locators.ts uses Gherkin inference (stability=0). "
        "Run Playwright MCP (npx @playwright/mcp@latest --port 8931) and use "
        "browser_navigate + browser_snapshot to build a verified context_map."
    )

    merge_note = (
        f"\n✓ Helix-QA Integration: Files prepared for {helix_project_root}\n"
        "  • Locators merged (no duplicates, conflicts renamed)\n"
        "  • Page objects merged (new methods appended)\n"
        "  • Step definitions merged (new steps appended)\n"
        "  • Feature file merged (new scenarios appended)\n"
        "  Ready to write to disk using write-helix-files or agent 4."
    ) if helix_project_root else ""

    # Run pre-output validation across all three checks
    validation = _run_pre_output_validation(
        files,
        f"src/test/features/{kebab}.feature",
        f"src/test/steps/{kebab}.steps.ts",
        kebab,
        page_class,
    )
    if normalization_warnings:
        validation["warnings"] = normalization_warnings + validation.get("warnings", [])
        validation["normalization_warnings"] = normalization_warnings

    return {
        "files": files, "file_count": len(files),
        "page_class": page_class, "feature_title": feature_title,
        "step_count": len(all_steps), "healing_strategy": healing_strategy,
        "healing_layers": layers,
        "locator_source": locator_source,
        "validation": validation,
        "note": hint + merge_note,
        "scaffold_note": "Run scaffold_locator_repository once per project to generate all 6 infrastructure files.",
    }


def _gen_locators(page_class, kebab, gherkin, context_map=None):
    if context_map and context_map.get("locators"):
        # context_map from Playwright MCP snapshot: list of { key, selector, intent, stability, visualIntent? }
        cdp_entries = _gen_locators_from_context_map(context_map)
    elif context_map and isinstance(context_map, dict) and any(
        isinstance(v, dict) and "selector" in v for v in context_map.values()
    ):
        # context_map as flat dict: { key: { selector, intent, stability, visualIntent? } }
        cdp_entries = _gen_locators_from_flat_map(context_map)
    else:
        cdp_entries = None

    if cdp_entries is None:
        extras = ""
        for k, t, i in _extract_element_hints(gherkin):
            extras += f"  {k}: {{ selector: \"[data-testid='{t}']\", intent: '{i}', visualIntent: true }},\n"
        source_note = (
            " *\n"
            " * ⚠ GHERKIN-INFERRED: These selectors are placeholder data-testid values.\n"
            " * Run Playwright MCP (npx @playwright/mcp@latest --port 8931) and use\n"
            " * browser_navigate + browser_snapshot to replace them with real selectors."
        )
        body = (
            f"  pageContainer:     {{ selector: \"[data-testid='{kebab}-container']\",    intent: '{page_class} main page container',    stability: 0 }},\n"
            f"  primaryActionBtn:  {{ selector: \"[data-testid='{kebab}-action-btn']\",   intent: 'primary action button',               stability: 0, visualIntent: true }},\n"
            f"  submitBtn:         {{ selector: \"[data-testid='{kebab}-submit']\",        intent: 'submit form button',                  stability: 0, visualIntent: true }},\n"
            f"  confirmBtn:        {{ selector: \"[data-testid='{kebab}-confirm']\",       intent: 'confirm action button',               stability: 0, visualIntent: true }},\n"
            f"  cancelBtn:         {{ selector: \"[data-testid='{kebab}-cancel']\",        intent: 'cancel or close button',              stability: 0 }},\n"
            f"  saveBtn:           {{ selector: \"[data-testid='{kebab}-save']\",          intent: 'save changes button',                 stability: 0, visualIntent: true }},\n"
            f"  primaryInputField: {{ selector: \"[data-testid='{kebab}-primary-input']\", intent: 'primary text input',                  stability: 0 }},\n"
            f"  fileInput:         {{ selector: \"input[type='file']\",                    intent: 'file upload input',                   stability: 70 }},\n"
            f"  successToast:      {{ selector: \"[data-testid='toast-success']\",         intent: 'success notification toast',          stability: 0, visualIntent: true }},\n"
            f"  errorToast:        {{ selector: \"[data-testid='toast-error']\",           intent: 'error notification toast',            stability: 0, visualIntent: true }},\n"
            f"  validationError:   {{ selector: \"[data-testid='validation-error']\",      intent: 'form validation error message',       stability: 0, visualIntent: true }},\n"
            f"  successIndicator:  {{ selector: \"[data-testid='{kebab}-success']\",       intent: 'success state indicator',             stability: 0, visualIntent: true }},\n"
            f"  updatedIndicator:  {{ selector: \"[data-testid='{kebab}-updated']\",       intent: 'updated state indicator',             stability: 0 }},\n"
            f"{extras}"
        )
    else:
        source_note = (
            " *\n"
            " * SOURCE: Playwright MCP accessibility tree snapshot.\n"
            " * Stability rank: data-testid(100) > aria-role+name(90) > id(80) > aria-label(70) > placeholder(60).\n"
            " * All selectors derived from real AX nodes — zero hallucinated values."
        )
        body = cdp_entries

    return f'''/**
 * Locators: {page_class}Page
 * Pattern: {{ selector, intent, stability?, visualIntent? }}
 *   selector:     CSS selector derived from accessibility tree (or Gherkin inference)
 *   intent:       used by LocatorHealer (AI Vision fallback) and VisualIntentChecker
 *   stability:    0–100 confidence score (100 = data-testid, 0 = gherkin-inferred)
 *   visualIntent: when true, VisualIntentChecker screenshots this element at assertions{source_note}
 */
export const {page_class}Locators = {{
{body}}} as const;
export type LocatorKey = keyof typeof {page_class}Locators;
'''


def _gen_locators_from_context_map(context_map: dict) -> str:
    """Convert context_map with a 'locators' list (from Playwright MCP snapshot parsing)."""
    lines = []
    for loc in context_map.get("locators", []):
        key       = loc["key"]
        selector  = loc["selector"]
        intent    = loc["intent"]
        stability = loc.get("stability", 0)
        vi        = ", visualIntent: true" if loc.get("visualIntent") else ""
        lines.append(
            f"  // stability={stability}\n"
            f"  {key}: {{ selector: \"{selector}\", intent: '{intent}', stability: {stability}{vi} }},"
        )
    return "\n".join(lines) + "\n"


def _gen_locators_from_flat_map(context_map: dict) -> str:
    """Convert context_map as flat dict { key: { selector, intent, stability, visualIntent? } }."""
    lines = []
    for key, entry in context_map.items():
        if not isinstance(entry, dict) or "selector" not in entry:
            continue
        selector  = entry["selector"]
        intent    = entry.get("intent", key)
        stability = entry.get("stability", 0)
        vi        = ", visualIntent: true" if entry.get("visualIntent") else ""
        lines.append(
            f"  // stability={stability}\n"
            f"  {key}: {{ selector: \"{selector}\", intent: '{intent}', stability: {stability}{vi} }},"
        )
    return "\n".join(lines) + "\n"


def _gen_page_object(page_class, kebab, camel, gherkin, healing_strategy,
                      enable_visual_regression, enable_timing_healing,
                      derived_methods=None, context_map=None, app_url=None):
    steps  = _parse_gherkin_steps(gherkin)["all_steps"]
    stubs  = _build_method_stubs(camel, steps, derived_methods or [])
    vi_import = 'import { VisualIntentChecker } from "@utils/locators/VisualIntentChecker";' if enable_visual_regression else ""
    th_import = 'import { TimingHealer } from "@utils/locators/TimingHealer";' if enable_timing_healing else ""
    tn = '    await fixture().locatorTiming.waitForNetworkIdle("navigate");' if enable_timing_healing else ""
    ts_ = '    await fixture().locatorTiming.waitForNetworkIdle("submitForm");' if enable_timing_healing else ""
    vc  = '    await fixture().locatorVisual.check("successToast", this.loc.successToast.selector, this.loc.successToast.intent);' if enable_visual_regression else ""

    # Extract verified locator keys from context_map.
    # When avail is non-empty (real capture), template methods are emitted only for
    # locator keys that actually exist — prevents TypeScript errors and invented code.
    avail: set = set()
    if context_map and context_map.get("locators"):
        avail = {loc["key"] for loc in context_map["locators"]}
    elif context_map and isinstance(context_map, dict):
        avail = {k for k, v in context_map.items() if isinstance(v, dict) and "selector" in v}

    def _has(*keys: str) -> bool:
        return not avail or all(k in avail for k in keys)

    # ── navigate() ──────────────────────────────────────────────────────────
    if app_url:
        nav = (
            "\n\n  async navigate(): Promise<void> {\n"
            f'    this.logger.info("Navigating to {app_url}");\n'
            f'    await this.page.goto("{app_url}", {{ waitUntil: "domcontentloaded" }});\n'
            f"{tn}\n"
            "  }"
        )
    elif not avail:
        nav = (
            "\n\n  async navigate(): Promise<void> {\n"
            "    // TODO: replace with actual URL (e.g. this.env.baseUrl + '/inventory.html')\n"
            "    const url = `${this.env.baseUrl}`;\n"
            "    this.logger.info(`Navigating to ${url}`);\n"
            "    await this.page.goto(url, { waitUntil: 'domcontentloaded' });\n"
            f"{tn}\n"
            "  }"
        )
    else:
        nav = ""  # real context_map but no URL — omit rather than invent a path

    # ── conditional template methods ─────────────────────────────────────────
    parts: list = []

    parts.append(
        "  async waitForPageLoad(): Promise<void> {\n"
        "    await this.waitHelper.waitForPageLoad();\n"
        "  }"
    )
    if _has("submitBtn"):
        parts.append(
            "  async submitForm(): Promise<void> {\n"
            "    await fixture().locatorHealer.clickWithHealing(\n"
            '      "submitBtn", this.loc.submitBtn.selector, this.loc.submitBtn.intent);\n'
            f"{ts_}\n"
            "  }"
        )
    if _has("confirmBtn"):
        parts.append(
            "  async confirmAction(): Promise<void> {\n"
            "    await fixture().locatorHealer.clickWithHealing(\n"
            '      "confirmBtn", this.loc.confirmBtn.selector, this.loc.confirmBtn.intent);\n'
            "  }"
        )
    if _has("cancelBtn"):
        parts.append(
            "  async clickCancel(): Promise<void> {\n"
            "    await fixture().locatorHealer.clickWithHealing(\n"
            '      "cancelBtn", this.loc.cancelBtn.selector, this.loc.cancelBtn.intent);\n'
            "  }"
        )
    if _has("saveBtn"):
        parts.append(
            "  async clickSave(): Promise<void> {\n"
            "    await fixture().locatorHealer.clickWithHealing(\n"
            '      "saveBtn", this.loc.saveBtn.selector, this.loc.saveBtn.intent);\n'
            "  }"
        )
    if _has("primaryInputField"):
        parts.append(
            "  async fillPrimaryInput(value: string): Promise<void> {\n"
            "    await fixture().locatorHealer.fillWithHealing(\n"
            '      "primaryInputField", this.loc.primaryInputField.selector, value, this.loc.primaryInputField.intent);\n'
            "  }"
        )
    if _has("fileInput"):
        parts.append(
            "  async uploadFile(buffer: Buffer, fileName: string, mimeType: string): Promise<void> {\n"
            "    await this.page.locator(this.loc.fileInput.selector).setInputFiles({ name: fileName, mimeType, buffer });\n"
            "  }"
        )
    if _has("successToast"):
        parts.append(
            "  async verifySuccessToast(): Promise<void> {\n"
            "    await fixture().locatorHealer.assertVisibleWithHealing(\n"
            '      "successToast", this.loc.successToast.selector, this.loc.successToast.intent);\n'
            f"{vc}\n"
            '    fixture().logger.info("✓ Success toast verified (locator + visual)");\n'
            "  }"
        )
    if _has("errorToast"):
        parts.append(
            "  async verifyErrorToast(): Promise<void> {\n"
            "    await fixture().locatorHealer.assertVisibleWithHealing(\n"
            '      "errorToast", this.loc.errorToast.selector, this.loc.errorToast.intent);\n'
            '    fixture().logger.info("✓ Error toast verified");\n'
            "  }"
        )
    if _has("validationError"):
        parts.append(
            "  async verifyValidationErrors(): Promise<void> {\n"
            "    await fixture().locatorHealer.assertVisibleWithHealing(\n"
            '      "validationError", this.loc.validationError.selector, this.loc.validationError.intent);\n'
            "    const count = await this.page.locator(this.loc.validationError.selector).count();\n"
            "    expect(count).toBeGreaterThan(0);\n"
            "  }"
        )
    if _has("successIndicator"):
        parts.append(
            "  async verifySuccessState(): Promise<void> {\n"
            "    await fixture().locatorHealer.assertVisibleWithHealing(\n"
            '      "successIndicator", this.loc.successIndicator.selector, this.loc.successIndicator.intent);\n'
            "  }"
        )
    if _has("updatedIndicator"):
        parts.append(
            "  async verifyStatePersisted(): Promise<void> {\n"
            "    await fixture().locatorHealer.assertVisibleWithHealing(\n"
            '      "updatedIndicator", this.loc.updatedIndicator.selector, this.loc.updatedIndicator.intent);\n'
            "  }"
        )
        parts.append(
            "  async reloadAndVerify(): Promise<void> {\n"
            "    await this.page.reload({ waitUntil: 'networkidle' });\n"
            "    await this.verifyStatePersisted();\n"
            "  }"
        )

    methods_block = "\n\n".join(parts)
    layer2    = " * Layer 2 — Timing Healing (TimingHealer): intercepts network traces, auto-heals timeout drift.\n *" if enable_timing_healing else ""
    layer3    = " * Layer 3 — Visual Intent (VisualIntentChecker): element screenshot diff at assertions.\n *" if enable_visual_regression else ""
    dashboard = " * HealingDashboard: http://localhost:7890 — approve/reject all AI suggestions." if (enable_timing_healing or enable_visual_regression) else ""

    return (
        f'import {{ Page, expect }} from "@playwright/test";\n'
        f'import {{ fixture }} from "@hooks/pageFixture";\n'
        f'import {{ {page_class}Locators }} from "../locators/{kebab}.locators";\n'
        f'import {{ LocatorHealer }} from "@utils/locators/LocatorHealer";\n'
        f'import {{ LocatorRepository }} from "@utils/locators/LocatorRepository";\n'
        f'import {{ BasePage }} from "./BasePage";\n'
        f'{vi_import}\n'
        f'{th_import}\n'
        f'\n'
        f'/**\n'
        f' * {page_class}Page — Three-Layer Self-Healing Page Object\n'
        f' *\n'
        f' * Layer 1 — Locator Healing ({healing_strategy}):\n'
        f' *   primary selector → role-based → label-based → text-based → ariaSnapshot + Claude AI Vision\n'
        f' *   → DevToolsHealer AX tree (CDPSession) → DevToolsHealer bounding box (CDPSession)\n'
        f' *   Healed selectors persisted in LocatorRepository — zero overhead on repeat runs.\n'
        f' *\n'
        + (f' * Layer 2 — Timing Healing (TimingHealer): intercepts network traces, auto-heals timeout drift.\n *\n' if enable_timing_healing else '')
        + (f' * Layer 3 — Visual Intent (VisualIntentChecker): element screenshot diff at assertions.\n *\n' if enable_visual_regression else '')
        + (f' * HealingDashboard: http://localhost:7890 — approve/reject all AI suggestions.\n' if (enable_timing_healing or enable_visual_regression) else '')
        + f' *\n'
        f' * RULE: Never call page.click / page.fill / page.locator directly.\n'
        f' */\n'
        f'export default class {page_class}Page extends BasePage {{\n'
        f'    private readonly loc = {page_class}Locators;\n'
        f'\n'
        f'    constructor(page?: Page) {{\n'
        f'        super(page ?? fixture().page);\n'
        f'        Object.entries(this.loc).forEach(([key, val]) =>\n'
        f'            this.repo.register(key, val.selector, val.intent));\n'
        f'    }}'
        + nav
        + '\n\n'
        + methods_block
        + ('\n\n' + stubs if stubs.strip() else '')
        + '\n}\n'
    )


def _build_method_stubs(camel, steps, derived_methods=None):
    stubs, seen = [], set()
    for step in steps:
        lower = step.lower()
        if "refresh" in lower and "refresh" not in seen:
            seen.add("refresh")
            stubs.append('''  async refreshPage(): Promise<void> {
    await this.page.reload({ waitUntil: "networkidle" });
    await this.waitForPageLoad();
  }''')
        if "initials" in lower and "initials" not in seen:
            seen.add("initials")
            stubs.append('''  async verifyInitialsAvatar(): Promise<void> {
    const sel = (this.loc as any).initialsAvatar?.selector ?? "[data-testid='initials-avatar']";
    const el  = this.page.locator(sel);
    await expect(el).toBeVisible({ timeout: 10_000 });
    const text = await el.textContent() ?? "";
    expect(text.trim()).toMatch(/^[A-Z]{1,2}$/);
    fixture().logger.info(`✓ Initials: "${text.trim()}"`);
  }''')
        if "persist" in lower and "persist" not in seen:
            seen.add("persist")
            stubs.append('''  async verifyDataPersistenceAfterRefresh(): Promise<void> {
    await this.page.reload({ waitUntil: "networkidle" });
    await this.verifyStatePersisted();
    fixture().logger.info("✓ State persisted after refresh");
  }''')

    # Emit stubs for every derived method called by the step definitions.
    # These throw so tests fail loudly instead of silently passing with no-ops.
    for method_name in (derived_methods or []):
        if method_name not in seen:
            seen.add(method_name)
            stubs.append(
                f'  async {method_name}(...args: unknown[]): Promise<void> {{\n'
                f'    throw new Error("not yet implemented: {method_name}");\n'
                f'  }}'
            )

    return "\n\n".join(stubs)


def _step_text_to_method_name(text: str) -> str:
    """Derive a camelCase page-method name from a Gherkin step text."""
    lower = text.lower()
    is_assertion = any(kw in lower for kw in (
        "should", "must", "is displayed", "are displayed",
        "is shown", "are shown", "should see", "should be",
        "should have", "is visible", "are visible", "is empty",
        "is present", "contains", "count", "equal",
    ))
    is_nav  = any(kw in lower for kw in (
        "navigates to", "navigate to", "is on the", "visits",
        "goes to", "opens", "on the page",
    ))
    is_click = any(kw in lower for kw in (
        "clicks", "click", "taps", "tap", "presses", "press",
        "selects", "select", "chooses", "choose",
    ))
    is_fill = any(kw in lower for kw in (
        "enters", "enter", "fills", "fill", "types", "type",
        "inputs", "input", "sets", "set",
    ))
    is_given = any(kw in lower for kw in (
        "has", "have", "with", "contains", "there is", "there are", "exists",
    ))
    clean = re.sub(r'\{[^}]+\}', '', text)
    clean = re.sub(r'"[^"]*"', '', clean)
    clean = re.sub(r"'[^']*'", '', clean)
    stops = {
        'a', 'an', 'the', 'is', 'are', 'has', 'have', 'be', 'been',
        'to', 'of', 'on', 'in', 'at', 'for', 'by', 'with', 'and',
        'or', 'it', 'its', 'that', 'this', 'their', 'from', 'as',
        'user', 'page', 'screen', 'should', 'will', 'can',
        'then', 'when', 'given', 'but', 'after', 'before',
    }
    tokens = [t for t in re.findall(r"[a-zA-Z]+", clean) if t.lower() not in stops][:4]
    if not tokens:
        tokens = re.findall(r"[a-zA-Z]+", clean)[:2] or ["action"]
    body = "".join(t.capitalize() for t in tokens)
    if is_assertion:
        return f"verify{body}"
    if is_nav:
        return f"navigateTo{body}" if body else "navigate"
    if is_click:
        return f"click{body}"
    if is_fill:
        return f"enter{body}"
    if is_given:
        return f"setup{body}" if body else "setup"
    return f"action{body}" if body else "performAction"


def _step_text_to_cucumber_pattern(text: str) -> str:
    """Convert step text to a Cucumber expression string (no raw regex)."""
    pattern = re.sub(r'"[^"]*"', '{string}', text)
    pattern = re.sub(r'\b\d+\.\d+\b', '{float}', pattern)
    pattern = re.sub(r'\b\d+\b', '{int}', pattern)
    return pattern


def _build_step_callback(kw, text, method_call, camel, page_class):
    """Build one Cucumber step definition block using Cucumber expressions."""
    # @cucumber/cucumber does not export And/But as step-definition functions.
    # Normalise them to When (action) or Then (assertion) based on the method name.
    if kw in ("And", "But"):
        _lower_m = method_call.lower()
        _lower_t = text.lower()
        _assertion_words = (
            "verify", "assert", "should", "expect", "check",
            "visible", "enabled", "disabled", "present", "contain",
            "navigated", "remain", "not_", "available", "reachable",
        )
        kw = "Then" if any(w in _lower_m or w in _lower_t for w in _assertion_words) else "When"

    pattern    = _step_text_to_cucumber_pattern(text)
    param_types = re.findall(r'\{(string|int|float|word)\}', pattern)
    if param_types:
        params   = ", ".join(
            f"p{i}: {'string' if t == 'string' else 'number'}"
            for i, t in enumerate(param_types)
        )
        fn_sig   = f"async function ({params}): Promise<void>"
        if "()" in method_call:
            arg_list   = ", ".join(f"p{i}" for i in range(len(param_types)))
            actual_call = method_call.replace("()", f"({arg_list})")
        else:
            actual_call = method_call
    else:
        fn_sig      = "async function (): Promise<void>"
        actual_call = method_call
    return (
        f"{kw}(\n"
        f"  '{pattern}',\n"
        f"  {fn_sig} {{\n"
        f"    if (!{camel}Page) {{ {camel}Page = new {page_class}Page(); }}\n"
        f"    await {actual_call};\n"
        f"  }},\n"
        f");\n"
    )


def _gen_step_defs(page_class, kebab, camel, all_steps, auth_hook):
    """Generate a complete *.steps.ts for EVERY step in the feature.

    Previous behaviour silently dropped any step whose text didn't match one of
    14 hardcoded keyword phrases, leaving the file with only the two SSO Given
    stubs.  New behaviour:
    - Extended mappings cover common e-commerce/SaaS patterns.
    - Any step not matched gets a derived method name and typed stub.
    - All patterns use Cucumber expression strings (not raw /^...$/ regex) to
      avoid the paren-balance validation errors in write_helix_files.
    """
    bg = ""
    if auth_hook == "microsoft-sso":
        bg = (
            "\nGiven(\n"
            "  'user logs in with Microsoft SSO as {string}',\n"
            "  async function (userType: string): Promise<void> {\n"
            f"    if (!{camel}Page) {{ {camel}Page = new {page_class}Page(); }}\n"
            f"    fixture().logger.info(`SSO login as ${{userType}}`);\n"
            "  },\n"
            ");\n"
            "Given(\n"
            "  'user of type {string} is ready to login',\n"
            "  async function (userType: string): Promise<void> {\n"
            f"    if (!{camel}Page) {{ {camel}Page = new {page_class}Page(); }}\n"
            f"    fixture().logger.info(`Preparing login for ${{userType}}`);\n"
            "  },\n"
            ");\n"
        )

    mappings: list = [
        # navigation — only match unambiguously page-level navigation phrases;
        # "opens the" and "is on the" are intentionally excluded: they are too broad
        # and incorrectly match steps like "opens the sidebar menu" or "is on the cart page".
        # Those steps fall through to _step_text_to_method_name and get correct derived names.
        (["navigates to", "navigate to", "visits the", "goes to",
          "is on the main page", "is on the home page", "on the site"],
         f"{camel}Page.navigate()"),
        # cart setup
        (["has items in", "has item in", "items in the cart",
          "item in the cart", "product in the cart",
          "item in their cart", "items in their cart"],
         f"{camel}Page.setupCartWithItems()"),
        # add to cart
        (["adds the item", "add the item", "adds item", "add item",
          "clicks add to cart", "click add to cart",
          "adds a product", "add a product",
          "adds to cart", "add to cart"],
         f"{camel}Page.addItemToCart()"),
        # remove from cart
        (["remove button", "clicks the remove", "click the remove",
          "clicks remove", "click remove",
          "removes the item", "remove the item",
          "removes item", "remove item",
          "removes the product", "remove the product"],
         f"{camel}Page.clickRemoveButton()"),
        # verify removed
        (["item is removed", "item should be removed",
          "item no longer", "product is removed",
          "product should be removed", "product no longer"],
         f"{camel}Page.verifyItemRemoved()"),
        # cart empty
        (["cart is empty", "cart should be empty",
          "empty cart", "no items in the cart"],
         f"{camel}Page.verifyCartEmpty()"),
        # cart count / badge
        (["cart count", "cart badge", "item count",
          "number of items", "quantity in cart",
          "shopping cart count"],
         f"{camel}Page.verifyCartCount()"),
        # cart total
        (["cart total", "total price", "cart subtotal",
          "subtotal", "order total"],
         f"{camel}Page.verifyCartTotal()"),
        # product display
        (["product page", "product detail", "product listing",
          "product is visible", "products are displayed",
          "products are listed", "product list"],
         f"{camel}Page.verifyProductsDisplayed()"),
        # click product
        (["clicks on the product", "click on the product",
          "clicks the product", "click the product",
          "selects the product", "select the product"],
         f"{camel}Page.clickProduct()"),
        # product info
        (["product name", "product title", "product description",
          "product price", "product detail is"],
         f"{camel}Page.verifyProductDetails()"),
        # checkout
        (["proceeds to checkout", "proceed to checkout",
          "clicks checkout", "click checkout"],
         f"{camel}Page.proceedToCheckout()"),
        # order confirmation
        (["order is placed", "order placed", "order confirmed",
          "order confirmation", "purchase complete"],
         f"{camel}Page.verifyOrderConfirmation()"),
        # form fill
        (["fills in", "fill in", "fills out", "fill out",
          "enters details", "completes the form"],
         f"{camel}Page.fillForm()"),
        # submit
        (["submits the form", "submit the form",
          "clicks submit", "click submit"],
         f"{camel}Page.submitForm()"),
        # confirm
        (["confirms the action", "confirm the action",
          "clicks confirm", "click confirm"],
         f"{camel}Page.confirmAction()"),
        # cancel / close
        (["clicks cancel", "click cancel",
          "clicks close", "click close"],
         f"{camel}Page.clickCancel()"),
        # save
        (["clicks save", "click save"],
         f"{camel}Page.clickSave()"),
        # text input
        (["enters", "enter", "types", "type", "fills", "fill",
          "inputs", "input"],
         f"{camel}Page.fillPrimaryInput()"),
        # file upload
        (["selects a valid file", "uploads a valid file",
          "valid file", "valid image"],
         f"{camel}Page.uploadFile(Buffer.alloc(5*1024*1024),'test.jpg','image/jpeg')"),
        (["exceeds the maximum", "oversized file", "file too large"],
         f"{camel}Page.uploadFile(Buffer.alloc(5*1024*1024+1),'over.jpg','image/jpeg')"),
        # success
        (["success toast", "success notification",
          "success message", "successfully"],
         f"{camel}Page.verifySuccessToast()"),
        # error
        (["error toast", "error notification",
          "error message is displayed", "error is displayed"],
         f"{camel}Page.verifyErrorToast()"),
        # validation
        (["validation error", "validation message",
          "field error", "required field"],
         f"{camel}Page.verifyValidationErrors()"),
        # success state
        (["operation completes", "updated state",
          "action is complete", "successfully updated",
          "successfully deleted"],
         f"{camel}Page.verifySuccessState()"),
        # persisted
        (["still be visible", "persists", "is still",
          "remains", "after reload", "after refresh"],
         f"{camel}Page.verifyStatePersisted()"),
        # reload
        (["refreshes the page", "refresh the page",
          "reloads the page", "reload the page"],
         f"{camel}Page.refreshPage()"),
        # generic display check
        (["is displayed", "are displayed", "is shown",
          "are shown", "is visible", "are visible",
          "should see", "should display"],
         f"{camel}Page.verifySuccessState()"),
        # avatar
        (["initials", "fallback avatar", "avatar"],
         f"{camel}Page.verifyInitialsAvatar()"),
        # modal
        (["modal", "dialog", "popup", "overlay"],
         f"{camel}Page.verifyModalVisible()"),
        # sort
        (["sorted", "sort order", "in order"],
         f"{camel}Page.verifySortOrder()"),
        # filter
        (["filter", "filtered", "filters"],
         f"{camel}Page.applyFilter()"),
        # search
        (["search", "searches"],
         f"{camel}Page.search()"),
    ]

    blocks: list = []
    seen_patterns: set = set()
    unmapped_steps: list = []

    for step in all_steps:
        m = re.match(r"^(Given|When|Then|And|But)\s+", step)
        if not m:
            continue
        kw   = m.group(1)
        text = step[len(m.group(0)):]
        norm = re.sub(r"\s+", " ", text.lower().strip())
        if norm in seen_patterns:
            continue
        seen_patterns.add(norm)

        lower = step.lower()
        method_call = next(
            (mth for kws, mth in mappings if any(k in lower for k in kws)),
            None,
        )

        if method_call:
            blocks.append(
                _build_step_callback(kw, text, method_call, camel, page_class)
            )
        else:
            method_name = _step_text_to_method_name(text)
            unmapped_steps.append((kw, text, method_name))
            blocks.append(
                _build_step_callback(
                    kw, text, f"{camel}Page.{method_name}()", camel, page_class,
                )
            )

    unmapped_comment = ""
    if unmapped_steps:
        lines_u = [
            f" *   {kw}: {text}  →  {camel}Page.{mn}()"
            for kw, text, mn in unmapped_steps
        ]
        unmapped_comment = (
            "\n/**\n"
            " * Derived step implementations — add these methods to the page object:\n"
            + "\n".join(lines_u) + "\n"
            " * (auto-generated by qa-playwright-generator)\n"
            " */\n"
        )

    derived_method_names = [mn for _kw, _text, mn in unmapped_steps]

    content = (
        f'import {{ Given, When, Then }} from "@cucumber/cucumber";\n'
        f'import {{ expect }} from "@playwright/test";\n'
        f'import {{ fixture }} from "@hooks/pageFixture";\n'
        f'import {page_class}Page from "@pages/{kebab}.page";\n'
        f'\n'
        f'/**\n'
        f' * Step Definitions: {page_class}\n'
        f' * HealingDashboard: http://localhost:7890 — review pending suggestions.\n'
        f' */\n'
        f'let {camel}Page: {page_class}Page;\n'
        f'{bg}\n'
        f'{unmapped_comment}'
        + "".join(blocks)
    )
    return content, derived_method_names


def _gen_feature_file(gherkin: str, kebab: str, page_class: str) -> str:
    """Generate feature file content. Returns the full feature file as-is from gherkin."""
    # The feature file is already in gherkin format, so we just return it
    # (This allows consume the gherkin directly without modification)
    return gherkin


def _gen_cucumber_profile(kebab, app_name, auth_hook):
    profile = kebab.replace("-", "_")
    sso = '"src/test/steps/microsoftSSO.steps.ts",' if auth_hook == "microsoft-sso" else ""
    return f'''// Add to config/cucumber.js
{profile}: {{
  tags: process.env.tags || "@{profile}",
  paths: ["src/test/features/{kebab}.feature"],
  require: ["src/test/steps/{kebab}.steps.ts", {sso} "src/hooks/hooks.ts"],
  requireModule: ["ts-node/register", "tsconfig-paths/register"],
  format: ["progress", "html:test-results/cucumber-report-{kebab}.html",
           "json:test-results/cucumber-report-{kebab}.json", "rerun:@rerun.txt"],
  parallel: 2, retry: 2, retryTagFilter: "@flaky",
}},
// Run: ENABLE_SELF_HEALING=true HEALING_DASHBOARD_PORT=7890 \\
//   cucumber-js --config=config/cucumber.js -p {profile}
'''


# ============================================================================
# Post-generation validation
# ============================================================================

_STEP_STOP_WORDS = frozenset([
    'a', 'an', 'the', 'is', 'are', 'has', 'have', 'to', 'be', 'with',
    'in', 'on', 'at', 'for', 'of', 'by', 'as', 'it', 'its', 'from',
    'and', 'or', 'not', 'no', 'true', 'false', 'user', 'page',
])


def _normalize_feature_steps(feature_content: str, step_defs_content: str) -> tuple:
    """Auto-quote unquoted string parameters in feature file steps.

    Builds compiled regex patterns from the generated step definitions, then for
    each feature step that does NOT already match any pattern, iteratively wraps
    unquoted alphanumeric tokens in double-quotes until a match is found.

    Returns (normalized_content: str, warnings: list[str]).
    """
    warnings: list = []

    # Compile step-definition regex patterns
    patterns: list = []
    for line in step_defs_content.splitlines():
        m = re.search(r'/\^(.+?)\$/', line)
        if m:
            pat_str = m.group(1).replace(r'\ ', r'\s+')
            try:
                patterns.append(re.compile(r'^' + pat_str + r'$', re.IGNORECASE))
            except re.error:
                pass

    if not patterns:
        return feature_content, warnings

    kws = ('Given', 'When', 'Then', 'And', 'But')
    result_lines: list = []

    for line in feature_content.splitlines():
        stripped = line.strip()
        kw = next((k for k in kws if stripped.startswith(k + ' ')), None)
        if not kw:
            result_lines.append(line)
            continue

        indent = line[: len(line) - len(line.lstrip())]
        text = stripped[len(kw) + 1:]

        # Already matches a pattern — keep as-is
        if any(p.match(text) for p in patterns):
            result_lines.append(line)
            continue

        # Iteratively quote the first unquoted token that produces a match
        fixed = text
        for _ in range(5):  # at most 5 params per step
            replaced = False
            for tm in re.finditer(r'(?<!["\'\w])([A-Za-z][A-Za-z0-9_\-]+)(?!["\'\w])', fixed):
                tok = tm.group(1)
                if tok.lower() in _STEP_STOP_WORDS:
                    continue
                candidate = fixed[: tm.start(1)] + f'"{tok}"' + fixed[tm.end(1):]
                if any(p.match(candidate) for p in patterns):
                    warnings.append(
                        f"Auto-quoted '{tok}' in step: \"{kw} {text}\" → \"{kw} {candidate}\""
                    )
                    fixed = candidate
                    replaced = True
                    break
            if not replaced:
                break

        result_lines.append(f"{indent}{kw} {fixed}")

    return '\n'.join(result_lines), warnings


def _check_import_consistency(files: dict, kebab: str, page_class: str) -> list:
    """Verify internal imports between generated .ts files resolve to known file keys.

    Maps Helix-QA tsconfig path aliases and relative paths to expected keys in the
    files dict. Reports any import whose resolved target is absent.

    Returns list of issue strings.
    """
    issues: list = []

    alias_map: dict = {
        f"@pages/{kebab}/{kebab}.page": f"src/pages/{kebab}.page.ts",
        f"@pages/{kebab}.page":         f"src/pages/{kebab}.page.ts",
        "./locators":                    f"src/locators/{kebab}.locators.ts",
        f"../locators/{kebab}.locators": f"src/locators/{kebab}.locators.ts",
        f"@locators/{kebab}.locators":   f"src/locators/{kebab}.locators.ts",
    }
    internal_prefixes = ('./', '../', '@pages/', '@locators/')

    for file_key, content in files.items():
        if not file_key.endswith('.ts'):
            continue
        for m in re.finditer(r"""from\s+['"]([^'"]+)['"]""", content):
            path = m.group(1)
            if not any(path.startswith(p) for p in internal_prefixes):
                continue
            if not path.strip():
                issues.append(f"{file_key}: empty import path detected")
                continue
            if path in alias_map:
                target = alias_map[path]
                if target not in files:
                    issues.append(
                        f"{file_key}: import '{path}' → expected '{target}' not in generated files"
                    )

    return issues


def _validate_ts_syntax(content: str, file_name: str) -> list:
    """Basic TypeScript syntax sanity checks.

    Strips string literals, template literals, and comments before counting
    brace/paren balance to avoid false positives from characters inside strings.

    Returns list of error strings.
    """
    errors: list = []

    # Strip literals and comments before balance checks
    s = re.sub(r'`[^`]*`', '``', content)
    s = re.sub(r'"[^"\n]*"', '""', s)
    s = re.sub(r"'[^'\n]*'", "''", s)
    s = re.sub(r'//[^\n]*', '', s)
    s = re.sub(r'/\*.*?\*/', '', s, flags=re.DOTALL)

    nb = s.count('{') - s.count('}')
    if nb != 0:
        errors.append(f"{file_name}: unbalanced braces (delta={nb:+d})")

    np_ = s.count('(') - s.count(')')
    if np_ != 0:
        errors.append(f"{file_name}: unbalanced parentheses (delta={np_:+d})")

    if re.search(r"""from\s+['"]{2}""", content):
        errors.append(f"{file_name}: empty import path (from \"\")")

    if ';;' in content:
        errors.append(f"{file_name}: double semicolons found")

    return errors


def _validate_step_coverage(feature_content: str, step_defs_content: str) -> list:
    """Return feature steps that have no matching step definition regex pattern.

    Returns list of uncovered step strings (keyword included).
    """
    patterns: list = []
    for line in step_defs_content.splitlines():
        m = re.search(r'/\^(.+?)\$/', line)
        if m:
            pat_str = m.group(1).replace(r'\ ', r'\s+')
            try:
                patterns.append(re.compile(r'^' + pat_str + r'$', re.IGNORECASE))
            except re.error:
                pass

    if not patterns:
        return []

    kws = ('Given', 'When', 'Then', 'And', 'But')
    uncovered: list = []
    seen: set = set()

    for line in feature_content.splitlines():
        stripped = line.strip()
        kw = next((k for k in kws if stripped.startswith(k + ' ')), None)
        if not kw:
            continue
        text = stripped[len(kw) + 1:]
        key = text.strip().lower()
        if key in seen:
            continue
        seen.add(key)
        if not any(p.match(text) for p in patterns):
            uncovered.append(f"{kw} {text}")

    return uncovered


def _run_pre_output_validation(
    files: dict, feature_key: str, step_key: str,
    kebab: str, page_class: str,
) -> dict:
    """Run all post-generation validations and return a consolidated summary.

    Checks:
      1. Import consistency — internal @pages / ./ imports resolve to files dict keys.
      2. TypeScript syntax  — brace/paren balance, empty imports, double semicolons.
      3. Step coverage      — every unique feature step has a matching step definition.

    Returns dict with keys: valid, errors, warnings, import_issues,
                            syntax_issues, uncovered_steps.
    """
    all_errors: list = []
    all_warnings: list = []

    # 1 — Import consistency
    import_issues = _check_import_consistency(files, kebab, page_class)
    all_errors.extend(import_issues)

    # 2 — TypeScript syntax per file
    syntax_issues: dict = {}
    for fk, fc in files.items():
        if fk.endswith('.ts'):
            errs = _validate_ts_syntax(fc, fk)
            if errs:
                syntax_issues[fk] = errs
                all_errors.extend(errs)

    # 3 — Step coverage
    feature_content   = files.get(feature_key, "")
    step_defs_content = files.get(step_key, "")
    uncovered = _validate_step_coverage(feature_content, step_defs_content)
    for step in uncovered:
        all_warnings.append(f"No step definition matches: '{step}'")

    return {
        "valid":           len(all_errors) == 0,
        "errors":          all_errors,
        "warnings":        all_warnings,
        "import_issues":   import_issues,
        "syntax_issues":   syntax_issues,
        "uncovered_steps": uncovered,
    }


# ============================================================================
# Scrubber + TelemetryClient generators
#
# Emitted alongside LocatorRepository.ts into src/utils/locators/ so the
# customer's project has the full crowd-intelligence pipeline locally.
#
# Privacy contract — enforced by Scrubber.ts at the source:
#   • Only enums / booleans / integers / a closed-vocabulary keyword list ever
#     leave the box.
#   • The closed vocabulary is sourced from heal_intel.intent_keyword_allowlist —
#     identical to the vocabulary the aggregation server expects, so the upload
#     schema is auditable end-to-end.
#   • Attribute *names* are bounded by the same allowlist; attribute *values*
#     are never read.
#   • Uploads only fire when the user clicks "Approve" on the HealingDashboard
#     AND has opted in (STLC_TELEMETRY=on, or one-time consent stored in the
#     LocatorRepository envelope).
# ============================================================================


def _gen_click_navigate_helper():
    """Emit src/utils/locators/clickAndAwaitNavigation.ts.

    A small reusable helper that bundles the four landmines we hit on
    click-then-navigate flows during SCWeb's Cancel-button stabilisation:

      1. Strict-mode multi-match — caller passes a `.first()` / scoped
         locator, the helper never widens.
      2. Playwright's `.click()` auto-wait for `load` event hanging the
         step on slow servers — fixed with `noWaitAfter: true`.
      3. `beforeunload` / `confirm()` dialogs blocking navigation when no
         handler is registered — auto-accepted (Leave / OK).
      4. `Promise.race` rejecting on first rejection instead of resolving
         on first success — each signal is wrapped in `.catch()` so the
         race resolves on the first OK signal regardless of others timing
         out.

    The caller still verifies the resulting URL / state separately;
    this helper only owns the "click and wait for SOMETHING to settle"
    half of the flow.
    """
    return '''import { Page, BrowserContext, Locator, Dialog } from "@playwright/test";

export interface ClickAndAwaitNavigationOptions {
  /** Milliseconds to wait for any navigation signal. Default 5000. */
  timeoutMs?: number;
  /**
   * Wait until the URL no longer matches this pattern. Use when the click
   * is leaving a known page (e.g. `/\\\\/edit\\\\//`) and the destination
   * is dynamic.
   */
  awayFromUrl?: RegExp;
  /**
   * Wait until the URL matches this pattern. Use when the destination is
   * known (e.g. `/\\\\/list$/`).
   */
  toUrl?: RegExp;
  /**
   * Auto-accept dialogs (`beforeunload`, `confirm`, `prompt`) fired by the
   * click. Default true. The handler is `page.on(\'dialog\', accept)` for
   * the duration of the click, with a `finally` cleanup so it never
   * outlives this call.
   */
  acceptDialogs?: boolean;
}

/**
 * Click a locator and wait for ANY of: URL change away-from / to-target,
 * networkidle. Resolves when the first signal settles successfully;
 * never throws on an individual signal timeout (caller asserts the
 * resulting state).
 *
 * Handles the four common pitfalls of click-then-navigate flows:
 *   1. Strict-mode multi-match — caller must pass a `.first()` /
 *      `.locator("visible=true").first()` locator. This helper does not
 *      widen further.
 *   2. Playwright\'s `.click()` auto-wait for `load` after clicking an
 *      `<a>` — bypassed with `noWaitAfter: true` because slow servers
 *      can hang the step beyond the click timeout.
 *   3. `beforeunload` / `confirm()` prompts blocking navigation — a
 *      persistent dialog handler accepts them for the duration of the
 *      click, then unregisters in `finally`.
 *   4. `Promise.race` rejecting on first rejection — each navigation
 *      signal is wrapped in `.catch()` so the race resolves on first
 *      success regardless of which others time out.
 */
export async function clickAndAwaitNavigation(
  page: Page,
  locator: Locator,
  options: ClickAndAwaitNavigationOptions = {},
): Promise<void> {
  const timeoutMs = options.timeoutMs ?? 5_000;
  const acceptDialogs = options.acceptDialogs ?? true;

  let dialogHandler: ((d: Dialog) => void) | null = null;
  if (acceptDialogs) {
    dialogHandler = (d: Dialog) => {
      d.accept().catch(() => {});
    };
    page.on("dialog", dialogHandler);
  }

  try {
    const signals: Promise<string>[] = [];
    if (options.awayFromUrl) {
      const re = options.awayFromUrl;
      signals.push(
        page
          .waitForURL((u) => !re.test(String(u)), { timeout: timeoutMs })
          .then(() => "away-from-url"),
      );
    }
    if (options.toUrl) {
      const re = options.toUrl;
      signals.push(
        page
          .waitForURL((u) => re.test(String(u)), { timeout: timeoutMs })
          .then(() => "to-url"),
      );
    }
    signals.push(
      page
        .waitForLoadState("networkidle", { timeout: timeoutMs })
        .then(() => "networkidle"),
    );
    const settle = signals.map((p) => p.catch(() => "timeout"));

    const [winner] = await Promise.all([
      Promise.race(settle),
      locator.click({ noWaitAfter: true }),
    ]);
    // If the race resolved on the "timeout" path, EVERY signal we set
    // up timed out — the click fired but nothing observable happened.
    // Throwing here turns a silent post-click hang into an explicit
    // failure with diagnostic info, so the caller doesn't get a vague
    // "URL still wrong" error from a downstream assertion.
    if (winner === "timeout") {
      throw new Error(
        `clickAndAwaitNavigation: no navigation signal fired within ${timeoutMs}ms ` +
          `(URL: ${page.url()}). Click may have been swallowed by JS preventDefault, ` +
          `an unhandled beforeunload prompt, or a slow server. Increase timeoutMs ` +
          `or assert separately if your flow legitimately stays on the same URL.`,
      );
    }
  } finally {
    if (dialogHandler) page.off("dialog", dialogHandler);
  }
}


export interface ClickAndAwaitNewTabOptions {
  /** Milliseconds to wait for the popup event. Default 10_000. */
  timeoutMs?: number;
  /** Auto-accept dialogs in the originating page. Default true. */
  acceptDialogs?: boolean;
  /**
   * Wait for the new tab to load past `domcontentloaded` before returning.
   * Default true. Set to false if you want to bind to the popup\'s URL
   * immediately (e.g. the redirect chain matters and you want each step).
   */
  waitForLoad?: boolean;
}

/**
 * Click an element that opens a new tab (target="_blank" anchor,
 * `window.open(...)` JS handler, OAuth redirect link) and return the
 * newly-opened Page.
 *
 * Why a separate helper from `clickAndAwaitNavigation`: a new-tab click
 * does NOT change the origin page\'s URL or fire networkidle on it; the
 * new Page object appears via the BrowserContext\'s `page` event. Trying
 * to use `clickAndAwaitNavigation` on a `target="_blank"` link throws
 * "no navigation signal fired" because nothing on the origin page
 * changes.
 *
 * Usage:
 *   const newTab = await clickAndAwaitNewTab(
 *     this.page.context(), this.page, openInvoiceLink,
 *   );
 *   await expect(newTab).toHaveURL(/\\/invoices\\/\\d+/);
 *   // ... assertions against newTab ...
 *   await newTab.close();
 */
export async function clickAndAwaitNewTab(
  context: BrowserContext,
  originPage: Page,
  locator: Locator,
  options: ClickAndAwaitNewTabOptions = {},
): Promise<Page> {
  const timeoutMs     = options.timeoutMs ?? 10_000;
  const acceptDialogs = options.acceptDialogs ?? true;
  const waitForLoad   = options.waitForLoad ?? true;

  let dialogHandler: ((d: Dialog) => void) | null = null;
  if (acceptDialogs) {
    dialogHandler = (d: Dialog) => {
      d.accept().catch(() => {});
    };
    originPage.on("dialog", dialogHandler);
  }

  try {
    // Listen on the BrowserContext (not the page) — popups attach to the
    // context, not the originating Page. `noWaitAfter: true` on the click
    // prevents Playwright from also waiting for in-page navigation on
    // origin (there isn\'t one).
    const [newPage] = await Promise.all([
      context.waitForEvent("page", { timeout: timeoutMs }),
      locator.click({ noWaitAfter: true }),
    ]);
    if (waitForLoad) {
      await newPage.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
    }
    return newPage;
  } finally {
    if (dialogHandler) originPage.off("dialog", dialogHandler);
  }
}
'''


def _gen_scrubber_cls():
    """Emit src/utils/locators/Scrubber.ts.

    The scrubber converts a (locator entry, healed entry, winning strategy)
    triple into a `ScrubbedHealEvent` made entirely of categorical fields.
    No raw selectors, URLs, or DOM text ever appear in the output.
    """
    allow_kw   = _heal_intel.intent_keyword_allowlist()
    allow_attr = _heal_intel.selector_attribute_allowlist()
    shapes     = _heal_intel.intent_shapes()

    # JSON-serialize so the TS file gets exact strings, with no f-string brace surprises.
    kw_json    = json.dumps(allow_kw)
    attr_json  = json.dumps(allow_attr)
    shapes_json = json.dumps(shapes)

    return f'''// ─────────────────────────────────────────────────────────────────────────
// Scrubber — convert a heal entry into a categorical event safe to upload.
//
// PRIVACY CONTRACT (read before changing):
//   • No raw selector strings ever leave this module. Only the *shape*
//     (which attribute *names* were used, depth, combinators).
//   • No DOM text, no URLs, no file paths, no usernames.
//   • The intent_keywords field is the only string-typed output and is
//     restricted to the allowlist below.
//   • Attribute names are restricted to the allowlist below; values are
//     never read.
//
// The allowlists below MUST stay in lockstep with
// stlc_agents/data/heal_intel.json so the aggregation server can reject
// any off-schema upload as defence-in-depth.
// ─────────────────────────────────────────────────────────────────────────

export const INTENT_KEYWORD_ALLOWLIST: ReadonlySet<string> = new Set({kw_json});
export const SELECTOR_ATTRIBUTE_ALLOWLIST: ReadonlySet<string> = new Set({attr_json});
const INTENT_SHAPE_KEYWORDS: Record<string, string[]> = {shapes_json};

export type IntentShape =
  | "input" | "button" | "link" | "select" | "textarea"
  | "checkbox" | "radio" | "heading" | "image" | "generic";

export type WinningStrategy =
  | "attribute" | "type-hint" | "role" | "label"
  | "text" | "structural" | "nearby" | "fallback";

export type StabilityTier = "high" | "medium" | "low";

export interface SelectorShape {{
  kind: "css" | "xpath" | "text" | "role" | "testid" | "label" | "placeholder";
  uses_id: boolean;
  uses_class: boolean;
  uses_attribute_names: string[];  // allowlisted attribute *names* only
  uses_text: boolean;
  uses_nth: boolean;
  depth: number;                    // clipped to 10
  combinators: Array<"descendant" | "child" | "sibling" | "adjacent">;
}}

export interface ScrubbedHealEvent {{
  schema: 1;
  day: string;             // "YYYY-MM-DD" — bucketed, no exact timestamp
  install_id: string;      // random UUID, never derived from user/host
  intent_shape: IntentShape;
  intent_keywords: string[];
  element: {{
    tag: string;
    role: string | null;
    type_attr: string | null;
    has_data_testid: boolean;
    has_aria_label: boolean;
    has_aria_labelledby: boolean;
    has_stable_id: boolean;
    has_name_attr: boolean;
    has_placeholder: boolean;
    visible_text_present: boolean;
  }};
  original_shape: SelectorShape;
  healed_shape: SelectorShape;
  winning_strategy: WinningStrategy;
  stability_tier: StabilityTier;
}}

/** Classify an intent string into one of the known shapes. */
export function classifyIntentShape(intent: string): IntentShape {{
  const lower = (intent || "").toLowerCase();
  for (const shape of Object.keys(INTENT_SHAPE_KEYWORDS) as IntentShape[]) {{
    for (const kw of INTENT_SHAPE_KEYWORDS[shape]) {{
      if (lower.includes(kw)) return shape;
    }}
  }}
  return "generic";
}}

/** Extract up to N closed-vocabulary keywords from an intent string. */
export function extractIntentKeywords(intent: string, max = 4): string[] {{
  const out: string[] = [];
  const seen = new Set<string>();
  for (const raw of (intent || "").toLowerCase().split(/[^a-z0-9]+/)) {{
    if (!raw) continue;
    if (INTENT_KEYWORD_ALLOWLIST.has(raw) && !seen.has(raw)) {{
      seen.add(raw);
      out.push(raw);
      if (out.length >= max) break;
    }}
  }}
  return out;
}}

/** Tokenise a selector string into a SelectorShape — values stripped. */
export function selectorShape(selector: string): SelectorShape {{
  const s = String(selector || "");
  // Bracketed attribute names (we ignore the value entirely).
  const attrNames: string[] = [];
  const attrRe = /\\[([a-zA-Z_-][a-zA-Z0-9_-]*)\\b[^\\]]*\\]/g;
  let m: RegExpExecArray | null;
  while ((m = attrRe.exec(s)) !== null) {{
    const name = m[1].toLowerCase();
    if (SELECTOR_ATTRIBUTE_ALLOWLIST.has(name)) attrNames.push(name);
  }}
  const depth = Math.min(10, s.split(/\\s+|>|\\+|~/).filter(Boolean).length);
  const combinators: SelectorShape["combinators"] = [];
  if (/\\s+/.test(s) && !/^\\s*$/.test(s)) combinators.push("descendant");
  if (/>/.test(s)) combinators.push("child");
  if (/~/.test(s)) combinators.push("sibling");
  if (/\\+/.test(s)) combinators.push("adjacent");

  let kind: SelectorShape["kind"] = "css";
  if (/^xpath=/i.test(s) || /^\\//.test(s)) kind = "xpath";
  else if (/^text=/i.test(s)) kind = "text";
  else if (/^role=/i.test(s)) kind = "role";
  else if (/\\[data-testid/i.test(s) || /^data-testid=/i.test(s)) kind = "testid";

  return {{
    kind,
    uses_id:    /(^|\\s)#[\\w-]/.test(s),
    uses_class: /(^|\\s)\\.[\\w-]/.test(s),
    uses_attribute_names: attrNames,
    uses_text:  /:has-text\\(|^text=/i.test(s),
    uses_nth:   /:nth-/i.test(s) || /:first-|:last-/.test(s),
    depth,
    combinators,
  }};
}}

/** Sanitise an attribute *name* — anything outside the allowlist is dropped. */
export function safeAttrNames(names: string[]): string[] {{
  const out: string[] = [];
  for (const n of names) {{
    const nn = (n || "").toLowerCase();
    if (SELECTOR_ATTRIBUTE_ALLOWLIST.has(nn) && !out.includes(nn)) out.push(nn);
  }}
  return out;
}}

/** Heuristic: an `id` is "stable" only if it has no digit-suffix and no hash-like body. */
export function isStableId(id: string): boolean {{
  if (!id) return false;
  if (!/^[a-zA-Z][a-zA-Z0-9_-]{{2,}}$/.test(id)) return false;
  if (/\\d{{2,}}$/.test(id)) return false;            // foo-123
  if (/^[a-f0-9]{{8,}}$/i.test(id)) return false;     // bare hash
  return true;
}}

export interface ScrubInput {{
  intent: string;
  originalSelector: string;
  healedSelector: string;
  winningStrategy: WinningStrategy;
  stabilityTier: StabilityTier;
  element?: {{
    tag?: string;
    role?: string | null;
    typeAttr?: string | null;
    id?: string | null;
    hasDataTestId?: boolean;
    hasAriaLabel?: boolean;
    hasAriaLabelledBy?: boolean;
    hasName?: boolean;
    hasPlaceholder?: boolean;
    visibleTextPresent?: boolean;
  }};
  installId: string;
  day?: string;
}}

/** Build a ScrubbedHealEvent from a heal entry. Pure function — no I/O. */
export function buildScrubbedEvent(input: ScrubInput): ScrubbedHealEvent {{
  const day = input.day ?? new Date().toISOString().slice(0, 10);
  const el = input.element ?? {{}};
  return {{
    schema: 1,
    day,
    install_id: input.installId,
    intent_shape: classifyIntentShape(input.intent),
    intent_keywords: extractIntentKeywords(input.intent),
    element: {{
      tag: (el.tag || "").toLowerCase(),
      role: el.role ?? null,
      type_attr: el.typeAttr ?? null,
      has_data_testid: Boolean(el.hasDataTestId),
      has_aria_label: Boolean(el.hasAriaLabel),
      has_aria_labelledby: Boolean(el.hasAriaLabelledBy),
      has_stable_id: isStableId(el.id ?? ""),
      has_name_attr: Boolean(el.hasName),
      has_placeholder: Boolean(el.hasPlaceholder),
      visible_text_present: Boolean(el.visibleTextPresent),
    }},
    original_shape: selectorShape(input.originalSelector),
    healed_shape:   selectorShape(input.healedSelector),
    winning_strategy: input.winningStrategy,
    stability_tier:   input.stabilityTier,
  }};
}}
'''


def _gen_telemetry_client_cls(repository_path):
    """Emit src/utils/locators/TelemetryClient.ts.

    Opt-in batched POST of ScrubbedHealEvent objects. Honors STLC_TELEMETRY=on
    AND a one-time consent flag stored in the LocatorRepository envelope.
    Includes a `preview` CLI mode (npm run healix:telemetry-preview) that
    prints the exact payload that *would* be uploaded — never sends it.
    """
    endpoint = _heal_intel.telemetry_endpoint_default()
    return f'''// ─────────────────────────────────────────────────────────────────────────
// TelemetryClient — opt-in upload of ScrubbedHealEvent objects.
//
// Two gates BOTH must pass before anything is sent:
//   1. STLC_TELEMETRY=on  (environment variable; default off)
//   2. consent: "accepted" recorded in the LocatorRepository envelope
//      (set by the HealingDashboard telemetry-consent banner).
//
// Either gate alone is insufficient. The dashboard banner explicitly shows
// a sample payload (see /api/telemetry-preview) so users see exactly what
// they're opting into.
// ─────────────────────────────────────────────────────────────────────────
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as crypto from "crypto";
import {{ ScrubbedHealEvent, buildScrubbedEvent, ScrubInput }} from "./Scrubber";

const TELEMETRY_ENDPOINT = process.env.STLC_TELEMETRY_ENDPOINT
  || "{endpoint}";
const REPO_PATH = process.env.HEAL_STORE_PATH || "{repository_path}";
const INSTALL_ID_PATH = path.join(os.homedir(), ".stlc-agents", "install-id");
const PREVIEW_BUFFER_PATH = path.join(path.dirname(REPO_PATH), "telemetry-preview.json");

/** Random per-install UUID. Created on first call, NEVER derived from
 *  hostname/username/path — it's an opaque tag for crowd-aggregation only. */
export function installId(): string {{
  try {{
    if (fs.existsSync(INSTALL_ID_PATH)) {{
      const id = fs.readFileSync(INSTALL_ID_PATH, "utf8").trim();
      if (id) return id;
    }}
  }} catch {{ /* ignore */ }}
  const id = crypto.randomUUID();
  try {{
    fs.mkdirSync(path.dirname(INSTALL_ID_PATH), {{ recursive: true }});
    fs.writeFileSync(INSTALL_ID_PATH, id, "utf8");
  }} catch {{ /* ignore */ }}
  return id;
}}

/** Read the consent flag from the LocatorRepository envelope. */
export function consentStatus(): "accepted" | "declined" | "unset" {{
  try {{
    if (!fs.existsSync(REPO_PATH)) return "unset";
    const raw = JSON.parse(fs.readFileSync(REPO_PATH, "utf8"));
    const c = raw?._telemetryConsent;
    if (c === "accepted" || c === "declined") return c;
    return "unset";
  }} catch {{ return "unset"; }}
}}

/** Persist the user's choice into the LocatorRepository envelope. */
export function recordConsent(choice: "accepted" | "declined"): void {{
  let raw: any = {{ _version: 1, entries: {{}} }};
  try {{
    if (fs.existsSync(REPO_PATH)) raw = JSON.parse(fs.readFileSync(REPO_PATH, "utf8")) || raw;
  }} catch {{ /* ignore */ }}
  raw._telemetryConsent  = choice;
  raw._telemetryDecidedAt = new Date().toISOString();
  fs.mkdirSync(path.dirname(REPO_PATH), {{ recursive: true }});
  fs.writeFileSync(REPO_PATH, JSON.stringify(raw, null, 2), "utf8");
}}

/** Build a scrubbed event for a single (entry, healed) pair from a LocatorRepository. */
export function eventFromEntry(key: string, entry: any): ScrubbedHealEvent | null {{
  if (!entry || !entry.healedSelector) return null;
  const lastHistory = entry.healingHistory?.[entry.healingHistory.length - 1];
  const winning = (lastHistory?.strategy || "fallback").toString().split("-")[0] as ScrubInput["winningStrategy"];
  const stability = entry.stability >= 70 ? "high" : entry.stability >= 40 ? "medium" : "low";
  return buildScrubbedEvent({{
    intent: entry.intent || "",
    originalSelector: entry.selector || "",
    healedSelector: entry.healedSelector,
    winningStrategy: ([
      "attribute", "type-hint", "role", "label", "text",
      "structural", "nearby", "fallback",
    ] as const).includes(winning as any) ? winning : "fallback",
    stabilityTier: stability,
    element: {{
      tag: "",
      role: null,
      typeAttr: null,
      id: null,
      hasDataTestId:    /\\[data-testid/i.test(entry.healedSelector),
      hasAriaLabel:     /\\[aria-label/i.test(entry.healedSelector),
      hasAriaLabelledBy:/\\[aria-labelledby/i.test(entry.healedSelector),
      hasName:          /\\[name=/i.test(entry.healedSelector),
      hasPlaceholder:   /\\[placeholder/i.test(entry.healedSelector),
      visibleTextPresent: /:has-text\\(/i.test(entry.healedSelector),
    }},
    installId: installId(),
  }});
}}

/** Build a preview of every confirmed heal in the repo as scrubbed events. */
export function buildPreview(): ScrubbedHealEvent[] {{
  try {{
    if (!fs.existsSync(REPO_PATH)) return [];
    const raw = JSON.parse(fs.readFileSync(REPO_PATH, "utf8"));
    const entries: Record<string, any> = (raw && raw.entries) ? raw.entries : raw || {{}};
    const out: ScrubbedHealEvent[] = [];
    for (const [k, v] of Object.entries(entries)) {{
      if (k.startsWith("_")) continue;
      const evt = eventFromEntry(k, v);
      if (evt) out.push(evt);
    }}
    return out;
  }} catch {{ return []; }}
}}

/** Upload a batch — only fires when BOTH gates pass. Returns the count actually sent. */
export async function uploadBatch(events: ScrubbedHealEvent[]): Promise<number> {{
  if ((process.env.STLC_TELEMETRY ?? "").toLowerCase() !== "on") return 0;
  if (consentStatus() !== "accepted") return 0;
  if (!events.length) return 0;
  try {{
    const resp = await fetch(TELEMETRY_ENDPOINT, {{
      method: "POST",
      headers: {{ "Content-Type": "application/json" }},
      body: JSON.stringify({{ schema: 1, events }}),
    }});
    return resp.ok ? events.length : 0;
  }} catch {{ return 0; }}
}}

// ── CLI: `ts-node TelemetryClient.ts preview` ────────────────────────────────
// Prints the exact JSON that would be uploaded — without uploading anything.
// Wired up as `npm run healix:telemetry-preview` by the migration scaffolder.
const isCli = typeof require !== "undefined" && require.main === module;
if (isCli) {{
  const events = buildPreview();
  try {{
    fs.mkdirSync(path.dirname(PREVIEW_BUFFER_PATH), {{ recursive: true }});
    fs.writeFileSync(PREVIEW_BUFFER_PATH, JSON.stringify(events, null, 2), "utf8");
  }} catch {{ /* ignore */ }}
  // eslint-disable-next-line no-console
  console.log(JSON.stringify({{
    endpoint: TELEMETRY_ENDPOINT,
    install_id: installId(),
    consent: consentStatus(),
    telemetry_env: (process.env.STLC_TELEMETRY ?? "off").toLowerCase(),
    would_send_count: events.length,
    events,
  }}, null, 2));
}}
'''


# ============================================================================
# scaffold_locator_repository
# ============================================================================

def _scaffold_locator_repository(
    output_dir, enable_ai_vision, repository_path,
    dashboard_port, enable_timing_healing, enable_visual_regression,
    ai_healing_provider="anthropic",
):
    files = {
        f"{output_dir}/LocatorHealer.ts":            _gen_locator_healer_cls(enable_ai_vision),
        f"{output_dir}/LocatorRepository.ts":        _gen_locator_repo_cls(repository_path),
        f"{output_dir}/clickAndAwaitNavigation.ts":  _gen_click_navigate_helper(),
        f"{output_dir}/Scrubber.ts":                 _gen_scrubber_cls(),
        f"{output_dir}/TelemetryClient.ts":          _gen_telemetry_client_cls(repository_path),
    }
    if enable_timing_healing:
        files[f"{output_dir}/TimingHealer.ts"] = _gen_timing_healer_cls(dashboard_port)
    if enable_visual_regression:
        files[f"{output_dir}/VisualIntentChecker.ts"] = _gen_visual_checker_cls(dashboard_port)
    files[f"{output_dir}/HealingDashboard.ts"] = _gen_healing_dashboard_cls(repository_path, dashboard_port)
    files[f"{output_dir}/dashboard-server.ts"] = _gen_dashboard_server_cls()

    strategies = [
        "1. Healed selector from LocatorRepository (zero overhead on repeat runs)",
        "2. Primary CSS/data-testid selector",
        "3. Role-based: page.getByRole() with intent keywords",
        "4. Label-based: page.getByLabel() from intent string",
        "5. Text-based: page.getByText() partial match",
    ]
    if enable_ai_vision:
        strategies.append(
            "6. ariaSnapshot → AI Vision (provider: anthropic | copilot | claude-code) "
            "→ healed selector persisted  [skip with ENABLE_AI_HEALING=false]"
        )
    if enable_timing_healing:
        strategies.append(f"+ TimingHealer: network trace drift detection → auto-adjusted timeouts → HealingDashboard :{dashboard_port}")
    if enable_visual_regression:
        strategies.append(f"+ VisualIntentChecker: element screenshot diff → baseline comparison → HealingDashboard :{dashboard_port}")
    strategies.append(f"+ HealingDashboard: http://localhost:{dashboard_port} — review/approve all AI suggestions before commit")

    return {
        "files": files,
        "output_dir": output_dir,
        "repository_path": repository_path,
        "dashboard_port": dashboard_port,
        "dashboard_url": f"http://localhost:{dashboard_port}",
        "healing_strategies": strategies,
        "healing_layers": {
            "locator":   "LocatorHealer.ts — selector chain + AI Vision (anthropic|copilot|claude-code, ENABLE_AI_HEALING=false to skip)",
            "timing":    f"TimingHealer.ts — network drift auto-healing {'enabled' if enable_timing_healing else 'disabled'}",
            "visual":    f"VisualIntentChecker.ts — screenshot regression {'enabled' if enable_visual_regression else 'disabled'}",
            "dashboard": f"HealingDashboard.ts — http://localhost:{dashboard_port}",
        },
        "ai_healing_provider": ai_healing_provider if enable_ai_vision else "disabled",
        "env_vars": {
            "ENABLE_AI_HEALING":   "Set to 'false' to skip strategy 6 without disabling strategies 7 & 8.",
            "AI_HEALING_PROVIDER": "Override provider at runtime: anthropic | copilot | claude-code.",
            "AI_HEALING_API_KEY":  "Anthropic secret key — required when AI_HEALING_PROVIDER=anthropic.",
            "GITHUB_TOKEN":        "GitHub Copilot token — required when AI_HEALING_PROVIDER=copilot.",
            "ANTHROPIC_API_KEY":   "Set automatically by the claude CLI session (AI_HEALING_PROVIDER=claude-code).",
        },
    }


def _gen_locator_healer_cls(enable_ai_vision):
    ai_import  = ""

    # ── Heal-intel-driven defaults ──────────────────────────────────────────
    # `DEFAULT_HEAL_STRATEGY_ORDER` and the body of `_intentShapeFilter` used
    # to be hardcoded TS literals. They're now templated from
    # `stlc_agents/data/heal_intel.json` (which the aggregation pipeline
    # refreshes as scrubbed heal events accumulate). When the JSON is
    # missing/malformed, `heal_intel.load()` returns its built-in fallback so
    # the emitted TS is always identical to the historical scaffold.
    _generic_order = _heal_intel.strategy_order_for("generic")
    _shapes        = _heal_intel.intent_shapes()
    _shape_filters = _heal_intel.intent_shape_filters()
    _intel_meta    = _heal_intel.load()
    _intel_through = _intel_meta.get("trained_through") or "(bootstrap)"
    _intel_samples = _intel_meta.get("sample_size", 0)

    default_order_items = ",\n  ".join(json.dumps(s) for s in _generic_order)
    shape_check_lines: list[str] = []
    for _shape, _keywords in _shapes.items():
        _css = _shape_filters.get(_shape)
        if not _css or not _keywords:
            continue
        _alt = "|".join(re.escape(k) for k in _keywords)
        # `\\b` in Python source → `\b` in the emitted TS regex (word boundary).
        shape_check_lines.append(
            f"    if (/\\b({_alt})\\b/.test(lower)) return {json.dumps(_css)};"
        )
    intent_shape_filter_body = "\n".join(shape_check_lines) if shape_check_lines else "    // (no intent-shape rules in heal_intel.json)"

    # ------------------------------------------------------------------
    # AI Vision method — emitted only when enable_ai_vision=True.
    #
    # Runtime guard: ENABLE_AI_HEALING=false skips strategy 6 entirely
    # so the full healing chain remains active in air-gapped / strict-CI
    # environments.
    #
    # Provider routing via AI_HEALING_PROVIDER:
    #   "anthropic"    — direct Anthropic API (requires AI_HEALING_API_KEY)
    #   "copilot"      — GitHub Copilot chat completions endpoint
    #                    (uses GITHUB_TOKEN from the Copilot extension / CLI)
    #   "claude-code"  — Claude Code local proxy (process.env.ANTHROPIC_API_KEY
    #                    set automatically by `claude` CLI session)
    #
    # If AI_HEALING_PROVIDER is unset, "anthropic" is used and
    # AI_HEALING_API_KEY must be present.  In Copilot / Claude Code mode
    # no separate key is needed — the coding agent's session token is reused.
    # ------------------------------------------------------------------
    ai_method = """
  // ── AI Vision provider routing ─────────────────────────────────────────
  //
  // Controlled by env vars — no code change needed to switch providers:
  //
  //   ENABLE_AI_HEALING=false          → skip strategy 6; exhausts all selectors first
  //   AI_HEALING_PROVIDER=anthropic    → direct Anthropic API (default)
  //   AI_HEALING_PROVIDER=copilot      → GitHub Copilot chat completions
  //   AI_HEALING_PROVIDER=claude-code  → Claude Code local proxy
  //
  // Provider auth:
  //   anthropic   → AI_HEALING_API_KEY  (Anthropic secret key)
  //   copilot     → GITHUB_TOKEN        (set by Copilot extension / gh auth login)
  //   claude-code → ANTHROPIC_API_KEY   (set automatically by `claude` CLI session)
  //
  private _aiVisionEndpoint(): { url: string; authHeader: string; bodyMapper: (prompt: string) => object } {
    const provider = (process.env.AI_HEALING_PROVIDER ?? "anthropic").toLowerCase();
    if (provider === "copilot") {
      // GitHub Copilot chat completions — reuses the coding agent's session token.
      // Token is set by the Copilot VS Code extension or `gh auth login --scopes copilot`.
      const token = process.env.GITHUB_TOKEN ?? "";
      return {
        url: "https://api.githubcopilot.com/chat/completions",
        authHeader: `Bearer ${token}`,
        bodyMapper: (prompt: string) => ({
          model: "gpt-4o",
          messages: [{ role: "user", content: prompt }],
          max_tokens: 500,
        }),
      };
    }
    if (provider === "claude-code") {
      // Claude Code sets ANTHROPIC_API_KEY automatically in its spawned shell.
      // Uses the same Anthropic messages endpoint but authenticates via the
      // coding agent's licence — no separate key needed at the project level.
      const key = process.env.ANTHROPIC_API_KEY ?? "";
      return {
        url: "https://api.anthropic.com/v1/messages",
        authHeader: `x-api-key ${key}`,
        bodyMapper: (prompt: string) => ({
          model: "claude-sonnet-4-20250514", max_tokens: 1000,
          messages: [{ role: "user", content: prompt }],
        }),
      };
    }
    // Default: direct Anthropic API with explicit project key.
    const key = process.env.AI_HEALING_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? "";
    return {
      url: "https://api.anthropic.com/v1/messages",
      authHeader: `x-api-key ${key}`,
      bodyMapper: (prompt: string) => ({
        model: "claude-sonnet-4-20250514", max_tokens: 1000,
        messages: [{ role: "user", content: prompt }],
      }),
    };
  }

  private _extractSelector(provider: string, data: any): string {
    let raw: string;
    if (provider === "copilot") {
      // GitHub Copilot returns OpenAI-compatible response shape.
      raw = (data.choices?.[0]?.message?.content ?? "");
    } else {
      // Anthropic (direct or Claude Code proxy) returns content blocks.
      raw = (data.content?.[0]?.text ?? "");
    }
    return LocatorHealer._sanitiseAiSelector(raw);
  }

  /**
   * LLMs frequently dress a CSS selector in markdown or jQuery syntax:
   *   `a[href="/x"]`               → single backticks
   *   ```css\\na[href="/x"]\\n```    → fenced code block
   *   Selector: a[href="/x"]       → prose prefix
   *   button:contains("Save")      → jQuery, not valid CSS
   * Playwright's locator parser rejects all of these. Strip wrappers and
   * translate jQuery-isms so the model output that "knew" the answer can
   * actually be fed to the page.
   */
  static _sanitiseAiSelector(raw: string): string {
    let s = (raw ?? "").trim();
    if (!s) return s;
    // Triple-backtick code fence (with optional language tag).
    const fence = s.match(/^```(?:[a-zA-Z]+)?\\s*\\n([\\s\\S]*?)\\n```$/);
    if (fence) s = fence[1].trim();
    // Single-backtick wrap.
    if (s.startsWith("`") && s.endsWith("`")) s = s.slice(1, -1).trim();
    // Surrounding double or single quotes.
    if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
      const inner = s.slice(1, -1);
      if (!inner.includes(s[0])) s = inner.trim();
    }
    // Common prose prefixes: "Selector: <css>", "CSS: <css>".
    s = s.replace(/^(?:selector|css)\\s*[:=]\\s*/i, "").trim();
    // jQuery-isms: empty :contains("") is dropped; otherwise → :has-text(...)
    s = s.replace(/:contains\\(\\s*(["'])\\s*\\1\\s*\\)/g, "");
    s = s.replace(/:contains\\(/g, ":has-text(");
    return s;
  }

  private async healViaAiVision(key: string, intent: string): Promise<Locator> {
    // ── Runtime guard — skip strategy 6 without disabling the full chain ──
    if (process.env.ENABLE_AI_HEALING === "false") {
      this.logger.info("⚕ AI Vision skipped (ENABLE_AI_HEALING=false)");
      // Still dump a snapshot so the heuristic-only run is debuggable.
      await this._dumpExhaustionSnapshot(key, intent);
      throw new Error(`LocatorHealer: all heuristic strategies exhausted; AI Vision disabled via ENABLE_AI_HEALING=false (key="${key}" intent="${intent}")`);
    }

    const provider = (process.env.AI_HEALING_PROVIDER ?? "anthropic").toLowerCase();
    this.logger.warn(`⚕ AI Vision [${provider}]: key="${key}" intent="${intent}"`);
    try {
      const snapshot = await this.scope.locator("body").ariaSnapshot();
      const { url, authHeader, bodyMapper } = this._aiVisionEndpoint();
      // Selector-generalising prompt: every rule below has been observed
      // mis-handled by at least one model in production. Cached selectors
      // must work across runs / users / records — UID-specific hrefs are
      // useless on the next test that creates a different record.
      const prompt =
        `You are helping a Playwright test bind to an element on a real web page.\\n` +
        `The selector you return will be cached and reused across many test runs,\\n` +
        `so it must work for ANY instance of the intent — not just the one in this snapshot.\\n` +
        `\\n` +
        `DOM aria-snapshot (truncated):\\n${snapshot.slice(0, 3500)}\\n` +
        `\\n` +
        `Find the element matching this intent: "${intent}"\\n` +
        `\\n` +
        `Rules:\\n` +
        `- Return ONE Playwright-compatible CSS selector. Nothing else. No prose, no markdown, no backticks.\\n` +
        `- For text matching use ':has-text("...")'. NEVER use jQuery's ':contains(...)' — it is invalid CSS.\\n` +
        `- Prefer stable attributes in this order: data-testid > id > aria-label > role+name > href shape > class.\\n` +
        `- GENERALISE dynamic attribute values so the selector survives across runs:\\n` +
        `    * numeric IDs in URLs: use 'a[href*="/edit/"]' NOT 'a[href="/edit/189332"]'\\n` +
        `    * generated ids ('mui-1234'): use '[id^="mui-"]' NOT '[id="mui-1234"]'\\n` +
        `    * hash / token suffixes: prefer '^=' / '*=' over '='\\n` +
        `    * user-supplied text that varies: scope by structure (parent/ancestor), not by the text itself\\n` +
        `- If multiple elements would match, scope with a unique ANCESTOR so it picks ONE.\\n` +
        `- Prefer selectors that would match the same logical element across different users / records / states.\\n` +
        `- The selector must target the element a USER would click / interact with for that intent.`;

      const headers: Record<string, string> = { "Content-Type": "application/json" };
      // authHeader format is "<scheme> <token>" for Bearer, or "<header-name> <value>" for x-api-key
      if (authHeader.startsWith("Bearer ")) {
        headers["Authorization"] = authHeader;
      } else if (authHeader.startsWith("x-api-key ")) {
        headers["x-api-key"] = authHeader.slice("x-api-key ".length);
        headers["anthropic-version"] = "2023-06-01";
      }
      // Copilot Chat's gateway requires IDE-identification headers on every
      // request (not just the token-exchange). Without them the endpoint
      // returns HTTP 400 "missing Editor-Version header for IDE auth".
      if (provider === "copilot") {
        headers["Editor-Version"] = "vscode/1.95.0";
        headers["Editor-Plugin-Version"] = "copilot-chat/0.20.0";
        headers["Copilot-Integration-Id"] = "vscode-chat";
        headers["User-Agent"] = "GitHubCopilotChat/0.20.0";
        headers["OpenAI-Intent"] = "conversation-panel";
      }

      const resp = await fetch(url, {
        method: "POST",
        headers,
        body: JSON.stringify(bodyMapper(prompt)),
      });
      if (!resp.ok) {
        const body = await resp.text();
        throw new Error(`AI Vision HTTP ${resp.status}: ${body.slice(0, 200)}`);
      }
      const data = await resp.json();
      const sel = this._extractSelector(provider, data);
      if (sel) {
        // AI selectors are often "correct but ambiguous" — e.g. the same
        // anchor rendered twice for mobile + desktop responsive layouts.
        // Prefer the first visible match so strict-mode violations don't
        // throw away a valid heal.
        const loc = this.scope.locator(sel).locator("visible=true").first();
        await loc.waitFor({ state: "visible", timeout: 5_000 });
        this.repo.updateHealed(key, sel, intent, `ai-vision-${provider}`);
        this.logger.info(`⚕ AI Vision healed [${provider}]: ${key} → ${sel}`);
        return loc;
      }
    } catch (err) { this.logger.error(`⚕ AI Vision failed [${provider}]: ${err}`); }
    // Chain truly exhausted — dump an aria-snapshot of the page so the
    // next debugging session doesn't need a fresh test run to see what
    // was on screen when binding failed.
    await this._dumpExhaustionSnapshot(key, intent);
    throw new Error(`LocatorHealer: all strategies exhausted for key="${key}" intent="${intent}"`);
  }
""" if enable_ai_vision else """
  private async healViaAiVision(key: string, intent: string): Promise<Locator> {
    // AI Vision disabled at scaffold time — heuristics are the only path.
    // Still dump an aria-snapshot so post-mortem diagnosis doesn't need a
    // fresh test run when the chain exhausts.
    await this._dumpExhaustionSnapshot(key, intent);
    throw new Error(`LocatorHealer: all strategies exhausted for key="${key}" intent="${intent}"`);
  }
"""
    return f'''import {{ Page, Locator, FrameLocator, expect }} from "@playwright/test";
import {{ Logger }} from "winston";
import * as fs from "fs";
import * as path from "path";
{ai_import}
import {{ LocatorRepository }} from "./LocatorRepository";

/**
 * Locator scope — either a top-level Page or a FrameLocator (for iframe
 * content). Both expose `.locator()` and `.getByRole/Label/Text(...)` so
 * the heal chain works against either with no code changes. URL / wait /
 * snapshot operations route through the underlying Page captured
 * separately when the healer is frame-scoped.
 */
export type LocatorScope = Page | FrameLocator;

/**
 * LocatorHealer — Multi-Strategy Locator Healing
 *
 * Chain: cached-healed → primary → attribute → type-hint → role → label → text → AI Vision
 *
 * Form fields (username, password, email, …) heal via the attribute pass —
 * the most reliable strategy for inputs that have no accessible name. The
 * AI Vision step is optional and only fires when every CSS / Playwright
 * heuristic has been exhausted; it requires an Anthropic API key.
 *
 * All healed selectors are persisted to LocatorRepository so repeat runs
 * skip the entire chain after the first successful heal.
 */
/**
 * Identifiers for the fallback strategies the resolver runs after the
 * primary selector fails. Used by HEAL_STRATEGY_ORDER (CSV env var) to
 * reorder or disable specific strategies without forking the scaffold.
 *
 * Order in this list is the historical default chain.
 */
// Templated from stlc_agents/data/heal_intel.json (trained_through={_intel_through},
// sample_size={_intel_samples}). Refreshed by the crowd-aggregation pipeline.
const DEFAULT_HEAL_STRATEGY_ORDER = [
  {default_order_items},
] as const;
type HealStrategyName = typeof DEFAULT_HEAL_STRATEGY_ORDER[number];

function _resolveStrategyOrder(): HealStrategyName[] {{
  const raw = (process.env.HEAL_STRATEGY_ORDER ?? "").trim();
  if (!raw) return [...DEFAULT_HEAL_STRATEGY_ORDER];
  const known = new Set<string>(DEFAULT_HEAL_STRATEGY_ORDER);
  const picked: HealStrategyName[] = [];
  for (const part of raw.split(/[,\\s]+/).filter(Boolean)) {{
    const norm = part.toLowerCase();
    if (known.has(norm)) picked.push(norm as HealStrategyName);
  }}
  // Empty / all-invalid CSV → fall back to defaults to keep the chain useful.
  return picked.length ? picked : [...DEFAULT_HEAL_STRATEGY_ORDER];
}}

export class LocatorHealer {{
  // Original scaffold defaults — preserved verbatim. LOCATOR_TIMEOUT, when
  // set, overrides the per-strategy attach probe (ATTACH_TO); TIMEOUT and
  // HEAL_TO retain their historical values so action timeouts don't shift
  // for existing tests that depend on them.
  private static readonly TIMEOUT   = 8_000;
  private static readonly HEAL_TO   = 15_000;
  private static readonly ATTACH_TO = Number(process.env.LOCATOR_TIMEOUT ?? 3_000);
  // Cap on the number of healing strategies the resolver will try BEFORE
  // falling through to AI Vision. 0 = unlimited (whole chain runs, original
  // behavior). Set LOCATOR_HEAL_ATTEMPTS=N to bound it.
  private static readonly MAX_ATTEMPTS = Number(process.env.LOCATOR_HEAL_ATTEMPTS ?? 0);
  // Ordered list of fallback strategies — read once from HEAL_STRATEGY_ORDER.
  private static readonly STRATEGY_ORDER = _resolveStrategyOrder();

  // Optional scenario label populated by hooks/pageFixture wiring.
  // Used in dashboard events when present.
  currentScenario?: string;

  /**
   * Derive a CSS `:is(...)` shape filter from the locator intent so the
   * attribute strategy can't substring-match an unrelated element type.
   *
   * Examples:
   *   "password input"   → `:is(input, textarea, select, [contenteditable])`
   *   "login button"     → `:is(button, [role="button"], input[type="submit"], input[type="button"])`
   *   "forgot link"      → `:is(a, [role="link"])`
   *   "username field"   → `:is(input, textarea, select, [contenteditable])`
   *   "ministry id modal" (no shape word) → "" (no filter)
   *
   * Returns an empty string when the intent doesn't imply a specific shape,
   * preserving the original behaviour for ambiguous intents.
   */
  static _intentShapeFilter(intent: string): string {{
    const lower = (intent || "").toLowerCase();
    if (/\bpassword\b/.test(lower)) return ':is(input[type="password"], input[name*="password" i], input[id*="password" i], input[autocomplete*="password" i])';
    if (/\bemail\b/.test(lower)) return ':is(input[type="email"], input[name*="email" i], input[id*="email" i], input[autocomplete="email"])';
    // Auto-generated from heal_intel.intent_shapes — order is significant:
    // input-like shapes are checked first because keywords like "field" or
    // "textbox" can co-occur with other shape words.
{intent_shape_filter_body}
    return '';
  }}

  /**
   * @param page    The owning Playwright Page — used for URL / snapshot /
   *                wait-for-load operations.
   * @param logger  Winston logger; chain emits info/warn/error diagnostic
   *                lines through it.
   * @param repo    LocatorRepository for healed-selector persistence.
   * @param scope   Optional FrameLocator. When supplied, the heal chain
   *                scopes all locator queries to the iframe instead of
   *                the top-level page. URL / snapshot operations still
   *                use `page`. Construct via `LocatorHealer.forFrame(...)`
   *                rather than passing scope directly.
   */
  constructor(
    private readonly page:   Page,
    private readonly logger: Logger,
    private readonly repo:   LocatorRepository,
    private readonly scope:  LocatorScope = page,
  ) {{}}

  /**
   * Construct a healer scoped to an iframe. Pass the same selector you'd
   * pass to `page.frameLocator(...)` — typically the iframe's `name`,
   * `id`, or CSS selector. All `locator()` / `getByRole()` / etc. calls
   * route into the frame; URL waits still operate on the top-level page.
   *
   * Usage:
   *   const frameHealer = LocatorHealer.forFrame(this.page, "#payment-frame",
   *                                              this.logger, this.repo);
   *   await frameHealer.fillWithHealing("paymentPage.cardNumber",
   *                                     "#cc-number", "4111...", "card number");
   */
  static forFrame(
    page: Page,
    frameSelector: string,
    logger: Logger,
    repo: LocatorRepository,
  ): LocatorHealer {{
    return new LocatorHealer(page, logger, repo, page.frameLocator(frameSelector));
  }}

  async clickWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.click({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
    this.logger.info(`✓ click: ${{key}}`);
  }}

  async fillWithHealing(key: string, selector: string, value: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.fill(value, {{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertVisibleWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.waitFor({{ state: "visible", timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
    this.logger.info(`✓ visible: ${{key}}`);
  }}

  async assertHiddenWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.waitFor({{ state: "hidden", timeout: LocatorHealer.HEAL_TO }});
    this.logger.info(`✓ hidden: ${{key}}`);
  }}

  async assertContainsTextWithHealing(key: string, selector: string, expectedText: string | RegExp, intent: string): Promise<void> {{
    // Compatibility alias for migrated call sites.
    await this.assertTextWithHealing(key, selector, expectedText, intent);
  }}

  async assertAttachedWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toBeAttached({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertAttributeWithHealing(
    key: string,
    selector: string,
    name: string,
    value: string | RegExp,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toHaveAttribute(name, value, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertCSSWithHealing(
    key: string,
    selector: string,
    name: string,
    value: string | RegExp,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toHaveCSS(name, value, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertClassWithHealing(
    key: string,
    selector: string,
    value: string | RegExp | (string | RegExp)[],
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toHaveClass(value as any, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertCountWithHealing(
    key: string,
    selector: string,
    count: number,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toHaveCount(count, {{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertEditableWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toBeEditable({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertFocusedWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toBeFocused({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertIdWithHealing(
    key: string,
    selector: string,
    value: string | RegExp,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toHaveId(value, {{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertInViewportWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).toBeInViewport({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotAttachedWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toBeAttached({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotAttributeWithHealing(
    key: string,
    selector: string,
    name: string,
    value: string | RegExp,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toHaveAttribute(name, value, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotCSSWithHealing(
    key: string,
    selector: string,
    name: string,
    value: string | RegExp,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toHaveCSS(name, value, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotCheckedWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toBeChecked({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotClassWithHealing(
    key: string,
    selector: string,
    value: string | RegExp | (string | RegExp)[],
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toHaveClass(value as any, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotContainsTextWithHealing(
    key: string,
    selector: string,
    value: string | RegExp | (string | RegExp)[],
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toContainText(value as any, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotCountWithHealing(
    key: string,
    selector: string,
    count: number,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toHaveCount(count, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotEditableWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toBeEditable({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotFocusedWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toBeFocused({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotIdWithHealing(
    key: string,
    selector: string,
    value: string | RegExp,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toHaveId(value, {{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotInViewportWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toBeInViewport({{ timeout: LocatorHealer.HEAL_TO }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotTextWithHealing(
    key: string,
    selector: string,
    value: string | RegExp | (string | RegExp)[],
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toHaveText(value as any, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertNotValueWithHealing(
    key: string,
    selector: string,
    value: string | RegExp,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await expect(loc).not.toHaveValue(value, {{
      timeout: LocatorHealer.HEAL_TO,
    }});
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async blurWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.blur({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async dblclickWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.dblclick({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async focusWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.focus({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async pressWithHealing(
    key: string,
    selector: string,
    keyToPress: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.press(keyToPress, {{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async setInputFilesWithHealing(
    key: string,
    selector: string,
    files:
      | string
      | string[]
      | {{ name: string; mimeType: string; buffer: Buffer }},
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.setInputFiles(files as any, {{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async tapWithHealing(
    key: string,
    selector: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.tap({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async typeWithHealing(
    key: string,
    selector: string,
    text: string,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    // `Locator.type` is deprecated by Playwright in favour of pressSequentially;
    // we call pressSequentially so consumers get the supported behaviour even
    // though the migrated source called `.type(...)`.
    await loc.pressSequentially(text, {{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    await this._maybeUpgradeToTestId(key, selector, intent, loc);
  }}

  async assertDisabledWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.waitFor({{ state: "visible", timeout: LocatorHealer.HEAL_TO }});
    if (await loc.isEnabled({{ timeout: LocatorHealer.TIMEOUT }})) {{
      throw new Error(`assertDisabledWithHealing: locator for "${{key}}" was visible but enabled`);
    }}
    this.logger.info(`✓ disabled: ${{key}}`);
  }}

  // ── Role-aware healing ──────────────────────────────────────────────────
  // Migrated getByRole(role, {{ name }}) call sites route here so the author's
  // explicit role + accessible name drive the PRIMARY locator (Playwright has
  // no `role=` selector engine, so a flattened string would never resolve).
  // On failure we fall back to the standard heal chain via resolve(key, '',
  // intent).
  private async resolveByRole(key: string, role: string, name: string, intent: string): Promise<Locator> {{
    try {{
      const primary = name
        ? this.scope.getByRole(role as any, {{ name }})
        : this.scope.getByRole(role as any);
      const l = primary.locator("visible=true").first();
      await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
      return l;
    }} catch (err) {{
      this.logger.warn(`⚕ Primary role failed: ${{key}} — getByRole(${{role}}${{name ? `, {{ name: "${{name}}" }}` : ""}}) — ${{String(err).split("\\n")[0]}}`);
      this.repo.incrementFailure(key);
    }}
    return this.resolve(key, "", intent);
  }}

  async clickByRoleWithHealing(key: string, role: string, name: string, intent: string): Promise<void> {{
    const loc = await this.resolveByRole(key, role, name, intent);
    await loc.click({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    this.logger.info(`✓ click(role): ${{key}}`);
  }}

  async fillByRoleWithHealing(key: string, role: string, name: string, value: string, intent: string): Promise<void> {{
    const loc = await this.resolveByRole(key, role, name, intent);
    await loc.fill(value, {{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
  }}

  /**
   * Opportunistic data-testid upgrade — when the resolved element has a
   * `data-testid` attribute AND the current selector doesn't already
   * use one, record `[data-testid="<value>"]` in the heal store as the
   * preferred form for this key. Gated by `ENABLE_TESTID_UPGRADE=true`
   * so it stays opt-in.
   *
   * Net effect: after one full run-through against an env that ships
   * data-testid attributes (e.g. qa8 here), every locator whose target
   * element has one gets a stable, build-resilient cached selector for
   * all subsequent runs. Tests gradually self-migrate from id/class/
   * text selectors toward data-testid wherever the application provides
   * them — without touching the page-object source.
   *
   * Best-effort: any I/O / DOM-read failure is swallowed so the calling
   * action's result is never affected.
   */
  private async _maybeUpgradeToTestId(
    key: string,
    currentSelector: string,
    intent: string,
    loc: Locator,
  ): Promise<void> {{
    if (process.env.ENABLE_TESTID_UPGRADE !== "true") return;
    if (/\\bdata-testid\\b/.test(currentSelector)) return;
    try {{
      const tid = await loc.getAttribute("data-testid", {{ timeout: 1_000 }});
      if (!tid) return;
      const upgraded = `[data-testid="${{tid.replace(/"/g, '\\\\"')}}"]`;
      const cached = this.repo.getHealed(key);
      if (cached === upgraded) return;
      this.repo.updateHealed(key, upgraded, intent, "testid-upgrade", currentSelector);
      this.logger.info(`⬆ data-testid upgrade: ${{key}} → ${{upgraded}}`);
    }} catch {{
      // Silent — upgrade never blocks the calling action.
    }}
  }}

  async getTextWithHealing(key: string, selector: string, intent: string): Promise<string> {{
    return (await (await this.resolve(key, selector, intent)).textContent()) ?? "";
  }}

  // Heal the base SELECTOR lookup but return the UN-narrowed locator, so the
  // caller's own narrowing (.all(), .last(), .nth(), .filter(), chained
  // .locator(), .textContent(), expect(...)) is preserved exactly. Used for
  // migrated `const x = page.getByTestId(sel)` / `page.locator(sel)` handles
  // that are multi-element or chained — the single-element *WithHealing action
  // methods can't represent those. Unlike resolve(), this never applies
  // `.first()`. The healed selector is recorded so repeat runs are stable.
  async locatorWithHealing(key: string, selector: string, intent: string): Promise<Locator> {{
    const healed = this.repo.getHealed(key);
    const candidate = healed && healed !== selector ? healed : selector;
    try {{
      const probe = this.scope.locator(candidate).first();
      await probe.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
      return this.scope.locator(candidate);   // broad, un-narrowed
    }} catch (err) {{
      this.logger.warn(`⚕ Base locator failed: ${{key}} — ${{String(err).split("\\n")[0]}}`);
    }}
    // This returns a LOCATOR HANDLE, replacing a lazy `page.locator(...)`. A
    // handle must NEVER throw on a missing element — callers routinely do
    // `if (await x.isVisible())` / `await x.count()` for OPTIONAL elements
    // (e.g. clickCollapseDrawerIfVisible). So we try the heal chain to find a
    // better selector, but if it exhausts, we fall back to returning the
    // primary locator rather than throwing — preserving `page.locator(...)`
    // semantics. The downstream `.isVisible()` then returns false gracefully,
    // and a downstream `.click()` throws the same normal Playwright timeout it
    // would have without healing.
    try {{
      await this.resolve(key, selector, intent);
      const healedSel = this.repo.getHealed(key);
      if (healedSel) return this.scope.locator(healedSel);
    }} catch (err) {{
      this.logger.warn(`⚕ Heal chain exhausted for handle ${{key}} — returning primary locator (lazy): ${{String(err).split("\\n")[0]}}`);
    }}
    return this.scope.locator(candidate);
  }}

  // Dynamic test-id lookup with healing. Migrated sites that compute a
  // data-testid at runtime (a locator base plus an index suffix, or a fully
  // interpolated id like module_card-NAME) pass the COMPUTED id string here.
  // We resolve [data-testid="<testId>"] through the un-narrowed locator healer
  // so the caller's own chain (.all(), .nth(), .textContent(), .click(),
  // expect(...)) is preserved. The heal key is dynamic (caller supplies it),
  // so different runtime ids don't collide in the heal store.
  async getByTestIdWithHealing(key: string, testId: string, intent: string): Promise<Locator> {{
    return this.locatorWithHealing(key, `[data-testid="${{testId}}"]`, intent);
  }}

  // ── Extended action verbs (added 2026-05) ──────────────────────────────
  // The heal chain now wraps every common stateful action so tests that
  // toggle checkboxes, pick dropdown options, or hover for tooltips get
  // the same self-healing behaviour as clicks and fills. Previously these
  // verbs went raw, so a single id rename in a dropdown broke the test
  // with no chain fallback.

  async checkWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.check({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    this.logger.info(`✓ check: ${{key}}`);
  }}

  async uncheckWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.uncheck({{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    this.logger.info(`✓ uncheck: ${{key}}`);
  }}

  /**
   * Heal-wrap a `<select>` option pick. `values` accepts every shape
   * Playwright's `locator.selectOption()` accepts: a label string,
   * an array of labels, `{{ label }}`, `{{ value }}`, `{{ index }}`,
   * or arrays of those. The heal chain finds the select; the option
   * argument is passed through untouched.
   */
  async selectOptionWithHealing(
    key: string,
    selector: string,
    values: string | string[] | {{ value?: string; label?: string; index?: number }} | Array<{{ value?: string; label?: string; index?: number }}>,
    intent: string,
  ): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.selectOption(values as any, {{ timeout: LocatorHealer.TIMEOUT }});
    this.repo.incrementSuccess(key);
    this.logger.info(`✓ selectOption: ${{key}}`);
  }}

  async hoverWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.hover({{ timeout: LocatorHealer.TIMEOUT }});
    this.logger.info(`✓ hover: ${{key}}`);
  }}

  // ── Extended assertion verbs (added 2026-05) ───────────────────────────
  // Companions to assertVisibleWithHealing. Each waits for the heal chain
  // to resolve the locator, then runs the state assertion. Failures
  // propagate as Playwright-style timeouts so test reports show what was
  // expected vs. observed.

  async assertEnabledWithHealing(key: string, selector: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    // Use expect-style poll so the assertion message is clear; manual
    // `isEnabled()` checks return false silently when the element is
    // still loading.
    await loc.waitFor({{ state: "visible", timeout: LocatorHealer.HEAL_TO }});
    if (!(await loc.isEnabled({{ timeout: LocatorHealer.TIMEOUT }}))) {{
      throw new Error(`assertEnabledWithHealing: locator for "${{key}}" was visible but disabled`);
    }}
    this.logger.info(`✓ enabled: ${{key}}`);
  }}

  async assertCheckedWithHealing(key: string, selector: string, intent: string, expected: boolean = true): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.waitFor({{ state: "visible", timeout: LocatorHealer.HEAL_TO }});
    const actual = await loc.isChecked({{ timeout: LocatorHealer.TIMEOUT }});
    if (actual !== expected) {{
      throw new Error(`assertCheckedWithHealing: "${{key}}" expected isChecked=${{expected}} but got ${{actual}}`);
    }}
    this.logger.info(`✓ checked=${{expected}}: ${{key}}`);
  }}

  async assertValueWithHealing(key: string, selector: string, expectedValue: string, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.waitFor({{ state: "visible", timeout: LocatorHealer.HEAL_TO }});
    const actual = await loc.inputValue({{ timeout: LocatorHealer.TIMEOUT }});
    if (actual !== expectedValue) {{
      throw new Error(`assertValueWithHealing: "${{key}}" expected value="${{expectedValue}}" but got "${{actual}}"`);
    }}
    this.logger.info(`✓ value: ${{key}}`);
  }}

  async assertTextWithHealing(key: string, selector: string, expectedText: string | RegExp, intent: string): Promise<void> {{
    const loc = await this.resolve(key, selector, intent);
    await loc.waitFor({{ state: "visible", timeout: LocatorHealer.HEAL_TO }});
    const actual = (await loc.textContent({{ timeout: LocatorHealer.TIMEOUT }})) ?? "";
    const matches = expectedText instanceof RegExp
      ? expectedText.test(actual)
      : actual.includes(expectedText);
    if (!matches) {{
      throw new Error(`assertTextWithHealing: "${{key}}" expected text matching ${{expectedText}} but got "${{actual.trim().slice(0, 200)}}"`);
    }}
    this.logger.info(`✓ text: ${{key}}`);
  }}

  private async resolve(key: string, selector: string, intent: string): Promise<Locator> {{
    const healed = this.repo.getHealed(key);
    if (healed && healed !== selector) {{
      try {{
        // .locator("visible=true").first() handles CSS-union locators
        // that match multiple elements — Playwright's waitFor enforces
        // strict mode in newer versions, so a bare multi-match throws
        // "strict mode violation: resolved to N elements" before we get
        // a chance to pick one.
        const l = this.scope.locator(healed).locator("visible=true").first();
        await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
        return l;
      }} catch (err) {{
        this.logger.info(
          `⚕ Cached heal stale (key="${{key}}", healed="${{healed}}") — ${{String(err).split("\\n")[0]}}`,
        );
      }}
    }}
    try {{
      const l = this.scope.locator(selector).locator("visible=true").first();
      await l.waitFor({{ state: "attached", timeout: 5_000 }});
      return l;
    }} catch (err) {{
      this.logger.warn(`⚕ Primary failed: ${{key}} — ${{String(err).split("\\n")[0]}}`);
      this.repo.incrementFailure(key);
    }}

    // Per-call attempt counter for LOCATOR_HEAL_ATTEMPTS. When set to a
    // positive number, the chain stops trying fallback strategies after N
    // attempts and falls through to AI Vision (or throws if AI is disabled).
    let attempts = 0;
    const capReached = () => LocatorHealer.MAX_ATTEMPTS > 0 && attempts >= LocatorHealer.MAX_ATTEMPTS;

    // Strategy dispatcher — iteration order is set by HEAL_STRATEGY_ORDER
    // (parsed once into LocatorHealer.STRATEGY_ORDER). Each strategy gets a
    // consume() callback to bump the attempts counter so LOCATOR_HEAL_ATTEMPTS
    // bounds *probes* (not just strategies).
    for (const strategy of LocatorHealer.STRATEGY_ORDER) {{
      if (capReached()) break;
      const result = await this._runStrategy(strategy, key, intent, selector, () => {{ attempts++; }});
      if (result) return result;
    }}

    // Final stage: AI Vision (needs API key — see _aiVisionEndpoint).
    return this.healViaAiVision(key, intent);
  }}

  /**
   * Run one named healing strategy. Returns the matched Locator or null.
   *
   * `consume` is invoked once per probe so LOCATOR_HEAL_ATTEMPTS bounds the
   * total work across the chain — even strategies that loop internally
   * (role, type-hint) count one slot per probe.
   */
  private async _runStrategy(
    strategy: HealStrategyName,
    key: string,
    intent: string,
    selector: string,
    consume: () => void,
  ): Promise<Locator | null> {{
    if (strategy === "attribute") {{
      const lowerIntent = intent.toLowerCase();
      const stopWords = new Set([
        "oauth", "auth0", "input", "field", "textbox", "text", "button", "btn",
        "link", "option", "message", "icon", "code",
      ]);
      const intentTokens = lowerIntent
        .split(/[^a-z0-9]+/)
        .map(t => t.trim())
        .filter(t => t.length > 2 && !stopWords.has(t));
      const selectorTokens = selector
        .toLowerCase()
        .replace(/[#.\[\]"'=]/g, " ")
        .split(/[^a-z0-9_-]+/)
        .map(t => t.trim())
        .filter(t => t.length > 2)
        .flatMap((t) => {{
          const out = [t];
          // Handle deliberate typo probes like `emailrr` / `passworrrrd` by
          // trying trimmed suffix variants as additional attribute keywords.
          if (t.length > 5) out.push(t.slice(0, -1));
          if (t.length > 6) out.push(t.slice(0, -2));
          return out;
        }});
      const keywords = Array.from(new Set([...selectorTokens, ...intentTokens])).filter(k => k.length > 2);
      if (keywords.length === 0) keywords.push(intent.split(" ").find(w => w.length > 2) ?? intent);
      // Constrain by element shape implied by the intent so substring matches
      // like `[id*="password"]` can't pick up `<a id="forgot-password">` when
      // we're looking for a password *input*. Empty string = no shape filter.
      const shape = LocatorHealer._intentShapeFilter(intent);
      for (const keyword of keywords) {{
        // Word-boundary matchers (kebab/camel/snake/space) so e.g. keyword "edit"
        // can't substring-match `id="editNoteSave"` (a hidden modal Save button).
        // We keep broad contains selectors at the end so typo probes like
        // `#emailrr` can still recover to `#email`.
        const boundaryAttrs = [
          `[name="${{keyword}}" i]`, `[name^="${{keyword}}-" i]`, `[name$="-${{keyword}}" i]`, `[name*="-${{keyword}}-" i]`, `[name*="${{keyword}}" i]`,
          `[placeholder="${{keyword}}" i]`, `[placeholder^="${{keyword}} " i]`, `[placeholder$=" ${{keyword}}" i]`, `[placeholder*=" ${{keyword}} " i]`, `[placeholder*="${{keyword}}" i]`,
          `[id="${{keyword}}" i]`, `[id^="${{keyword}}-" i]`, `[id$="-${{keyword}}" i]`, `[id*="-${{keyword}}-" i]`, `[id^="${{keyword}}_" i]`, `[id$="_${{keyword}}" i]`, `[id*="_${{keyword}}_" i]`, `[id*="${{keyword}}" i]`,
          `[aria-label="${{keyword}}" i]`, `[aria-label^="${{keyword}} " i]`, `[aria-label$=" ${{keyword}}" i]`, `[aria-label*=" ${{keyword}} " i]`, `[aria-label*="${{keyword}}" i]`,
          `[data-testid="${{keyword}}" i]`, `[data-testid^="${{keyword}}-" i]`, `[data-testid$="-${{keyword}}" i]`, `[data-testid*="${{keyword}}" i]`,
          `[data-test="${{keyword}}" i]`, `[data-test^="${{keyword}}-" i]`, `[data-test$="-${{keyword}}" i]`, `[data-test*="${{keyword}}" i]`,
          `[data-cy="${{keyword}}" i]`, `[data-cy^="${{keyword}}-" i]`, `[data-cy$="-${{keyword}}" i]`, `[data-cy*="${{keyword}}" i]`,
          `[data-qa="${{keyword}}" i]`, `[data-qa^="${{keyword}}-" i]`, `[data-qa$="-${{keyword}}" i]`, `[data-qa*="${{keyword}}" i]`,
          `[data-tid="${{keyword}}" i]`, `[data-tid^="${{keyword}}-" i]`, `[data-tid$="-${{keyword}}" i]`, `[data-tid*="${{keyword}}" i]`,
          `[autocomplete="${{keyword}}" i]`, `[autocomplete*="${{keyword}}" i]`,
        ];
        const attrSel = boundaryAttrs.map(a => `${{shape}}${{a}}`).join(", ");
        consume();
        try {{
          const l = this.scope.locator(attrSel).locator("visible=true").first();
          await l.waitFor({{ state: "visible", timeout: LocatorHealer.ATTACH_TO }});
          const specific = await this._resolveSpecificSelector(l, attrSel);
          this.repo.updateHealed(key, specific, intent, "attr", selector);
          this.logger.warn(`⚕ Attribute-healed: ${{key}} → ${{specific}}`);
          return l;
        }} catch {{}}
      }}
      this.logger.info(
        `⚕ attribute [keywords="${{keywords.join(',')}}" shape="${{shape || '*'}}"] — no bind`,
      );
      return null;
    }}

    if (strategy === "type-hint") {{
      const lower = intent.toLowerCase();
      const typeHints: Array<[RegExp, string]> = [
        [/\\bpassword\\b/, 'input[type="password"]'],
        [/\\bemail\\b/, 'input[type="email"], input[autocomplete="email"]'],
        [/\\bsearch\\b/, 'input[type="search"], [role="search"] input'],
        [/\\bphone\\b/, 'input[type="tel"], input[autocomplete*="tel"]'],
        [/\\bcheckbox\\b/, 'input[type="checkbox"]'],
        [/\\bradio\\b/, 'input[type="radio"]'],
        [/\\b(submit|login|sign\\s?in|save|continue|next|confirm)\\b/,
          'button[type="submit"], input[type="submit"]'],
      ];
      const triedHints: string[] = [];
      for (const [re, sel] of typeHints) {{
        if (!re.test(lower)) continue;
        consume();
        try {{
          const l = this.scope.locator(sel).first();
          await l.waitFor({{ state: "attached", timeout: LocatorHealer.ATTACH_TO }});
          const specific = await this._resolveSpecificSelector(l, sel);
          this.repo.updateHealed(key, specific, intent, "type-hint", selector);
          this.logger.warn(`⚕ Type-hint healed: ${{key}} → ${{specific}}`);
          return l;
        }} catch {{}}
        let count = -1;
        try {{ count = await this.scope.locator(sel).count(); }} catch {{}}
        triedHints.push(`"${{sel}}"→${{count}}`);
      }}
      if (triedHints.length === 0) {{
        this.logger.info(`⚕ type-hint [intent="${{intent}}"] — no hint keyword matched, skipped`);
      }} else {{
        this.logger.info(`⚕ type-hint — tried ${{triedHints.join(", ")}} — no bind`);
      }}
      return null;
    }}

    if (strategy === "role") {{
      // Word-boundary regex so intent "edit action" doesn't match an anchor
      // named "Interactions" (which contains "action" as a substring) — the
      // same substring trap that bit the attribute strategy. Also require
      // :visible so a collapsed nav-menu match can't win and then time out
      // on the subsequent click.
      const words = intent
        .split(" ")
        .slice(0, 3)
        .filter(w => w.length > 1)
        .map(w => w.replace(/[.*+?^${{}}()|[\\]\\\\]/g, "\\\\$&"))
        .join("|");
      if (!words) {{
        this.logger.info(`⚕ role [intent="${{intent}}"] — no usable keywords, skipped`);
        return null;
      }}
      const namePattern = new RegExp(`\\\\b(?:${{words}})\\\\b`, "i");
      const perRole: string[] = [];
      for (const role of ["button", "link", "textbox", "combobox", "checkbox", "radio"] as const) {{
        consume();
        try {{
          const l = this.scope.getByRole(role, {{ name: namePattern }}).locator("visible=true").first();
          await l.waitFor({{ state: "visible", timeout: LocatorHealer.ATTACH_TO }});
          const specific = await this._resolveSpecificSelector(l, `[role:${{role}}]`);
          this.repo.updateHealed(key, specific, intent, "role", selector);
          this.logger.warn(`⚕ Role-healed: ${{key}} → ${{specific}}`);
          return l;
        }} catch {{}}
        // Defensive diagnostic counts — never throw out of the strategy.
        let total = -1, visible = -1;
        try {{ total   = await this.scope.getByRole(role, {{ name: namePattern }}).count(); }} catch {{}}
        try {{ visible = await this.scope.getByRole(role, {{ name: namePattern }}).locator("visible=true").count(); }} catch {{}}
        perRole.push(`${{role}}=${{total}}/${{visible}}`);
      }}
      this.logger.info(
        `⚕ role [pattern=/${{namePattern.source}}/i] — ${{perRole.join(" ")}} (total/visible) — no bind`,
      );
      return null;
    }}

    if (strategy === "label") {{
      consume();
      try {{
        // `.locator("visible=true").first()` mirrors the attribute/role
        // strategies — without it, multi-match labels hit strict mode
        // when `_resolveSpecificSelector` calls `.evaluate(...)`.
        const l = this.scope.getByLabel(intent, {{ exact: false }}).locator("visible=true").first();
        await l.waitFor({{ state: "visible", timeout: LocatorHealer.ATTACH_TO }});
        const specific = await this._resolveSpecificSelector(l, "[label-healed]");
        this.repo.updateHealed(key, specific, intent, "label", selector);
        this.logger.warn(`⚕ Label-healed: ${{key}} → ${{specific}}`);
        return l;
      }} catch {{}}
      let count = -1;
      try {{ count = await this.scope.getByLabel(intent, {{ exact: false }}).count(); }} catch {{}}
      this.logger.info(`⚕ label [intent="${{intent}}"] — ${{count}} matches — no bind`);
      return null;
    }}

    if (strategy === "text") {{
      consume();
      const w = intent.split(" ").find(w => w.length > 3) ?? intent;
      try {{
        const l = this.scope.getByText(w, {{ exact: false }}).locator("visible=true").first();
        await l.waitFor({{ state: "visible", timeout: LocatorHealer.ATTACH_TO }});
        const specific = await this._resolveSpecificSelector(l, "[text-healed]");
        this.repo.updateHealed(key, specific, intent, "text", selector);
        this.logger.warn(`⚕ Text-healed: ${{key}} → ${{specific}}`);
        return l;
      }} catch {{}}
      let count = -1;
      try {{ count = await this.scope.getByText(w, {{ exact: false }}).count(); }} catch {{}}
      this.logger.info(`⚕ text [word="${{w}}"] — ${{count}} matches — no bind`);
      return null;
    }}

    return null;
  }}

  /**
   * Inspect the actually-matched element and return the most stable
   * SINGLE selector that uniquely identifies it on the live page.
   *
   * The healer's search selectors are deliberately broad (CSS unions, role
   * queries, etc.) so the chain has the best chance of finding *some*
   * matching element when the original primary selector breaks. But once
   * we've matched, we don't want to persist the union — we want the one
   * concrete locator that actually pinned the element. That's what the
   * qa-playwright-generator agent emits at generation time, and it's
   * what the operator wants to see in the heal store.
   *
   * Preference order (most stable first):
   *   1. `data-testid` / `data-test`     — purpose-built test hooks
   *   2. `id`                            — unique by HTML spec
   *   3. `name`                          — typical for form fields
   *   4. `placeholder`                   — visible-only forms
   *   5. `aria-label`                    — accessible name
   *   6. `autocomplete`                  — semantic hint
   *   7. The fallback selector passed in — used when none of the above
   *      are present (rare; means the element is genuinely anonymous).
   */
  private async _resolveSpecificSelector(loc: Locator, fallback: string): Promise<string> {{
    try {{
      const attrs = await loc.evaluate((el: Element) => {{
        const get = (name: string) => el.getAttribute(name) ?? null;
        return {{
          tag:           el.tagName.toLowerCase(),
          id:            (el as HTMLElement).id || null,
          name:          get("name"),
          placeholder:   get("placeholder"),
          ariaLabel:     get("aria-label"),
          dataTestId:    get("data-testid"),
          dataTest:      get("data-test"),
          autocomplete:  get("autocomplete"),
          type:          get("type"),
        }};
      }});
      const esc = (v: string) => v.replace(/(["\\\\])/g, "\\\\$1");
      if (attrs.dataTestId)     return `[data-testid="${{esc(attrs.dataTestId)}}"]`;
      if (attrs.dataTest)       return `[data-test="${{esc(attrs.dataTest)}}"]`;
      if (attrs.id)             return `#${{attrs.id.replace(/([^\\w-])/g, "\\\\$1")}}`;
      if (attrs.name)           return `${{attrs.tag}}[name="${{esc(attrs.name)}}"]`;
      if (attrs.placeholder)    return `${{attrs.tag}}[placeholder="${{esc(attrs.placeholder)}}"]`;
      if (attrs.ariaLabel)      return `[aria-label="${{esc(attrs.ariaLabel)}}"]`;
      if (attrs.autocomplete)   return `${{attrs.tag}}[autocomplete="${{esc(attrs.autocomplete)}}"]`;
      // Element has no identifying attribute — fall back to type+tag if it's a typed input.
      if (attrs.tag === "input" && attrs.type)
        return `input[type="${{esc(attrs.type)}}"]`;
    }} catch {{}}
    return fallback;
  }}

  /**
   * Write an aria-snapshot of the current page to disk when every healing
   * strategy has been exhausted. Goes to `self-heals/exhausted-<key>-<ts>.txt`
   * by default (or alongside HEAL_STORE_PATH when set). Best-effort: any
   * I/O failure here is swallowed so the "all strategies exhausted" error
   * still surfaces to the test runner. Snapshot is capped at 8 KB so a
   * runaway page can't fill the disk.
   */
  private async _dumpExhaustionSnapshot(key: string, intent: string): Promise<void> {{
    try {{
      const dir = process.env.HEAL_STORE_PATH
        ? path.dirname(process.env.HEAL_STORE_PATH)
        : path.join(process.cwd(), "self-heals");
      try {{ fs.mkdirSync(dir, {{ recursive: true }}); }} catch {{}}
      const safeKey = key.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80);
      const ts = new Date().toISOString().replace(/[:.]/g, "-");
      const file = path.join(dir, `exhausted-${{safeKey}}-${{ts}}.txt`);
      const url = this.page.url();
      let snapshot = "";
      try {{ snapshot = await this.scope.locator("body").ariaSnapshot(); }} catch {{}}
      const payload =
        `# Exhausted heal — ${{new Date().toISOString()}}\\n` +
        `key:    ${{key}}\\n` +
        `intent: ${{intent}}\\n` +
        `url:    ${{url}}\\n` +
        `\\n## aria-snapshot (truncated to 8KB)\\n` +
        snapshot.slice(0, 8 * 1024);
      fs.writeFileSync(file, payload, {{ encoding: "utf-8" }});
      this.logger.warn(`⚕ Exhaustion snapshot saved: ${{file}}`);
    }} catch (err) {{
      this.logger.warn(`⚕ Could not save exhaustion snapshot: ${{err}}`);
    }}
  }}
{ai_method}
}}
'''


def _gen_locator_repo_cls(repository_path):
    return f'''import * as fs from "fs";
import * as path from "path";

export interface BoundingBox {{ x:number; y:number; width:number; height:number; }}

export interface LocatorEntry {{
  selector: string; intent: string; stability?: number; healedSelector?: string;
  healingHistory: Array<{{ from:string; to:string; timestamp:string; strategy:string }}>;
  lastHealed?: string; successCount: number; failureCount: number;
  /** Bounding box from last successful action — used by DevToolsHealer.findByBoundingBox */
  lastBoundingBox?: BoundingBox;
  /** Pending AI suggestion awaiting HealingDashboard approval before commit */
  pendingSuggestion?: {{ selector:string; strategy:string; suggestedAt:string }};
  /** Set by HealingDashboard.approve — confirms a human signed off on the heal. */
  approvedAt?: string;
  /** Set when HealingDashboard.approve also wrote the healed selector back to the locator .ts file. */
  appliedToSourceAt?: string;
}}

/** Current persisted-file schema version. Bumped when the on-disk shape changes. */
export const HEAL_STORE_VERSION = 1;

/**
 * Resolve the heal-store path with env-var precedence:
 *   1. constructor argument                      (explicit override)
 *   2. HEAL_STORE_PATH                           (operator override)
 *   3. compile-time default ("{repository_path}")
 *
 * If `ENV` or `HELIX_ENV` is set, the chosen path is auto-suffixed with the
 * env slug — e.g. `./self-heals/healed-locators.json` →
 * `./self-heals/healed-locators.qa4.json` — so parallel runs against
 * different test environments don't trample each other's healed selectors.
 */
function _resolveHealStorePath(override?: string): string {{
  const base = override ?? process.env.HEAL_STORE_PATH ?? "{repository_path}";
  const envTag = (process.env.ENV ?? process.env.HELIX_ENV ?? "").trim().toLowerCase();
  if (!envTag) return base;
  const ext = path.extname(base);
  if (ext) return base.slice(0, -ext.length) + "." + envTag + ext;
  return base + "." + envTag;
}}

/**
 * LocatorRepository — Shared state store for all healing layers.
 *
 * Persisted on-disk format (HEAL_STORE_VERSION=1):
 *   {{ "_version": 1, "entries": {{ <key>: LocatorEntry, ... }} }}
 *
 * Legacy v0 files (flat {{ <key>: LocatorEntry, ... }}) are still readable.
 * On first write after load, the file is upgraded to the v1 envelope.
 *
 * History retention is controlled by ENABLE_LOCATOR_VERSIONING:
 *   • unset / "true"  → keep the full healingHistory[] of every heal (default)
 *   • "false"         → keep only the latest heal entry (lighter file)
 *
 * History length is capped by HEAL_HISTORY_CAP (default 50). Setting to 0
 * disables the cap; otherwise the array keeps the most recent N entries so
 * heavily-healed locators don't grow the file unboundedly.
 *
 * Writes are coalesced with a {{LOCATOR_REPO_DEBOUNCE_MS}}ms debounce — every
 * mutation schedules a flush rather than writing synchronously. A flush is
 * forced on process exit so no in-flight state is lost.
 *
 * queueSuggestion / approveSuggestion / rejectSuggestion power the HealingDashboard.
 */
const LOCATOR_REPO_DEBOUNCE_MS = 500;

// Process-wide registry of every LocatorRepository instance, keyed by
// resolved file path. A single set of exit hooks is registered on first
// instantiation and flushes all known instances on shutdown. Required
// because every page object instantiates its own LocatorRepository — if
// each instance registered its own SIGINT/beforeExit listeners we'd hit
// Node's MaxListenersExceededWarning at ~4 instances, and if each instance
// only knew about its own state we'd lose data when the *last* instance to
// write overwrote everyone else's heals.
const _LIVE_REPOSITORIES: Map<string, LocatorRepository[]> = (() => {{
  const g = globalThis as any;
  if (!g.__locatorRepoRegistry) g.__locatorRepoRegistry = new Map();
  return g.__locatorRepoRegistry as Map<string, LocatorRepository[]>;
}})();

let _exitHooksInstalled = false;
function _installProcessExitHooks(): void {{
  if (_exitHooksInstalled) return;
  _exitHooksInstalled = true;
  const flushAll = () => {{
    _LIVE_REPOSITORIES.forEach(group => group.forEach(r => {{
      try {{ r.flush(); }} catch {{}}
    }}));
  }};
  // 'exit' fires on every termination path including explicit process.exit()
  // (which cucumber-js calls after the run). beforeExit is skipped in that
  // case, so without this hook the debounced 500ms persist timer is killed
  // before it fires and healed locators never reach disk.
  process.on("exit",         flushAll);
  process.once("beforeExit", flushAll);
  process.once("SIGINT",     () => {{ flushAll(); process.exit(130); }});
  process.once("SIGTERM",    () => {{ flushAll(); process.exit(143); }});
}}

export class LocatorRepository {{
  private readonly store = new Map<string, LocatorEntry>();
  // Versioning defaults to ON — matches the scaffold's historical behavior
  // of always appending to healingHistory. Set ENABLE_LOCATOR_VERSIONING=false
  // to keep only the latest heal entry.
  private readonly versioningEnabled = process.env.ENABLE_LOCATOR_VERSIONING !== "false";
  private readonly historyCap = (() => {{
    const n = Number(process.env.HEAL_HISTORY_CAP ?? 50);
    return Number.isFinite(n) && n >= 0 ? n : 50;
  }})();
  private flushTimer: NodeJS.Timeout | null = null;
  readonly filePath: string;

  constructor(filePath?: string) {{
    this.filePath = _resolveHealStorePath(filePath);
    this.load();
    const group = _LIVE_REPOSITORIES.get(this.filePath) ?? [];
    group.push(this);
    _LIVE_REPOSITORIES.set(this.filePath, group);
    _installProcessExitHooks();
  }}

  register(key: string, selector: string, intent: string, stability = 0): void {{
    const existing = this.store.get(key);
    if (!existing) {{
      this.store.set(key, {{ selector, intent, stability, healingHistory: [], successCount: 0, failureCount: 0 }});
      return;
    }}
    // Always re-sync the baseline `selector` so the heal-store stays in
    // step with source-code edits (e.g. dashboard Confirm just wrote a
    // healed selector back into the locator .ts file — next register call
    // should reflect that). If the new baseline now equals the previously
    // healed selector, drop the heal state: the source has caught up and
    // the entry's just a baseline registration again.
    existing.intent = intent;
    existing.stability = stability;
    if (existing.healedSelector && existing.healedSelector === selector) {{
      delete existing.healedSelector;
      delete existing.lastHealed;
      delete existing.approvedAt;
      existing.healingHistory = [];
    }}
    existing.selector = selector;
  }}

  getHealed(key: string): string | undefined {{ return this.store.get(key)?.healedSelector; }}

  updateHealed(
    key: string,
    sel: string,
    intent: string,
    strategy = "unknown",
    sourceSelector?: string,
  ): void {{
    // Upsert: if the key was never register()'d (e.g. step-file uses a
    // qualified key like "loginPage.usernameInput" while the page-object
    // boot loop only registered the short "usernameInput"), create a
    // baseline on the fly so the heal isn't silently dropped and the
    // healed-locators JSON actually gets a persistable entry.
    let e = this.store.get(key);
    if (!e) {{
      // When a key was never register()'d, preserve the *primary* selector as
      // baseline if available. Using `sel` here would make from===to and skip
      // persistence entirely on first heal.
      e = {{
        selector: sourceSelector ?? "",
        intent,
        healingHistory: [],
        successCount: 0,
        failureCount: 0,
      }};
      this.store.set(key, e);
    }}
    const from = e.healedSelector ?? e.selector;
    // Skip the heal-store write when the strategy ended up rebuilding the
    // same selector we already had (`from === to`). Common case: the
    // attribute strategy walks the DOM, `_resolveSpecificSelector`
    // reconstructs `#login-username` for an element that already has
    // `id="login-username"` — the heal output equals the primary.
    // Recording it just clutters the JSON with audit-only entries that
    // bypass the cache anyway (resolve() does `if (healed && healed !== selector)`).
    if (from === sel) {{
      return;
    }}
    const entry = {{ from, to: sel,
                     timestamp: new Date().toISOString(), strategy }};
    if (this.versioningEnabled) {{
      e.healingHistory.push(entry);
      // Trim oldest entries when history exceeds the cap so the JSON file
      // stays bounded even for locators that heal hundreds of times.
      if (this.historyCap > 0 && e.healingHistory.length > this.historyCap) {{
        e.healingHistory = e.healingHistory.slice(-this.historyCap);
      }}
    }} else {{
      // Keep only the most recent heal so the JSON file stays small. The
      // current selector is on `healedSelector` itself; the single-element
      // history exists just so consumers (dashboard, exportJson) still see
      // *something* in the array.
      e.healingHistory = [entry];
    }}
    e.healedSelector = sel; e.lastHealed = new Date().toISOString();
    delete e.pendingSuggestion;
    this._appendHealLog(key, strategy, sel);
    // Force-flush on heal events so the operator can see the healed
    // selector in the JSON file immediately — debouncing is fine for
    // incrementSuccess / incrementFailure (which fire on every step) but
    // would hide the heal-store change behind a 500ms delay an interrupted
    // test run could miss.
    this.flush();
  }}

  /**
   * Append a one-line record per heal to `./self-heals/heal.log`
   * (override path via HEAL_LOG_PATH). Format is tab-separated:
   *
   *   <ISO timestamp>\\t<key>\\t<strategy>\\t<selector>
   *
   * Set HEAL_LOG_PATH=/dev/null (or HEAL_LOG_DISABLED=true) to skip.
   */
  private _appendHealLog(key: string, strategy: string, selector: string): void {{
    if (process.env.HEAL_LOG_DISABLED === "true") return;
    try {{
      const logPath = process.env.HEAL_LOG_PATH
        ?? path.join(path.dirname(this.filePath), "heal.log");
      if (logPath === "/dev/null") return;
      fs.mkdirSync(path.dirname(logPath), {{ recursive: true }});
      const line = `${{new Date().toISOString()}}\\t${{key}}\\t${{strategy}}\\t${{selector}}\\n`;
      fs.appendFileSync(logPath, line, "utf8");
    }} catch {{}}
  }}

  queueSuggestion(key: string, suggestedSelector: string, strategy: string): void {{
    const e = this.store.get(key); if (!e) return;
    e.pendingSuggestion = {{ selector: suggestedSelector, strategy, suggestedAt: new Date().toISOString() }};
    this.persist();
  }}

  approveSuggestion(key: string): boolean {{
    const e = this.store.get(key);
    if (!e?.pendingSuggestion) return false;
    this.updateHealed(key, e.pendingSuggestion.selector, e.intent,
                      e.pendingSuggestion.strategy + ":approved");
    return true;
  }}

  rejectSuggestion(key: string): boolean {{
    const e = this.store.get(key);
    if (!e?.pendingSuggestion) return false;
    delete e.pendingSuggestion; this.persist(); return true;
  }}

  updateBoundingBox(key: string, bbox: BoundingBox): void {{
    const e = this.store.get(key);
    if (e) {{ e.lastBoundingBox = bbox; this.persist(); }}
  }}
  getBBox(key: string): BoundingBox | null {{
    return this.store.get(key)?.lastBoundingBox ?? null;
  }}

  getPendingSuggestions() {{
    return [...this.store.entries()]
      .filter(([, v]) => v.pendingSuggestion)
      .map(([k, v]) => ({{ key: k, ...v }}));
  }}

  incrementSuccess(key: string) {{ const e=this.store.get(key); if(e) {{ e.successCount++; this.persist(); }} }}
  incrementFailure(key: string) {{ const e=this.store.get(key); if(e) {{ e.failureCount++; this.persist(); }} }}

  getAnalytics() {{
    const entries = [...this.store.entries()];
    return {{
      totalLocators:    entries.length,
      healedLocators:   entries.filter(([, v]) => v.healedSelector).length,
      pendingApprovals: entries.filter(([, v]) => v.pendingSuggestion).length,
      mostHealedKeys:   entries
        .sort(([, a], [, b]) => b.healingHistory.length - a.healingHistory.length)
        .slice(0, 10)
        .map(([k, v]) => ({{ key: k, healCount: v.healingHistory.length, intent: v.intent }})),
    }};
  }}

  /** Predicate: should this entry be written to disk? */
  private _isPersistable(v: LocatorEntry): boolean {{
    // Only entries with an actual heal recorded (healedSelector) or a
    // pending suggestion awaiting review qualify. Pure-registered baseline
    // entries (`register(...)` was called on construction but the locator
    // has never needed healing) stay in memory only — keeps the JSON file
    // focused on what humans actually need to look at.
    return Boolean(v.healedSelector) || Boolean(v.pendingSuggestion);
  }}

  exportJson(): string {{
    const entries: Record<string, LocatorEntry> = {{}};
    this.store.forEach((v, k) => {{
      if (this._isPersistable(v)) entries[k] = v;
    }});
    return JSON.stringify({{
      _version: HEAL_STORE_VERSION,
      _writtenAt: new Date().toISOString(),
      entries,
    }}, null, 2);
  }}

  /**
   * Schedule a debounced flush of the in-memory store to disk. Multiple
   * mutations within {{LOCATOR_REPO_DEBOUNCE_MS}}ms coalesce into one write —
   * a big speedup for parallel runs that previously triggered a synchronous
   * file write on every step.
   */
  private persist(): void {{
    if (this.flushTimer) return;
    this.flushTimer = setTimeout(() => {{
      this.flushTimer = null;
      this._writeNow();
    }}, LOCATOR_REPO_DEBOUNCE_MS);
    // Keep node alive long enough for the flush; cleared on _writeNow.
    this.flushTimer.unref?.();
  }}

  /** Force an immediate synchronous write — used by exit hooks and tests. */
  flush(): void {{
    if (this.flushTimer) {{ clearTimeout(this.flushTimer); this.flushTimer = null; }}
    this._writeNow();
  }}

  private _writeNow(): void {{
    try {{
      fs.mkdirSync(path.dirname(this.filePath), {{ recursive: true }});
      // Read-modify-write: merge in-memory state on top of whatever's on
      // disk so concurrent LocatorRepository instances (one per page object
      // class, plus per-worker copies in parallel cucumber runs) don't
      // trample each other's heals. Last writer still wins per-key, but
      // *keys this instance has never touched* are preserved.
      const merged = this._mergeWithDisk();
      fs.writeFileSync(this.filePath, merged, "utf8");
    }} catch {{}}
  }}

  /**
   * Serialize the on-disk state, overlay this instance's in-memory changes,
   * and return the resulting JSON string. Preserves entries the current
   * instance doesn't know about (held by sibling LocatorRepository instances
   * or written by a previous test run).
   */
  private _mergeWithDisk(): string {{
    let onDisk: Record<string, LocatorEntry> = {{}};
    try {{
      if (fs.existsSync(this.filePath)) {{
        const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
        if (raw && typeof raw === "object" && "entries" in raw && raw.entries) {{
          onDisk = raw.entries as Record<string, LocatorEntry>;
        }} else if (raw && typeof raw === "object") {{
          for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {{
            if (!k.startsWith("_")) onDisk[k] = v as LocatorEntry;
          }}
        }}
      }}
    }} catch {{}}
    // Overlay this instance's state, then filter to only the entries worth
    // persisting (healed or pending). Pure-registered baseline entries drop
    // out of the file, while heals from sibling LocatorRepository instances
    // remain (read from disk above).
    this.store.forEach((v, k) => {{ onDisk[k] = v; }});
    const filtered: Record<string, LocatorEntry> = {{}};
    for (const [k, v] of Object.entries(onDisk)) {{
      if (this._isPersistable(v)) filtered[k] = v;
    }}
    return JSON.stringify({{
      _version: HEAL_STORE_VERSION,
      _writtenAt: new Date().toISOString(),
      entries: filtered,
    }}, null, 2);
  }}

  private load(): void {{
    try {{
      if (!fs.existsSync(this.filePath)) return;
      const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
      // v1 envelope: {{ _version, entries: {{...}} }}
      if (raw && typeof raw === "object" && "entries" in raw && raw.entries) {{
        Object.entries(raw.entries as Record<string, unknown>).forEach(([k, v]) => {{
          this.store.set(k, v as LocatorEntry);
        }});
        return;
      }}
      // Legacy v0 (flat map). Read it; the next persist() upgrades the file.
      Object.entries(raw as Record<string, unknown>).forEach(([k, v]) => {{
        // Defensive: skip top-level metadata keys that happen to start with "_".
        if (k.startsWith("_")) return;
        this.store.set(k, v as LocatorEntry);
      }});
    }} catch {{}}
  }}

}}
'''


def _gen_timing_healer_cls(dashboard_port):
    return f'''import {{ Page }} from "@playwright/test";
import {{ Logger }} from "winston";
import {{ LocatorRepository }} from "./LocatorRepository";

/**
 * TimingHealer — Layer 2: Network Trace Timing Healing
 *
 * When an endpoint drifts > 20% from baseline, auto-adjusts timeout for the
 * current run and queues a suggestion to HealingDashboard :{dashboard_port}.
 */
export class TimingHealer {{
  private static readonly HEADROOM        = 1.5;
  private static readonly DRIFT_THRESHOLD = 0.2;
  private static readonly MAX_TIMEOUT     = 60_000;
  private readonly observations           = new Map<string, number[]>();

  constructor(
    private readonly page:   Page,
    private readonly logger: Logger,
    private readonly repo:   LocatorRepository,
  ) {{}}

  async waitForNetworkIdle(actionKey: string, baselineMs = 10_000): Promise<void> {{
    const adjusted = this._adjusted(actionKey, baselineMs);
    const start    = Date.now();
    try {{
      await this.page.waitForLoadState("networkidle", {{ timeout: adjusted }});
    }} catch {{
      this.logger.warn(`⏱ TimingHealer: networkidle timeout for "${{actionKey}}" (${{adjusted}}ms)`);
    }}
    this._record(actionKey, Date.now() - start, baselineMs);
  }}

  async waitForApiResponse(
    actionKey: string, urlPattern: string | RegExp, baselineMs = 15_000,
  ): Promise<void> {{
    const adjusted = this._adjusted(actionKey, baselineMs);
    const start    = Date.now();
    try {{
      await this.page.waitForResponse(
        r => typeof urlPattern === "string"
          ? r.url().includes(urlPattern)
          : urlPattern.test(r.url()),
        {{ timeout: adjusted }},
      );
    }} catch {{
      this.logger.warn(`⏱ TimingHealer: API timeout for "${{actionKey}}"`);
    }}
    this._record(actionKey, Date.now() - start, baselineMs);
  }}

  private _adjusted(actionKey: string, defaultMs: number): number {{
    const obs = this.observations.get(actionKey);
    if (!obs?.length) return defaultMs;
    const last = obs[obs.length - 1];
    return Math.min(Math.ceil(last * TimingHealer.HEADROOM), TimingHealer.MAX_TIMEOUT);
  }}

  private _record(actionKey: string, observedMs: number, baselineMs: number): void {{
    const obs = this.observations.get(actionKey) ?? [];
    obs.push(observedMs);
    this.observations.set(actionKey, obs.slice(-20));
    const drift = (observedMs - baselineMs) / baselineMs;
    if (drift > TimingHealer.DRIFT_THRESHOLD) {{
      const adjusted = Math.min(Math.ceil(observedMs * TimingHealer.HEADROOM), TimingHealer.MAX_TIMEOUT);
      this.logger.warn(
        `⏱ TimingHealer: "${{actionKey}}" drifted ${{Math.round(drift * 100)}}% ` +
        `(${{baselineMs}}ms → ${{observedMs}}ms). Auto-adjusted to ${{adjusted}}ms. ` +
        `Queued for engineer review at http://localhost:{dashboard_port}`,
      );
      this.repo.queueSuggestion(`timing:${{actionKey}}`, `timeout:${{adjusted}}`, "timing-drift");
    }}
  }}
}}
'''


def _gen_visual_checker_cls(dashboard_port):
    return f'''import {{ Page, expect }} from "@playwright/test";
import {{ Logger }} from "winston";
import * as fs   from "fs";
import * as path from "path";
import {{ LocatorRepository }} from "./LocatorRepository";

/**
 * VisualIntentChecker — Layer 3: Visual Regression alongside Locator Healing
 *
 * Captures element screenshots at assertions and diffs against approved baselines.
 * Pixel diff > 0.5% → queued to HealingDashboard :{dashboard_port}.
 */
export class VisualIntentChecker {{
  private static readonly DIFF_THRESHOLD = 0.5;
  private readonly baselineDir: string;
  private readonly actualDir:   string;

  constructor(
    private readonly page:   Page,
    private readonly logger: Logger,
    private readonly repo?:  LocatorRepository,
  ) {{
    this.baselineDir = path.join("test-results", "visual-baselines");
    this.actualDir   = path.join("test-results", "visual-actuals");
    fs.mkdirSync(this.baselineDir, {{ recursive: true }});
    fs.mkdirSync(this.actualDir,   {{ recursive: true }});
  }}

  async check(key: string, selector: string, intent: string): Promise<void> {{
    const safe     = key.replace(/[^a-z0-9_-]/gi, "_");
    const baseline = path.join(this.baselineDir, `${{safe}}.png`);
    const actual   = path.join(this.actualDir,   `${{safe}}.png`);
    try {{
      const loc = this.page.locator(selector).first();
      if ((await loc.count()) === 0) {{
        this.logger.warn(`📸 VisualIntent: element not found for "${{key}}" — skipping`);
        return;
      }}
      const screenshot = await loc.screenshot({{ timeout: 5_000 }});
      fs.writeFileSync(actual, new Uint8Array(screenshot));
      if (!fs.existsSync(baseline)) {{
        fs.copyFileSync(actual, baseline);
        this.logger.info(`📸 VisualIntent: new baseline saved for "${{key}}"`);
        return;
      }}
      await expect(loc).toHaveScreenshot(`${{safe}}.png`, {{
        maxDiffPixelRatio: VisualIntentChecker.DIFF_THRESHOLD / 100,
        threshold: 0.2,
      }});
      this.logger.info(`📸 VisualIntent: ${{key}} — passed`);
    }} catch {{
      this.logger.warn(
        `📸 VisualIntent: "${{key}}" visual regression detected! ` +
        `Queued for review at http://localhost:{dashboard_port}`,
      );
      this.repo?.queueSuggestion(`visual:${{key}}`, actual, `visual-regression:${{intent}}`);
    }}
  }}

  approveBaseline(key: string): void {{
    const safe     = key.replace(/[^a-z0-9_-]/gi, "_");
    const baseline = path.join(this.baselineDir, `${{safe}}.png`);
    const actual   = path.join(this.actualDir,   `${{safe}}.png`);
    if (fs.existsSync(actual)) {{
      fs.copyFileSync(actual, baseline);
      this.logger.info(`📸 Baseline updated for "${{key}}"`);
    }}
  }}
}}
'''


def _gen_dashboard_server_cls():
    """
    Standalone launcher for the HealingDashboard HTTP server.

    Invoked by the `healix:dashboard` and `healix:review` npm scripts. Loads
    .env first so HEAL_STORE_PATH / HEALING_DASHBOARD_PORT / HEALIX_REVIEW_PORT
    are honored without re-running the whole test suite.

    `--review` selects the review-only flavour: the launcher sets
    HEALING_DASHBOARD_PORT=0 and defaults HEALIX_REVIEW_PORT=7891 in JS so
    the npm script doesn't have to do bash-style `${VAR:-default}`
    substitution (which silently breaks on Windows shells).
    """
    return '''import "dotenv/config";
import { HealingDashboard } from "./HealingDashboard";

// `--review` selects the review-only flavour: main dashboard off, review
// server on 7891 unless overridden. Setting the defaults here (instead of
// `KEY=${KEY:-7891}` in the npm script) keeps the launcher portable to
// Windows shells, which don't support bash-style parameter expansion.
if (process.argv.includes("--review")) {
  process.env.HEALING_DASHBOARD_PORT = "0";
  if (!process.env.HEALIX_REVIEW_PORT) process.env.HEALIX_REVIEW_PORT = "7891";
}

const dash = new HealingDashboard();
dash.start();

function shutdown(code: number): void {
  try { dash.stop(); } catch {}
  process.exit(code);
}

process.on("SIGINT",  () => shutdown(130));
process.on("SIGTERM", () => shutdown(143));

// Idle for ever — Node would otherwise exit because the HTTP server's only
// effective work happens inside its event loop.
setInterval(() => {}, 1 << 30);
'''


def _gen_healing_dashboard_cls(repository_path, dashboard_port):
    return f'''import * as fs   from "fs";
import * as path from "path";
import * as http from "http";
import * as url  from "url";
import {{
  buildPreview as _telemetryBuildPreview,
  consentStatus as _telemetryConsentStatus,
  recordConsent as _telemetryRecordConsent,
  installId as _telemetryInstallId,
}} from "./TelemetryClient";

/**
 * HealingDashboard — CI/CD Telemetry for AI-Suggested Changes
 *
 * Two-server design:
 *
 *   1. **Dashboard server** (HEALING_DASHBOARD_PORT, default {dashboard_port}) —
 *      full read/write UI for engineers; serves the HTML dashboard and the
 *      approve / reject endpoints that commit changes to the heal store.
 *      Set HEALING_DASHBOARD_PORT=0 to disable.
 *
 *   2. **Review server** (HEALIX_REVIEW_PORT, optional) — read-only mirror
 *      for QA leads / managers / external dashboards. Serves the same JSON
 *      endpoints (`/api/healed`, `/api/pending`, `/api/analytics`,
 *      `/api/heal-log`) but rejects any POST. Off by default — set
 *      HEALIX_REVIEW_PORT=<port> to enable.
 *
 * Dashboard endpoints (read/write):
 *   GET  /                  → HTML dashboard (auto-refreshes every 10s)
 *   GET  /api/healed        → JSON of all healed locators (current state)
 *   GET  /api/pending       → JSON pending AI suggestions
 *   GET  /api/analytics     → JSON healing analytics
 *   GET  /api/heal-log      → Tail of the heal log (recent heals, plain text)
 *   POST /api/approve/:key  → Approve → commit to the heal store
 *   POST /api/reject/:key   → Reject → discard
 *   GET  /api/visual/:key   → Serve actual PNG for visual diff review
 *
 * Suggestions are written to the heal store regardless of dashboard state,
 * so async review still works even when both servers are disabled.
 */
/**
 * Locate the `<bareKey>: {{ selector: "<oldSelector>", ... }}` entry inside a
 * `*.locators.ts` source string and rewrite the `selector` field to point at
 * `newSelector`. Idempotent — if the entry isn't present or the selector
 * doesn't match `oldSelector`, the content is returned unchanged.
 *
 * Quote handling: TypeScript locator files use double-quoted strings by
 * convention (this matches `locator_registrar.py`'s `_quote` preference),
 * but we also accept single-quoted and back-ticked forms for robustness.
 * The rewrite preserves the quote style.
 *
 * Lives at module scope (not inside the class) so the surrounding Python
 * f-string template doesn't have to deal with the regex backslashes that
 * a class method would require — they get tangled into invalid syntax
 * once the f-string evaluates.
 */
function LocatorHealer_rewriteEntrySelector(
  content: string, bareKey: string, oldSelector: string, newSelector: string,
): string {{
  if (oldSelector === newSelector) return content;
  const REGEX_META = ".*+?^${{}}()|[]\\\\";
  const escForRegex = (s: string) => {{
    let out = "";
    for (const c of s) {{
      if (REGEX_META.indexOf(c) >= 0) out += "\\\\";
      out += c;
    }}
    return out;
  }};
  const escForStringBody = (s: string, quote: string) => {{
    let out = "";
    for (const c of s) {{
      if (c === "\\\\" || c === quote) out += "\\\\";
      out += c;
    }}
    return out;
  }};
  let result = content;
  for (const quote of ['"', "'", "`"]) {{
    // Allow the source `oldSelector` to use either a literal quote or an
    // escaped one. We don't try to be exhaustive — most real heal entries
    // contain neither.
    const oldEscaped = escForRegex(oldSelector);
    const pattern = new RegExp(
      "(\\\\b" + escForRegex(bareKey) + "\\\\s*:\\\\s*\\\\{{\\\\s*selector\\\\s*:\\\\s*" +
      quote + ")" + oldEscaped + "(" + quote + ")",
      "g",
    );
    result = result.replace(pattern, (_m, prefix: string, q: string) => {{
      return prefix + escForStringBody(newSelector, q) + q;
    }});
  }}
  return result;
}}

/**
 * Resolve the heal-store path with the same env-suffix logic the
 * LocatorRepository uses, so the dashboard reads the file the test run
 * actually wrote (e.g. `healed-locators.qa4.json` when ENV=qa4).
 *
 * Inlined rather than imported from LocatorRepository.ts to keep the
 * dashboard server self-contained — it can boot without ever loading the
 * full repository class (which would also install exit hooks etc.).
 */
function _resolveDashboardStorePath(override?: string): string {{
  const base = override ?? process.env.HEAL_STORE_PATH ?? "{repository_path}";
  const envTag = (process.env.ENV ?? process.env.HELIX_ENV ?? "").trim().toLowerCase();
  if (!envTag) return base;
  const ext = path.extname(base);
  if (ext) return base.slice(0, -ext.length) + "." + envTag + ext;
  return base + "." + envTag;
}}

/**
 * List every heal-store sibling of `primary` — i.e. files in the same
 * directory whose basename starts with the same stem (e.g. given
 * `./self-heals/healed-locators.json`, return `healed-locators.json`,
 * `healed-locators.qa4.json`, `healed-locators.dev1.json`, ...). Lets the
 * dashboard show heals from all environments when the operator launches
 * `healix:dashboard` without an explicit ENV.
 */
function _siblingStoreFiles(primary: string): string[] {{
  try {{
    const dir = path.dirname(primary);
    const base = path.basename(primary);
    const ext = path.extname(base);
    const stem = ext ? base.slice(0, -ext.length) : base;
    // Strip any existing `.<env>` suffix so siblings of `*.qa4.json`
    // resolve as well as siblings of `*.json`.
    const root = stem.split(".")[0];
    if (!fs.existsSync(dir)) return [];
    return fs.readdirSync(dir)
      .filter(name => name.startsWith(root + ".") || name === root + (ext || ".json"))
      .filter(name => name.endsWith(ext || ".json"))
      .map(name => path.join(dir, name));
  }} catch {{
    return [];
  }}
}}

export class HealingDashboard {{
  private server: http.Server | null = null;
  private reviewServer: http.Server | null = null;
  private readonly port: number;
  private readonly reviewPort: number;
  private readonly repoPath: string;

  constructor(repoPathOverride?: string, port = {dashboard_port}) {{
    this.repoPath   = _resolveDashboardStorePath(repoPathOverride);
    this.port       = Number(process.env.HEALING_DASHBOARD_PORT ?? port);
    // Review server is opt-in (default 0 = disabled) so we don't grab a
    // random port without the operator asking for it.
    this.reviewPort = Number(process.env.HEALIX_REVIEW_PORT ?? 0);
  }}

  start(): void {{
    if (this.port !== 0) {{
      this.server = http.createServer((req, res) => this._route(req, res, /* readOnly */ false));
      this.server.listen(this.port, () => {{
        console.log(`\\n🩺 HealingDashboard running at http://localhost:${{this.port}}`);
        console.log(`   Approve or reject AI-suggested changes before committing.\\n`);
      }});
    }}
    if (this.reviewPort !== 0 && this.reviewPort !== this.port) {{
      this.reviewServer = http.createServer((req, res) => this._route(req, res, /* readOnly */ true));
      this.reviewServer.listen(this.reviewPort, () => {{
        console.log(`👁  HealingReview (read-only) at http://localhost:${{this.reviewPort}}\\n`);
      }});
    }}
  }}

  stop(): void {{ this.server?.close(); this.reviewServer?.close(); }}

  private _route(req: http.IncomingMessage, res: http.ServerResponse, readOnly: boolean): void {{
    const p = url.parse(req.url ?? "/").pathname ?? "/";
    const m = req.method ?? "GET";
    res.setHeader("Access-Control-Allow-Origin", "*");
    if (m === "OPTIONS") {{ res.writeHead(204); res.end(); return; }}

    if (m === "GET"  && p === "/")              {{ res.writeHead(200, {{"Content-Type":"text/html"}}); res.end(this._html(readOnly)); return; }}
    if (m === "GET"  && p === "/api/healed")    {{ res.writeHead(200, {{"Content-Type":"application/json"}}); res.end(JSON.stringify(this._healed(), null, 2)); return; }}
    if (m === "GET"  && p === "/api/pending")   {{ res.writeHead(200, {{"Content-Type":"application/json"}}); res.end(JSON.stringify(this._pending(), null, 2)); return; }}
    if (m === "GET"  && p === "/api/analytics") {{ res.writeHead(200, {{"Content-Type":"application/json"}}); res.end(JSON.stringify(this._analytics(), null, 2)); return; }}
    if (m === "GET"  && p === "/api/heal-log")  {{ res.writeHead(200, {{"Content-Type":"text/plain"}}); res.end(this._healLogTail()); return; }}

    // ── Telemetry endpoints ─────────────────────────────────────────────────
    // Used by the consent banner in the dashboard UI. `preview` is read-only
    // (safe on the review server too); `consent` mutates state so it's
    // blocked when readOnly is set.
    if (m === "GET"  && p === "/api/telemetry-preview") {{
      const events = _telemetryBuildPreview();
      res.writeHead(200, {{"Content-Type":"application/json"}});
      res.end(JSON.stringify({{
        endpoint: process.env.STLC_TELEMETRY_ENDPOINT || "(default)",
        install_id: _telemetryInstallId(),
        consent: _telemetryConsentStatus(),
        telemetry_env: (process.env.STLC_TELEMETRY ?? "off").toLowerCase(),
        sample: events.slice(0, 3),
        total_would_send: events.length,
      }}, null, 2));
      return;
    }}
    if (m === "GET"  && p === "/api/telemetry-status") {{
      res.writeHead(200, {{"Content-Type":"application/json"}});
      res.end(JSON.stringify({{
        consent: _telemetryConsentStatus(),
        telemetry_env: (process.env.STLC_TELEMETRY ?? "off").toLowerCase(),
        install_id: _telemetryInstallId(),
      }}));
      return;
    }}
    if (m === "POST" && p === "/api/telemetry-consent") {{
      if (readOnly) {{
        res.writeHead(403, {{"Content-Type":"application/json"}});
        res.end(JSON.stringify({{ error: "read-only review server" }}));
        return;
      }}
      let body = "";
      req.on("data", (chunk: Buffer) => {{ body += chunk.toString(); }});
      req.on("end", () => {{
        try {{
          const parsed = JSON.parse(body || "{{}}");
          const choice = parsed?.choice === "accepted" ? "accepted"
                       : parsed?.choice === "declined" ? "declined" : null;
          if (!choice) {{
            res.writeHead(400, {{"Content-Type":"application/json"}});
            res.end(JSON.stringify({{ error: "choice must be 'accepted' or 'declined'" }}));
            return;
          }}
          _telemetryRecordConsent(choice);
          res.writeHead(200, {{"Content-Type":"application/json"}});
          res.end(JSON.stringify({{ success: true, consent: choice }}));
        }} catch (err) {{
          res.writeHead(400, {{"Content-Type":"application/json"}});
          res.end(JSON.stringify({{ error: String(err) }}));
        }}
      }});
      return;
    }}

    if (m === "POST" && p === "/api/approve-all") {{
      if (readOnly) {{
        res.writeHead(403, {{"Content-Type":"application/json"}});
        res.end(JSON.stringify({{ error: "read-only review server" }}));
        return;
      }}
      const r = this._approveAll();
      res.writeHead(200, {{"Content-Type":"application/json"}});
      res.end(JSON.stringify({{ success: true, ...r }}));
      return;
    }}

    const am = p.match(/^[/]api[/](approve|reject)[/](.+)$/);
    if (m === "POST" && am) {{
      if (readOnly) {{
        res.writeHead(403, {{"Content-Type":"application/json"}});
        res.end(JSON.stringify({{ error: "read-only review server" }}));
        return;
      }}
      const ok = am[1] === "approve"
        ? this._approve(decodeURIComponent(am[2]))
        : this._reject(decodeURIComponent(am[2]));
      res.writeHead(ok ? 200 : 404, {{"Content-Type":"application/json"}});
      res.end(JSON.stringify({{ success: ok }}));
      return;
    }}

    const vm = p.match(/^[/]api[/]visual[/](.+)$/);
    if (m === "GET" && vm) {{
      const key = decodeURIComponent(vm[1]).replace("visual:", "");
      const img = path.join("test-results", "visual-actuals", `${{key}}.png`);
      if (fs.existsSync(img)) {{ res.writeHead(200, {{"Content-Type":"image/png"}}); res.end(fs.readFileSync(img)); }}
      else {{ res.writeHead(404); res.end("Not found"); }}
      return;
    }}
    res.writeHead(404); res.end("Not found");
  }}

  /** All healed locators (current state). Used by the dashboard UI and `/api/healed`. */
  private _healed(): Array<{{ key: string; selector: string; healedSelector: string; intent: string; lastHealed?: string; strategy?: string }}> {{
    return Object.entries(this._load())
      .filter(([, v]) => (v as any).healedSelector)
      .map(([k, v]) => {{
        const entry = v as any;
        const lastHistory = entry.healingHistory?.[entry.healingHistory.length - 1];
        return {{
          key: k,
          selector:        entry.selector,
          healedSelector:  entry.healedSelector,
          intent:          entry.intent,
          lastHealed:      entry.lastHealed,
          strategy:        lastHistory?.strategy,
        }};
      }});
  }}

  /** Tail of the heal log (./self-heals/heal.log by default). Plain text. */
  private _healLogTail(): string {{
    try {{
      const logPath = process.env.HEAL_LOG_PATH
        ?? path.join(path.dirname(this.repoPath), "heal.log");
      if (!fs.existsSync(logPath)) return "";
      const content = fs.readFileSync(logPath, "utf8");
      // Return only the last ~500 lines so the response stays small.
      const lines = content.split("\\n");
      return lines.slice(-500).join("\\n");
    }} catch {{
      return "";
    }}
  }}

  // Per-key source-file map populated by `_load()`. Required so dashboard
  // approve/reject (`_save()`) writes the entry back to the SAME file it
  // came from when multiple heal stores (e.g. healed-locators.qa4.json and
  // healed-locators.dev1.json) coexist in the same directory.
  private _entrySource: Map<string, string> = new Map();

  /** Read a single heal-store file, returning its `entries` map (or {{}}). */
  private _readStore(filePath: string): Record<string, any> {{
    try {{
      const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
      if (raw && typeof raw === "object" && "entries" in raw && raw.entries) {{
        return raw.entries as Record<string, any>;
      }}
      // Legacy flat shape — strip top-level metadata keys (`_*`).
      const out: Record<string, any> = {{}};
      for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {{
        if (!k.startsWith("_")) out[k] = v;
      }}
      return out;
    }} catch {{
      return {{}};
    }}
  }}

  /**
   * Load heal entries — primary file first, then sibling env-suffixed
   * files in the same directory (so a single dashboard process shows heals
   * from `healed-locators.qa4.json`, `healed-locators.dev1.json`, etc.
   * without the operator having to set ENV).
   *
   * Each entry's source file is remembered in `_entrySource` so subsequent
   * approve/reject calls (`_save()`) write the updated entry back to the
   * exact file it came from.
   */
  private _load(): Record<string, any> {{
    this._entrySource.clear();
    const merged: Record<string, any> = {{}};
    const candidates = new Set<string>([this.repoPath, ..._siblingStoreFiles(this.repoPath)]);
    for (const file of candidates) {{
      if (!fs.existsSync(file)) continue;
      const entries = this._readStore(file);
      for (const [k, v] of Object.entries(entries)) {{
        merged[k] = v;
        // Last file wins on key conflict — should never happen since
        // env-suffixed files describe different environments.
        this._entrySource.set(k, file);
      }}
    }}
    return merged;
  }}

  /**
   * Persist the post-approve/reject state.
   *
   * Entries get grouped by their original source file (tracked in
   * `_entrySource`) and each file is re-emitted in the v1 envelope shape
   * with the heal-only filter applied. Entries we have no source for
   * (shouldn't happen in practice — they came from `_load()`) fall back
   * to the configured `repoPath`.
   */
  private _save(entries: Record<string, any>): void {{
    const buckets: Map<string, Record<string, any>> = new Map();
    for (const [k, v] of Object.entries(entries)) {{
      const source = this._entrySource.get(k) ?? this.repoPath;
      let bucket = buckets.get(source);
      if (!bucket) {{ bucket = {{}}; buckets.set(source, bucket); }}
      bucket[k] = v;
    }}
    // Make sure every file that originally had entries gets a re-emit
    // (so deletions / reverts actually persist) — even if every entry was
    // rejected.
    for (const source of this._entrySource.values()) {{
      if (!buckets.has(source)) buckets.set(source, {{}});
    }}
    for (const [file, fileEntries] of buckets) {{
      fs.mkdirSync(path.dirname(file), {{ recursive: true }});
      const filtered: Record<string, any> = {{}};
      for (const [k, v] of Object.entries(fileEntries)) {{
        const vv = v as any;
        if (vv?.healedSelector || vv?.pendingSuggestion) filtered[k] = vv;
      }}
      const envelope = {{
        _version: 1,
        _writtenAt: new Date().toISOString(),
        entries: filtered,
      }};
      fs.writeFileSync(file, JSON.stringify(envelope, null, 2), "utf8");
    }}
  }}

  private _pending(): any[] {{
    return Object.entries(this._load())
      .filter(([, v]) => (v as any).pendingSuggestion)
      .map(([k, v]) => {{
        const vv = v as any;
        return {{
          key: k, intent: vv.intent,
          current: vv.healedSelector ?? vv.selector,
          suggestion: vv.pendingSuggestion,
          failureCount: vv.failureCount,
          healCount: vv.healingHistory.length,
        }};
      }});
  }}

  private _analytics(): any {{
    const e = Object.entries(this._load());
    return {{
      totalLocators:    e.length,
      healedLocators:   e.filter(([, v]) => (v as any).healedSelector).length,
      pendingApprovals: e.filter(([, v]) => (v as any).pendingSuggestion).length,
      timingSuggestions: e.filter(([k]) => k.startsWith("timing:")).length,
      visualSuggestions: e.filter(([k]) => k.startsWith("visual:")).length,
      mostHealedKeys:   e
        .sort(([, a], [, b]) => (b as any).healingHistory.length - (a as any).healingHistory.length)
        .slice(0, 10)
        .map(([k, v]) => ({{ key: k, healCount: (v as any).healingHistory.length, intent: (v as any).intent }})),
    }};
  }}

  /**
   * Approve a heal or a pending suggestion.
   *
   * Two flows converge here:
   *   • Pending suggestion (AI Vision, etc.) — commits suggestion.selector to
   *     healedSelector and clears the pending state.
   *   • Already-healed entry (automatic strategy succeeded mid-test) — stamps
   *     `approvedAt` on the most recent history record so future runs know
   *     a human has signed off; the selector itself is unchanged.
   *
   * Either way, the healed selector is propagated back to the source
   * locator `.ts` file by `_writeBackToLocatorFile` so the next test run
   * starts with the corrected primary selector (no healing chain probe
   * needed until the page changes again).
   *
   * Returns false only when the key isn't in the store at all.
   */
  private _approve(key: string): boolean {{
    const d = this._load(); const e = d[key];
    if (!e) return false;
    let wrote = false;
    let kind: "suggestion" | "heal" | null = null;
    if (e.pendingSuggestion) {{
      const oldSelector = e.healedSelector ?? e.selector;
      const newSelector = e.pendingSuggestion.selector;
      wrote = this._writeBackToLocatorFile(key, oldSelector, newSelector);
      kind = "suggestion";
    }} else if (e.healedSelector) {{
      wrote = this._writeBackToLocatorFile(key, e.selector, e.healedSelector);
      kind = "heal";
    }} else {{
      return false;
    }}
    // Clear heal state so the entry is dropped by `_save`'s heal-only filter
    // and disappears from the dashboard — same shape as `_reject`, so Accept
    // and Revert behave consistently. The locator `.ts` file now holds the
    // healed selector (when write-back succeeded), so the page-object's
    // constructor will re-register the fresh baseline on the next run.
    delete e.pendingSuggestion;
    delete e.healedSelector;
    delete e.lastHealed;
    delete e.approvedAt;
    e.healingHistory = [];
    this._save(d);
    const label = kind === "suggestion" ? "approved suggestion" : "confirmed heal";
    console.log(`✅ HealingDashboard: ${{label}} for "${{key}}"${{wrote ? " + applied to locator file" : ""}}`);
    return true;
  }}

  /**
   * Rewrite the `selector:` field for a single locator entry inside a
   * `*.locators.ts` file. Matches by `<key>: {{ selector: "<oldSelector>", ...`
   * so we only touch the exact entry the heal came from — bare-key callers
   * (e.g. heal key = `"usernameInput"`) still resolve because the locator
   * file uses bare keys inside the `xxxLocators = {{ ... }}` object literal.
   *
   * Handles all three TypeScript string forms — `"..."`, `'...'`, and
   * `` `...` `` — and preserves the quote style when writing back.
   *
   * Scans every `*.ts` file under `LOCATOR_FILES_DIR` (default
   * `./src/locators`). Returns true if any file was modified.
   */
  private _writeBackToLocatorFile(key: string, oldSelector: string, newSelector: string): boolean {{
    if (!oldSelector || !newSelector || oldSelector === newSelector) return false;
    const dir = process.env.LOCATOR_FILES_DIR ?? "./src/locators";
    if (!fs.existsSync(dir)) return false;
    const bareKey = key.includes(".") ? (key.split(".").pop() ?? key) : key;
    let updated = false;
    const visit = (d: string): void => {{
      for (const entry of fs.readdirSync(d, {{ withFileTypes: true }})) {{
        const p = path.join(d, entry.name);
        if (entry.isDirectory()) {{ visit(p); continue; }}
        if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
        let content = fs.readFileSync(p, "utf8");
        const next = LocatorHealer_rewriteEntrySelector(content, bareKey, oldSelector, newSelector);
        if (next !== content) {{
          fs.writeFileSync(p, next, "utf8");
          updated = true;
          console.log(`  ✓ wrote new selector for "${{bareKey}}" → ${{p}}`);
        }}
      }}
    }};
    try {{ visit(dir); }} catch {{}}
    return updated;
  }}

  /**
   * Reject a heal or pending suggestion. The entry is reverted to its
   * baseline (only `selector`/`intent`/`stability` remain) and *not*
   * persisted by the next write — the LocatorRepository filter keeps only
   * entries with healedSelector or pendingSuggestion. Effectively, reject
   * makes the bad heal disappear from the file.
   */
  private _reject(key: string): boolean {{
    const d = this._load(); const e = d[key];
    if (!e) return false;
    const had = Boolean(e.pendingSuggestion) || Boolean(e.healedSelector);
    if (!had) return false;
    delete e.pendingSuggestion;
    delete e.healedSelector;
    delete e.lastHealed;
    delete e.approvedAt;
    e.healingHistory = [];
    this._save(d);
    // Re-save filters: an entry with no heal state no longer qualifies,
    // so the file no longer mentions this key. The page-object's
    // constructor will re-register the baseline on the next process start.
    console.log(`❌ HealingDashboard: rejected heal for "${{key}}"`);
    return true;
  }}

  /**
   * Bulk-approve every entry currently shown in the "Healed locators"
   * table — applies each healed (or pending-on-top-of-healed) selector
   * back to its locator `.ts` file and clears the heal state in one
   * save pass. Entries that are pending-only (no `healedSelector`) are
   * skipped, since per-row Approve is the right surface for those.
   */
  private _approveAll(): {{ approved: number; written: number }} {{
    const d = this._load();
    let approved = 0;
    let written = 0;
    for (const [key, ev] of Object.entries(d)) {{
      const e = ev as any;
      if (!e.healedSelector) continue;
      let wrote = false;
      if (e.pendingSuggestion) {{
        const oldSelector = e.healedSelector ?? e.selector;
        const newSelector = e.pendingSuggestion.selector;
        wrote = this._writeBackToLocatorFile(key, oldSelector, newSelector);
      }} else {{
        wrote = this._writeBackToLocatorFile(key, e.selector, e.healedSelector);
      }}
      if (wrote) written++;
      delete e.pendingSuggestion;
      delete e.healedSelector;
      delete e.lastHealed;
      delete e.approvedAt;
      e.healingHistory = [];
      approved++;
    }}
    if (approved > 0) {{
      this._save(d);
      console.log(`✅ HealingDashboard: accepted all ${{approved}} healed entries (${{written}} written to locator files)`);
    }}
    return {{ approved, written }};
  }}

  private _html(readOnly: boolean = false): string {{
    const pending   = this._pending();
    const healed    = this._healed();
    const analytics = this._analytics();
    // ── Telemetry banner state ──────────────────────────────────────────────
    // The banner is only rendered when:
    //   • we have at least one healed entry to talk about, AND
    //   • consent is still "unset" (user hasn't seen the banner yet), AND
    //   • the dashboard is not in read-only review mode.
    // The banner shows a *live sample* of the scrubbed payload, then lets the
    // user accept or decline. The choice is persisted into the heal-store
    // envelope as `_telemetryConsent` so the next page load skips the banner.
    const telemetryConsent   = _telemetryConsentStatus();
    const telemetryEnv       = (process.env.STLC_TELEMETRY ?? "off").toLowerCase();
    const telemetryPreviewAll = _telemetryBuildPreview();
    const telemetrySample    = telemetryPreviewAll.slice(0, 2);
    const showTelemetryBanner = !readOnly
      && healed.length > 0
      && telemetryConsent === "unset";
    const actionsCell = (key: string, kind: string, visualLink: boolean) => readOnly
      ? `<small style="color:#64748b">read-only</small>${{visualLink ? ` &middot; <a href="/api/visual/${{encodeURIComponent(key)}}" target="_blank" style="color:#818cf8">View diff</a>` : ""}}`
      : `<button onclick="act('approve','${{encodeURIComponent(key)}}')" style="background:#22c55e;color:#000;border:none;padding:4px 10px;border-radius:3px;cursor:pointer">${{kind === "pending" ? "Approve" : "Confirm"}}</button>
          <button onclick="act('reject','${{encodeURIComponent(key)}}')"  style="background:#ef4444;color:#fff;border:none;padding:4px 10px;border-radius:3px;cursor:pointer;margin-left:4px">${{kind === "pending" ? "Reject" : "Revert"}}</button>
          ${{visualLink ? `<a href="/api/visual/${{encodeURIComponent(key)}}" target="_blank" style="color:#818cf8;margin-left:6px">View diff</a>` : ""}}`;
    const pendingRows = pending.map(p => `
      <tr>
        <td><code>${{p.key}}</code></td>
        <td>${{p.intent}}</td>
        <td><code style="color:#888">${{p.current}}</code></td>
        <td><code style="color:#f59e0b">${{p.suggestion.selector}}</code></td>
        <td>${{p.suggestion.strategy}}</td>
        <td><small>${{new Date(p.suggestion.suggestedAt).toLocaleString()}}</small></td>
        <td>${{actionsCell(p.key, "pending", p.key.startsWith("visual:"))}}</td>
      </tr>`).join("");
    const healedRows = healed.map(h => `
      <tr>
        <td><code>${{h.key}}</code></td>
        <td>${{h.intent}}</td>
        <td><code style="color:#888">${{h.selector}}</code></td>
        <td><code style="color:#22c55e">${{h.healedSelector}}</code></td>
        <td>${{h.strategy ?? ""}}</td>
        <td><small>${{h.lastHealed ? new Date(h.lastHealed).toLocaleString() : ""}}</small></td>
        <td>${{actionsCell(h.key, "healed", h.key.startsWith("visual:"))}}</td>
      </tr>`).join("");
    return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"><title>🩺 Healing Dashboard</title>
  <meta http-equiv="refresh" content="10">
  <style>
    body {{ font-family: monospace; background:#0f172a; color:#e2e8f0; padding:24px }}
    h1   {{ color:#38bdf8; margin-bottom:4px }}
    h2   {{ color:#94a3b8; margin:24px 0 8px; font-size:13px; letter-spacing:1px; text-transform:uppercase }}
    .sub {{ color:#64748b; font-size:12px; display:block; margin-bottom:20px }}
    .stats {{ display:flex; gap:12px; margin-bottom:20px }}
    .stat {{ background:#1e293b; border:1px solid #334155; border-radius:6px; padding:10px 16px }}
    .sv  {{ font-size:26px; font-weight:700; color:#38bdf8 }}
    .sl  {{ font-size:9px; color:#64748b; letter-spacing:1px }}
    table{{ width:100%; border-collapse:collapse; background:#1e293b; border-radius:6px; overflow:hidden; margin-bottom:24px }}
    th   {{ background:#0f172a; padding:9px 12px; font-size:9px; letter-spacing:1px; color:#64748b; text-align:left }}
    td   {{ padding:9px 12px; border-bottom:1px solid #334155; font-size:11px }}
    .telem-banner {{ background:#0c1c34; border:1px solid #2563eb; border-radius:8px; padding:18px 22px; margin:0 0 20px 0 }}
    .telem-title  {{ color:#60a5fa; font-size:14px; font-weight:700; margin:0 0 4px 0; letter-spacing:0.5px }}
    .telem-body   {{ color:#cbd5e1; font-size:12px; line-height:1.55; margin:0 0 12px 0 }}
    .telem-body code {{ background:#1e293b; padding:1px 5px; border-radius:3px; color:#fcd34d }}
    .telem-sample {{ background:#0f172a; border:1px solid #1e293b; border-radius:5px; padding:10px 14px;
                     font-size:10.5px; color:#94a3b8; max-height:240px; overflow:auto; margin:8px 0 12px 0;
                     white-space:pre; font-family:ui-monospace,monospace }}
    .telem-actions button {{ font-family:monospace; font-size:11px; font-weight:700;
                              padding:7px 18px; border-radius:4px; border:none; cursor:pointer; margin-right:8px }}
    .telem-accept {{ background:#22c55e; color:#0f172a }}
    .telem-decline{{ background:#475569; color:#e2e8f0 }}
    .telem-info   {{ color:#64748b; font-size:10.5px; margin-top:8px; display:block }}
    .telem-badge  {{ background:#1e293b; color:#94a3b8; font-size:9px; padding:3px 8px;
                     border-radius:99px; letter-spacing:0.6px; margin-left:8px; text-transform:uppercase }}
  </style>
</head>
<body>
  <h1>🩺 Healing Dashboard
    ${{telemetryConsent === "accepted" ? `<span class="telem-badge" style="color:#86efac">telemetry: accepted</span>` : ""}}
    ${{telemetryConsent === "declined" ? `<span class="telem-badge" style="color:#fda4af">telemetry: declined</span>` : ""}}
  </h1>
  ${{showTelemetryBanner ? `
  <div class="telem-banner" id="telem-banner">
    <p class="telem-title">📡 Help train better selector healing</p>
    <p class="telem-body">
      To make the agents in <code>qa-stlc-agents</code> smarter at writing
      self-healing Playwright code, a <strong>scrubbed</strong> version of
      your healed locators can be uploaded for training. <strong>Nothing in
      the payload identifies your app</strong>: selectors, DOM text, URLs,
      filenames, usernames, and intent strings are all stripped. Only
      categorical fields (intent <em>shape</em>, winning strategy, attribute
      <em>names</em>, stability tier) are sent.
    </p>
    <p class="telem-body" style="margin-bottom:6px">
      Here is a real sample of what would be sent based on
      <strong>${{healed.length}}</strong> healed locator(s) currently in
      your repository (showing first ${{telemetrySample.length}} of
      ${{telemetryPreviewAll.length}}):
    </p>
    <div class="telem-sample">${{(JSON.stringify(telemetrySample, null, 2) || "[]").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}}</div>
    <div class="telem-actions">
      <button class="telem-accept"  onclick="telemConsent('accepted')">Yes, share scrubbed data</button>
      <button class="telem-decline" onclick="telemConsent('declined')">No, keep local only</button>
    </div>
    <small class="telem-info">
      Upload also requires <code>STLC_TELEMETRY=on</code>
      (currently: <code>${{telemetryEnv}}</code>). You can change your mind
      anytime by editing <code>_telemetryConsent</code> in the heal-store
      JSON, or run <code>npm run healix:telemetry-preview</code> to see the
      full payload offline. Install ID:
      <code>${{telemetryConsent === "unset" ? "n/a" : ""}}</code>
    </small>
  </div>` : ""}}
  ${{healed.length > 1 && !readOnly ? `<button onclick="actAll()" style="position:absolute;top:20px;right:24px;background:#22c55e;color:#000;border:none;padding:8px 16px;border-radius:4px;cursor:pointer;font-family:monospace;font-size:12px;font-weight:700">Approve all (${{healed.length}})</button>` : ""}}
  <span class="sub">
    Auto-refreshes every 10s &middot;
    <a href="/api/healed"    style="color:#818cf8">Healed JSON</a> &middot;
    <a href="/api/pending"   style="color:#818cf8">Pending JSON</a> &middot;
    <a href="/api/analytics" style="color:#818cf8">Analytics</a> &middot;
    <a href="/api/heal-log"  style="color:#818cf8">Heal log</a>
  </span>
  <div class="stats">
    <div class="stat"><div class="sv">${{analytics.healedLocators}}</div><div class="sl">HEALED</div></div>
    <div class="stat"><div class="sv">${{analytics.pendingApprovals}}</div><div class="sl">PENDING</div></div>
    <div class="stat"><div class="sv">${{analytics.timingSuggestions}}</div><div class="sl">TIMING</div></div>
    <div class="stat"><div class="sv">${{analytics.visualSuggestions}}</div><div class="sl">VISUAL</div></div>
  </div>

  <h2>Healed locators</h2>
  <table>
    <thead>
      <tr><th>Key</th><th>Intent</th><th>Original</th><th>Healed</th><th>Strategy</th><th>Last healed</th><th>Action</th></tr>
    </thead>
    <tbody>
      ${{healedRows || '<tr><td colspan="7" style="padding:24px;text-align:center;color:#64748b">✓ No healed locators yet</td></tr>'}}
    </tbody>
  </table>

  <h2>Pending AI suggestions</h2>
  <table>
    <thead>
      <tr><th>Key</th><th>Intent</th><th>Current</th><th>Suggested</th><th>Strategy</th><th>Time</th><th>Action</th></tr>
    </thead>
    <tbody>
      ${{pendingRows || '<tr><td colspan="7" style="padding:24px;text-align:center;color:#64748b">✓ No pending suggestions</td></tr>'}}
    </tbody>
  </table>

  <script>
    async function act(action, key) {{
      await fetch("/api/" + action + "/" + key, {{ method: "POST" }});
      location.reload();
    }}
    async function actAll() {{
      if (!confirm("Accept all healed locators? Each healed selector will be written back to its locator file and the entries will be cleared from this dashboard.")) return;
      const r = await fetch("/api/approve-all", {{ method: "POST" }});
      const j = await r.json().catch(function() {{ return {{}}; }});
      if (j && typeof j.approved === "number") {{
        console.log("Accepted " + j.approved + " (" + j.written + " written to locator files)");
      }}
      location.reload();
    }}
    async function telemConsent(choice) {{
      var msg = choice === "accepted"
        ? "Thanks — scrubbed heal events will be uploaded only when STLC_TELEMETRY=on. You can run \\"npm run healix:telemetry-preview\\" anytime to see exactly what would be sent."
        : "OK — nothing will be uploaded. The choice is stored in the heal-store JSON; delete the _telemetryConsent key to be asked again.";
      const r = await fetch("/api/telemetry-consent", {{
        method: "POST",
        headers: {{ "Content-Type": "application/json" }},
        body: JSON.stringify({{ choice: choice }}),
      }});
      if (r.ok) {{ alert(msg); location.reload(); }}
      else      {{ alert("Could not record consent — see dashboard logs."); }}
    }}
  </script>
</body>
</html>`;
  }}
}}
'''




# ============================================================================
# Entry point  (DevToolsHealer removed — not part of the boilerplate framework)
# ============================================================================

async def _run():
    async with stdio_server() as (r, w):
        await app.run(r, w, app.create_initialization_options())


def main():
    try:
        get_auth_headers()
    except Exception as e:
        print(f"[qa-playwright-generator] Auth error: {e}", file=sys.stderr)
        sys.exit(1)
    user = get_signed_in_user()
    if user:
        print(f"[qa-playwright-generator] Authenticated as: {user}", file=sys.stderr)
    asyncio.run(_run())


if __name__ == "__main__":
    main()