"""
Intra-run batch replay: do a mechanical sub-flow ONCE, then replay it for many
inputs without re-screenshotting/re-analyzing each time.

The case (the user's): the agent searches LinkedIn and reads person A's profile,
realizes it must do the same for B, C, D... Instead of the full
screenshot->analyze->decide loop per person, it hands us the step template (with
{{value}} where the input varies) + the list of values, and we replay it per
value at machine speed: zero screenshots, zero LLM turns.

Held to extreme rigor because this is the HIGHEST ghost-risk feature, per-item
pages vary (profile A has "Message", B is connect-only, C hits a wall), so blind
replay would click the wrong thing and claim success it didn't earn:

1. VERIFY EVERY STEP, FALL BACK NEVER GHOST. Each step's result is checked; the
   instant an item's page doesn't match the template (any step errors), that
   item is abandoned and reported as needs-manual, the loop never pretends.
   Honest tally always: "did N of M; these need you."

2. SENDS ARE GATED, READS ARE FREE. Read/search/navigate loops are safe and run
   freely. Any irreversible step (click "Send"/"Submit"/"Connect"/"Pay"/..., or
   typing into a message composer) makes the whole template unsafe to auto-replay,
   we refuse and tell the agent to do those one at a time with confirmation. A
   ghost in a send-loop isn't slow, it's wrong messages, so we don't allow it.

3. NETWORK-FIRST WHERE POSSIBLE. A step can be a `replay_route` (hit a captured
   API endpoint with the value substituted) instead of clicking the UI, the fast,
   reliable tier the audit found we use ~0% of the time. A read-loop over an
   endpoint is dramatically faster than navigating N pages.

This module is the PURE, browser-free core (validate / gate / fill); the execute+
verify+report loop lives in browser_agent where the executor is.
"""

import re

PLACEHOLDER = "{{value}}"

# Agent-facing step action -> (tool_name, the param keys it carries).
P_STEP_TOOLS: dict[str, tuple[str, tuple[str, ...]]] = {
    "navigate":     ("BrowserNavigate",    ("url",)),
    "get_text":     ("BrowserGetText",     ()),
    "evaluate":     ("BrowserEvaluate",    ("expression",)),
    "type":         ("BrowserType",        ("selector", "text")),
    "click":        ("BrowserClickByName", ("role", "name")),
    "press_key":    ("BrowserPressKey",    ("key",)),
    "scroll":       ("BrowserScroll",      ("direction", "amount")),
    "replay_route": ("BrowserReplayRoute", ("url",)),  # the fast network tier
}

# Reads/navigation don't mutate anything irreversible; safe to loop freely.
P_READONLY_ACTIONS = {"navigate", "get_text", "evaluate", "scroll", "replay_route"}

# Irreversible / outward-facing words on a clicked control. Conservative on purpose: we'd rather refuse a borderline loop than auto-send 10 messages.
P_SEND_NAME_RE = re.compile(
    r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
    r"share|message|dm|pay|buy|order|checkout|purchase|place\s*order|book|"
    r"confirm|apply|accept|decline|delete|remove|unsend|withdraw|endorse)\b",
    re.I,
)
# A field that reads like a message/comment composer; typing here is part of a send.
P_COMPOSE_SEL_RE = re.compile(r"message|compose|comment|msg|reply|editor|body|tweet|post", re.I)


def is_send_step(step: dict) -> bool:
    """True if this step is irreversible / outward-facing, so the whole loop must
    be gated rather than auto-replayed."""
    action = step.get("action")
    if action == "click" and P_SEND_NAME_RE.search(str(step.get("name") or "")):
        return True
    if action == "type" and P_COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
        return True
    return False


def validate_template(steps) -> tuple[bool, str]:
    """Structural check: non-empty, every step a known action with its required
    fields present. Returns (ok, reason)."""
    if not isinstance(steps, list) or not steps:
        return False, "no steps provided"
    for i, step in enumerate(steps):
        if not isinstance(step, dict):
            return False, f"step {i+1} is not an object"
        action = step.get("action")
        spec = P_STEP_TOOLS.get(action)
        if not spec:
            return False, f"step {i+1}: unknown action {action!r} (allowed: {', '.join(P_STEP_TOOLS)})"
        _, required = spec
        for key in required:
            if step.get(key) in (None, ""):
                return False, f"step {i+1} ({action}) is missing '{key}'"
    return True, ""


def template_safety(steps) -> tuple[bool, str]:
    """True if the template is safe to auto-replay (no irreversible step). On a
    send/submit step, returns (False, reason naming it) so the caller refuses and
    routes those through normal per-item confirmation."""
    for i, step in enumerate(steps):
        if is_send_step(step):
            what = step.get("name") or step.get("selector") or step.get("action")
            return False, (f"step {i+1} looks irreversible/outward-facing ({what!r}); "
                           "do sends/submits one at a time with confirmation, not in a batch")
    return True, ""


