"""
Hoist inline-string locators from local variables into the centralised
xxxLocators objects, then rewrite the variable RHS to reference the new entry.

Input (page object):
    const banner = this.page.getByTestId("nova_impersonate-banner");
    const saveBtn = this.page.getByRole("button", { name: "Save" });

Output (page object):
    const banner = this.page.getByTestId(novaImpersonateLocators.banner.selector);
    const saveBtn = this.page.getByRole("button", { name: "Save" });  // left as-is

Side effect: emits hoist actions describing entries to be appended to the
target locator file's xxxLocators object literal during a post-pass.

Hoist matrix:
    .locator("CSS")                       → selector "CSS"
    .getByTestId("X")                     → selector "[data-testid=\"X\"]"
    .getByRole("R"[, { name: "N" }])      → skipped (no resolvable string form;
                                            left in place — see _try_hoist_rhs)
    .getByText("T")                       → selector "text=\"T\""
    .getByLabel(...)                      → skipped (no clean string form)
    template strings with ${}             → skipped
    already-centralised xxxLocators refs  → skipped
"""
from __future__ import annotations
import re
from dataclasses import dataclass


# ---------------------------------------------------------------------------
# Hoist action — emitted during per-file scan, applied in the post-pass.
# ---------------------------------------------------------------------------

@dataclass
class HoistAction:
    locator_object: str   # e.g. "invoiceManagementLocators"
    entry_name:     str   # e.g. "chargebeeContent"
    selector:       str   # the static-string selector value
    intent:         str   # human-readable
    stability:      int   # 0-100


# ---------------------------------------------------------------------------
# Heuristics — mirrored from locator_registrar.py so hoisted entries have the
# same shape and stability scoring as the original locator-file entries.
# ---------------------------------------------------------------------------

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


def _has_descendant_combinator(selector: str) -> bool:
    """True if `selector` joins simple selectors with a combinator.

    Mirrors locator_registrar._has_descendant_combinator: a structural space
    or `> + ~`, but NOT a space inside `[]`, `()`, or quotes.
    """
    depth = 0
    quote = ""
    for ch in selector:
        if quote:
            if ch == quote:
                quote = ""
            continue
        if ch in "\"'":
            quote = ch
        elif ch in "[(":
            depth += 1
        elif ch in "])":
            depth = max(0, depth - 1)
        elif depth == 0 and (ch in ">+~" or ch == " "):
            return True
    return False


def _infer_stability(selector: str) -> int:
    """0-100 confidence. Mirrors locator_registrar._infer_stability."""
    # Playwright text/role engine selectors carry their own (non-CSS) spaces,
    # e.g. `text=Save all` — those are content, not combinators, so score them
    # before the combinator cap can misfire on the embedded space.
    if "[data-testid" in selector or "data-testid=" in selector:
        return 100
    if selector.startswith("role="):
        return 80
    if selector.startswith("text="):
        return 50
    # A compound/combinator CSS selector is brittle no matter how stable its
    # leading part looks (`#id button`), so it caps at the combinator tier.
    if _has_descendant_combinator(selector):
        return 30
    if selector.startswith("#"):
        return 80
    if re.match(r"^[a-z_][\w-]*$", selector):
        return 80
    if "[aria-label=" in selector or "[placeholder=" in selector:
        return 70
    if "[dusk=" in selector or "[data-" in selector:
        return 70
    if selector.startswith(".") or any(c in selector for c in ">+~"):
        return 30
    return 50


# ---------------------------------------------------------------------------
# Regexes for the various RHS shapes we know how to hoist.
# ---------------------------------------------------------------------------

# `const|let|var|private|public|protected|readonly [readonly] <name> [: T] = <rhs>;`
# DOTALL so multi-line RHS 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*)"
    r"(.+?);",
    re.DOTALL,
)

