"""
Inject TimingHealer and VisualIntentChecker into migrated Playwright code.

Scope: ALL Playwright interactions — page objects, step definitions, and
       any helper file that contains raw page.click / page.fill calls.

Design (since 2026-05): every healer instance is owned by the cucumber World
(`PageFixture`) and accessed via `fixture().locatorHealer` / `.locatorTiming`
/ `.locatorVisual` / `.locatorRepository`. Page objects and step files no
longer declare or instantiate their own healers.

This module:

  1. NEW FILES  — emits no per-class fields / per-step setup blocks. Action
     rewrites point at `fixture().locatorX` directly.
  2. OLD FILES  — files migrated by an earlier version of this module
     (`private _healer = new LocatorHealer(...)` etc.) are auto-upgraded:
     the boilerplate is stripped, references are renamed to the fixture
     equivalents, and a `fixture` import is added if missing.

The single import this module adds is `fixture` from `@hooks/pageFixture`.
"""
from __future__ import annotations
import re

# The only import this module now adds. The healer/timing/visual/repository
# classes are referenced exclusively via `fixture().*`, so files don't need
# direct imports of those types.
_HEALER_IMPORTS = "import { fixture } from '@hooks/pageFixture';"

# Per-class field declarations and constructor wiring are no longer emitted.
# The shared instances live on PageFixture and are set up once per scenario
# in `hooks.ts` `Before`. These constants stay as empty strings so the rest
# of the file (which still concatenates them into the output) keeps working.
_HEALER_FIELDS = ""
_HEALER_CTOR_ASSIGN = "{registrations}"
_STEP_HEALER_SETUP = ""
_STEP_ARROW_HEALER_SETUP = ""

# Detect `import { xxxLocators[, ...] } from '@locators/...'`
_LOCATOR_IMPORT_RE = re.compile(
    r"import\s+\{([^}]+)\}\s+from\s+['\"]@locators/[^'\"]+['\"]"
)

# Patterns used by the upgrade pass to strip boilerplate emitted by earlier
# versions of this module. They are anchored on the EXACT shapes we used to
# emit, so they don't accidentally chew through hand-written code that
# happens to mention `LocatorHealer`.

# 1. Old healer imports — each occupies its own line; match them
#    individually so partial-removal (one line out of five) still works.
_OLD_HEALER_IMPORT_LINE_RES = [
    re.compile(r"^import \{ createLogger, transports \} from ['\"]winston['\"];\s*\n", re.MULTILINE),
    re.compile(r"^import \{ LocatorHealer \} from ['\"]@utils/locators/LocatorHealer['\"];\s*\n", re.MULTILINE),
    re.compile(r"^import \{ LocatorRepository \} from ['\"]@utils/locators/LocatorRepository['\"];\s*\n", re.MULTILINE),
    re.compile(r"^import \{ TimingHealer \} from ['\"]@utils/locators/TimingHealer['\"];\s*\n", re.MULTILINE),
    re.compile(r"^import \{ VisualIntentChecker \} from ['\"]@utils/locators/VisualIntentChecker['\"];\s*\n", re.MULTILINE),
]

# 2. Old per-class field declarations (page objects). Indentation tolerant.
_OLD_PO_FIELDS_RE = re.compile(
    r"^[ \t]*private _repo\s*=\s*new LocatorRepository\(\);\s*\n"
    r"[ \t]*private _logger\s*=\s*createLogger\(\{ transports: \[new transports\.Console\(\)\] \}\);\s*\n"
    r"[ \t]*private _healer!:\s*LocatorHealer;\s*\n"
    r"[ \t]*private _timing!:\s*TimingHealer;\s*\n"
    r"[ \t]*private _visual!:\s*VisualIntentChecker;\s*\n",
    re.MULTILINE,
)

# 3. Old per-class constructor wiring (3 lines, occasionally surrounded by
#    blank lines).
_OLD_PO_CTOR_RE = re.compile(
    r"\n?[ \t]*this\._healer\s*=\s*new LocatorHealer\(this\.page,\s*this\._logger,\s*this\._repo\);\s*\n"
    r"[ \t]*this\._timing\s*=\s*new TimingHealer\(this\.page,\s*this\._logger,\s*this\._repo\);\s*\n"
    r"[ \t]*this\._visual\s*=\s*new VisualIntentChecker\(this\.page,\s*this\._logger,\s*this\._repo\);\s*\n",
    re.MULTILINE,
)

# 4. Old per-step setup blocks (function-style and arrow-style). The first
#    `_repo` line owns the leading newline so removal collapses cleanly.
_OLD_STEP_SETUP_RE = re.compile(
    r"\n?[ \t]*const _repo\s*=\s*new LocatorRepository\(\);\s*\n"
    r"[ \t]*const _logger\s*=\s*createLogger\(\{ transports: \[new transports\.Console\(\)\] \}\);\s*\n"
    r"[ \t]*const _healer\s*=\s*new LocatorHealer\(this\.page!,\s*_logger,\s*_repo\);\s*\n"
    r"[ \t]*const _timing\s*=\s*new TimingHealer\(this\.page!,\s*_logger,\s*_repo\);\s*\n"
    r"[ \t]*const _visual\s*=\s*new VisualIntentChecker\(this\.page!,\s*_logger,\s*_repo\);\s*\n",
    re.MULTILINE,
)

_OLD_STEP_ARROW_SETUP_RE = re.compile(
    r"\n?[ \t]*const _repo\s*=\s*new LocatorRepository\(\);\s*\n"
    r"[ \t]*const _logger\s*=\s*createLogger\(\{ transports: \[new transports\.Console\(\)\] \}\);\s*\n"
    r"[ \t]*const _page\s*=\s*fixture\(\)\.page;\s*\n"
    r"[ \t]*const _healer\s*=\s*new LocatorHealer\(_page,\s*_logger,\s*_repo\);\s*\n"
    r"[ \t]*const _timing\s*=\s*new TimingHealer\(_page,\s*_logger,\s*_repo\);\s*\n"
    r"[ \t]*const _visual\s*=\s*new VisualIntentChecker\(_page,\s*_logger,\s*_repo\);\s*\n",
    re.MULTILINE,
)

# 5. Reference renames — `(?<!\w)` instead of `\b` so we don't accidentally
#    match `_cardFrameHealer` or other identifiers that happen to end in
#    `_healer`. We DON'T rename `this._healer` to `_healer` then `fixture(...)`
#    because order-of-operations matters; the explicit `this._healer\.` form
#    is renamed first, then any remaining bare `_healer\.` in step bodies.
_REWIRE_REFERENCE_RES = [
    (re.compile(r"\bthis\._healer\."), "fixture().locatorHealer."),
    (re.compile(r"\bthis\._timing\."), "fixture().locatorTiming."),
    (re.compile(r"\bthis\._visual\."), "fixture().locatorVisual."),
    (re.compile(r"\bthis\._repo\.register"), "fixture().locatorRepository.register"),
    (re.compile(r"(?<![\w.])_healer\."), "fixture().locatorHealer."),
    (re.compile(r"(?<![\w.])_timing\."), "fixture().locatorTiming."),
    (re.compile(r"(?<![\w.])_visual\."), "fixture().locatorVisual."),
]


def _strip_old_boilerplate(content: str, role: str, changes: list[str]) -> str:
    """
    Strip per-class fields, per-step setup blocks, and the 5 healer imports
    emitted by earlier versions of this module, then rename existing
    references to their `fixture()`-based equivalents. After this pass, the
    file looks like one the current emitter would produce, and the normal
    `inject_healers` flow can proceed as if it were a fresh file.

    Idempotent: running it on a file that's already on the new pattern is
    a no-op (the regexes don't match anything).
    """
    before = content

    # Imports
    for pat in _OLD_HEALER_IMPORT_LINE_RES:
        content = pat.sub("", content)

    # Per-class fields + ctor wiring (page objects only)
    if role == "page_object":
        content = _OLD_PO_FIELDS_RE.sub("", content)
        content = _OLD_PO_CTOR_RE.sub("\n", content)

    # Per-step setup blocks (step files only)
    if role == "step_def":
        content = _OLD_STEP_SETUP_RE.sub("", content)
        content = _OLD_STEP_ARROW_SETUP_RE.sub("", content)

    # Rename remaining references to the fixture forms.
    for pat, repl in _REWIRE_REFERENCE_RES:
        content = pat.sub(repl, content)

    if content != before:
        changes.append("Upgraded old per-class/per-step boilerplate to fixture() references")
    return content

# Receivers for `<X>.click(...)` etc.
#   • `page` / `this.page` / `world.page` / `ctx.page` — direct
#   • `currentFixture.page` / `this.currentFixture.page` — common QA fixture
# Anything else (e.g. raw `popup.click`) stays untouched.
_PAGE_EXPR   = r"(?:(?:this|world|ctx)\s*\.\s*)?(?:currentFixture\s*\.\s*)?page"

# `await` is optional: Playwright actions also appear unawaited inside
# `Promise.all([...])` blocks (the await is on the surrounding Promise.all).
# Allowing optional await covers both forms without inventing matches —
# the rest of the pattern still requires the exact `<receiver>.<verb>(...)`
# shape so we don't grab unrelated method calls.
_AWAIT_OPT   = r"(?:await\s+)?"

_CLICK_RE    = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*click\(\s*([^)]+)\s*\)")
_FILL_RE     = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*fill\(\s*([^,)]+)\s*,\s*([^)]+)\s*\)")
_CHECK_RE    = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*check\(\s*([^)]+)\s*\)")
_UNCHECK_RE  = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*uncheck\(\s*([^)]+)\s*\)")
_SELECT_RE   = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*selectOption\(\s*([^,)]+)\s*,\s*([^)]+)\s*\)")
# Extended page-level action verbs (added 2026-05) — used by _inject_generic's
# detection check and to keep parity with the action-rewriter wiring.
_HOVER_RE      = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*hover\(\s*([^)]+)\s*\)")
_DBLCLICK_RE   = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*dblclick\(\s*([^)]+)\s*\)")
_FOCUS_RE      = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*focus\(\s*([^)]+)\s*\)")
_TAP_RE        = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*tap\(\s*([^)]+)\s*\)")
_PRESS_RE      = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*press\(\s*([^,)]+)\s*,\s*([^)]+)\s*\)")
_TYPE_RE       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*type\(\s*([^,)]+)\s*,\s*([^)]+)\s*\)")
_SET_FILES_RE  = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*setInputFiles\(\s*([^,)]+)\s*,\s*([^)]+)\s*\)")
_GOTO_RE     = re.compile(rf"await\s+({_PAGE_EXPR})\s*\.\s*goto\(\s*([^)]+)\s*\)")
_EXPECT_VISIBLE_RE = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeVisible\([^)]*\)")
# Extended state assertions — match `expect(<inner>).toBeEnabled() / .toBeChecked() / .toHaveValue(v) / .toHaveText(v)`.
# Argument captures (where present) intentionally allow trailing options blocks (`, { timeout: 5000 }`)
# by stopping at the FIRST top-level `,` or the closing `)`.
_EXPECT_ENABLED_RE = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeEnabled\([^)]*\)")
_EXPECT_CHECKED_RE = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeChecked\([^)]*\)")
_EXPECT_HAVE_VALUE_RE = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toHaveValue\(\s*([^,)]+)[^)]*\)")
_EXPECT_HAVE_TEXT_RE  = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toHaveText\(\s*([^,)]+)[^)]*\)")

# ── Extended assertion matchers (added 2026-05) ──────────────────────────────
# Positive (no .not.) forms. Order rationale: these run AFTER the legacy
# matchers above so legacy callers keep their original change-log lines.
#
# Zero-arg matchers:
_EXPECT_HIDDEN_RE       = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeHidden\([^)]*\)")
_EXPECT_DISABLED_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeDisabled\([^)]*\)")
_EXPECT_FOCUSED_RE      = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeFocused\([^)]*\)")
_EXPECT_EDITABLE_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeEditable\([^)]*\)")
_EXPECT_ATTACHED_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeAttached\([^)]*\)")
_EXPECT_INVIEWPORT_RE   = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toBeInViewport\([^)]*\)")
# One-arg matchers (value/count/class/id/text-substring):
_EXPECT_CONTAIN_TEXT_RE = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toContainText\(\s*([^,)]+)[^)]*\)")
_EXPECT_HAVE_COUNT_RE   = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toHaveCount\(\s*([^,)]+)[^)]*\)")
_EXPECT_HAVE_CLASS_RE   = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toHaveClass\(\s*([^,)]+)[^)]*\)")
_EXPECT_HAVE_ID_RE      = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toHaveId\(\s*([^,)]+)[^)]*\)")
# Two-arg matchers (attribute name + expected value, CSS prop + value):
_EXPECT_HAVE_ATTR_RE    = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toHaveAttribute\(\s*([^,]+)\s*,\s*([^,)]+)[^)]*\)")
_EXPECT_HAVE_CSS_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*toHaveCSS\(\s*([^,]+)\s*,\s*([^,)]+)[^)]*\)")

# Negated (`.not.`) forms. Each matcher gets its own regex so we can map to
# either a natural antonym method (`not.toBeVisible → assertHiddenWithHealing`)
# or to a dedicated `assertNot<Verb>WithHealing` method. Keeping them as
# separate patterns lets the rewriter pick the right healer method per
# matcher without runtime branching on a "negated" flag.
_NOT_EXPECT_VISIBLE_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeVisible\([^)]*\)")
_NOT_EXPECT_HIDDEN_RE      = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeHidden\([^)]*\)")
_NOT_EXPECT_ENABLED_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeEnabled\([^)]*\)")
_NOT_EXPECT_DISABLED_RE    = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeDisabled\([^)]*\)")
_NOT_EXPECT_CHECKED_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeChecked\([^)]*\)")
_NOT_EXPECT_FOCUSED_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeFocused\([^)]*\)")
_NOT_EXPECT_EDITABLE_RE    = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeEditable\([^)]*\)")
_NOT_EXPECT_ATTACHED_RE    = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeAttached\([^)]*\)")
_NOT_EXPECT_INVIEWPORT_RE  = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toBeInViewport\([^)]*\)")
_NOT_EXPECT_HAVE_VALUE_RE  = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toHaveValue\(\s*([^,)]+)[^)]*\)")
_NOT_EXPECT_HAVE_TEXT_RE   = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toHaveText\(\s*([^,)]+)[^)]*\)")
_NOT_EXPECT_CONTAIN_TEXT_RE = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toContainText\(\s*([^,)]+)[^)]*\)")
_NOT_EXPECT_HAVE_COUNT_RE  = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toHaveCount\(\s*([^,)]+)[^)]*\)")
_NOT_EXPECT_HAVE_CLASS_RE  = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toHaveClass\(\s*([^,)]+)[^)]*\)")
_NOT_EXPECT_HAVE_ID_RE     = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toHaveId\(\s*([^,)]+)[^)]*\)")
_NOT_EXPECT_HAVE_ATTR_RE   = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toHaveAttribute\(\s*([^,]+)\s*,\s*([^,)]+)[^)]*\)")
_NOT_EXPECT_HAVE_CSS_RE    = re.compile(r"await\s+expect\(([^)]+)\)\s*\.\s*not\s*\.\s*toHaveCSS\(\s*([^,]+)\s*,\s*([^,)]+)[^)]*\)")

_counter: list[int] = [0]


def _next_key(prefix: str) -> str:
    _counter[0] += 1
    return f"{prefix}{_counter[0]}"


