"""
Convert a Playwright JS/TS spec file into a Cucumber Gherkin feature + a step
definition file — **losslessly** and without fabricating step text.

Design principles (zero-inference contract):

  • Each `test('name', body)`  →  one `Scenario: name` with one step
    `When name`. The full original body is placed inside that step's
    definition verbatim. We do not invent generic step text like
    "I run step #1" or split arbitrary statements into Given/When/Then.

  • `test.skip` / `test.only` / `test.fixme` / `test.fail` modifiers
    surface as `@skip` / `@only` / `@fixme` / `@fail` tags on the scenario.

  • `test.beforeEach`  →  Cucumber `Before(...)`
    `test.afterEach`   →  Cucumber `After(...)`
    `test.beforeAll`   →  Cucumber `BeforeAll(...)`
    `test.afterAll`    →  Cucumber `AfterAll(...)`

  • Every statement that lives at top level of a `test.describe(...)`
    body (helpers, classes, top-level `let`/`const`/`var`) is carried
    into the steps file. Variables that are *closure-captured* across
    hooks and tests are hoisted onto Cucumber's World (`this`); everything
    else is emitted at module scope.

  • Comments and whitespace inside test/hook bodies are preserved.

What the converter does NOT do:
  • It does not classify per-statement intent into Given/When/Then.
  • It does not dedupe or rename step texts beyond escaping inner quotes.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field


# ---------------------------------------------------------------------------
# Brace / paren matching (string- and comment-aware)
# ---------------------------------------------------------------------------

def _find_matching_brace(text: str, open_idx: int) -> int:
    assert text[open_idx] == "{"
    depth = 1
    i = open_idx + 1
    in_str: str | None = None
    while i < len(text):
        c = text[i]
        if in_str:
            if c == "\\":
                i += 2
                continue
            if c == in_str:
                in_str = None
            i += 1
            continue
        if c in ('"', "'", "`"):
            in_str = c
            i += 1
            continue
        if c == "/" and i + 1 < len(text):
            if text[i + 1] == "/":
                nl = text.find("\n", i)
                i = nl + 1 if nl >= 0 else len(text)
                continue
            if text[i + 1] == "*":
                end = text.find("*/", i + 2)
                i = end + 2 if end >= 0 else len(text)
                continue
        if c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return -1


def _find_matching_paren(text: str, open_idx: int) -> int:
    assert text[open_idx] == "("
    depth = 1
    i = open_idx + 1
    in_str: str | None = None
    while i < len(text):
        c = text[i]
        if in_str:
            if c == "\\":
                i += 2
                continue
            if c == in_str:
                in_str = None
            i += 1
            continue
        if c in ('"', "'", "`"):
            in_str = c
            i += 1
            continue
        if c == "/" and i + 1 < len(text):
            if text[i + 1] == "/":
                nl = text.find("\n", i)
                i = nl + 1 if nl >= 0 else len(text)
                continue
            if text[i + 1] == "*":
                end = text.find("*/", i + 2)
                i = end + 2 if end >= 0 else len(text)
                continue
        if c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return -1


# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------

# Recognised test modifiers and their Gherkin tag equivalents.
_TEST_MODIFIERS = {"skip", "only", "fixme", "fail", "slow"}


@dataclass
class TestBlock:
    name: str
    body: str
    modifier: str = ""   # one of _TEST_MODIFIERS or ""


@dataclass
class Suite:
    name: str
    before_each: str = ""
    before_all:  str = ""
    after_each:  str = ""
    after_all:   str = ""
    tests:       list[TestBlock] = field(default_factory=list)
    shared_vars: set[str] = field(default_factory=set)
    preamble:    str = ""
    is_synth:    bool = False   # True when the file has no top-level test.describe


# ---------------------------------------------------------------------------
# Spec parsing
# ---------------------------------------------------------------------------

_DESCRIBE_RE = re.compile(
    r"test\.describe(?:\.\w+)?\s*\(\s*['\"]([^'\"]+)['\"]\s*,",
)

# Captures `test(`, `test.skip(`, `test.only(`, `test.fixme(`, `test.fail(`, `test.slow(`.
_TEST_RE = re.compile(
    r"\btest(?:\.(\w+))?\s*\(\s*['\"]([^'\"]+)['\"]\s*,",
)

_HOOK_RE = re.compile(
    r"\btest\.(beforeEach|beforeAll|afterEach|afterAll)\s*\(",
)


def _strip_async_arrow_prefix(rest: str) -> str:
    """
    Given text starting INSIDE the parens of a test/hook call, return the
    body of the callback function. Handles a leading title string + comma,
    `async`, `function`, destructured param lists, and arrow vs function
    expressions.
    """
    i = 0
    n = len(rest)
    while i < n and rest[i].isspace():
        i += 1
    if i < n and rest[i] in ("'", '"'):
        quote = rest[i]
        i += 1
        while i < n and rest[i] != quote:
            if rest[i] == "\\" and i + 1 < n:
                i += 2
                continue
            i += 1
        i += 1
        while i < n and rest[i].isspace():
            i += 1
        if i < n and rest[i] == ",":
            i += 1
            while i < n and rest[i].isspace():
                i += 1
    if rest[i:i+5] == "async":
        i += 5
        while i < n and rest[i].isspace():
            i += 1
    if rest[i:i+8] == "function":
        i += 8
        while i < n and (rest[i].isspace() or rest[i].isalnum() or rest[i] == "_"):
            i += 1
    if i < n and rest[i] == "(":
        end = _find_matching_paren(rest, i)
        if end < 0:
            return ""
        i = end + 1
    while i < n and rest[i].isspace():
        i += 1
    if rest[i:i+2] == "=>":
        i += 2
        while i < n and rest[i].isspace():
            i += 1
    if i < n and rest[i] == "{":
        end = _find_matching_brace(rest, i)
        if end < 0:
            return ""
        return rest[i + 1 : end]
    brace = rest.find("{", i)
    if brace < 0:
        return ""
    end = _find_matching_brace(rest, brace)
    if end < 0:
        return ""
    return rest[brace + 1 : end]


def parse_spec(content: str) -> list[Suite]:
    """Extract top-level test.describe blocks and the tests inside each."""
    suites: list[Suite] = []
    for m in _DESCRIBE_RE.finditer(content):
        paren_open = content.find("(", m.start())
        if paren_open < 0:
            continue
        paren_close = _find_matching_paren(content, paren_open)
        if paren_close < 0:
            continue
        describe_body = _strip_async_arrow_prefix(content[m.end():paren_close])
        suite = Suite(name=m.group(1))
        _populate_suite_from_body(suite, describe_body)
        suites.append(suite)
    if not suites and _TEST_RE.search(content):
        synth = Suite(name="Migrated tests", is_synth=True)
        _populate_suite_from_body(synth, content)
        if synth.tests:
            suites.append(synth)
    return suites


# ---------------------------------------------------------------------------
# Strip Playwright-runner-only calls (test.skip / test.use / test.step / ...)
# from a body. These have no Cucumber equivalent; the user surfaces intent
# via `@skip` / `@fixme` tags or step-level conditionals. We comment them out
# rather than drop so the original intent stays visible in source.
# ---------------------------------------------------------------------------

_RUNNER_CALL_RE = re.compile(
    r"^(\s*)(test\.(?:skip|only|fixme|fail|slow|info|step|use|extend)\b[^\n;]*;?)",
    re.MULTILINE,
)


def _comment_runner_only_calls(body: str) -> str:
    return _RUNNER_CALL_RE.sub(
        lambda m: f"{m.group(1)}// MIGRATION TODO: {m.group(2)}",
        body,
    )


_IMPORT_LINE_RE = re.compile(
    r"^\s*(?:import\b.*$|(?:const|let|var)\s+(?:\{[^}]*\}|\w+)\s*=\s*require\([^)]*\)\s*;?\s*$)",
    re.MULTILINE,
)


def _strip_imports(body: str) -> str:
    return _IMPORT_LINE_RE.sub("", body)


# ---------------------------------------------------------------------------
# Statement splitting (used only for hoisting shared vars from describe body)
# ---------------------------------------------------------------------------

def split_statements(body: str) -> list[str]:
    body = body.strip()
    stmts: list[str] = []
    i = 0
    n = len(body)
    while i < n:
        while i < n and body[i].isspace():
            i += 1
        if i >= n:
            break

        start = i
        block_kw_match = re.match(
            r"(if|for|while|switch|try)\b",
            body[i:],
        )
        if block_kw_match:
            j = i + block_kw_match.end()
            while j < n and body[j].isspace():
                j += 1
            if j < n and body[j] == "(":
                j = _find_matching_paren(body, j) + 1
            while j < n and body[j].isspace():
                j += 1
            if j < n and body[j] == "{":
                j = _find_matching_brace(body, j) + 1
            while True:
                k = j
                while k < n and body[k].isspace():
                    k += 1
                tail = re.match(r"(else\s+if|else|catch|finally)\b", body[k:])
                if not tail:
                    break
                k += tail.end()
                while k < n and body[k].isspace():
                    k += 1
                if k < n and body[k] == "(":
                    k = _find_matching_paren(body, k) + 1
                while k < n and body[k].isspace():
                    k += 1
                if k < n and body[k] == "{":
                    k = _find_matching_brace(body, k) + 1
                j = k
            stmts.append(body[start:j].strip())
            i = j
            continue

        # function / class declaration — grab through the matching `}`
        if re.match(r"(?:async\s+)?function\s+\w+\s*\(", body[i:]) or \
           re.match(r"class\s+\w+", body[i:]):
            j = i
            paren_idx = body.find("(", j)
            brace_idx = body.find("{", j)
            if paren_idx >= 0 and (brace_idx < 0 or paren_idx < brace_idx):
                j = _find_matching_paren(body, paren_idx) + 1
            while j < n and body[j].isspace():
                j += 1
            if j < n and body[j] == "{":
                j = _find_matching_brace(body, j) + 1
            stmts.append(body[start:j].strip())
            i = j
            continue

        depth = 0
        in_str: str | None = None
        j = i
        while j < n:
            c = body[j]
            if in_str:
                if c == "\\":
                    j += 2
                    continue
                if c == in_str:
                    in_str = None
                j += 1
                continue
            if c in ('"', "'", "`"):
                in_str = c
                j += 1
                continue
            if c == "/" and j + 1 < n:
                if body[j + 1] == "/":
                    nl = body.find("\n", j)
                    j = nl + 1 if nl >= 0 else n
                    continue
                if body[j + 1] == "*":
                    end = body.find("*/", j + 2)
                    j = end + 2 if end >= 0 else n
                    continue
            if c in "([{":
                depth += 1
            elif c in ")]}":
                depth -= 1
            elif c == ";" and depth == 0:
                stmts.append(body[start:j].strip())
                j += 1
                break
            j += 1
        else:
            tail = body[start:].strip()
            if tail:
                stmts.append(tail)
            break
        i = j
    return [s for s in stmts if s]


# ---------------------------------------------------------------------------
# Shared-variable lifting (closure capture across hooks/tests)
# ---------------------------------------------------------------------------

def _strip_leading_comments(stmt: str) -> str:
    i = 0
    n = len(stmt)
    while i < n:
        while i < n and stmt[i].isspace():
            i += 1
        if i + 1 < n and stmt[i:i+2] == "//":
            nl = stmt.find("\n", i)
            i = nl + 1 if nl >= 0 else n
            continue
        if i + 1 < n and stmt[i:i+2] == "/*":
            end = stmt.find("*/", i + 2)
            i = end + 2 if end >= 0 else n
            continue
        break
    return stmt[i:]


def _collect_shared_vars(suite_body: str) -> set[str]:
    """
    Find variable names declared at the top level of the describe body —
    `let foo;`, `let foo = ...;`, `var bar;`, `const baz = ...;`. These are
    shared via closure across hooks and tests, so generated step/hook
    bodies must reference them as `(this as any).foo`.
    """
    names: set[str] = set()
    for stmt in split_statements(suite_body):
        cleaned = _strip_leading_comments(stmt)
        m = re.match(r"^\s*(?:let|var|const)\s+(\w+)", cleaned)
        if m:
            names.add(m.group(1))
    return names


def _rewrite_shared_refs(body: str, shared: set[str]) -> str:
    if not shared:
        return body
    out = body
    for name in shared:
        out = re.sub(rf"\b(?:let|var|const)\s+{re.escape(name)}\s*;\s*\n?", "", out)
        out = re.sub(
            rf"\b(?:let|var|const)\s+{re.escape(name)}\s*=",
            f"(this as any).{name} =",
            out,
        )
    for name in shared:
        # Spread `...name` → `...(this as any).name`. Must run before the
        # plain-ref rewrite because the lookbehind in that rule excludes `.`,
        # which would skip spread occurrences entirely.
        out = re.sub(
            rf"(?<!\w)\.\.\.{re.escape(name)}\b",
            f"...(this as any).{name}",
            out,
        )
        out = re.sub(
            rf"(?<![.\w])(?<!any\)\.){re.escape(name)}\b(?!\s*:)",
            f"(this as any).{name}",
            out,
        )
    return out


# ---------------------------------------------------------------------------
# Suite body → hooks + tests + preamble
# ---------------------------------------------------------------------------

def _populate_suite_from_body(suite: Suite, body: str) -> None:
    suite.shared_vars = _collect_shared_vars(body)
    suite.preamble    = _extract_describe_preamble(body)

    # Hooks
    for hm in _HOOK_RE.finditer(body):
        kind = hm.group(1)
        paren_open  = body.find("(", hm.start())
        paren_close = _find_matching_paren(body, paren_open)
        if paren_close < 0:
            continue
        hook_body = _strip_async_arrow_prefix(body[paren_open + 1 : paren_close])
        attr = {
            "beforeEach": "before_each",
            "beforeAll":  "before_all",
            "afterEach":  "after_each",
            "afterAll":   "after_all",
        }[kind]
        setattr(suite, attr, hook_body)

    # Tests (incl. modifiers test.skip / test.only / test.fixme / test.fail / test.slow)
    for tm in _TEST_RE.finditer(body):
        modifier = (tm.group(1) or "").strip()
        # Filter out hook calls — _HOOK_RE handled those.
        if modifier in {"beforeEach", "beforeAll", "afterEach", "afterAll", "describe"}:
            continue
        # Filter out non-test method calls accidentally matching `test.<x>(...)`
        # where <x> is something like `step`, `use`, `info`, `extend` — these
        # aren't test definitions. Keep only the known modifiers (and bare test).
        if modifier and modifier not in _TEST_MODIFIERS:
            continue
        paren_open  = body.find("(", tm.start())
        paren_close = _find_matching_paren(body, paren_open)
        if paren_close < 0:
            continue
        test_body = _strip_async_arrow_prefix(body[paren_open + 1 : paren_close])
        suite.tests.append(TestBlock(name=tm.group(2), body=test_body, modifier=modifier))


# ---------------------------------------------------------------------------
# Preamble extraction — KEEP EVERYTHING that isn't a test/hook call
# ---------------------------------------------------------------------------

def _extract_describe_preamble(body: str) -> str:
    """
    Return everything in the describe body that ISN'T a `test(...)` or
    `test.<hook>(...)` call. Helpers (functions, classes), shared `let`/
    `const`/`var` bindings, and stray expressions are all preserved.

    Variable bindings that are shared via closure across hooks/tests are
    rewritten onto Cucumber's World (`this`) by the caller via
    _rewrite_shared_refs. Statements left here are emitted at module scope.
    """
    out: list[str] = []
    i = 0
    n = len(body)
    block_start_re = re.compile(r"\btest(?:\.\w+)?\s*\(")
    while i < n:
        m = block_start_re.search(body, i)
        if not m:
            out.append(body[i:])
            break
        out.append(body[i:m.start()])
        paren_open = body.find("(", m.start())
        if paren_open < 0:
            i = m.end()
            continue
        paren_close = _find_matching_paren(body, paren_open)
        if paren_close < 0:
            i = m.end()
            continue
        end = paren_close + 1
        while end < n and body[end] in ";\n\r ":
            end += 1
        i = end
    return "".join(out).strip()


def extract_module_preamble(content: str) -> str:
    """
    Module-level code (outside any test.describe/test/hook).
    Imports / requires are stripped — they're re-emitted by rewrite_imports().
    """
    lines = content.splitlines()
    kept: list[str] = []
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("import ") or "= require(" in stripped or \
           (stripped.startswith("const {") and "require(" in stripped):
            continue
        kept.append(line)
    text = "\n".join(kept)

    out: list[str] = []
    i = 0
    n = len(text)
    block_start_re = re.compile(r"\btest(?:\.\w+)?\s*\(")
    while i < n:
        m = block_start_re.search(text, i)
        if not m:
            out.append(text[i:])
            break
        out.append(text[i:m.start()])
        paren_open = text.find("(", m.start())
        if paren_open < 0:
            i = m.end()
            continue
        paren_close = _find_matching_paren(text, paren_open)
        if paren_close < 0:
            i = m.end()
            continue
        end = paren_close + 1
        while end < n and text[end] in ";\n\r ":
            end += 1
        i = end
    return "".join(out).strip()


# ---------------------------------------------------------------------------
# Imports
# ---------------------------------------------------------------------------

_REQUIRE_RE = re.compile(
    r"(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\(\s*['\"]([^'\"]+)['\"]\s*\)\s*;?"
)
_DEFAULT_REQUIRE_RE = re.compile(
    r"(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*['\"]([^'\"]+)['\"]\s*\)\s*;?"
)


def rewrite_imports(content: str) -> list[str]:
    imports: list[str] = []
    seen: set[str] = set()

    for m in _REQUIRE_RE.finditer(content):
        names_raw = m.group(1).strip()
        src       = m.group(2).strip()
        names = [n.strip() for n in names_raw.split(",") if n.strip()]
        if src == "@playwright/test":
            keep = [n for n in names if n != "test"]
            if not keep:
                continue
            line = f'import {{ {", ".join(keep)} }} from "@playwright/test";'
        else:
            line = f'import {{ {", ".join(names)} }} from "{src}";'
        if line not in seen:
            imports.append(line)
            seen.add(line)

    for m in _DEFAULT_REQUIRE_RE.finditer(content):
        name = m.group(1)
        src  = m.group(2)
        if src == "@playwright/test":
            continue
        line = f'import {name} from "{src}";'
        if line not in seen:
            imports.append(line)
            seen.add(line)

    return imports


# ---------------------------------------------------------------------------
# Public converter
# ---------------------------------------------------------------------------

def convert_spec_to_bdd(content: str, base_stem: str) -> tuple[str, str, list[str]]:
    """
    Convert a Playwright spec file → (feature_content, steps_content, changes).

    One scenario per test, one step per scenario. The test name is reused as
    both the scenario name and the step text (escaping inner quotes only).
    The original test body is preserved verbatim inside that single step.
    Hooks become real Cucumber Before/After/BeforeAll/AfterAll. Test
    modifiers (.skip / .only / .fixme / .fail / .slow) surface as Gherkin tags.
    """
    changes: list[str] = []
    suites = parse_spec(content)
    if not suites:
        return "", "", []

    # ── Imports & module-level preamble ──────────────────────────────────────
    source_imports = rewrite_imports(content)
    pw_imports = [imp for imp in source_imports if '"@playwright/test"' in imp]
    if not pw_imports:
        source_imports.insert(0, 'import { expect } from "@playwright/test";')

    module_preamble = extract_module_preamble(content)

    steps_lines: list[str] = [
        'import { Before, After, BeforeAll, AfterAll, Given, When, Then } from "@cucumber/cucumber";',
        'import { fixture } from "@hooks/pageFixture";',
        *source_imports,
        "",
    ]
    module_preamble = _comment_runner_only_calls(module_preamble)
    if module_preamble:
        steps_lines.append("// ── Module-level helpers carried over from the source spec ──")
        steps_lines.append(module_preamble)
        steps_lines.append("")

    # ── Feature header ───────────────────────────────────────────────────────
    feature_title = suites[0].name
    feature_lines: list[str] = [f"Feature: {feature_title}", ""]

    # Track scenario-name uniqueness inside this feature; if two tests share
    # the same name, suffix with index so Cucumber can resolve them. We do not
    # invent names — only disambiguate identical ones.
    seen_scenarios: dict[str, int] = {}
    # Same for step text inside the steps file.
    seen_steps: set[str] = set()
    # Step text is prefixed with the file slug to keep it unique across
    # features (Cucumber registers step text globally — two specs each with a
    # test named "Wizard Test" would otherwise collide).
    step_prefix = f"[{base_stem}] " if base_stem else ""

    for suite in suites:
        # When the source file has no top-level test.describe, the synth
        # suite's preamble overlaps entirely with the module preamble we
        # already emitted — skip it to avoid duplicate declarations.
        if not suite.is_synth:
            suite_preamble = _strip_imports(suite.preamble)
            suite_preamble = _comment_runner_only_calls(suite_preamble)
            suite_preamble = _rewrite_shared_refs(suite_preamble, suite.shared_vars)
            if suite_preamble.strip():
                steps_lines.append(f"// ── Suite-scope code carried over from describe('{suite.name}') ──")
                steps_lines.append(suite_preamble)
                steps_lines.append("")

        # Hooks → real Cucumber hooks.
        def _hook_body(b: str) -> str:
            return _comment_runner_only_calls(_rewrite_shared_refs(b, suite.shared_vars))

        if suite.before_all.strip():
            steps_lines.append(_render_hook(
                "BeforeAll", suite.name, _hook_body(suite.before_all), bind_page=False,
            ))
        if suite.before_each.strip():
            steps_lines.append(_render_hook(
                "Before", suite.name, _hook_body(suite.before_each),
            ))
        if suite.after_each.strip():
            steps_lines.append(_render_hook(
                "After", suite.name, _hook_body(suite.after_each),
            ))
        if suite.after_all.strip():
            steps_lines.append(_render_hook(
                "AfterAll", suite.name, _hook_body(suite.after_all), bind_page=False,
            ))

        # One scenario per test.
        for test in suite.tests:
            # Disambiguate identical test names (rare; preserves source intent).
            base_name = test.name
            n = seen_scenarios.get(base_name, 0)
            seen_scenarios[base_name] = n + 1
            scenario_name = base_name if n == 0 else f"{base_name} #{n + 1}"

            tag = _modifier_to_tag(test.modifier)
            if tag:
                feature_lines.append(f"  {tag}")
            # Normalize whitespace once so the feature line and step
            # definition string are byte-identical. Gherkin's own parser
            # collapses inner whitespace and strips trailing whitespace,
            # so the step definition must match that normalized form to
            # bind correctly at runtime.
            step_text = _escape_gherkin(step_prefix + scenario_name)
            feature_lines.append(f"  Scenario: {scenario_name}")
            feature_lines.append(f"    When {step_text}")
            feature_lines.append("")

            # Step body = test body verbatim, with shared-var rewriting and
            # Playwright-runner-only calls commented out.
            step_body = _rewrite_shared_refs(test.body, suite.shared_vars)
            step_body = _comment_runner_only_calls(step_body)

            if step_text in seen_steps:
                changes.append(f"duplicate step text suppressed: {step_text}")
            else:
                seen_steps.add(step_text)
                steps_lines.append(_render_step("When", step_text, step_body))
            changes.append(f"converted test → scenario: {scenario_name}")

    feature_content = "\n".join(feature_lines).rstrip() + "\n"
    steps_content   = "\n".join(steps_lines).rstrip() + "\n"
    return feature_content, steps_content, changes


# ---------------------------------------------------------------------------
# Rendering helpers
# ---------------------------------------------------------------------------

def _modifier_to_tag(modifier: str) -> str:
    if not modifier:
        return ""
    if modifier in {"skip", "only", "fixme", "fail", "slow"}:
        return f"@{modifier}"
    return ""


def _escape_gherkin(text: str) -> str:
    """Gherkin step text is a single line — collapse newlines, keep quotes."""
    return re.sub(r"\s+", " ", text).strip()


_CUKE_EXPR_META_RE = re.compile(r"[/(){}]")
# JavaScript regex metacharacters that must be backslash-escaped inside a
# `/.../ ` literal. Note `-` and ` ` are NOT in this set (Python's `re.escape`
# escapes them, but those backslashes are illegal in JS regex syntax).
_JS_REGEX_ESCAPE_RE = re.compile(r"[.*+?^${}()|\[\]\\/]")


def _js_regex_escape(text: str) -> str:
    return _JS_REGEX_ESCAPE_RE.sub(lambda m: "\\" + m.group(0), text)


def _render_step(keyword: str, text: str, body: str) -> str:
    indented = "\n".join(("  " + ln) if ln.strip() else "" for ln in body.splitlines())
    # Cucumber Expressions treat `/` as alternation and `{...}` / `(...)` as
    # parameter / optional groups. When the step text contains any of those,
    # render the step as a RegExp literal so the original text is matched
    # verbatim instead of being parsed.
    if _CUKE_EXPR_META_RE.search(text):
        regex_src = _js_regex_escape(text)
        return (
            f'{keyword}(/^{regex_src}$/, async function () {{\n'
            f'  const page = fixture().page;\n'
            f'{indented}\n'
            f'}});\n'
        )
    text_esc = text.replace("\\", "\\\\").replace('"', '\\"')
    return (
        f'{keyword}("{text_esc}", async function () {{\n'
        f'  const page = fixture().page;\n'
        f'{indented}\n'
        f'}});\n'
    )


def _render_hook(kind: str, suite_name: str, body: str, bind_page: bool = True) -> str:
    """
    Render a Cucumber Before/After/BeforeAll/AfterAll hook.

    BeforeAll/AfterAll do not have a per-scenario `page`, so we skip the
    fixture binding in those.

    Note: Cucumber's BeforeAll/AfterAll run once per process, not once per
    feature — the closest faithful mapping for Playwright's test.beforeAll.
    Per-feature scoping can be added later via tagged Before hooks if needed.
    """
    indented = "\n".join(("  " + ln) if ln.strip() else "" for ln in body.splitlines())
    header = f"// ── Hook: {kind} carried over from describe('{suite_name}') ──"
    if bind_page:
        return (
            f"{header}\n"
            f"{kind}(async function () {{\n"
            f"  const page = fixture().page;\n"
            f"{indented}\n"
            f"}});\n"
        )
    return (
        f"{header}\n"
        f"{kind}(async function () {{\n"
        f"{indented}\n"
        f"}});\n"
    )