# Inside an RHS, match the LAST `.locator("...")` / `.getByXxx(...)` call.
# Quoted strings: prefer double-quoted, fall back to single-quoted. We avoid
# backreferences because backreference numbers shift when patterns are composed.
_DQ   = r'"([^"]*)"'           # 1 capture: content
_SQ   = r"'([^']*)'"            # 1 capture: content
_QSTR = rf"(?:{_DQ}|{_SQ})"    # 2 captures: (dq, sq) — one will be non-None

_LOCATOR_CALL_RE  = re.compile(rf"\.locator\(\s*{_QSTR}\s*,?\s*\)", re.DOTALL)
_TESTID_CALL_RE   = re.compile(rf"\.getByTestId\(\s*{_QSTR}\s*,?\s*\)", re.DOTALL)
_TEXT_CALL_RE     = re.compile(rf"\.getByText\(\s*{_QSTR}\s*,?\s*\)", re.DOTALL)
# Note: getByRole is deliberately NOT matched/hoisted — see _try_hoist_rhs.
# Its role+name are only faithfully usable via the real page.getByRole(...)
# call, which we leave in place rather than flatten into a dead `role=` string.


def _qstr_pick(m: re.Match, base: int) -> str:
    """Given a 2-capture _QSTR pair starting at group `base`, return the matched string."""
    return m.group(base) if m.group(base) is not None else (m.group(base + 1) or "")

# Detect already-centralised access — skip hoisting if RHS already references it.
_LOCATOR_ACCESS_RE = re.compile(r"\b\w+Locators\.\w+\b")

# Detect template-string interpolation — skip if present anywhere in RHS.
_INTERP_RE = re.compile(r"`[^`]*\$\{[^}]*\}[^`]*`")

# Identify the page object's primary xxxLocators import.
# `import { fooLocators[, barLocators...] } from "<path>";`
_LOCATOR_IMPORT_RE = re.compile(
    r"""import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*;?"""
)


# ---------------------------------------------------------------------------
# Per-RHS hoist detection — returns (new_rhs, selector_value, stability) or None.
# ---------------------------------------------------------------------------

def _try_hoist_rhs(rhs: str, var_name: str) -> tuple[str, str, str, int] | None:
    """
    If `rhs` contains a hoistable static-string locator call, return:
        (receiver_prefix, trailing_suffix, selector_value, stability)

    `receiver_prefix` is everything in `rhs` BEFORE the matched call
    (e.g. `this.page` or `await this.page.frameLocator(x)`).
    `trailing_suffix` is everything AFTER the call (e.g. `.textContent()` or
    a closing `)` from an outer wrapper like `.or(...)`).

    Callers reassemble as: <prefix>.locator(<repo_ref>)<suffix>.
    """
    # Skip if anything in the RHS is already centralised — no point hoisting.
    if _LOCATOR_ACCESS_RE.search(rhs):
        return None
    # Skip template-string interpolation — selector is dynamic.
    if _INTERP_RE.search(rhs):
        return None

    # Find ALL call-site matches and pick the LAST one so we hoist the
    # innermost-applied selector (the rightmost in the chain). The receiver
    # (everything to its left) is kept verbatim.
    candidates: list[tuple[int, int, str]] = []   # (start, end, selector_value)
    for m in _LOCATOR_CALL_RE.finditer(rhs):
        sel = _qstr_pick(m, 1)
        candidates.append((m.start(), m.end(), sel))
    for m in _TESTID_CALL_RE.finditer(rhs):
        v = _qstr_pick(m, 1)
        candidates.append((m.start(), m.end(), f"[data-testid=\"{v}\"]"))
    for m in _TEXT_CALL_RE.finditer(rhs):
        v = _qstr_pick(m, 1)
        candidates.append((m.start(), m.end(), f"text=\"{v}\""))
    # getByRole is intentionally NOT hoisted. A hoisted entry is stored as a
    # `selector:` string and resolved at runtime via `page.locator(...)`, which
    # has no `role=` engine — a flattened `role=R[name="N"]` string would never
    # resolve and silently fall through to intent-inferred role guessing. The
    # author's explicit role+name are only faithfully usable through the real
    # `page.getByRole(role, { name })` call, so we leave that call in place in
    # the page object. The action on it still heals via the role-aware
    # *ByRoleWithHealing methods emitted by healer_injector.

    if not candidates:
        return None

    candidates.sort(key=lambda t: t[0])
    last_start, last_end, selector_value = candidates[-1]

    return (
        rhs[:last_start],
        rhs[last_end:],
        selector_value,
        _infer_stability(selector_value),
    )