def _find_matching_brace(content: str, open_pos: int) -> int | None:
    """
    Walk forward from a `{` at `open_pos` and return the index of the
    matching `}`. Honours nested `{` `[` `(`, plus single, double, template
    string literals (and their `\\` escapes) so braces inside strings don't
    affect the depth count. Returns None if no match is found.

    Used by `_inject_page_object` to land at the END of a constructor body
    when injecting healer assignments. Inserting at the start breaks
    constructors that do `this.page = page` later in the body — the healer
    captures `this.page` while it's still undefined.
    """
    if open_pos < 0 or open_pos >= len(content) or content[open_pos] != "{":
        return None
    depth = 1
    i = open_pos + 1
    in_str: str | None = None
    while i < len(content):
        ch = content[i]
        if in_str is not None:
            if ch == "\\" and i + 1 < len(content):
                i += 2
                continue
            if ch == in_str:
                in_str = None
            i += 1
            continue
        if ch in ('"', "'", "`"):
            in_str = ch
            i += 1
            continue
        # Skip `//` and `/* */` comments so braces inside them don't count.
        if ch == "/" and i + 1 < len(content):
            nxt = content[i + 1]
            if nxt == "/":
                nl = content.find("\n", i + 2)
                i = nl + 1 if nl != -1 else len(content)
                continue
            if nxt == "*":
                end = content.find("*/", i + 2)
                i = end + 2 if end != -1 else len(content)
                continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return None


# ── Dynamic heal-key plumbing ───────────────────────────────────────────────
#
# `_extract_runtime_parts` and `_sanitise_key_slug` together let the healer
# rewriter emit a per-parameter heal-store key for selectors that are
# computed at runtime (template literals, function-call results, bare
# identifiers). Without them, every parameterised call site collapses to a
# single static `dyn.<prefix>.<N>` key — e.g. `clickIndividualTab(label)`
# invoked with five tab labels writes to the same heal-store row, so the
# cached selector is whichever tab clicked last, defeating the cache.

_TEMPLATE_PLACEHOLDER_RE = re.compile(r"\$\{([^{}]+)\}")
_IDENT_OR_DOTTED_RE      = re.compile(r"^[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*$")
_FUNC_CALL_RE            = re.compile(r"^([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\((.*)\)\s*$", re.DOTALL)
_SUBSCRIPT_RE            = re.compile(r"^([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\[\s*(.+?)\s*\]\s*$", re.DOTALL)