# Like P_SEND_NAME_RE minus composer-openers ("Message"/"DM" buttons open a compose box, they don't send), so routine flows still batch freely.
P_LIVE_IRREVERSIBLE_RE = re.compile(
    r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
    r"share|pay|buy|order|checkout|purchase|place\s*order|book|"
    r"confirm|apply|accept|decline|delete|remove|unsend|withdraw|endorse)\b",
    re.I,
)
# Composer OPENERS phrased with a send-word: LinkedIn's profile button is literally
# named "Send a message to <person>", which opens the compose box (reversible), not
# a real Send. A true Send control is short and exact ("Send", "Send now"); these
# describe opening a conversation, so they must NOT trip the irreversible boundary.
P_SEND_OPENER_RE = re.compile(r"send (a |an |the )?(message|note|inmail|dm) to\b", re.I)


def is_replay_boundary(step: dict) -> bool:
    """The genuinely irreversible step where a learned skill's mechanical replay
    must STOP and hand to the live agent. Same as is_send_step EXCEPT a composer
    OPENER ('Message'/'DM' click, incl. 'Send a message to X') is reversible and NOT
    a boundary: the prefix can mechanically open the composer, and only the real Send
    (and composer typing) crosses to the live model."""
    action = step.get("action")
    name = str(step.get("name") or "")
    if action == "click" and P_SEND_OPENER_RE.search(name):
        return False  # opener phrasing, not a real send
    if action == "click" and P_LIVE_IRREVERSIBLE_RE.search(name):
        return True
    if action == "type" and P_COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
        return True
    return False


P_SEND_COMPLETED_RE = re.compile(
    r"\b(send|submit|pay|place\s*order|complete\s*(order|purchase|checkout|payment))\b",
    re.I,
)
P_OPENER_ROLES = frozenset({"menuitem", "menuitemcheckbox", "menuitemradio", "link", "tab"})


def is_send_completed(step: dict) -> bool:
    """True only when the click was a non-opener role AND the label matches an
    unambiguous send-completion verb. Menuitems, links, and tabs label proximate
    UI rather than the action itself, so they never count even if their name
    matches (Drive's 'Share' menuitem was the false positive that prompted this)."""
    if step.get("action") != "click":
        return False
    role = str(step.get("role") or "").lower()
    if role in P_OPENER_ROLES:
        return False
    return bool(P_SEND_COMPLETED_RE.search(str(step.get("name") or "")))


def live_batch_guard(actions, seen_lines, composer_pending: bool = False) -> str:
    """Reason string if a live BrowserBatch carries an irreversible step, else ''.

    The solo-send rule was prompt-only until now; this makes it physical. A
    click_index resolves to its element line from the last attached state (an
    unresolvable index passes: it fails at execution anyway), and Enter after
    typing into a composer counts as the send it is. composer_pending arms the
    Enter check across turns: r47 typed solo then batched [Enter, wait], which
    slid past the within-batch check."""
    typed_composer = composer_pending
    for i, a in enumerate(actions or []):
        if not isinstance(a, dict):
            continue
        typ = a.get("type")
        params = a.get("params") if isinstance(a.get("params"), dict) else {}
        label = ""
        if typ == "click_index":
            prefix = f"[{params.get('index')}]"
            label = next((l for l in (seen_lines or ()) if str(l).startswith(prefix)), "")
        elif typ == "click":
            label = str(params.get("selector") or "")
        elif typ == "type":
            if P_COMPOSE_SEL_RE.search(str(params.get("selector") or "")):
                typed_composer = True
            continue
        elif typ == "press_key":
            if typed_composer and str(params.get("key") or "").strip().lower() in ("enter", "return"):
                return (f"sub-action {i+1} presses Enter after typing into a message "
                        "composer, which sends it")
            continue
        else:
            continue
        # selectors hide words behind underscores/dashes (msg-form__send-button), which defeat \b; flatten separators so the word check still sees them
        if label and P_LIVE_IRREVERSIBLE_RE.search(re.sub(r"[_\-./#\[\]]+", " ", label)):
            return (f"sub-action {i+1} ({typ}) targets {label.strip()!r}, "
                    "which is irreversible/outward-facing")
    return ""