# ---------------------------------------------------------------------------
# Public entry — invoked from the per-file pipeline for page_object/step_def.
# ---------------------------------------------------------------------------

def hoist_inline_locators(content: str) -> tuple[str, list[HoistAction], list[str]]:
    """
    Scan `content` for hoistable local variable assignments and rewrite them
    in place.

    Returns:
      (new_content, hoist_actions, changes)

    hoist_actions: HoistAction list to be merged into the target locator file
    during a post-pass. The post-pass groups by `locator_object` and appends
    entries to the matching xxxLocators object literal.

    changes: human-readable change descriptions for the migration report.
    """
    # Find the primary xxxLocators import. If none, nothing to hoist into.
    primary_locator_obj = _primary_locator_object(content)
    if not primary_locator_obj:
        return content, [], []

    actions: list[HoistAction] = []
    changes: list[str] = []
    used_entry_names: set[str] = set()

    def _replace(m: re.Match) -> str:
        prefix   = m.group(1)
        var_name = m.group(2)
        rhs      = m.group(3)
        hoist = _try_hoist_rhs(rhs, var_name)
        if not hoist:
            return m.group(0)
        receiver_prefix, trailing_suffix, selector_value, stability = hoist

        # Pick an entry name. Default to the var name; suffix on collision.
        entry_name = var_name
        n = 2
        while entry_name in used_entry_names:
            entry_name = f"{var_name}{n}"
            n += 1
        used_entry_names.add(entry_name)

        intent = _camel_to_words(entry_name) or entry_name
        actions.append(HoistAction(
            locator_object=primary_locator_obj,
            entry_name=entry_name,
            selector=selector_value,
            intent=intent,
            stability=stability,
        ))
        changes.append(
            f"hoist {var_name} → {primary_locator_obj}.{entry_name} "
            f"(selector={selector_value!r}, stability={stability})"
        )

        # Reconstruct the RHS: receiver + `.locator(<repo-ref>)` + whatever came
        # after the matched call (`.textContent()`, `.first()`, outer `.or(...)`'s
        # closing `)`, etc.). Even if the original was getByRole/getByText/
        # getByTestId, we collapse to `.locator(...)` because the hoisted selector
        # string is in Playwright's universal selector syntax.
        repo_ref = f"{primary_locator_obj}.{entry_name}.selector"
        new_rhs  = f"{receiver_prefix}.locator({repo_ref}){trailing_suffix}"
        return f"{prefix}{new_rhs};"

    new_content = _VAR_ASSIGN_RE.sub(_replace, content)
    return new_content, actions, changes


def _primary_locator_object(content: str) -> str | None:
    """
    Return the name of the xxxLocators object imported by this file. If the
    file imports multiple xxxLocators in the same statement, prefer the first
    one that ISN'T `sharedLocators` — page-specific entries should land in
    the page-specific object, not the shared bucket.
    """
    fallback = None
    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")]
        if not names:
            continue
        # Prefer a non-shared object.
        for n in names:
            if n != "sharedLocators":
                return n
        fallback = fallback or names[0]
    return fallback


# ---------------------------------------------------------------------------
# Post-pass — apply buffered HoistActions to an already-modernised locator file.
# ---------------------------------------------------------------------------