def _split_top_level_args(args_str: str) -> list[str]:
    """
    Split a function-call argument list on top-level commas only — respects
    nested parens, brackets, braces, and string / template literals.
    """
    out: list[str] = []
    depth = 0
    in_str: str | None = None
    buf: list[str] = []
    i = 0
    while i < len(args_str):
        ch = args_str[i]
        if in_str is not None:
            buf.append(ch)
            if ch == "\\" and i + 1 < len(args_str):
                buf.append(args_str[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:
            out.append("".join(buf).strip())
            buf = []
        else:
            buf.append(ch)
        i += 1
    tail = "".join(buf).strip()
    if tail:
        out.append(tail)
    return out


def _extract_runtime_parts(sel_expr: str) -> list[str]:
    """
    Return the list of JS sub-expressions that vary at runtime in a selector
    expression, suitable for embedding as `${...}` segments in a heal-key
    template literal.

      • String literal (`'#submit'`)               → []  (fully static)
      • Template literal `` `...${X}...${Y}...` `` → ["X", "Y"]
      • Function call `foo.bar(arg1, arg2)`        → ["arg1", "arg2"]
      • Subscript access `map[key]`                → ["key"]
      • Bare identifier / dotted ref `someVar`     → ["someVar"]
      • Anything else (complex expression)         → [stripped expression]

    The returned segments are inserted verbatim into a JS template literal,
    so each is evaluated at runtime in the caller's scope and stringified.
    """
    s = sel_expr.strip()
    if not s:
        return []
    # Strip a trailing comma if present (call-site parser sometimes leaves one).
    if s.endswith(","):
        s = s[:-1].rstrip()

    # 1. String literal — fully static, no runtime parts.
    if (len(s) >= 2) and (
        (s[0] == "'" and s[-1] == "'")
        or (s[0] == '"' and s[-1] == '"')
    ):
        return []

    # 2. Template literal — extract `${...}` placeholders verbatim.
    if s.startswith("`") and s.endswith("`"):
        return [m.group(1).strip() for m in _TEMPLATE_PLACEHOLDER_RE.finditer(s)]

    # 3. Function-call result — use the call's args. `foo()` with no args
    #    still distinguishes the *call site* from a static literal, but has
    #    no runtime parts to interpolate; fall through to the identifier
    #    branch so the function name itself becomes the runtime part.
    fc = _FUNC_CALL_RE.match(s)
    if fc:
        args = _split_top_level_args(fc.group(2))
        if args:
            return args
        # zero-arg call: use the callee expression so different call sites
        # can still be told apart inside one file.
        return [fc.group(1).strip()]

    # 4. Subscript access `map[key]` — the index expression is the runtime
    #    discriminator. The receiver is constant in this scope.
    sub = _SUBSCRIPT_RE.match(s)
    if sub:
        return [sub.group(2).strip()]

    # 5. Bare identifier or dotted access — the identifier itself is the
    #    runtime discriminator.
    if _IDENT_OR_DOTTED_RE.match(s):
        return [s]

    # 6. Anything else (computed expressions, ternaries, etc.): embed
    #    verbatim. The healer key may end up long, but it'll still be
    #    deterministic for a given value of the expression.
    return [s]


def _sanitise_key_slug(raw: str) -> str:
    """
    Normalise a free-form slug (typically derived from a file path) into a
    safe heal-key segment: lowercase, alphanumerics + `-` only, no leading
    or trailing dashes, capped at 64 chars. Empty input → empty string,
    which keeps the legacy `dyn.<prefix>.<N>` key shape for callers that
    haven't been updated to pass a slug.
    """
    if not raw:
        return ""
    s = raw.strip().lower()
    s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
    return s[:64]


def _build_locator_registrations(content: str) -> str:
    """
    Scan the file for `import { xxxLocators } from '@locators/...'` statements
    and build constructor registration calls so the LocatorRepository is populated.

    For each imported locator object (xxxLocators), each entry is registered
    under BOTH key shapes so callers using either convention resolve:

      • Namespaced:  `xxxPage.<key>`   (emitted by this migration's healer rewriter)
      • Bare:        `<key>`           (emitted by older migration tools / hand-written code)

    Older migrated trees frequently have a mix — the rewriter touched some
    `page.click(...)` sites and converted them to `_healer.clickWithHealing('xxx.key', ...)`
    while leaving pre-existing `_healer.fillWithHealing("key", ...)` calls
    untouched. Registering both shapes means neither convention silently
    no-ops on `updateHealed` (the early-return on `store.get(key) === undefined`).

    Only emitted when the locator file has already been converted to the
    { selector, intent, stability } format by locator_registrar.
    """
    registrations: list[str] = []
    for m in _LOCATOR_IMPORT_RE.finditer(content):
        names_raw = m.group(1)
        names = [n.strip() for n in names_raw.split(",") if n.strip().endswith("Locators")]
        for name in names:
            # Derive a namespace prefix: dashboardLocators → "dashboard"
            ns = name[0].lower() + name[1:]
            ns = ns.replace("Locators", "")
            # Register through the SHARED repository on the cucumber World so
            # every page object in the scenario sees the same set of keys.
            registrations.append(
                f"    Object.entries({name}).forEach(([key, val]) => {{\n"
                f"      if (typeof val === 'object' && val !== null && 'selector' in val) {{\n"
                f"        fixture().locatorRepository.register(`{ns}.${{key}}`, "
                f"(val as any).selector, (val as any).intent, (val as any).stability ?? 0);\n"
                f"        // Bare-key alias for legacy call sites that didn't namespace the key.\n"
                f"        fixture().locatorRepository.register(key, "
                f"(val as any).selector, (val as any).intent, (val as any).stability ?? 0);\n"
                f"      }}\n"
                f"    }});\n"
            )
    return "".join(registrations)


def _ensure_locator_registrations(content: str, changes: list[str]) -> str:
    """
    Patch a *page-object* file that already has the healer scaffolded (imports
    + private fields + ctor assignments) but is missing the per-key
    `this._repo.register(...)` calls that populate the in-memory store.

    Without these calls every `updateHealed(...)` invocation finds no entry
    for its key, returns early, and the heal store JSON is never written —
    the symptom that surfaced on re-migration runs.

    Idempotent: if `fixture().locatorRepository.register(` already appears
    anywhere in the file, no change is made.
    """
    if "fixture().locatorRepository.register(" in content:
        return content
    registrations = _build_locator_registrations(content)
    if not registrations:
        return content
    # Insert into the constructor body of the first class we find. We anchor
    # on `constructor(...)\s*{` and then walk to the matching `}` so the
    # registrations slot in just before the constructor closes — same
    # placement strategy used by `_inject_page_object`.
    ctor_open_re = re.compile(r"constructor\s*\([^)]*\)\s*\{", re.MULTILINE)
    m_open = ctor_open_re.search(content)
    if m_open:
        ctor_end = _find_matching_brace(content, m_open.end() - 1)
        if ctor_end is not None:
            content = content[:ctor_end] + registrations + "  " + content[ctor_end:]
            changes.append("Injected missing fixture().locatorRepository.register() calls (re-migration patch)")
            return content
    return content


# `<receiver>.getByTestId(<obj>.<key>[.selector])` — captures receiver, object,
# key. Only `.selector`-accessed or bare locator-object refs are candidates;
# string-literal getByTestId("…") args are left alone (the source passed a real
# id there, not a wrapped locator).
# `<receiver>.getByTestId(<obj>.<key>[.selector])` — direct ref.
_GETBYTESTID_LOCREF_RE = re.compile(
    r"(?P<recv>(?:await\s+)?[\w.]*?)\.getByTestId\(\s*(?P<obj>\w+Locators)\.(?P<key>\w+)(?P<sel>\.selector)?\s*\)"
)
# `<receiver>.getByTestId(`${<obj>.<key>[.selector]}`)` — pure template wrapper
# (no suffix), equivalent to the direct form.
_GETBYTESTID_PUREWRAP_RE = re.compile(
    r"(?P<recv>(?:await\s+)?[\w.]*?)\.getByTestId\(\s*`\$\{\s*(?P<obj>\w+Locators)\.(?P<key>\w+)(?P<sel>\.selector)?\s*\}`\s*\)"
)
# A MANUAL `[data-testid="${<obj>.<key>[.selector]}"]` CSS-string construction
# (used in `waitForSelector(...)`, `locator(...)`, etc.). The source wrapped a
# BARE id this way; once the stored value is itself `[data-testid="..."]`
# (wrapped), this template double-wraps → an invalid selector
# `[data-testid="[data-testid=..."]"]`. For wrapped keys we collapse the manual
# template to just the locator ref (which already carries the data-testid form).
_MANUAL_TESTID_TEMPLATE_RE = re.compile(
    r"`\[data-testid=\"\$\{\s*(?P<obj>\w+Locators)\.(?P<key>\w+)(?P<sel>\.selector)?\s*\}\"\]`"
)


def _convert_wrapped_testid_calls(
    content: str,
    changes: list[str],
    wrapped_testid_keys: dict[str, set[str]] | None,
) -> str:
    """Rewrite `X.getByTestId(obj.key[.selector])` and the pure-template form
    `X.getByTestId(`${obj.key[.selector]}`)` → `X.locator(obj.key[.selector])`
    for locators whose stored value was wrapped to `[data-testid="..."]`.

    getByTestId wants a bare id; once the locator value is a CSS selector, only
    `.locator(...)` is valid. Non-wrapped keys, COMPOSED test-id bases
    (`${obj.key}_${i}` — those keys are excluded from wrapping upstream), and
    string-literal getByTestId args are left untouched."""
    if not wrapped_testid_keys:
        return content

    def _sub(m: re.Match) -> str:
        obj, key = m.group("obj"), m.group("key")
        if key not in wrapped_testid_keys.get(obj, set()):
            return m.group(0)
        sel = m.group("sel") or ""
        changes.append(f"getByTestId→locator for wrapped {obj}.{key}")
        return f"{m.group('recv')}.locator({obj}.{key}{sel})"

    def _collapse_manual_template(m: re.Match) -> str:
        obj, key = m.group("obj"), m.group("key")
        if key not in wrapped_testid_keys.get(obj, set()):
            return m.group(0)
        sel = m.group("sel") or ""
        changes.append(f"collapsed manual [data-testid] template for wrapped {obj}.{key}")
        return f"{obj}.{key}{sel}"

    content = _GETBYTESTID_LOCREF_RE.sub(_sub, content)
    content = _GETBYTESTID_PUREWRAP_RE.sub(_sub, content)
    content = _MANUAL_TESTID_TEMPLATE_RE.sub(_collapse_manual_template, content)
    return content


def inject_healers(
    content: str,
    role: str,
    object_keys: dict[str, set[str]] | None = None,
    file_slug: str = "",
    wrapped_testid_keys: dict[str, set[str]] | None = None,
) -> tuple[str, list[str], int]:
    """
    Inject healer wrappers.

    role: "page_object" | "step_def" | anything else (generic).
    object_keys: optional map of `xxxLocators` name -> set of keys that are
        object-valued ({selector, intent, stability}). Used to skip healing
        wraps for string-valued display labels (e.g. `myBundleText: "My Bundle"`)
        which would otherwise produce `.selector` accesses on a plain string.
        When None, every locator reference is treated as object-valued (legacy
        behavior — safe for callers that haven't been updated).
    file_slug: optional kebab-case slug derived from the source file path
        (e.g. "member-view" from "src/pages/scweb/member-view.page.ts").
        Embedded in synthesised `dyn.*` keys so identical placeholders in
        different files don't all collide on `dyn.po.1`. Empty string keeps
        the legacy `dyn.<prefix>.<N>` shape for back-compat with callers
        that don't know the path.
    Returns (transformed_content, changes, todo_count).
    """
    _counter[0] = 0
    changes: list[str] = []
    todo_count = 0

    # Detect re-migration: a file where a previous pass already wired up the
    # healer's runtime objects (fields for page objects, const setup for step
    # callbacks). We check for the actual assignments — `new LocatorHealer(`
    # or `clickWithHealing(` — NOT just the import line, because an import
    # alone doesn't establish `_healer` / `_timing` / `_visual` in scope.
    # Misclassifying an import-only file as "already injected" caused the
    # setup pass to be skipped and the rewriter to reference undefined
    # `_timing` / `_visual` symbols in step callbacks.
    # `already_injected` detects files that were migrated by an EARLIER
    # version of this module (when boilerplate was injected per-class /
    # per-step). On those files we run the upgrade pass first to strip the
    # old shape and rename references — after which the file looks like a
    # fresh one and the normal flow proceeds.
    already_injected = (
        "new LocatorHealer(" in content
        or "this._healer" in content
        or "_healer.clickWithHealing(" in content
        or "_healer.fillWithHealing(" in content
        or "_healer.assertVisibleWithHealing(" in content
    )

    if already_injected:
        content = _strip_old_boilerplate(content, role, changes)

    # Always make sure the `fixture` import is present — every rewritten
    # action call needs it. Skips the insertion when the symbol is already
    # imported (idempotent).
    content = _prepend_imports(content, changes)

    # Keep getByTestId call sites consistent with wrapped locator values.
    # When a locator was rewritten from a bare test-id name to
    # `[data-testid="..."]` (registrar), a surviving
    # `getByTestId(<obj>.<key>.selector)` would become
    # `getByTestId('[data-testid="..."]')` — invalid (getByTestId wants the
    # bare id). Convert those to `.locator(...)`, for which the wrapped CSS
    # selector is correct. Runs before action rewriting so the resulting
    # `.locator(...).click()` etc. still heal via the locator-chain rules.
    content = _convert_wrapped_testid_calls(content, changes, wrapped_testid_keys)

    if role == "page_object":
        content = _inject_page_object(content, changes, object_keys, file_slug=file_slug)
    elif role == "step_def":
        content = _inject_step_defs(content, changes, object_keys, file_slug=file_slug)
    else:
        content, todo_count = _inject_generic(content, changes, object_keys, file_slug=file_slug)

    content = _asyncify_awaited_arrows(content, changes)
    # Safety: any plain method that now contains `await` (e.g. a handle
    # assignment we rewrote) but isn't declared `async` gets `async` added.
    content = _asyncify_awaited_methods(content, changes)

    return content, changes, todo_count


# Concise arrow whose body we rewrote to start with `await`, but the arrow head
# is not `async`. e.g.  `const f = (a, b) => await healer.fillWithHealing(...)`.
# Inserting `await` into a non-async arrow body is a TS error (TS1308), so the
# arrow must be marked `async`. We match the arrow head right before `=> await`
# and prepend `async ` when it isn't already there. Handles both parenthesised
# (`(a, b) =>`) and single-identifier (`x =>`) parameter lists.
_AWAITED_ARROW_RE = re.compile(
    r"""
    (?P<lead>[=:(,]\s*|\breturn\s+|=>\s*)   # what precedes the arrow head
    (?P<head>
        (?:\([^()]*\)|[A-Za-z_$][\w$]*)     # (params)  OR  bareIdent
        \s*(?::\s*[^=\n]+?)?                 # optional return-type annotation
        \s*=>\s*
    )
    await\b\s*
    """,
    re.VERBOSE,
)


def _asyncify_awaited_arrows(content: str, changes: list[str]) -> str:
    """Mark any concise arrow as `async` when its body was rewritten to `await`.

    Idempotent: a `lead` already ending in `async ` is skipped because the
    `async` keyword would have been consumed by a prior token, so `lead` would
    not match the patterns above for an already-async arrow head.
    """
    def _fix(m: re.Match) -> str:
        lead = m.group("lead")
        # Already async? The `async` keyword sits just before the head.
        # `lead` patterns never capture `async`, so guard by looking back is
        # unnecessary for the captured slice — but a head like `async (a) =>`
        # would have `async ` as part of the preceding text, not `lead`.
        changes.append("marked arrow async (await body)")
        return f"{lead}async {m.group('head')}await "

    # Avoid double-prefixing: only rewrite heads not already preceded by async.
    def _guarded(m: re.Match) -> str:
        start = m.start()
        preceding = content[max(0, start - 6):start + len(m.group("lead"))]
        if "async" in preceding:
            return m.group(0)
        return _fix(m)

    return _AWAITED_ARROW_RE.sub(_guarded, content)


# ---------------------------------------------------------------------------
# Import injection
# ---------------------------------------------------------------------------

def _prepend_imports(content: str, changes: list[str]) -> str:
    """
    Ensure `import { fixture } from '@hooks/pageFixture';` is present.

    Idempotent in three ways:
      • If the symbol `fixture` is already imported from `@hooks/pageFixture`
        (in any import shape, including a multi-name import), no change.
      • If a bare `import '@hooks/pageFixture'` for some other reason is
        already there, we still need to add the symbol — so we add a new
        line. (TS de-duplicates symbols at compile time.)
      • Otherwise we insert after the last existing import.
    """
    # Symbol-level idempotence: look for `fixture` inside an import-block
    # that names `@hooks/pageFixture`. Matches both single-line and
    # multi-line import shapes, since `re.DOTALL` would over-match —
    # we instead scan import statements one at a time.
    for m in re.finditer(
        r"import\s+\{([^}]+)\}\s+from\s+['\"]@hooks/pageFixture['\"]",
        content,
    ):
        names = [n.strip() for n in m.group(1).split(",")]
        if "fixture" in names:
            return content

    lines = content.split("\n")
    # Find the last complete import / require statement.
    # Multi-line imports like `import {\n  Foo,\n} from '...'` must be tracked
    # so we insert AFTER the closing `} from '...'` line, not inside the braces.
    last_import = -1
    in_multiline = False
    for i, line in enumerate(lines):
        stripped = line.strip()
        if in_multiline:
            # Wait for the closing `} from '...'` line
            if re.match(r"^\}\s*from\s+['\"]", stripped):
                last_import = i
                in_multiline = False
            continue
        if stripped.startswith("import ") and stripped.endswith("{") and "from" not in stripped:
            # Opening of a multi-line import: `import {` or `import Foo, {`
            in_multiline = True
            continue
        if stripped.startswith("import "):
            last_import = i
        # Only match top-level require() — NOT indented ones inside class methods
        elif line.startswith("const ") and "require" in line:
            last_import = i

    insert_at = last_import + 1 if last_import >= 0 else 0
    lines.insert(insert_at, _HEALER_IMPORTS)
    changes.append("Injected fixture import")
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Page-object injection
# ---------------------------------------------------------------------------

def _inject_page_object(content: str, changes: list[str], object_keys: dict[str, set[str]] | None = None, file_slug: str = "") -> str:
    # Step A — registration block. Locators are registered with the SHARED
    # `fixture().locatorRepository` so every page object in the scenario
    # sees the same key set. The block is injected at the end of the
    # constructor body (before the closing `}`). We don't inject anything
    # else into the class — healers are accessed via `fixture()` directly
    # from method bodies.
    registrations = _build_locator_registrations(content)
    if registrations:
        ctor_open_re = re.compile(r"constructor\s*\([^)]*\)\s*\{", re.MULTILINE)
        m_open = ctor_open_re.search(content)
        if m_open:
            ctor_end = _find_matching_brace(content, m_open.end() - 1)
            if ctor_end is not None:
                content = content[:ctor_end] + "\n" + registrations + "  " + content[ctor_end:]
                changes.append("Injected fixture().locatorRepository.register() calls for imported locators")
            else:
                changes.append("Constructor opening brace found but no matching close — locator registrations skipped")
        else:
            changes.append("No constructor found — locator registrations skipped; add a constructor to register locators")

    # Step B — rewrite raw Playwright actions to `fixture().locatorHealer.X()` etc.
    content = _replace_actions(
        content, changes, prefix="po", use_this=True, object_keys=object_keys, file_slug=file_slug,
    )
    return content


# ---------------------------------------------------------------------------
# Step-definition injection
# ---------------------------------------------------------------------------

def _inject_step_defs(content: str, changes: list[str], object_keys: dict[str, set[str]] | None = None, file_slug: str = "") -> str:
    # Step bodies no longer get a per-callback setup block. Action calls in
    # each step are rewritten to `fixture().locatorHealer.X(...)` directly,
    # which keeps the step body free of healer plumbing and means a step
    # that needs only one heal verb doesn't pay for the three-class setup.
    #
    # If a step references the healers more than once and the call sites
    # would be more readable with a destructure, the author can add
    # `const { locatorHealer, locatorTiming } = fixture();` themselves —
    # but this is a style preference, not a correctness requirement.
    return _replace_actions(content, changes, prefix="step", object_keys=object_keys, file_slug=file_slug)


# ---------------------------------------------------------------------------
# Generic injection
# ---------------------------------------------------------------------------

def _inject_generic(content: str, changes: list[str], object_keys: dict[str, set[str]] | None = None, file_slug: str = "") -> tuple[str, int]:
    # Detection probes every action verb the rewriter knows how to handle.
    # Missing one here would silently skip files that ONLY use the missing
    # verb (e.g. a helper that does just `page.press(sel, key)`).
    # Include helper-local locator chains too (`page.getByTestId(...).click()`,
    # `const field = page.getByTestId(...); await field.clear();`) so helper
    # classes actually reach the generic rewriter.
    has_actions = bool(
        _CLICK_RE.search(content)
        or _FILL_RE.search(content)
        or _CHECK_RE.search(content)
        or _UNCHECK_RE.search(content)
        or _SELECT_RE.search(content)
        or _HOVER_RE.search(content)
        or _DBLCLICK_RE.search(content)
        or _FOCUS_RE.search(content)
        or _TAP_RE.search(content)
        or _PRESS_RE.search(content)
        or _TYPE_RE.search(content)
        or _SET_FILES_RE.search(content)
        or re.search(r"\.(?:locator|getByTestId|getByRole|getByText|getByLabel|getByPlaceholder|getByAltText|getByTitle)\([^)]*\)\s*\.\s*(?:click|fill|clear|check|uncheck|hover|dblclick|focus|blur|tap|selectOption|press|type|setInputFiles)\(", content)
        or re.search(r"await\s+\w+\s*\.\s*(?:click|fill|clear|check|uncheck|hover|dblclick|focus|blur|tap|selectOption|press|type|setInputFiles)\(", content)
        or _WRAPPER_CLICK_RE.search(content)
        or _WRAPPER_FILL_RE.search(content)
    )
    if not has_actions:
        return content, 0

    # The action rewriter emits `fixture().locatorX.Y()` directly, so a
    # plain helper file just needs the `fixture` import (added by
    # `_prepend_imports`) — no TODO scaffolding required.
    content = _replace_actions(content, changes, prefix="gen", object_keys=object_keys, file_slug=file_slug)
    changes.append("Generic healer rewrite applied (calls now route through fixture())")
    return content, 0


# ---------------------------------------------------------------------------
# Shared action replacement
# ---------------------------------------------------------------------------

def _balanced(s: str) -> bool:
    """Return True if parentheses in s are balanced (no unclosed subexpressions)."""
    depth = 0
    for ch in s:
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
            if depth < 0:
                return False
    return depth == 0


def _extract_call_args(text: str, paren_idx: int) -> tuple[list[str], int] | None:
    """
    Given `text` and an index pointing at `(`, return `(args, end_idx)`
    where `args` is the list of comma-separated top-level arguments
    (stripped) and `end_idx` is one past the matching `)`.

    Respects nested parens, brackets, braces, single/double quoted strings,
    and template literals. Returns None if the call is unbalanced.
    """
    if paren_idx >= len(text) or text[paren_idx] != "(":
        return None
    depth = 1
    i = paren_idx + 1
    arg_start = i
    args: list[str] = []
    in_str: str | None = None
    n = len(text)
    while i < n:
        c = text[i]
        if in_str:
            if c == "\\" and i + 1 < n:
                i += 2
                continue
            if c == in_str:
                in_str = None
            i += 1
            continue
        if c in ('"', "'", "`"):
            in_str = c
            i += 1
            continue
        if c in "([{":
            depth += 1
        elif c in ")]}":
            depth -= 1
            if depth == 0:
                tail = text[arg_start:i].strip()
                # Append only non-empty trailing args. A trailing comma
                # (`fill(a, b,)`) is common in multi-line formatting; we
                # treat it as 2 args, not 3.
                if tail:
                    args.append(tail)
                return args, i + 1
        elif c == "," and depth == 1:
            args.append(text[arg_start:i].strip())
            arg_start = i + 1
        i += 1
    return None


def _scan_and_rewrite(
    content: str,
    prefix_re: re.Pattern,
    rewrite_args: callable,
) -> str:
    """
    Find each match of `prefix_re` (a regex whose final char-class is `(`).
    For each, extract the balanced argument list via _extract_call_args and
    delegate to `rewrite_args(prefix_text, args)` to build the replacement.

    `rewrite_args` returns the replacement string, or None to leave the
    original call intact.
    """
    out: list[str] = []
    last = 0
    for m in prefix_re.finditer(content):
        # prefix_re ends with `\(` — locate the action's `(` in the matched
        # text. For nested-paren prefixes like `locator(SEL)\s*\.\s*click(`,
        # we want the LAST `(`, not the first one (which belongs to the
        # locator call). rfind respects the [start, end) window.
        paren_idx = content.rfind("(", m.start(), m.end())
        if paren_idx < 0:
            continue
        result = _extract_call_args(content, paren_idx)
        if result is None:
            continue
        args, end_idx = result
        replacement = rewrite_args(m.group(0)[: paren_idx - m.start()], args)
        out.append(content[last : m.start()])
        if replacement is None:
            out.append(content[m.start() : end_idx])
        else:
            out.append(replacement)
        last = end_idx
    out.append(content[last:])
    return "".join(out)


# Detect `xxxLocators.yyy` accesses (NOT followed by `(` → not a function call).
# This lets us derive a stable, semantic healing key from the locator itself
# instead of using an auto-generated counter like `po_vis1`.
_LOCATOR_ACCESS_RE = re.compile(r"\b(\w+Locators)\.(\w+)\b(?!\s*\()")

# Detect local variable assignments whose RHS references a locator-repo entry.
# Matches: `const x = ...`, `let x = ...`, `private x = ...`, `private readonly x = ...`, etc.
# DOTALL so multi-line RHS like `this.page.locator(\n  fooLocators.bar.selector,\n)` is captured.
_VAR_ASSIGN_RE = re.compile(
    r"\b(?:const|let|var|private|public|protected|readonly)"
    r"(?:\s+(?:private|public|protected|readonly))*"
    r"\s+(\w+)(?:\s*:\s*[^=;]+)?\s*=\s*(.+?);",
    re.DOTALL,
)

# Detect chained-locator calls on a bare identifier: `await <var>.click()` / `.fill(...)`.
# The negative lookahead skips `page.`, `this.`, `world.`, `ctx.` so it doesn't shadow _CLICK_RE.
_CHAINED_CLICK_RE = re.compile(
    r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*click\(\s*\)"
)
_CHAINED_CLICK_ARGS_RE = re.compile(
    r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*click\(\s*([^)]+)\s*\)"
)
_CHAINED_FILL_RE = re.compile(
    r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*fill\(\s*([^)]+)\s*\)"
)
_CHAINED_CLEAR_RE = re.compile(
    r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*clear\(\s*\)"
)
# Extended var-chained verbs (added 2026-05). 0-arg shapes mirror _CHAINED_CLICK_RE;
# 1-arg shapes mirror _CHAINED_FILL_RE.
_CHAINED_HOVER_RE    = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*hover\(\s*\)")
_CHAINED_CHECK_RE    = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*check\(\s*\)")
_CHAINED_UNCHECK_RE  = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*uncheck\(\s*\)")
_CHAINED_DBLCLICK_RE = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*dblclick\(\s*\)")
_CHAINED_FOCUS_RE    = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*focus\(\s*\)")
_CHAINED_BLUR_RE     = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*blur\(\s*\)")
_CHAINED_TAP_RE      = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*tap\(\s*\)")
_CHAINED_SELECT_RE   = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*selectOption\(\s*([^)]+)\s*\)")
_CHAINED_PRESS_RE    = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*press\(\s*([^)]+)\s*\)")
_CHAINED_TYPE_RE     = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*type\(\s*([^)]+)\s*\)")
_CHAINED_FILES_RE    = re.compile(r"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*setInputFiles\(\s*([^)]+)\s*\)")

_TIMEOUT_ONLY_OPTIONS_RE = re.compile(r"^\{\s*timeout\s*:\s*.+\}$", re.DOTALL)