def send_payload_from_log(action_log, prompt: str = "") -> str:
    """The text a failed run typed into a composer-ish field, '' if it never
    reached the send zone. Gates the recovery verify-first probe: r44's retry
    SAID it would verify first then didn't, so the check must be code, not prose."""
    typed: list[str] = []
    for a in action_log or []:
        if not isinstance(a, dict):
            continue
        tool = a.get("tool")
        inp = a.get("input") if isinstance(a.get("input"), dict) else {}
        text = str(inp.get("text") or "").strip()
        if not text and tool != "BrowserBatch":
            continue
        if tool == "BrowserClickIndex":
            name = str(a.get("clicked_name") or "")
            role = str(a.get("clicked_role") or "")
            summ = str(a.get("result_summary") or "")
            # focus+type results carry no clicked fields (r47's live miss); the executor's own "typed the text" wording is the surviving signal
            if P_COMPOSE_SEL_RE.search(name) or (len(text) >= 20 and (
                    role == "textbox" or "typed the text" in summ.lower())):
                typed.append(text)
        elif tool == "BrowserType":
            sel = str(inp.get("selector") or "")
            if P_COMPOSE_SEL_RE.search(sel) or (not sel and len(text) >= 20):
                typed.append(text)
        elif tool == "BrowserBatch":
            for sub in (inp.get("actions") or []):
                if not isinstance(sub, dict):
                    continue
                p = sub.get("params") if isinstance(sub.get("params"), dict) else {}
                sub_text = str(p.get("text") or "").strip()
                sub_sel = str(p.get("selector") or "")
                if sub.get("type") == "type" and sub_text and (
                        P_COMPOSE_SEL_RE.search(sub_sel)
                        or (not sub_sel and len(sub_text) >= 20)):
                    typed.append(sub_text)
    if not typed:
        return ""
    # the task usually quotes the message; a candidate echoed there beats a longer search query or a garbled retype
    for t in reversed(typed):
        if t in (prompt or ""):
            return t
    return typed[-1]


def p_sub(val, value: str):
    return value if val == PLACEHOLDER else (
        val.replace(PLACEHOLDER, value) if isinstance(val, str) else val
    )


def fill_step(step: dict, value: str) -> tuple[str, dict]:
    """Turn one template step + one value into (tool_name, params) ready for
    execute_browser_tool. Substitutes {{value}} anywhere it appears."""
    action = step["action"]
    tool_name, keys = P_STEP_TOOLS[action]
    params = {}
    for k in keys:
        if k in step:
            params[k] = p_sub(step[k], value)
    # carry an optional role default for clicks
    if action == "click" and "role" not in params:
        params["role"] = p_sub(step.get("role", ""), value)
    return tool_name, params


def fill_template(steps, value: str) -> list[tuple[str, dict]]:
    return [fill_step(s, value) for s in steps]


def is_readonly_template(steps) -> bool:
    """True if every step is a pure read/navigation (no clicks/types at all), the
    safest class of loop."""
    return all(s.get("action") in P_READONLY_ACTIONS for s in steps)


# A batch READ is useless if it doesn't hand the data back. We return each item's read output, capped so a 20-item batch stays cheap, and stay honest about failures (named, with the error) and truncation (named, never silently dropped).
P_MAX_ITEM_CHARS = 500
P_MAX_TOTAL_CHARS = 6000


def summarize_batch(records: list[dict], readonly: bool,
                    max_item_chars: int = P_MAX_ITEM_CHARS,
                    max_total_chars: int = P_MAX_TOTAL_CHARS) -> str:
    """Turn per-item batch results into the text the agent gets back.

    `records`: [{value, ok, text}]. For a successful item `text` is its read
    output (the data); for a failed one it's the error. Successes show their data
    (capped); once the total budget is hit, remaining successes are listed by
    value only (so nothing is silently lost); failures are always named with a
    short reason so a partial batch never reads as 'all done'."""
    done = [r for r in records if r.get("ok")]
    failed = [r for r in records if not r.get("ok")]
    verb = "Read" if readonly else "Completed"
    lines, used, overflow = [], 0, []
    for r in done:
        body = " ".join(str(r.get("text") or "").split())[:max_item_chars]
        line = f"- {r['value']}: {body}" if body else f"- {r['value']}: (done, no content)"
        if used and used + len(line) > max_total_chars:
            overflow.append(str(r["value"]))
            continue
        lines.append(line)
        used += len(line)
    out = f"{verb} {len(done)} of {len(records)}."
    if lines:
        out += "\n" + "\n".join(lines)
    if overflow:
        out += (f"\n(+{len(overflow)} more done but not shown to save space: "
                f"{', '.join(overflow[:20])}; ask for specific ones if needed)")
    if failed:
        fails = ", ".join(
            f"{r['value']} ({' '.join(str(r.get('text') or 'failed').split())[:60]})"
            for r in failed[:20]
        )
        out += (f"\n{len(failed)} couldn't be done and need you to handle them "
                f"individually: {fails}")
    return out