# Match `export const xxxLocators = {` and locate its closing `} as const;` /
# `};` so we can splice new entries in just before it.
_BLOCK_OPEN_RE = re.compile(
    r"(export\s+const\s+(\w+Locators)\s*=\s*)\{",
)


def append_entries_to_locator_file(
    content: str,
    entries_by_object: dict[str, list[HoistAction]],
) -> tuple[str, list[str]]:
    """
    For each xxxLocators block in the file, append any HoistActions targeted
    at it just before the closing brace.

    Idempotent: skips entries whose `entry_name` is already a key in the block.
    """
    changes: list[str] = []
    output: list[str] = []
    cursor = 0

    for m in _BLOCK_OPEN_RE.finditer(content):
        block_name  = m.group(2)
        body_start  = m.end()   # one past the `{`

        depth = 1
        j = body_start
        while j < len(content) and depth > 0:
            c = content[j]
            if c == "{":
                depth += 1
            elif c == "}":
                depth -= 1
            j += 1
        # j now points to one past the closing `}`.
        body         = content[body_start : j - 1]
        close_brace  = content[j - 1 : j]

        entries = entries_by_object.get(block_name, [])
        if not entries:
            output.append(content[cursor:j])
            cursor = j
            continue

        # Dedupe — skip entries already present as keys in the block body,
        # AND collapse duplicates *within this batch*. The same inline locator
        # (e.g. `const loginSettingsSlider = page.getByTestId(...)`) often
        # appears in several source files that all hoist into one object; each
        # arrives as its own HoistAction with the same entry_name. Appending
        # them all produced an invalid object literal with a duplicated key
        # (TS1117). Keep the first occurrence of each name; skip the rest.
        existing_keys = set(re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE))
        to_add = []
        seen_names = set(existing_keys)
        for e in entries:
            if e.entry_name in seen_names:
                continue
            seen_names.add(e.entry_name)
            to_add.append(e)
        if not to_add:
            output.append(content[cursor:j])
            cursor = j
            continue

        # Find the last non-empty line of the body to figure out the indent.
        body_lines = body.rstrip("\n").splitlines()
        indent = "  "
        for line in reversed(body_lines):
            stripped = line.lstrip()
            if stripped:
                indent = line[: len(line) - len(stripped)]
                break

        # Ensure the body's last entry ends with a comma so our appends are valid.
        body_rstripped = body.rstrip()
        needs_comma = bool(body_rstripped) and not body_rstripped.endswith((",", "{"))
        appended_lines = []
        if needs_comma:
            appended_lines.append(",")
        for e in to_add:
            sel_lit = _ts_string_literal(e.selector)
            intent_lit = _ts_string_literal(e.intent)
            appended_lines.append(
                f"\n{indent}{e.entry_name}: {{ selector: {sel_lit}, "
                f"intent: {intent_lit}, stability: {e.stability} }},"
            )
            changes.append(f"hoisted {block_name}.{e.entry_name} ({e.selector!r})")

        # Splice: everything up to the last newline before `}`, then our appends,
        # then the original trailing whitespace + `}`.
        # Find where the closing brace's leading whitespace starts (so the `}`
        # stays correctly indented).
        close_lead_start = j - 1
        while close_lead_start > body_start and content[close_lead_start - 1] in " \t":
            close_lead_start -= 1
        if close_lead_start > body_start and content[close_lead_start - 1] == "\n":
            close_lead_start -= 1

        output.append(content[cursor:close_lead_start])
        output.append("".join(appended_lines))
        output.append(content[close_lead_start:j])
        cursor = j

    output.append(content[cursor:])
    return "".join(output), changes


def _ts_string_literal(s: str) -> str:
    """Render a Python string as a TypeScript double-quoted literal, escaping as needed."""
    if "\"" in s and "'" not in s:
        # Use single quotes so we don't need to escape the double quotes.
        return "'" + s.replace("\\", "\\\\") + "'"
    escaped = s.replace("\\", "\\\\").replace("\"", "\\\"")
    return f"\"{escaped}\""