# Custom wrappers most QA projects define around Playwright actions — they
# take a selector string and dispatch to page.click / page.fill. Routing them
# to the healer instead means *every* call site goes through the healing
# chain, not just direct page.click() / page.fill() calls.
#
# Matches: `base.waitAndClick(X)`, `this.base.waitAndClick(X)`,
#          `base.waitAndClickByTestId(X)`, `this.base.waitAndClickByTestId(X)`
_WRAPPER_CLICK_RE = re.compile(
    rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndClick(?:ByTestId)?\(\s*([^)]+)\s*\)"
)
_WRAPPER_FILL_RE = re.compile(
    rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndFill(?:ByTestId)?\(\s*([^,]+)\s*,\s*([^)]+)\s*\)"
)

# Chained .locator(X).click() / .fill(X, v) — only when there are no
# intermediate filter calls (.first(), .nth(), .filter()) that would change
# the selector's semantics. The healer takes a raw selector string and
# handles filtering itself, so wrapping these is safe; chains with filters
# stay as raw Playwright since their semantics aren't reducible to a key.
_LOC_CHAIN_CLICK_RE = re.compile(
    rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\(\s*([^)]+?)\s*\)\.click\(\s*\)"
)
_LOC_CHAIN_FILL_RE = re.compile(
    rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\(\s*([^)]+?)\s*\)\.fill\(\s*([^)]+)\s*\)"
)
# These _LOC_CHAIN_*_RE constants are unused outside this module — kept for
# back-compat with external callers that imported them. The actual wiring
# uses the local _PRE_LOC_CHAIN_* prefix patterns inside _replace_actions.


def _semantic_for(
    loc_name: str,
    prop: str,
    object_keys: dict[str, set[str]] | None,
) -> tuple[str, str, str] | None:
    """Build (key, intent_expr, selector_expr) for a recognised locator ref."""
    if object_keys is not None and prop not in object_keys.get(loc_name, set()):
        return None
    namespace   = loc_name[0].lower() + loc_name[1:]
    namespace   = namespace.replace("Locators", "")
    return (
        f"{namespace}.{prop}",
        f"{loc_name}.{prop}.intent",
        f"{loc_name}.{prop}.selector",
    )


def _derive_semantic(
    expr: str,
    object_keys: dict[str, set[str]] | None = None,
    alias_map: dict[str, str] | None = None,
    inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
) -> tuple[str, str, str] | None:
    """
    If `expr` contains a recognised locator reference, return:
      (healing_key, intent_expr, selector_expr)

    Recognised forms:
      • Direct:        `xxxLocators.yyy`               (with or without `.selector`)
      • Aliased:       `this.Elements.yyy` / `E.yyy`   (via _collect_alias_map)
      • Inline field:  `this.MassInviteElements.yyy`   (via _collect_inline_field_map)

    When `object_keys` is provided, only keys that are object-valued
    ({selector, intent, stability}) qualify. String-valued display labels
    return None so the call stays raw instead of producing a `.selector`
    access on a plain string.

    Returns None when no qualifying locator access is found.
    """
    # 1. Direct: xxxLocators.prop  (optionally followed by .selector)
    for m in _LOCATOR_ACCESS_RE.finditer(expr):
        sem = _semantic_for(m.group(1), m.group(2), object_keys)
        if sem:
            return sem

    # 2. Aliased: <alias>.prop  — alias_map carries `this.Elements → xxxLocators`
    #    Match each alias's access pattern. We iterate longest first so a
    #    longer alias like `this.Elements` wins over a colliding bare `Elements`.
    if alias_map:
        for alias in sorted(alias_map, key=len, reverse=True):
            loc_name = alias_map[alias]
            alias_re = re.compile(
                rf"(?<![.\w]){re.escape(alias)}\.(\w+)\b(?!\s*\()"
            )
            for m in alias_re.finditer(expr):
                sem = _semantic_for(loc_name, m.group(1), object_keys)
                if sem:
                    return sem

    # 3. Inline-field re-export: `this.<FieldName>.<inline_key>` whose value
    #    in the class field is `<xxxLocators>.<orig_key>.selector`. Routes
    #    the healer call through the ORIGINAL locator's semantic info, but
    #    keeps the user-facing selector expression as the field reference
    #    (preserving the runtime string they wrote).
    if inline_field_map:
        # Sort aliases longest-first so `this.MassInviteElements` wins over
        # any colliding shorter alias.
        aliases = sorted({alias for (alias, _) in inline_field_map}, key=len, reverse=True)
        for alias in aliases:
            alias_re = re.compile(
                rf"(?<![.\w]){re.escape(alias)}\.(\w+)\b(?!\s*\()"
            )
            for m in alias_re.finditer(expr):
                inline_key = m.group(1)
                mapped = inline_field_map.get((alias, inline_key))
                if not mapped:
                    continue
                loc_name, orig_key = mapped
                # Build a custom semantic tuple: keep `selector_expr` as the
                # field reference so the runtime string-typed value passes
                # straight through to the healer.
                if object_keys is not None and orig_key not in object_keys.get(loc_name, set()):
                    continue
                ns  = loc_name[0].lower() + loc_name[1:]
                ns  = ns.replace("Locators", "")
                return (
                    f"{ns}.{orig_key}",
                    f"{loc_name}.{orig_key}.intent",
                    f"{alias}.{inline_key}",
                )
    return None


_IDENT_RE = re.compile(r"^\w+$")

# Locator chain methods that NARROW the selection beyond the base CSS selector.
# When any of these appear in the RHS after `.locator(...)`, the semantic
# selector recorded for the variable no longer represents what `.click()` /
# `.fill()` on that variable will hit — so routing the action through the
# healer (which re-resolves from the base selector) would target the wrong
# element. We disqualify such variables from healer rewriting and leave the
# original action call intact.
#
# `.first()` and `.last()` look benign for single-match selectors but trigger
# strict-mode violations when the base selector matches multiple elements
# (which is precisely when callers add `.first()` defensively). Safer to skip
# rewrite in both cases.
_LOCATOR_NARROWING_CHAIN_RE = re.compile(
    r"\.\s*(?:"
    r"filter|first|last|nth|"
    r"getByText|getByRole|getByLabel|"
    r"getByPlaceholder|getByAltText|getByTitle|getByTestId|"
    r"locator"
    r")\s*\("
)


def _has_narrowing_chain(rhs: str) -> bool:
    """
    True if RHS contains a locator-chain method after the initial `.locator(...)`
    call. Returns False for a bare `this.page.locator(X)` or `page.locator(X)`.
    """
    # Strip the *first* .locator(...) call so we only inspect what's chained
    # after it. Without this, the regex would match the initial .locator()
    # itself and flag every assignment.
    m = re.search(r"\.\s*locator\s*\(", rhs)
    if not m:
        # No initial .locator() — treat as benign (let _derive_semantic decide).
        return False
    tail = rhs[m.end():]
    # Find the matching ')' for the initial .locator(...) so chain methods
    # *inside* the locator's own arguments (rare but possible, e.g.
    # `page.locator(div).filter(...)` where filter is on the result) aren't
    # missed. We only care about what comes AFTER that closing paren.
    depth = 1
    i = 0
    while i < len(tail) and depth > 0:
        ch = tail[i]
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        i += 1
    after_locator = tail[i:]
    return bool(_LOCATOR_NARROWING_CHAIN_RE.search(after_locator))


def _build_var_to_semantic(
    content: str,
    object_keys: dict[str, set[str]] | None = None,
    alias_map: dict[str, str] | None = None,
    inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
) -> dict[str, tuple[str, str, str]]:
    """
    Scan local variable assignments and map each variable name to the semantic
    key/intent/selector triple derived from the locator-repo entry referenced on
    its RHS. Skips RHS that only references string-valued locator entries when
    object_keys is provided.

    A variable name that is `const`-assigned more than once in the same file
    with DIFFERENT semantic targets (e.g. `const input = ...fnameInput` in one
    method and `const input = ...emailInput` in another) is treated as
    AMBIGUOUS and excluded from the result — without scope analysis we can't
    tell which assignment a given action call refers to, and picking the
    last-seen one silently mis-keys earlier call sites.

    Similarly, a variable whose RHS narrows the locator with `.filter()`,
    `.first()`, `.last()`, `.nth()`, `.getBy*()`, or a nested `.locator()`
    is excluded — the recorded base selector no longer matches what the
    variable points at, and routing through the healer would re-resolve to
    a different element (or fail strict mode).

    In both excluded cases, the action regexes leave the call raw.
    """
    by_var: dict[str, list[tuple[str, str, str] | None]] = {}
    for m in _VAR_ASSIGN_RE.finditer(content):
        var_name = m.group(1)
        rhs = m.group(2)
        if _has_narrowing_chain(rhs):
            by_var.setdefault(var_name, []).append(None)
            continue
        sem = _derive_semantic(rhs, object_keys, alias_map, inline_field_map)
        if sem:
            by_var.setdefault(var_name, []).append(sem)

    result: dict[str, tuple[str, str, str]] = {}
    for var_name, sems in by_var.items():
        if any(s is None for s in sems):
            continue  # narrowing chain present — leave actions raw
        unique = {s for s in sems if s is not None}
        if len(unique) == 1:
            # Single semantic target across all assignments — safe to map.
            result[var_name] = next(iter(unique))
        # else: multiple distinct targets — ambiguous, leave out
    return result


def _resolve_semantic(
    expr: str,
    var_map: dict[str, tuple[str, str, str]],
    object_keys: dict[str, set[str]] | None = None,
    alias_map: dict[str, str] | None = None,
    inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
) -> tuple[str, str, str] | None:
    """Try direct locator-repo access first; fall back to local-variable lookup."""
    sem = _derive_semantic(expr, object_keys, alias_map, inline_field_map)
    if sem:
        return sem
    if _IDENT_RE.match(expr):
        return var_map.get(expr)
    return None


def _replace_actions(
    content: str,
    changes: list[str],
    prefix: str,
    use_this: bool = False,
    object_keys: dict[str, set[str]] | None = None,
    file_slug: str = "",
) -> str:
    # All healers live on the cucumber World — `fixture().locatorX`. The
    # `use_this` flag (legacy from the per-class-fields design) no longer
    # affects the accessor; we leave it in the signature so callers don't
    # have to change.
    h = "fixture().locatorHealer"
    t = "fixture().locatorTiming"
    v = "fixture().locatorVisual"

    # Collect alias + inline-field maps BEFORE the rewrites so wrapper /
    # action calls that reference `this.Elements.foo`, `this.MassInvite.foo`,
    # or bare aliased identifiers route through the healer too.
    alias_map = _collect_alias_map(content)
    inline_field_map = _collect_inline_field_map(content)
    var_map = _build_var_to_semantic(content, object_keys, alias_map, inline_field_map)

    # Counter for synthetic keys when a call site uses a runtime-computed
    # selector (template literal, variable, function-call result, raw
    # string literal). Routing these through the healer still adds value:
    # the healer's chain logs failures, retries with role/label/text/AI
    # strategies, and persists healed selectors to the repository.
    dyn_counter = [0]

    # Per-file slug — embedded into every dyn key so two files can't both
    # claim `dyn.po.1` and overwrite each other's heal-store entries.
    # Sanitised here at the emit boundary so callers can pass anything path-
    # like; empty string falls back to the legacy `dyn.<prefix>.<N>` shape.
    safe_slug = _sanitise_key_slug(file_slug)

    def _build_dyn_key_expr(sel_expr: str) -> str:
        """
        Build the JS expression used as the heal-store key for a dynamic
        selector. Static (string-literal) selectors get a quoted static key;
        selectors whose value varies at runtime (template-literal
        placeholders, function-call args, bare identifiers, member /
        subscript access) get a **template-literal** key that interpolates
        those runtime parts so each parameter value owns its own heal-store
        slot. Without this, every parameterised call site (e.g. one
        `clickIndividualTab(tabLabel)` method invoked with five tab labels)
        collides on a single key and the cached selector is whichever ran
        last — defeating the heal cache entirely.
        """
        dyn_counter[0] += 1
        base = f"dyn.{prefix}.{safe_slug}.{dyn_counter[0]}" if safe_slug else f"dyn.{prefix}.{dyn_counter[0]}"
        runtime_parts = _extract_runtime_parts(sel_expr)
        if not runtime_parts:
            return f"'{base}'"
        # Each runtime part is rendered as a `${expr}` segment in the
        # template literal. JS coerces the value to string at eval time.
        segments = ".".join("${" + p + "}" for p in runtime_parts)
        return f"`{base}.{segments}`"

    def _intent_for_dyn(sel_expr: str) -> str:
        """
        Synthesize an intent string from a dynamic selector expression. Bare
        identifier → camel-cased to words; everything else → "dynamic locator".
        The intent feeds the healer's role/label/text fallback heuristics.
        """
        stripped = sel_expr.strip()
        if _IDENT_RE.match(stripped):
            spaced = re.sub(r"([A-Z])", r" \1", stripped).strip().lower()
            spaced = re.sub(r"selector\b", "", spaced).strip() or stripped
            return f'"{spaced}"'
        return '"dynamic locator"'

    def _click(m: re.Match) -> str:
        sel = m.group(1).strip().rstrip(",").strip()
        if not _balanced(sel):
            return m.group(0)
        semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"page.click → {h}.clickWithHealing (key={key})")
            return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
        # Dynamic-selector fallback: synthesise a key and use the original
        # expression as both selector and intent source.
        key_expr = _build_dyn_key_expr(sel)
        changes.append(f"page.click → {h}.clickWithHealing (dyn key={key_expr})")
        return f"await {h}.clickWithHealing({key_expr}, {sel}, {_intent_for_dyn(sel)})"

    def _fill(m: re.Match) -> str:
        sel = m.group(1).strip().rstrip(",").strip()
        val = m.group(2).strip().rstrip(",").strip()
        if not _balanced(sel) or not _balanced(val):
            return m.group(0)
        semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"page.fill → {h}.fillWithHealing (key={key})")
            return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
        key_expr = _build_dyn_key_expr(sel)
        changes.append(f"page.fill → {h}.fillWithHealing (dyn key={key_expr})")
        return f"await {h}.fillWithHealing({key_expr}, {sel}, {val}, {_intent_for_dyn(sel)})"

    def _wrapper_click(m: re.Match) -> str:
        """`base.waitAndClick(X)` / `base.waitAndClickByTestId(X)` → healer."""
        sel = m.group(1).strip().rstrip(",").strip()
        if not _balanced(sel):
            return m.group(0)
        semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"base.waitAndClick* → {h}.clickWithHealing (key={key})")
            return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
        key_expr = _build_dyn_key_expr(sel)
        changes.append(f"base.waitAndClick* → {h}.clickWithHealing (dyn key={key_expr})")
        return f"await {h}.clickWithHealing({key_expr}, {sel}, {_intent_for_dyn(sel)})"

    def _wrapper_fill(m: re.Match) -> str:
        """`base.waitAndFill(X, v)` / `base.waitAndFillByTestId(X, v)` → healer."""
        sel = m.group(1).strip().rstrip(",").strip()
        val = m.group(2).strip().rstrip(",").strip()
        if not _balanced(sel) or not _balanced(val):
            return m.group(0)
        semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"base.waitAndFill* → {h}.fillWithHealing (key={key})")
            return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
        key_expr = _build_dyn_key_expr(sel)
        changes.append(f"base.waitAndFill* → {h}.fillWithHealing (dyn key={key_expr})")
        return f"await {h}.fillWithHealing({key_expr}, {sel}, {val}, {_intent_for_dyn(sel)})"

    def _loc_chain_click(m: re.Match) -> str:
        """`<recv>.locator(X).click()` (no intermediate filters) → healer."""
        sel = m.group(1).strip().rstrip(",").strip()
        if not _balanced(sel):
            return m.group(0)
        semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"locator(...).click → {h}.clickWithHealing (key={key})")
            return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
        key_expr = _build_dyn_key_expr(sel)
        changes.append(f"locator(...).click → {h}.clickWithHealing (dyn key={key_expr})")
        return f"await {h}.clickWithHealing({key_expr}, {sel}, {_intent_for_dyn(sel)})"

    def _loc_chain_fill(m: re.Match) -> str:
        sel = m.group(1).strip().rstrip(",").strip()
        val = m.group(2).strip().rstrip(",").strip()
        if not _balanced(sel) or not _balanced(val):
            return m.group(0)
        semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"locator(...).fill → {h}.fillWithHealing (key={key})")
            return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
        key_expr = _build_dyn_key_expr(sel)
        changes.append(f"locator(...).fill → {h}.fillWithHealing (dyn key={key_expr})")
        return f"await {h}.fillWithHealing({key_expr}, {sel}, {val}, {_intent_for_dyn(sel)})"

    def _goto(m: re.Match) -> str:
        page_expr = m.group(1)
        url       = m.group(2).strip()
        # Peek at the next ~250 chars in the source. If the author already
        # called `.waitForNetworkIdle(...)` right after this goto (common
        # in hand-written code), leave the goto untouched — re-injecting
        # our own would produce two back-to-back idle waits with the same
        # key (the kind of mess re-migrations used to create).
        lookahead = m.string[m.end(): m.end() + 250]
        if re.search(r"\.\s*waitForNetworkIdle\(", lookahead):
            return m.group(0)
        action_key = _next_key(f"{prefix}_nav")
        changes.append(f"page.goto + TimingHealer.waitForNetworkIdle (key={action_key})")
        return (
            f"await {page_expr}.goto({url}); "
            f"await {t}.waitForNetworkIdle('{action_key}')"
        )

    def _expect_visible(m: re.Match) -> str:
        locator = m.group(1).strip()
        semantic = _resolve_semantic(locator, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"expect.toBeVisible → {h}.assertVisibleWithHealing (key={key})")
            return f"await {h}.assertVisibleWithHealing('{key}', {sel_expr}, {intent_expr})"
        return m.group(0)

    def _build_zero_arg_assertion_rewriter(matcher_label: str, healer_method: str):
        """Rewrite `expect(LOC).<matcher>()` → `_healer.<healer_method>(key, sel, intent)`."""
        def rewrite(m: re.Match) -> str:
            locator = m.group(1).strip()
            semantic = _resolve_semantic(locator, var_map, object_keys, alias_map, inline_field_map)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"expect.{matcher_label} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {intent_expr})"
            return m.group(0)
        return rewrite

    def _build_value_assertion_rewriter(matcher_label: str, healer_method: str):
        """Rewrite `expect(LOC).<matcher>(VALUE)` → `_healer.<healer_method>(key, sel, value, intent)`."""
        def rewrite(m: re.Match) -> str:
            locator = m.group(1).strip()
            value   = m.group(2).strip()
            semantic = _resolve_semantic(locator, var_map, object_keys, alias_map, inline_field_map)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"expect.{matcher_label} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {value}, {intent_expr})"
            return m.group(0)
        return rewrite

    def _build_two_value_assertion_rewriter(matcher_label: str, healer_method: str):
        """
        Rewrite `expect(LOC).<matcher>(A, B)` → `_healer.<healer_method>(key, sel, A, B, intent)`.

        Used for toHaveAttribute(attr, value) and toHaveCSS(prop, value) — both
        take a (name, expectedValue) pair on top of the locator.
        """
        def rewrite(m: re.Match) -> str:
            locator = m.group(1).strip()
            arg1    = m.group(2).strip()
            arg2    = m.group(3).strip()
            semantic = _resolve_semantic(locator, var_map, object_keys, alias_map, inline_field_map)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"expect.{matcher_label} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {arg1}, {arg2}, {intent_expr})"
            return m.group(0)
        return rewrite

    def _chained_click(m: re.Match) -> str:
        var_name = m.group(1)
        semantic = var_map.get(var_name)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"{var_name}.click → {h}.clickWithHealing (key={key})")
            return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
        return m.group(0)

    def _chained_click_args(m: re.Match) -> str:
        var_name = m.group(1)
        options = m.group(2).strip()
        if not _TIMEOUT_ONLY_OPTIONS_RE.match(options):
            return m.group(0)
        semantic = var_map.get(var_name)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"{var_name}.click(timeout-only) → {h}.clickWithHealing (key={key})")
            return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
        return m.group(0)

    def _chained_fill(m: re.Match) -> str:
        var_name = m.group(1)
        val = m.group(2).strip()
        if not _balanced(val):
            return m.group(0)
        semantic = var_map.get(var_name)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"{var_name}.fill → {h}.fillWithHealing (key={key})")
            return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
        return m.group(0)

    def _chained_clear(m: re.Match) -> str:
        var_name = m.group(1)
        semantic = var_map.get(var_name)
        if semantic:
            key, intent_expr, sel_expr = semantic
            changes.append(f"{var_name}.clear → {h}.fillWithHealing('') (key={key})")
            return f"await {h}.fillWithHealing('{key}', {sel_expr}, '', {intent_expr})"
        return m.group(0)

    # ── Generic var-chained handlers (added 2026-05) ────────────────────────
    # The 0-arg form covers `<var>.hover()`, `.check()`, `.uncheck()`,
    # `.dblclick()`, `.focus()`, `.blur()`, `.tap()`. The 1-arg form covers
    # `.selectOption(v)`, `.press(k)`, `.type(t)`, `.setInputFiles(f)`. Both
    # only rewrite when the var maps to a known semantic — without a known
    # selector we have no key/intent to thread through the healer.
    def _build_chained_zero_arg(verb: str, healer_method: str):
        def handler(m: re.Match) -> str:
            var_name = m.group(1)
            semantic = var_map.get(var_name)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"{var_name}.{verb} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {intent_expr})"
            return m.group(0)
        return handler

    def _build_chained_one_arg(verb: str, healer_method: str):
        def handler(m: re.Match) -> str:
            var_name = m.group(1)
            val = m.group(2).strip()
            if not _balanced(val):
                return m.group(0)
            semantic = var_map.get(var_name)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"{var_name}.{verb} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {val}, {intent_expr})"
            return m.group(0)
        return handler

    # ── Balanced-paren rewriters ─────────────────────────────────────────────
    # The action regexes below match only up to the `(` — the actual argument
    # list is extracted via _extract_call_args which honours nested parens,
    # strings, and template literals (so `fill(X, String(visitorCount))` is
    # captured correctly). Each helper inspects the args and returns either
    # the rewritten call or None to leave the original intact.

    def _build_click_rewriter(label: str):
        def rewrite(_prefix: str, args: list[str]) -> str | None:
            if len(args) != 1:
                return None
            sel = args[0].strip()
            if not sel:
                return None
            semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"{label} → {h}.clickWithHealing (key={key})")
                return f"await {h}.clickWithHealing('{key}', {sel_expr}, {intent_expr})"
            key_expr = _build_dyn_key_expr(sel)
            changes.append(f"{label} → {h}.clickWithHealing (dyn key={key_expr})")
            return f"await {h}.clickWithHealing({key_expr}, {sel}, {_intent_for_dyn(sel)})"
        return rewrite

    def _build_fill_rewriter(label: str):
        def rewrite(_prefix: str, args: list[str]) -> str | None:
            if len(args) != 2:
                return None
            sel, val = args[0].strip(), args[1].strip()
            if not sel or not val:
                return None
            semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"{label} → {h}.fillWithHealing (key={key})")
                return f"await {h}.fillWithHealing('{key}', {sel_expr}, {val}, {intent_expr})"
            key_expr = _build_dyn_key_expr(sel)
            changes.append(f"{label} → {h}.fillWithHealing (dyn key={key_expr})")
            return f"await {h}.fillWithHealing({key_expr}, {sel}, {val}, {_intent_for_dyn(sel)})"
        return rewrite

    # ── Extended action rewriters (added 2026-05) ──────────────────────────
    # check / uncheck / hover share click's shape (one arg = selector).
    # selectOption shares fill's shape (selector + value).
    def _build_single_arg_action_rewriter(label: str, healer_method: str):
        def rewrite(_prefix: str, args: list[str]) -> str | None:
            if len(args) != 1:
                return None
            sel = args[0].strip()
            if not sel:
                return None
            semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"{label} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {intent_expr})"
            key_expr = _build_dyn_key_expr(sel)
            changes.append(f"{label} → {h}.{healer_method} (dyn key={key_expr})")
            return f"await {h}.{healer_method}({key_expr}, {sel}, {_intent_for_dyn(sel)})"
        return rewrite

    def _build_two_arg_action_rewriter(label: str, healer_method: str):
        def rewrite(_prefix: str, args: list[str]) -> str | None:
            if len(args) != 2:
                return None
            sel, val = args[0].strip(), args[1].strip()
            if not sel or not val:
                return None
            semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
            if semantic:
                key, intent_expr, sel_expr = semantic
                changes.append(f"{label} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {val}, {intent_expr})"
            key_expr = _build_dyn_key_expr(sel)
            changes.append(f"{label} → {h}.{healer_method} (dyn key={key_expr})")
            return f"await {h}.{healer_method}({key_expr}, {sel}, {val}, {_intent_for_dyn(sel)})"
        return rewrite

    # Prefix patterns end at `(` so the scanner takes over from there. Order
    # matters: longer / more-specific patterns are scanned first so a chained
    # `.locator(X).click()` is consumed before the bare `page.click(...)`
    # rule would even see it.
    _PRE_LOC_CHAIN_FILL = re.compile(
        rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*fill\("
    )
    _PRE_LOC_CHAIN_CLICK = re.compile(
        rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*click\("
    )
    _PRE_QUERY_CHAIN_FILL = re.compile(
        rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*(?:getByTestId|getByRole|getByText|getByLabel|getByPlaceholder|getByAltText|getByTitle)\([^)]*?\)\s*\.\s*fill\("
    )
    _PRE_QUERY_CHAIN_CLICK = re.compile(
        rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*(?:getByTestId|getByRole|getByText|getByLabel|getByPlaceholder|getByAltText|getByTitle)\([^)]*?\)\s*\.\s*click\("
    )
    _PRE_QUERY_CHAIN_CLEAR = re.compile(
        rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*(?:getByTestId|getByRole|getByText|getByLabel|getByPlaceholder|getByAltText|getByTitle)\([^)]*?\)\s*\.\s*clear\("
    )
    _PRE_VAR_CHAIN_FILL = re.compile(
        r"await\s+(?!page\b|this\b|world\b|ctx\b)\w+\s*\.\s*fill\("
    )
    _PRE_WRAPPER_FILL = re.compile(
        rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndFill(?:ByTestId)?\("
    )
    _PRE_WRAPPER_CLICK = re.compile(
        rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndClick(?:ByTestId)?\("
    )
    _PRE_FILL = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*fill\(")
    _PRE_CLICK = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*click\(")
    # Extended action verbs — page-level
    _PRE_CHECK         = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*check\(")
    _PRE_UNCHECK       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*uncheck\(")
    _PRE_SELECT_OPTION = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*selectOption\(")
    _PRE_HOVER         = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*hover\(")
    # Further extended verbs (added 2026-05). Single-arg shapes (sel only)
    # share the heal-key/dyn-key plumbing of click/check/hover. Two-arg shapes
    # mirror fill/selectOption — the second arg is passed verbatim to the
    # corresponding *WithHealing method.
    _PRE_DBLCLICK       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*dblclick\(")
    _PRE_FOCUS          = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*focus\(")
    _PRE_TAP            = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*tap\(")
    _PRE_PRESS          = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*press\(")
    _PRE_TYPE_ACT       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*type\(")
    _PRE_SET_INPUT_FILES = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*setInputFiles\(")
    # Extended chained `.locator(X).Y()` forms — same shape as the existing
    # click/fill ones; the bespoke rewriter below hops back to the locator()
    # call to find the selector.
    _PRE_LOC_CHAIN_HOVER       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*hover\(")
    _PRE_LOC_CHAIN_CHECK       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*check\(")
    _PRE_LOC_CHAIN_UNCHECK     = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*uncheck\(")
    _PRE_LOC_CHAIN_DBLCLICK    = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*dblclick\(")
    _PRE_LOC_CHAIN_FOCUS       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*focus\(")
    _PRE_LOC_CHAIN_BLUR        = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*blur\(")
    _PRE_LOC_CHAIN_TAP         = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*tap\(")
    _PRE_LOC_CHAIN_SELECT      = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*selectOption\(")
    _PRE_LOC_CHAIN_PRESS       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*press\(")
    _PRE_LOC_CHAIN_TYPE        = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*type\(")
    _PRE_LOC_CHAIN_FILES       = re.compile(rf"{_AWAIT_OPT}{_PAGE_EXPR}\s*\.\s*locator\([^)]*?\)\s*\.\s*setInputFiles\(")
    # Extended custom-wrapper forms.
    _PRE_WRAPPER_CHECK   = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndCheck(?:ByTestId)?\(")
    _PRE_WRAPPER_UNCHECK = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndUncheck(?:ByTestId)?\(")
    _PRE_WRAPPER_HOVER   = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndHover(?:ByTestId)?\(")
    _PRE_WRAPPER_DBLCLICK = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndDblclick(?:ByTestId)?\(")
    _PRE_WRAPPER_FOCUS   = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndFocus(?:ByTestId)?\(")
    _PRE_WRAPPER_SELECT  = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndSelectOption(?:ByTestId)?\(")
    _PRE_WRAPPER_PRESS   = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndPress(?:ByTestId)?\(")
    _PRE_WRAPPER_TYPE    = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndType(?:ByTestId)?\(")
    _PRE_WRAPPER_FILES   = re.compile(rf"{_AWAIT_OPT}(?:this\s*\.\s*)?base\s*\.\s*waitAndSetInputFiles(?:ByTestId)?\(")

    # Chained-locator action rewriter: the selector is the first arg of
    # `locator(SEL)`, NOT an arg of the action call. We need a bespoke
    # rewriter that hops back to the inner locator() args.
    #
    # `nargs` declares the expected arg count of the action call (0 for
    # `.click()` / `.hover()` / `.check()` etc., 1 for `.fill(v)` /
    # `.selectOption(v)` / `.press(k)` / `.type(t)` / `.setInputFiles(f)`).
    def _build_loc_chain_action_rewriter(action: str, healer_method: str, nargs: int, label: str):
        def rewrite(prefix_text: str, args: list[str]) -> str | None:
            # prefix_text contains everything up to (but not including) the
            # action's `(`. So we anchor on the action name at the end of
            # the string rather than requiring a literal `(`.
            loc_match = re.search(rf"locator\(([^)]*?)\)\s*\.\s*{action}\s*$", prefix_text)
            if not loc_match:
                return None
            sel = loc_match.group(1).strip()
            if not sel:
                return None
            if nargs == 0:
                # No args — but Playwright accepts an options object as the
                # sole arg (`.click({ force: true })`). Don't rewrite those,
                # since the options would be lost.
                if args and any(a for a in args):
                    if len(args) == 1 and _TIMEOUT_ONLY_OPTIONS_RE.match(args[0].strip()):
                        args = []
                    else:
                        return None
                semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
                if semantic:
                    key, intent_expr, sel_expr = semantic
                    changes.append(f"{label} → {h}.{healer_method} (key={key})")
                    return f"await {h}.{healer_method}('{key}', {sel_expr}, {intent_expr})"
                key_expr = _build_dyn_key_expr(sel)
                changes.append(f"{label} → {h}.{healer_method} (dyn key={key_expr})")
                return f"await {h}.{healer_method}({key_expr}, {sel}, {_intent_for_dyn(sel)})"
            if nargs == -1:
                if args and any(a for a in args):
                    return None
                semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
                if semantic:
                    key, intent_expr, sel_expr = semantic
                    changes.append(f"{label} → {h}.fillWithHealing('') (key={key})")
                    return f"await {h}.fillWithHealing('{key}', {sel_expr}, '', {intent_expr})"
                key_expr = _build_dyn_key_expr(sel)
                changes.append(f"{label} → {h}.fillWithHealing('') (dyn key={key_expr})")
                return f"await {h}.fillWithHealing({key_expr}, {sel}, '', {_intent_for_dyn(sel)})"
            if nargs == 1:
                if len(args) != 1:
                    return None
                val = args[0].strip()
                if not val:
                    return None
                semantic = _resolve_semantic(sel, var_map, object_keys, alias_map, inline_field_map)
                if semantic:
                    key, intent_expr, sel_expr = semantic
                    changes.append(f"{label} → {h}.{healer_method} (key={key})")
                    return f"await {h}.{healer_method}('{key}', {sel_expr}, {val}, {intent_expr})"
                key_expr = _build_dyn_key_expr(sel)
                changes.append(f"{label} → {h}.{healer_method} (dyn key={key_expr})")
                return f"await {h}.{healer_method}({key_expr}, {sel}, {val}, {_intent_for_dyn(sel)})"
            return None
        return rewrite

    _QUERY_CHAIN_METHODS = (
        "getByTestId",
        "getByRole",
        "getByText",
        "getByLabel",
        "getByPlaceholder",
        "getByAltText",
        "getByTitle",
    )

    def _build_query_selector_expr(method: str, query_args: list[str]) -> str | None:
        if not query_args:
            return None
        arg0 = query_args[0].strip()
        if not arg0:
            return None
        if method == "getByTestId":
            return f"`[data-testid=\"${{{arg0}}}\"]`"
        if method == "getByText":
            return f"`text=${{{arg0}}}`"
        if method == "getByLabel":
            return f"`[aria-label=\"${{{arg0}}}\"]`"
        if method == "getByPlaceholder":
            return f"`[placeholder=\"${{{arg0}}}\"]`"
        if method == "getByAltText":
            return f"`[alt=\"${{{arg0}}}\"]`"
        if method == "getByTitle":
            return f"`[title=\"${{{arg0}}}\"]`"
        if method == "getByRole":
            if len(query_args) == 1:
                return f"`role=${{{arg0}}}`"
            name_match = re.search(r"\bname\s*:\s*(.+)$", query_args[1].strip())
            if name_match:
                name_expr = name_match.group(1).strip().rstrip("}").strip()
                return f"`role=${{{arg0}}}[name=\"${{{name_expr}}}\"]`"
            return f"`role=${{{arg0}}}`"
        return None

    def _extract_query_chain(prefix_text: str, action: str) -> tuple[str, list[str]] | None:
        for method in _QUERY_CHAIN_METHODS:
            for m in re.finditer(rf"{method}\s*\(", prefix_text):
                paren_idx = prefix_text.find("(", m.start())
                if paren_idx < 0:
                    continue
                result = _extract_call_args(prefix_text, paren_idx)
                if result is None:
                    continue
                query_args, end_idx = result
                suffix = prefix_text[end_idx:]
                if re.fullmatch(rf"\s*\.\s*{action}\s*", suffix):
                    return method, query_args
        return None

    def _build_query_chain_action_rewriter(action: str, healer_method: str, nargs: int, label: str):
        def rewrite(prefix_text: str, args: list[str]) -> str | None:
            extracted = _extract_query_chain(prefix_text, action)
            if extracted is None:
                return None
            method, query_args = extracted
            primary_expr = query_args[0].strip() if query_args else ""
            selector_expr = _build_query_selector_expr(method, query_args)
            if not selector_expr:
                return None
            semantic = _resolve_semantic(primary_expr, var_map, object_keys, alias_map, inline_field_map)
            if method == "getByRole":
                # Playwright has NO `role=` selector engine, so we cannot flatten
                # getByRole into a selector string. Route click/fill to the
                # dedicated role-aware healer methods that call the real
                # page.getByRole(role, { name }) before falling back to heal.
                # Other actions (clear/check/etc.) are left intact (return None)
                # rather than emitting a dead `role=` selector.
                if nargs not in (0, 1):
                    return None
                role_expr = query_args[0].strip()
                name_expr = "''"
                if len(query_args) > 1:
                    # The options object may be multi-line, e.g.
                    #   { \n  name: loginLocators.fooRole, \n }
                    # so match across newlines (DOTALL) and capture the value up
                    # to the trailing comma / closing brace. Anchoring on `$`
                    # without DOTALL previously dropped multi-line names → the
                    # accessible name was silently lost (matched the wrong role).
                    name_match = re.search(
                        r"\bname\s*:\s*(.+?)\s*,?\s*\}?\s*$",
                        query_args[1].strip(),
                        re.DOTALL,
                    )
                    if name_match:
                        name_expr = name_match.group(1).strip() or "''"
                if semantic:
                    key, intent_expr, _sel_expr = semantic
                    key_arg = f"'{key}'"
                else:
                    intent_expr = _intent_for_dyn(primary_expr)
                    key_arg = _build_dyn_key_expr(selector_expr)
                if nargs == 0:
                    if args and any(a for a in args):
                        if len(args) == 1 and _TIMEOUT_ONLY_OPTIONS_RE.match(args[0].strip()):
                            args = []
                        else:
                            return None
                    changes.append(f"{label} → {h}.clickByRoleWithHealing (key={key_arg})")
                    return f"await {h}.clickByRoleWithHealing({key_arg}, {role_expr}, {name_expr}, {intent_expr})"
                # nargs == 1 (fill)
                if len(args) != 1:
                    return None
                val = args[0].strip()
                if not val:
                    return None
                changes.append(f"{label} → {h}.fillByRoleWithHealing (key={key_arg})")
                return f"await {h}.fillByRoleWithHealing({key_arg}, {role_expr}, {name_expr}, {val}, {intent_expr})"
            # Which selector expression the healer should resolve. For
            # getByTestId the locator value IS a CSS selector (wrapped to
            # `[data-testid="..."]`), so `semantic`'s `LOC.selector` is correct.
            # But getByText / getByLabel / getByPlaceholder / getByAltText /
            # getByTitle are PLAYWRIGHT TEXT/ATTR ENGINES — their arg is display
            # text / label, NOT a CSS selector. `page.locator(LOC.selector)`
            # (e.g. `page.locator("Continue")`) would match a <Continue> element,
            # not the text. So for those engines we must pass the engine form
            # from `_build_query_selector_expr` (e.g. `text=${LOC.selector}`),
            # which `page.locator(...)` DOES understand, while still using
            # `semantic`'s key/intent for heal-store stability.
            def _heal_selector(sem_sel_expr: str) -> str:
                return sem_sel_expr if method == "getByTestId" else selector_expr

            if nargs == 0:
                if args and any(a for a in args):
                    if len(args) == 1 and _TIMEOUT_ONLY_OPTIONS_RE.match(args[0].strip()):
                        args = []
                    else:
                        return None
                if semantic:
                    key, intent_expr, sel_expr = semantic
                    changes.append(f"{label} → {h}.{healer_method} (key={key})")
                    return f"await {h}.{healer_method}('{key}', {_heal_selector(sel_expr)}, {intent_expr})"
                key_expr = _build_dyn_key_expr(selector_expr)
                changes.append(f"{label} → {h}.{healer_method} (dyn key={key_expr})")
                return f"await {h}.{healer_method}({key_expr}, {selector_expr}, {_intent_for_dyn(primary_expr)})"
            if nargs == -1:
                if args and any(a for a in args):
                    return None
                if semantic:
                    key, intent_expr, sel_expr = semantic
                    changes.append(f"{label} → {h}.fillWithHealing('') (key={key})")
                    return f"await {h}.fillWithHealing('{key}', {_heal_selector(sel_expr)}, '', {intent_expr})"
                key_expr = _build_dyn_key_expr(selector_expr)
                changes.append(f"{label} → {h}.fillWithHealing('') (dyn key={key_expr})")
                return f"await {h}.fillWithHealing({key_expr}, {selector_expr}, '', {_intent_for_dyn(primary_expr)})"
            if nargs == 1:
                if len(args) != 1:
                    return None
                val = args[0].strip()
                if not val:
                    return None
                if semantic:
                    key, intent_expr, sel_expr = semantic
                    changes.append(f"{label} → {h}.{healer_method} (key={key})")
                    return f"await {h}.{healer_method}('{key}', {_heal_selector(sel_expr)}, {val}, {intent_expr})"
                key_expr = _build_dyn_key_expr(selector_expr)
                changes.append(f"{label} → {h}.{healer_method} (dyn key={key_expr})")
                return f"await {h}.{healer_method}({key_expr}, {selector_expr}, {val}, {_intent_for_dyn(primary_expr)})"
            return None
        return rewrite

    def _build_var_chain_action_rewriter(action: str, healer_method: str, nargs: int, label: str):
        def rewrite(prefix_text: str, args: list[str]) -> str | None:
            m = re.fullmatch(
                rf"await\s+(?!page\b|this\b|world\b|ctx\b)(\w+)\s*\.\s*{action}\s*",
                prefix_text.strip(),
            )
            if not m:
                return None
            var_name = m.group(1)
            semantic = var_map.get(var_name)
            if not semantic:
                return None
            key, intent_expr, sel_expr = semantic
            if nargs == -1:
                if args and any(a for a in args):
                    return None
                changes.append(f"{label} → {h}.fillWithHealing('') (key={key})")
                return f"await {h}.fillWithHealing('{key}', {sel_expr}, '', {intent_expr})"
            if nargs == 1:
                if len(args) != 1:
                    return None
                val = args[0].strip()
                if not val:
                    return None
                changes.append(f"{label} → {h}.{healer_method} (key={key})")
                return f"await {h}.{healer_method}('{key}', {sel_expr}, {val}, {intent_expr})"
            return None
        return rewrite

    content = _scan_and_rewrite(content, _PRE_LOC_CHAIN_FILL, _build_loc_chain_action_rewriter("fill", "fillWithHealing", 1, "locator(...).fill"))
    content = _scan_and_rewrite(content, _PRE_LOC_CHAIN_CLICK, _build_loc_chain_action_rewriter("click", "clickWithHealing", 0, "locator(...).click"))
    # Dynamic test-id lookups — `getByTestId(`…${…}…`)` — must run BEFORE the
    # getBy*-chain action rewriters so they don't flatten a runtime-computed id
    # into a nested-template selector string. Heals the computed-id lookup and
    # leaves the caller's trailing chain (.click(), .all(), .textContent(), …)
    # to bind to the awaited Locator.
    content = _rewrite_dynamic_getbytestid(
        content, changes, h, _build_dyn_key_expr, _intent_for_dyn,
    )
    content = _scan_and_rewrite(content, _PRE_QUERY_CHAIN_FILL, _build_query_chain_action_rewriter("fill", "fillWithHealing", 1, "getBy*(...).fill"))
    content = _scan_and_rewrite(content, _PRE_QUERY_CHAIN_CLICK, _build_query_chain_action_rewriter("click", "clickWithHealing", 0, "getBy*(...).click"))
    content = _scan_and_rewrite(content, _PRE_QUERY_CHAIN_CLEAR, _build_query_chain_action_rewriter("clear", "fillWithHealing", -1, "getBy*(...).clear"))
    content = _scan_and_rewrite(content, _PRE_VAR_CHAIN_FILL, _build_var_chain_action_rewriter("fill", "fillWithHealing", 1, "var.fill"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_FILL, _build_fill_rewriter("base.waitAndFill*"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_CLICK, _build_click_rewriter("base.waitAndClick*"))
    content = _scan_and_rewrite(content, _PRE_FILL, _build_fill_rewriter("page.fill"))
    content = _scan_and_rewrite(content, _PRE_CLICK, _build_click_rewriter("page.click"))
    # Extended action verbs — the heal chain now wraps every common
    # stateful operation. Migrating these patterns means the healer's
    # full chain fires for checkbox toggles, dropdown picks, and
    # hover-tooltips, not just clicks and fills.
    content = _scan_and_rewrite(
        content, _PRE_CHECK,
        _build_single_arg_action_rewriter("page.check", "checkWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_UNCHECK,
        _build_single_arg_action_rewriter("page.uncheck", "uncheckWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_SELECT_OPTION,
        _build_two_arg_action_rewriter("page.selectOption", "selectOptionWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_HOVER,
        _build_single_arg_action_rewriter("page.hover", "hoverWithHealing"),
    )
    # ── New page-level verbs (added 2026-05) ─────────────────────────────────
    content = _scan_and_rewrite(
        content, _PRE_DBLCLICK,
        _build_single_arg_action_rewriter("page.dblclick", "dblclickWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_FOCUS,
        _build_single_arg_action_rewriter("page.focus", "focusWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_TAP,
        _build_single_arg_action_rewriter("page.tap", "tapWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_PRESS,
        _build_two_arg_action_rewriter("page.press", "pressWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_TYPE_ACT,
        _build_two_arg_action_rewriter("page.type", "typeWithHealing"),
    )
    content = _scan_and_rewrite(
        content, _PRE_SET_INPUT_FILES,
        _build_two_arg_action_rewriter("page.setInputFiles", "setInputFilesWithHealing"),
    )

    # ── Extended `.locator(X).Y()` chains (added 2026-05) ───────────────────
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_HOVER,
        _build_loc_chain_action_rewriter("hover", "hoverWithHealing", 0, "locator(...).hover"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_CHECK,
        _build_loc_chain_action_rewriter("check", "checkWithHealing", 0, "locator(...).check"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_UNCHECK,
        _build_loc_chain_action_rewriter("uncheck", "uncheckWithHealing", 0, "locator(...).uncheck"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_DBLCLICK,
        _build_loc_chain_action_rewriter("dblclick", "dblclickWithHealing", 0, "locator(...).dblclick"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_FOCUS,
        _build_loc_chain_action_rewriter("focus", "focusWithHealing", 0, "locator(...).focus"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_BLUR,
        _build_loc_chain_action_rewriter("blur", "blurWithHealing", 0, "locator(...).blur"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_TAP,
        _build_loc_chain_action_rewriter("tap", "tapWithHealing", 0, "locator(...).tap"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_SELECT,
        _build_loc_chain_action_rewriter("selectOption", "selectOptionWithHealing", 1, "locator(...).selectOption"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_PRESS,
        _build_loc_chain_action_rewriter("press", "pressWithHealing", 1, "locator(...).press"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_TYPE,
        _build_loc_chain_action_rewriter("type", "typeWithHealing", 1, "locator(...).type"),
    )
    content = _scan_and_rewrite(
        content, _PRE_LOC_CHAIN_FILES,
        _build_loc_chain_action_rewriter("setInputFiles", "setInputFilesWithHealing", 1, "locator(...).setInputFiles"),
    )

    # ── Extended `base.waitAndX` wrappers (added 2026-05) ───────────────────
    content = _scan_and_rewrite(content, _PRE_WRAPPER_CHECK,
        _build_single_arg_action_rewriter("base.waitAndCheck*", "checkWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_UNCHECK,
        _build_single_arg_action_rewriter("base.waitAndUncheck*", "uncheckWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_HOVER,
        _build_single_arg_action_rewriter("base.waitAndHover*", "hoverWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_DBLCLICK,
        _build_single_arg_action_rewriter("base.waitAndDblclick*", "dblclickWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_FOCUS,
        _build_single_arg_action_rewriter("base.waitAndFocus*", "focusWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_SELECT,
        _build_two_arg_action_rewriter("base.waitAndSelectOption*", "selectOptionWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_PRESS,
        _build_two_arg_action_rewriter("base.waitAndPress*", "pressWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_TYPE,
        _build_two_arg_action_rewriter("base.waitAndType*", "typeWithHealing"))
    content = _scan_and_rewrite(content, _PRE_WRAPPER_FILES,
        _build_two_arg_action_rewriter("base.waitAndSetInputFiles*", "setInputFilesWithHealing"))

    # Chained-on-variable patterns are still simple enough for regex; they
    # don't take user-supplied args with nested parens.
    content = _CHAINED_CLICK_RE.sub(_chained_click, content)
    content = _CHAINED_CLICK_ARGS_RE.sub(_chained_click_args, content)
    content = _CHAINED_FILL_RE.sub(_chained_fill, content)
    content = _CHAINED_CLEAR_RE.sub(_chained_clear, content)
    # ── New var-chained verbs (added 2026-05) ───────────────────────────────
    content = _CHAINED_HOVER_RE.sub(_build_chained_zero_arg("hover", "hoverWithHealing"), content)
    content = _CHAINED_CHECK_RE.sub(_build_chained_zero_arg("check", "checkWithHealing"), content)
    content = _CHAINED_UNCHECK_RE.sub(_build_chained_zero_arg("uncheck", "uncheckWithHealing"), content)
    content = _CHAINED_DBLCLICK_RE.sub(_build_chained_zero_arg("dblclick", "dblclickWithHealing"), content)
    content = _CHAINED_FOCUS_RE.sub(_build_chained_zero_arg("focus", "focusWithHealing"), content)
    content = _CHAINED_BLUR_RE.sub(_build_chained_zero_arg("blur", "blurWithHealing"), content)
    content = _CHAINED_TAP_RE.sub(_build_chained_zero_arg("tap", "tapWithHealing"), content)
    content = _CHAINED_SELECT_RE.sub(_build_chained_one_arg("selectOption", "selectOptionWithHealing"), content)
    content = _CHAINED_PRESS_RE.sub(_build_chained_one_arg("press", "pressWithHealing"), content)
    content = _CHAINED_TYPE_RE.sub(_build_chained_one_arg("type", "typeWithHealing"), content)
    content = _CHAINED_FILES_RE.sub(_build_chained_one_arg("setInputFiles", "setInputFilesWithHealing"), content)

    content = _GOTO_RE.sub(_goto, content)

    # ── Negated assertions (added 2026-05) ──────────────────────────────────
    # Run BEFORE positive forms so `.not.toBeVisible` is consumed before the
    # `.toBeVisible` rule would even look at it. (Even though the positive
    # regex doesn't match `.not.toBeVisible()`, keeping negated-first is
    # cheaper than re-proving that for every new matcher we add.)
    #
    # Matchers with a natural antonym map to the antonym's positive healer
    # method (so `.not.toBeVisible → assertHiddenWithHealing`). Matchers
    # without a natural antonym map to `assertNot<Verb>WithHealing`.
    content = _NOT_EXPECT_VISIBLE_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeVisible", "assertHiddenWithHealing"), content)
    content = _NOT_EXPECT_HIDDEN_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeHidden", "assertVisibleWithHealing"), content)
    content = _NOT_EXPECT_ENABLED_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeEnabled", "assertDisabledWithHealing"), content)
    content = _NOT_EXPECT_DISABLED_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeDisabled", "assertEnabledWithHealing"), content)
    content = _NOT_EXPECT_CHECKED_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeChecked", "assertNotCheckedWithHealing"), content)
    content = _NOT_EXPECT_FOCUSED_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeFocused", "assertNotFocusedWithHealing"), content)
    content = _NOT_EXPECT_EDITABLE_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeEditable", "assertNotEditableWithHealing"), content)
    content = _NOT_EXPECT_ATTACHED_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeAttached", "assertNotAttachedWithHealing"), content)
    content = _NOT_EXPECT_INVIEWPORT_RE.sub(
        _build_zero_arg_assertion_rewriter("not.toBeInViewport", "assertNotInViewportWithHealing"), content)
    content = _NOT_EXPECT_HAVE_VALUE_RE.sub(
        _build_value_assertion_rewriter("not.toHaveValue", "assertNotValueWithHealing"), content)
    content = _NOT_EXPECT_HAVE_TEXT_RE.sub(
        _build_value_assertion_rewriter("not.toHaveText", "assertNotTextWithHealing"), content)
    content = _NOT_EXPECT_CONTAIN_TEXT_RE.sub(
        _build_value_assertion_rewriter("not.toContainText", "assertNotContainsTextWithHealing"), content)
    content = _NOT_EXPECT_HAVE_COUNT_RE.sub(
        _build_value_assertion_rewriter("not.toHaveCount", "assertNotCountWithHealing"), content)
    content = _NOT_EXPECT_HAVE_CLASS_RE.sub(
        _build_value_assertion_rewriter("not.toHaveClass", "assertNotClassWithHealing"), content)
    content = _NOT_EXPECT_HAVE_ID_RE.sub(
        _build_value_assertion_rewriter("not.toHaveId", "assertNotIdWithHealing"), content)
    content = _NOT_EXPECT_HAVE_ATTR_RE.sub(
        _build_two_value_assertion_rewriter("not.toHaveAttribute", "assertNotAttributeWithHealing"), content)
    content = _NOT_EXPECT_HAVE_CSS_RE.sub(
        _build_two_value_assertion_rewriter("not.toHaveCSS", "assertNotCSSWithHealing"), content)

    # ── Positive assertions ─────────────────────────────────────────────────
    content = _EXPECT_VISIBLE_RE.sub(_expect_visible, content)
    content = _EXPECT_ENABLED_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeEnabled", "assertEnabledWithHealing"),
        content,
    )
    content = _EXPECT_CHECKED_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeChecked", "assertCheckedWithHealing"),
        content,
    )
    content = _EXPECT_HAVE_VALUE_RE.sub(
        _build_value_assertion_rewriter("toHaveValue", "assertValueWithHealing"),
        content,
    )
    content = _EXPECT_HAVE_TEXT_RE.sub(
        _build_value_assertion_rewriter("toHaveText", "assertTextWithHealing"),
        content,
    )
    # New positive matchers (added 2026-05).
    content = _EXPECT_HIDDEN_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeHidden", "assertHiddenWithHealing"), content)
    content = _EXPECT_DISABLED_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeDisabled", "assertDisabledWithHealing"), content)
    content = _EXPECT_FOCUSED_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeFocused", "assertFocusedWithHealing"), content)
    content = _EXPECT_EDITABLE_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeEditable", "assertEditableWithHealing"), content)
    content = _EXPECT_ATTACHED_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeAttached", "assertAttachedWithHealing"), content)
    content = _EXPECT_INVIEWPORT_RE.sub(
        _build_zero_arg_assertion_rewriter("toBeInViewport", "assertInViewportWithHealing"), content)
    content = _EXPECT_CONTAIN_TEXT_RE.sub(
        _build_value_assertion_rewriter("toContainText", "assertContainsTextWithHealing"), content)
    content = _EXPECT_HAVE_COUNT_RE.sub(
        _build_value_assertion_rewriter("toHaveCount", "assertCountWithHealing"), content)
    content = _EXPECT_HAVE_CLASS_RE.sub(
        _build_value_assertion_rewriter("toHaveClass", "assertClassWithHealing"), content)
    content = _EXPECT_HAVE_ID_RE.sub(
        _build_value_assertion_rewriter("toHaveId", "assertIdWithHealing"), content)
    content = _EXPECT_HAVE_ATTR_RE.sub(
        _build_two_value_assertion_rewriter("toHaveAttribute", "assertAttributeWithHealing"), content)
    content = _EXPECT_HAVE_CSS_RE.sub(
        _build_two_value_assertion_rewriter("toHaveCSS", "assertCSSWithHealing"), content)

    # ── Bare handle assignments (added 2026-06) ─────────────────────────────
    # Rewrite ONLY the clean handle-assignment shape:
    #     const|let|var <name> = [await] this.page.getByTestId(<EXPR>);
    #     const|let|var <name> = [await] this.page.locator(<EXPR>);
    # into:
    #     const|let|var <name> = await fixture().locatorHealer.locatorWithHealing(<key>, <EXPR>, <intent>);
    #
    # These are multi-element / downstream-chained handles (`.all()`, `.last()`,
    # `.locator()`, `.textContent()`, …) that the single-element action rules
    # deliberately skip. `locatorWithHealing` heals the base selector lookup but
    # returns the UN-narrowed locator, so the caller's narrowing is preserved.
    #
    # Critical constraints (mirrors the action rewriters above):
    #   • Only match when the RHS is EXACTLY the single call ending at `);` —
    #     ANY trailing chain after the close paren (`.last()`, `.locator(`,
    #     `.textContent()`, `.click()`, …) disqualifies the statement so the
    #     action rules / narrowing-skip logic handle it instead.
    #   • Resolve <key>/<intent> via the SAME machinery as the action sites
    #     (`_resolve_semantic` → fall back to `_build_dyn_key_expr` +
    #     `_intent_for_dyn`), so keys/intents line up across call sites.
    #   • Emit <EXPR> unchanged as the selector arg.
    #   • Injecting `await` requires an async enclosing function. We take the
    #     CONSERVATIVE approach: only rewrite assignments whose enclosing
    #     function is already `async` (detected by scanning backward to the
    #     nearest function head). A safety post-pass additionally marks any
    #     plain method that now contains `await` but isn't `async` (handled by
    #     `_asyncify_awaited_methods`, run from `inject_healers`). Together this
    #     guarantees no `await`-in-non-async output.
    content = _rewrite_handle_assignments(
        content, changes, h,
        var_map, object_keys, alias_map, inline_field_map,
        _build_dyn_key_expr, _intent_for_dyn,
    )

    content = _inject_selector_accesses(content, changes, object_keys, alias_map, inline_field_map)
    return content


# Prefix of a bare handle assignment: `const|let|var <name> = [await] this.page.(getByTestId|locator)(`
# The scanner takes over at the `(` to extract the balanced EXPR, then we
# verify the statement ends EXACTLY at `);` (no trailing chain).
_HANDLE_ASSIGN_PRE_RE = re.compile(
    rf"(?P<indent>[ \t]*)(?P<decl>const|let|var)\s+(?P<name>\w+)\s*=\s*"
    rf"(?:await\s+)?{_PAGE_EXPR}\s*\.\s*(?P<method>getByTestId|locator)\("
)


def _is_inside_async_fn(content: str, pos: int) -> bool:
    """
    Heuristic: True if the statement at `pos` sits inside an `async` function.

    We scan backwards from `pos` for the nearest enclosing function head and
    check whether it carries the `async` keyword. Recognised heads:
        async name(args)…{        async (args) =>        async args =>
    Conservative by design — if we can't prove the context is async we return
    False and the caller leaves the assignment RAW (no `await` injected into a
    sync body). The companion `_asyncify_awaited_methods` post-pass upgrades
    plain methods that legitimately need `async` after a rewrite, so genuine
    async methods that this heuristic misses are still covered there.
    """
    head = content[:pos]
    # Nearest `async` keyword before us. If there's a plain (non-async)
    # function head between that `async` and our position, we may actually be
    # inside the plain function instead, so bail (conservative → leave raw).
    last_async = None
    for m in re.finditer(r"\basync\b", head):
        last_async = m.start()
    if last_async is None:
        return False
    # Between the last `async` and our position, ensure no plain (non-async)
    # function head opened a new scope that we're inside instead. We look for a
    # `) {` method/function opener that is NOT preceded by `async` on its line.
    between = content[last_async:pos]
    # Count braces opened by the async head region vs. plain heads. A cheap and
    # safe approximation: if a non-async method opener appears after the async
    # head, assume we may be inside it and bail (return False → leave raw).
    plain_head_re = re.compile(
        r"(?<!async\s)(?<![.\w])[A-Za-z_$][\w$]*\s*\([^)]*\)\s*(?::\s*[^{;=]+?)?\s*\{"
    )
    for hm in plain_head_re.finditer(between):
        # Skip if the matched head is actually the async one (starts right at 0).
        snippet = between[max(0, hm.start() - 6):hm.start()]
        if "async" in snippet:
            continue
        return False
    return True


def _rewrite_handle_assignments(
    content: str,
    changes: list[str],
    h: str,
    var_map: dict,
    object_keys,
    alias_map,
    inline_field_map,
    build_dyn_key_expr,
    intent_for_dyn,
) -> str:
    """
    Rewrite clean `const x = [await] this.page.getByTestId|locator(EXPR);`
    handle assignments to `const x = await <h>.locatorWithHealing(key, EXPR, intent);`.

    See the call-site comment in `_replace_actions` for the full contract.
    """
    out: list[str] = []
    last = 0
    for m in _HANDLE_ASSIGN_PRE_RE.finditer(content):
        paren_idx = content.rfind("(", m.start(), m.end())
        if paren_idx < 0:
            continue
        extracted = _extract_call_args(content, paren_idx)
        if extracted is None:
            continue
        args, end_idx = extracted
        # The RHS must be EXACTLY the single call: after the close paren, the
        # only thing allowed before the statement terminator is optional
        # whitespace then `;`. ANY chain (`.last()`, `.locator(`, `.click()`,
        # `.textContent()`, …) means we leave the statement RAW.
        rest = content[end_idx:]
        term = re.match(r"\s*;", rest)
        if not term:
            continue
        if len(args) != 1:
            continue
        expr = args[0].strip().rstrip(",").strip()
        if not expr or not _balanced(expr):
            continue
        # Conservative async gate — only inject `await` where the enclosing
        # function is already async (or will be made async by the post-pass).
        if not _is_inside_async_fn(content, m.start()):
            continue
        indent = m.group("indent")
        decl = m.group("decl")
        name = m.group("name")
        semantic = _resolve_semantic(expr, var_map, object_keys, alias_map, inline_field_map)
        if semantic:
            key, intent_expr, sel_expr = semantic
            key_arg = f"'{key}'"
            changes.append(f"handle assign → {h}.locatorWithHealing (key={key})")
        else:
            key_arg = build_dyn_key_expr(expr)
            intent_expr = intent_for_dyn(expr)
            changes.append(f"handle assign → {h}.locatorWithHealing (dyn key={key_arg})")
        # Emit EXPR unchanged as the selector arg (constraint 3). A bare
        # `xxxLocators.foo` here is given its `.selector` by the post-pass.
        replacement = (
            f"{indent}{decl} {name} = await {h}.locatorWithHealing("
            f"{key_arg}, {expr}, {intent_expr})"
        )
        out.append(content[last : m.start()])
        out.append(replacement)
        # Consume through the `;` (term matched leading ws + `;`).
        last = end_idx + term.end()
    out.append(content[last:])
    return "".join(out)


# Prefix of a dynamic test-id lookup: `<page>.getByTestId(` immediately
# followed (after the open paren) by a template literal. The scanner extracts
# the balanced arg; we only rewrite when that arg is a template literal that
# CONTAINS an interpolation (`${...}`) — i.e. a runtime-computed id. Pure
# `${X}` wrappers and string-literal ids are handled elsewhere / left as-is.
_DYN_GETBYTESTID_PRE_RE = re.compile(
    rf"(?P<recv>{_PAGE_EXPR})\s*\.\s*getByTestId\(\s*(?=`)"
)

# A pure-wrapper template literal: `${ <single expr> }` with NO surrounding
# literal text and exactly ONE interpolation — equivalent to just passing the
# expr. Disallow any `}` mid-expr (would mean composed) by forbidding `}`
# before the final one. These are handled by the wrapped-testid converter
# (→ .locator), so the dynamic-id rule skips them.
_PURE_WRAP_TEMPLATE_RE = re.compile(r"`\$\{[^`}]*\}`")


def _rewrite_dynamic_getbytestid(
    content: str,
    changes: list[str],
    h: str,
    build_dyn_key_expr,
    intent_for_dyn,
) -> str:
    """Rewrite `<page>.getByTestId(`…${…}…`)` (runtime-computed test-id) to
    `(await <h>.getByTestIdWithHealing(dynKey, `…${…}…`, intent))`, preserving
    whatever the caller chains afterward (.all(), .nth(), .textContent(),
    .click(), expect(...)). Heals the `[data-testid="<computed>"]` lookup.

    Only fires for template-literal args containing `${` (truly dynamic).
    Conservative async gate: only inside an already-async function (the
    `_asyncify_awaited_methods` post-pass upgrades the rare plain method)."""
    out: list[str] = []
    last = 0
    for m in _DYN_GETBYTESTID_PRE_RE.finditer(content):
        paren_idx = content.rfind("(", m.start(), m.end())
        if paren_idx < 0:
            continue
        extracted = _extract_call_args(content, paren_idx)
        if extracted is None:
            continue
        args, end_idx = extracted
        if len(args) != 1:
            continue
        expr = args[0].strip()
        # Must be a template literal WITH interpolation — that's the dynamic
        # case. A pure `${X}` wrapper (single interpolation, no literal text) is
        # handled by the wrapped-testid converter; bare string ids stay raw.
        if not (expr.startswith("`") and expr.endswith("`") and "${" in expr):
            continue
        if _PURE_WRAP_TEMPLATE_RE.fullmatch(expr):
            continue
        # No async gate here: a dynamic getByTestId is only ever called in an
        # already-`await`ed position (you can't sync-chain .textContent()/.all()
        # off it), so the enclosing function is necessarily async. The
        # `_asyncify_awaited_methods` post-pass also marks any method now holding
        # an injected `await` as async, covering the rare exception. Gating on
        # the `_is_inside_async_fn` heuristic here mis-skips deeply-nested sites
        # (loops, expect.soft(...includes(...))), so we rely on the post-pass.
        key_arg = build_dyn_key_expr(expr)
        intent_expr = intent_for_dyn(expr)
        changes.append(f"dynamic getByTestId → {h}.getByTestIdWithHealing (key={key_arg})")
        # Wrap in parens so the caller's trailing chain binds to the awaited
        # Locator: `(await h.getByTestIdWithHealing(...)).all()` etc.
        replacement = (
            f"(await {h}.getByTestIdWithHealing({key_arg}, {expr}, {intent_expr}))"
        )
        out.append(content[last : m.start()])
        out.append(replacement)
        last = end_idx
    out.append(content[last:])
    return "".join(out)


# Class/standalone method head that is NOT async: `name(args): Ret {` or
# `name(args) {`. We deliberately exclude `constructor`, control-flow keywords
# (`if`/`for`/`while`/`switch`/`catch`), and arrow forms (handled separately by
# `_asyncify_awaited_arrows`). Used by `_asyncify_awaited_methods` to add
# `async` to a plain method whose body was rewritten to contain `await`.
_METHOD_HEAD_RE = re.compile(
    r"(?P<lead>(?:^|[\n;{}])\s*)"
    r"(?P<head>(?P<name>[A-Za-z_$][\w$]*)\s*\([^()]*\)\s*(?::\s*[^{;=]+?)?\s*)\{"
)
_NON_METHOD_NAMES = {"if", "for", "while", "switch", "catch", "function", "constructor", "return", "do", "else"}


def _body_has_own_await(body: str) -> bool:
    """
    True if `body` contains an `await` that belongs to THIS function — i.e. an
    `await` that is NOT nested inside an inner `function`/arrow scope. Awaits
    inside `setTimeout(async () => { await … })` and similar belong to the
    inner arrow, not the outer method, so they must not trigger asyncify on the
    outer method (which would also break a `: void` return type).
    """
    n = len(body)
    i = 0
    in_str: str | None = None
    # stack of brace depths at which a nested function/arrow scope opened.
    fn_open_at: list[int] = []
    brace_depth = 0
    while i < n:
        ch = body[i]
        if in_str is not None:
            if ch == "\\" and i + 1 < n:
                i += 2
                continue
            if ch == in_str:
                in_str = None
            i += 1
            continue
        if ch in ("'", '"', "`"):
            in_str = ch
            i += 1
            continue
        if ch == "{":
            brace_depth += 1
            i += 1
            continue
        if ch == "}":
            if fn_open_at and brace_depth == fn_open_at[-1] + 1:
                fn_open_at.pop()
            brace_depth -= 1
            i += 1
            continue
        # Detect the opening `{` of a nested function/arrow. We mark the brace
        # depth so we know when we leave that scope.
        if body.startswith("=>", i):
            # find the `{` that opens the arrow body (if block-bodied)
            j = i + 2
            while j < n and body[j] in " \t\r\n":
                j += 1
            if j < n and body[j] == "{":
                fn_open_at.append(brace_depth)
            i += 2
            continue
        if re.match(r"\bfunction\b", body[i:]):
            j = body.find("{", i)
            if j != -1:
                # everything up to that `{` belongs to the function head;
                # record the scope at the depth just before the `{`.
                fn_open_at.append(brace_depth)
            i += len("function")
            continue
        if re.match(r"\bawait\b", body[i:]):
            # `await` belongs to this method only if we're not inside a nested
            # function scope.
            inside_nested = any(brace_depth > d for d in fn_open_at)
            if not inside_nested:
                return True
            i += len("await")
            continue
        i += 1
    return False


def _asyncify_awaited_methods(content: str, changes: list[str]) -> str:
    """
    Safety post-pass: mark any plain class/standalone method `name(args): Ret {`
    as `async` when its OWN body contains `await` and it isn't already async.
    The declared return type is also lifted to `Promise<…>` so an `async`
    method doesn't violate TS1064 (`async` requires a `Promise<T>` return type).

    Only methods whose body contains an await that belongs to the method itself
    (not a nested function/arrow) are touched; constructors and control-flow
    blocks are excluded. Idempotent — a head already preceded by `async` is left
    alone. Arrow functions are handled by `_asyncify_awaited_arrows`, so this
    pass complements it.
    """
    # (head_start, ret_span) where ret_span is (start,end) of an existing
    # non-Promise return-type annotation to wrap, or None.
    edits: list[tuple[int, tuple[int, int, str] | None]] = []
    for m in _METHOD_HEAD_RE.finditer(content):
        name = m.group("name")
        if name in _NON_METHOD_NAMES:
            continue
        head_start = m.start("head")
        preceding = content[max(0, head_start - 7):head_start]
        if re.search(r"\basync\s*$", preceding):
            continue
        brace_idx = content.find("{", m.start("head"))
        if brace_idx < 0:
            continue
        body_end = _find_matching_brace(content, brace_idx)
        if body_end is None:
            continue
        body = content[brace_idx + 1 : body_end]
        if not _body_has_own_await(body):
            continue
        # Inspect the return-type annotation (between the `)` and the `{`).
        # If present and not already a Promise<…>, wrap it.
        head_text = content[head_start:brace_idx]
        ret_edit: tuple[int, int, str] | None = None
        rt = re.search(r"\)\s*:\s*([^{]+?)\s*$", head_text)
        if rt:
            ret_type = rt.group(1).strip()
            if not ret_type.startswith("Promise<"):
                abs_start = head_start + rt.start(1)
                abs_end = head_start + rt.end(1)
                ret_edit = (abs_start, abs_end, f"Promise<{ret_type}>")
        edits.append((head_start, ret_edit))
    if not edits:
        return content
    # Apply all edits back-to-front so earlier offsets stay valid. Each method
    # may carry two insert/replace ops (async prefix + return-type wrap); we
    # flatten and sort by descending position.
    ops: list[tuple[int, int, str]] = []
    for head_start, ret_edit in edits:
        ops.append((head_start, head_start, "async "))
        if ret_edit:
            ops.append(ret_edit)
        changes.append("marked method async (await body)")
    for start, end, text in sorted(ops, key=lambda o: o[0], reverse=True):
        content = content[:start] + text + content[end:]
    return content


# ---------------------------------------------------------------------------
# Selector-access injection — post-pass that appends `.selector` to bare
# locator references (`xxxLocators.foo`, aliased `this.Elements.foo`, etc.)
# that the healer-rewrite passes above didn't already cover.
#
# Why this exists: `locator_registrar.transform_locator_file` turns plain
# string locator values into `{ selector, intent, stability }` objects so the
# LocatorHealer has metadata to work with. Call sites that still take a raw
# selector (`page.locator(...)`, `page.selectOption(...)`, custom wrappers like
# `base.waitAndClick(...)`) compile against `string`, so without an explicit
# `.selector` access they would receive the object and fail with TS2345.
#
# We only wrap keys known to be object-valued (`object_keys` map), and we skip
# accesses that are already followed by `.selector`/`.intent`/`.stability` or
# a function call (`(`). This keeps the transform idempotent and side-effect
# free on already-fixed code.
# ---------------------------------------------------------------------------

# `<obj>.<key>` not followed by another `.something` or `(`. Used for both the
# direct `xxxLocators.foo` form and aliased `this.Elements.foo` form (built
# below at runtime from detected alias assignments).
_BARE_LOCATOR_ACCESS_RE = re.compile(
    r"\b(\w+Locators)\.(\w+)\b"
    r"(?!\s*\.\s*\w)"     # not followed by another property access
    r"(?!\s*\()"           # not followed by a function call
)

# Alias detection — locator object stored under another name.
#   `const E = fooLocators;`
#   `let E = fooLocators;`
#   `private Elements = fooLocators;`  (with optional access modifiers / readonly)
#   `private readonly Elements: <type> = fooLocators;`
_ALIAS_VAR_RE = re.compile(
    r"\b(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=;]+)?\s*=\s*(\w+Locators)\s*;"
)
_ALIAS_FIELD_RE = re.compile(
    r"\b(?:(?:private|public|protected|readonly|static)\s+)+(\w+)"
    r"(?:\s*:\s*[^=;]+)?\s*=\s*(\w+Locators)\s*;"
)

# Inline-object class field that re-exports selectors from existing locator
# objects, e.g.:
#
#   private MassInviteElements = {
#     selectAllCheckbox: membersListPageLocators.selectAllCheckbox.selector,
#     memberCheckbox:    membersListPageLocators.memberCheckbox.selector,
#     ...
#   };
#
# Each value is a `.selector` reference into a registered xxxLocators object,
# so callers like `this.MassInviteElements.selectAllCheckbox` are runtime
# strings. We extract these inline fields so the healer rewriter can route
# them through `_healer.<...>WithHealing` using the original locator's
# semantic info (key, intent, selector).
_INLINE_FIELD_OPEN_RE = re.compile(
    r"\b(?:(?:private|public|protected|readonly|static)\s+)+(\w+)"
    r"(?:\s*:\s*[^={};]+)?\s*=\s*\{",
)
_INLINE_FIELD_ENTRY_RE = re.compile(
    r"^\s*(\w+)\s*:\s*(\w+Locators)\.(\w+)\.selector\s*,?\s*$",
    re.MULTILINE,
)


def _collect_alias_map(content: str) -> dict[str, str]:
    """
    Return aliases: `{ alias_access → locator_obj_name }`.

    For local vars (`const E = fooLocators;`), the alias access is the bare
    name (`E`). For class fields (`private Elements = fooLocators;`), the
    alias access is `this.<name>` (e.g. `this.Elements`).
    """
    aliases: dict[str, str] = {}
    for m in _ALIAS_VAR_RE.finditer(content):
        aliases[m.group(1)] = m.group(2)
    for m in _ALIAS_FIELD_RE.finditer(content):
        aliases[f"this.{m.group(1)}"] = m.group(2)
    return aliases


def _collect_inline_field_map(content: str) -> dict[tuple[str, str], tuple[str, str]]:
    """
    Return `{ (alias_access, inline_key) → (locator_obj_name, original_key) }`
    for inline class-field locator dictionaries that re-export
    `<xxxLocators>.<key>.selector` entries.

    Allows the healer rewriter to treat
        `this.MassInviteElements.selectAllCheckbox`
    as the equivalent of
        `membersListPageLocators.selectAllCheckbox`
    when generating `_healer.clickWithHealing(...)` calls.
    """
    mapping: dict[tuple[str, str], tuple[str, str]] = {}
    for m in _INLINE_FIELD_OPEN_RE.finditer(content):
        field_name = m.group(1)
        brace_idx  = content.find("{", m.start())
        if brace_idx < 0:
            continue
        # Find matching `}` with depth counting so nested object literals
        # inside the field don't confuse us.
        depth = 1
        i = brace_idx + 1
        while i < len(content) and depth > 0:
            c = content[i]
            if c == "{":
                depth += 1
            elif c == "}":
                depth -= 1
            i += 1
        body = content[brace_idx + 1 : i - 1]
        alias_access = f"this.{field_name}"
        # Each well-formed entry: `key: xxxLocators.prop.selector,`
        for em in _INLINE_FIELD_ENTRY_RE.finditer(body):
            inline_key = em.group(1)
            loc_name   = em.group(2)
            orig_key   = em.group(3)
            mapping[(alias_access, inline_key)] = (loc_name, orig_key)
    return mapping


def _inject_selector_accesses(
    content: str,
    changes: list[str],
    object_keys: dict[str, set[str]] | None,
    alias_map: dict[str, str] | None = None,
    inline_field_map: dict[tuple[str, str], tuple[str, str]] | None = None,
) -> str:
    if not object_keys:
        return content

    n_changes = 0

    # Pass 1 — direct `xxxLocators.key` references.
    def _wrap_direct(m: re.Match) -> str:
        nonlocal n_changes
        obj, key = m.group(1), m.group(2)
        if key not in object_keys.get(obj, set()):
            return m.group(0)
        n_changes += 1
        return f"{obj}.{key}.selector"

    content = _BARE_LOCATOR_ACCESS_RE.sub(_wrap_direct, content)

    # Pass 2 — aliased references (`this.Elements.key`, `E.key`). Re-detect
    # if no map was supplied (back-compat); callers in _replace_actions
    # pass the already-collected map to avoid double work.
    aliases = alias_map if alias_map is not None else _collect_alias_map(content)
    for alias, locator_obj in aliases.items():
        keys = object_keys.get(locator_obj)
        if not keys:
            continue
        # Build a regex matching `<alias>.<key>` for the keys in this object.
        # `alias` may contain `.` (e.g. `this.Elements`); escape it.
        alias_re = re.compile(
            rf"(?<!\.\.\.){re.escape(alias)}\.(\w+)\b"
            rf"(?!\s*\.\s*\w)"
            rf"(?!\s*\()"
        )

        def _wrap_alias(m: re.Match) -> str:
            nonlocal n_changes
            key = m.group(1)
            if key not in keys:
                return m.group(0)
            n_changes += 1
            return f"{alias}.{key}.selector"

        content = alias_re.sub(_wrap_alias, content)

    if n_changes:
        changes.append(f"Appended .selector to {n_changes} locator references")
    return content
