"""Pure perception-parsing for the staged send: read the browser's interactives listing + the
user's task and answer the structural questions the send orchestration needs, with no I/O and no
side effects. What quoted payload did the user name? Which listed row is the compose box / the
opener? Is this a login wall or a read-only request the script must decline? One host-agnostic
shape per question, so the same logic generalizes across X/Reddit/LinkedIn/Gmail/Slack/etc.

Lives BELOW browser_send_script (which orchestrates the fill/click/verify tail): send_script
imports from here, never the reverse.
"""

import re

# Double quotes are unambiguous. Single quotes only delimit when the opener is at a word boundary (start/space/colon), so an in-word apostrophe like "chen's" is never mistaken for a payload quote, that mispairing was silently corrupting the canonical "text him '...'" errand.
P_QUOTED_DQ_RE = re.compile(r'"([^"]{4,300})"')
P_QUOTED_SQ_RE = re.compile(r"(?:^|[\s:>])'([^']{4,300})'")
P_COMPOSER_ROW_RE = re.compile(r"\[(\d+)\]\*?<\s*textbox\s+\"([^\"]*)\"", re.I)
# A compose-shaped textbox name, generalized across messaging sites: LinkedIn "Write a
# message", X/Slack "Message", Discord "Message @user", Gmail "Message Body", "Post your
# reply", "What's happening", "Add a comment". Not per-site: one structural shape.
# "text editor" earns its place from a measurement, not a guess: LinkedIn's post box is named
# "Text editor for creating content" and its comment box "Text editor for creating comment", so
# without it the real composer was invisible while the comment box next to it matched on "comment".
# Landing on LinkedIn's own compose surface listed exactly one textbox and we still scored zero.
# Both shapes match now, and telling them apart is surface_mismatch's job, which already does it.
P_COMPOSER_NAME_RE = re.compile(
    r"write|messag|compose|reply|comment|post your|post text|what.?s happening|"
    r"tweet|caption|say something|start a|new message|body|your (message|note)|"
    r"add a comment|write something|text editor|creating content",
    re.I,
)

# Login/auth walls: a logged-out card lands here, and the structural reveal-finder would
# otherwise fill a login field and arm the page's own submit as a "send" (measured live on
# instagram/threads). A real composer never lives on one of these, so decline outright.
P_LOGIN_WALL_URL_RE = re.compile(
    r"accounts\.google\.com|/i/flow/login|/accounts/login|/uas/login|/users/sign_in|"
    r"/sessions/new|/checkpoint|force_authentication|"
    r"/(?:log[_-]?in|sign[_-]?in|signin|logon)(?:[/?#]|$)",
    re.I,
)
# A password box disqualifies the page whoever is signed in: whatever that form is for, typing a
# post into it is wrong. Kept apart from the softer copy below because only this one is absolute.
P_PASSWORD_FIELD_RE = re.compile(r'<\s*textbox\s+"[^"]*(?:password|passwd)', re.I)
# The rest of a sign-in form. A login page is password + these and nothing else; a content page that
# merely carries a header login widget also has a box that is none of them, and that box is the
# whole difference. Named structurally rather than by composer vocabulary on purpose: "New Paste"
# matches no compose word anyone would think to list, and it is still obviously somewhere to write.
P_AUTH_FIELD_NAME_RE = re.compile(
    r"password|passwd|e-?mail|user\s?name|\buser\b|\blogin\b|phone|mobile|"
    r"verification|one.?time|\botp\b|\bcode\b|captcha|security answer",
    re.I,
)
# Wording a login screen uses. Also, unfortunately, wording a signed-IN page uses in its footer and
# its upsells, which is why this half is overridable and the password field is not.
P_LOGIN_WALL_STATE_RE = re.compile(
    r"(?:log|sign)\s?in to |continue with (?:google|apple|facebook)",
    re.I,
)

P_OPENER_ROW_RE = re.compile(
    r"\[(\d+)\]\*?<\s*(?:link|button)\s+\"(Message|Reply|Compose|New message|"
    r"Direct message|DM|Send message|Write|New chat|Comment|Post)\"", re.I)
# Any control whose row we might read, so a name can be tested as a whole rather than anchored.
P_CONTROL_ROW_RE = re.compile(r"\[(\d+)\]\*?<\s*(?:link|button)\s+\"([^\"]*)\"", re.I)
# Openers whose label is a SENTENCE, not a word. tiktok's is `Read or add comments 526 comments`,
# and the exact-name rule above missed it, so a video page with its comment button in plain sight
# scored 0/4 while the aux model clicked at it for 27s. Matched as VERB + NOUN on purpose: that is
# what keeps the exactness the old rule was buying. A bare count ("526 comments") has no verb and a
# paid upsell ("Send InMail") has the wrong noun, so neither can reach a click through here.
P_OPENER_PHRASE_RE = re.compile(
    r"\b(?:add|write|leave|post|start|send|new|create)\s+(?:a\s+|an\s+|your\s+)?"
    r"(?:comment|reply|message|post|note|chat|thread|topic|paste)s?\b", re.I)

# A verification probe quotes the very payload it's checking for, which is exactly the trap this gate exists for: quoted payload + composer = fire. Caught live (r243): the read-only send-probe delivered a REAL message. Read-only directives decline in code, fail-safe (a false match just means the model path).
P_READONLY_RE = re.compile(
    r"read.?only|do\s+not\s+(?:send|type|click|post|submit|change|edit|delete)|"
    r"don'?t\s+(?:send|post|submit|change|edit|delete)|"
    # "verify/check/tell me/say/confirm WHETHER x is there" is the whole family, not two phrasings
    # of it. Measured: "say whether anything containing <quoted text> is still there. Change
    # nothing." slipped through and POSTED the quoted text to a real LinkedIn feed, because only
    # "verify whether" and "check whether" were listed. Anchor on the question shape.
    r"(?:verify|check|confirm|tell\s+me|say|see|find\s+out|look)\s+(?:me\s+)?(?:if|whether)|"
    r"is\s+(?:it|there|this|that)\s+(?:still\s+)?(?:there|published|posted|live|present)|"
    r"still\s+(?:there|published|posted|live|up)|"
    r"change\s+nothing|without\s+(?:sending|posting|changing)|verification",
    re.I,
)


def login_wall_reason(current_url: str, state_text: str) -> str:
    """WHY this page reads as a login wall, or "" when it does not.

    The bool alone sent a whole site to the model path with nothing to debug against: substack
    declined as a wall on `https://substack.com/` while the account was demonstrably signed in, and
    no amount of staring at the regexes reproduced it. A gate that can silently cost a site its
    entire write path should be able to say which words convinced it."""
    if current_url and P_LOGIN_WALL_URL_RE.search(current_url):
        return f"url: {P_LOGIN_WALL_URL_RE.search(current_url).group(0)}"
    if not state_text:
        return ""
    pw = P_PASSWORD_FIELD_RE.search(state_text)
    if pw:
        # A password field proves a login FORM is on the page, never that the whole page is a wall.
        # pastebin serves its "New Paste" box and a header login widget together, and this gate
        # refused the box sitting right beside it; every site with a header sign-in widget was
        # losing its write path the same way. The disproof is structural: a real login page's
        # editables are ALL auth fields, so one that is neither password nor email/username/OTP is
        # somewhere to write. Counting non-auth boxes rather than matching composer words matters,
        # because "New Paste" matches no compose vocabulary and is still plainly a composer.
        if any(not P_AUTH_FIELD_NAME_RE.search(name or "")
               for _, name in P_COMPOSER_ROW_RE.findall(state_text)):
            return ""
        return f"password field: {pw.group(0)[:60]}"
    soft = P_LOGIN_WALL_STATE_RE.search(state_text)
    if not soft:
        return ""
    # "Sign in to ..." and "Continue with Google" are what a login screen says, and ALSO what a
    # signed-in page's footer, upsell and embedded-content strip say. Treating them as proof cost
    # substack its whole write path. The veto looks_signed_out already trusts settles it: a control
    # that is meaningless unless you are authenticated outranks marketing copy.
    if P_SIGNED_IN_RE.search(state_text):
        return ""
    return f"copy: {soft.group(0)[:60]}"


def looks_like_login_wall(current_url: str, state_text: str) -> bool:
    """A login/auth page (by URL) or an auth form in the perception (a password field, a
    'Log in to X' heading, an OAuth 'Continue with ...'). The scripted send declines here:
    a real composer never shares a page with these, and filling here types a login field."""
    return bool(login_wall_reason(current_url, state_text))


# SOFT signed-out: the site serves a browsable page with no auth form and no login URL, it just
# withholds the composer and offers a "Sign in" control (bsky, stackoverflow, tiktok, threads all
# behave this way). The hard-wall gate above sees nothing, so the run used to report "I couldn't
# find the compose box" when the truth was "you are not signed in", which is a different problem
# with a different fix. Only ever consulted AFTER a composer miss, so it cannot affect a success.
P_SIGNIN_AFFORDANCE_RE = re.compile(
    r'<\s*(?:link|button)\s+"[^"]*(?:sign[_ -]?in|log[_ -]?in|sign[_ -]?up|create account|join now)',
    re.I)
# Anything only a signed-IN page shows. Its presence vetoes the verdict, so a stray "Log in" on an
# authenticated page (a second product's promo) can't make us tell the user to sign in again.
# Deliberately NARROW: an earlier draft also vetoed on "notifications"/"profile"/"inbox", which
# logged-OUT pages advertise all the time, and that silently suppressed the whole detector on the
# exact sites it exists for (measured: bsky with 0 cookies read as signed-in). Only a control that
# is meaningless unless you are already authenticated belongs here.
P_SIGNED_IN_RE = re.compile(
    r'(?:sign|log)[_ -]?out\b|your profile|account menu|my account',
    re.I)


def looks_signed_out(state_text: str) -> bool:
    """True when the page offers a way to sign IN and shows nothing only a signed-in user sees."""
    if not state_text:
        return False
    if P_SIGNED_IN_RE.search(state_text):
        return False
    return bool(P_SIGNIN_AFFORDANCE_RE.search(state_text))


# Creating a POST and commenting on someone else's are different actions on different content.
# LinkedIn's feed carries a comment box on EVERY post, and the capped interactives listing routinely
# starves the real post modal of its own composer, so the only compose-shaped textbox left in the
# list is a stranger's comment box. Filling that is not a slower path to the same place, it is the
# wrong action on the wrong person's content. Measured in a dry-run sweep: linkedin reached its
# composer 1/4, and two of the three misses targeted 'Text editor for creating comment'.
P_POST_INTENT_RE = re.compile(r"\b(post|tweet|publish|share)\b", re.I)
P_COMMENT_INTENT_RE = re.compile(r"\b(comment|reply|respond)\b", re.I)
P_COMMENT_SURFACE_RE = re.compile(r"\b(comment|reply)\b", re.I)
# The private-message box is the worst wrong surface there is, because getting it wrong is not a
# failed action, it is the user's words delivered privately to a named stranger. Live sweep, dry
# run: 'write a comment on the first post' walked to instagram.com/<someone>/, took that profile's
# 'Message' opener and filled 'Message...'. Armed, it would have DM'd them.
P_DM_SURFACE_RE = re.compile(r"\b(message|messages|dm)\b", re.I)
P_DM_INTENT_RE = re.compile(r"\b(dm|dms|message|messages|email|e-mail|mail|inbox|chat)\b", re.I)


def surface_mismatch(task: str, composer_name: str) -> bool:
    """True when the composer we found contradicts what the task actually asked for.

    A public ask (post, comment) is contradicted by a DM box, and a post ask is contradicted by a
    comment box. A task that asked for neither is left alone entirely, so 'text tyler hello' still
    gets its message box. Rejecting only ever costs a turn: the structural finder, which does find
    LinkedIn's real composer, gets its go instead."""
    t, name = task or "", composer_name or ""
    p_public = bool(P_POST_INTENT_RE.search(t) or P_COMMENT_INTENT_RE.search(t))
    if p_public and P_DM_SURFACE_RE.search(name) and not P_DM_INTENT_RE.search(t):
        return True
    if not P_POST_INTENT_RE.search(t) or P_COMMENT_INTENT_RE.search(t):
        return False
    return bool(P_COMMENT_SURFACE_RE.search(name))


def is_readonly(text: str) -> bool:
    """A read-only directive ('verify whether', 'do not send') that must decline the scripted
    send even with a quoted payload in hand. Keeps the regex private to this file."""
    return bool(text and P_READONLY_RE.search(text))


def quoted_payload(task: str) -> str:
    """The exact text the user quoted, only when it's unambiguous: exactly one
    distinct quoted span in the task. Anything else is the model's judgment call.
    Double quotes win outright; single quotes must be word-boundary-delimited so
    an apostrophe inside a name can't hijack the match."""
    dq = {m.group(1).strip() for m in P_QUOTED_DQ_RE.finditer(task or "") if m.group(1).strip()}
    if dq:
        return dq.pop() if len(dq) == 1 else ""
    sq = {m.group(1).strip() for m in P_QUOTED_SQ_RE.finditer(task or "") if m.group(1).strip()}
    return sq.pop() if len(sq) == 1 else ""


def opener_index_in_state(state_text: str):
    """(index, name) of the single composer OPENER, or None.

    An exact name, or a verb+noun compose phrase anywhere in a longer label. The second half is
    what reaches the openers whose label is a whole sentence, and it keeps the exactness the first
    half was buying: an upsell ('Send InMail') has the wrong noun and a count ('526 comments') has
    no verb. Still a SINGLETON, so two candidates stay the model's problem, not a coin flip."""
    hits = [(int(m.group(1)), m.group(2)) for m in P_OPENER_ROW_RE.finditer(state_text or "")]
    if not hits:
        hits = [(int(m.group(1)), m.group(2))
                for m in P_CONTROL_ROW_RE.finditer(state_text or "")
                if P_OPENER_PHRASE_RE.search(m.group(2) or "")]
    return hits[0] if len(hits) == 1 else None


def composer_index_in_state(state_text: str):
    """(index, name) of the single compose-shaped textbox, or None. Two
    candidates = ambiguous = model's problem."""
    hits = [(int(m.group(1)), m.group(2)) for m in P_COMPOSER_ROW_RE.finditer(state_text or "")
            if P_COMPOSER_NAME_RE.search(m.group(2) or "")]
    return hits[0] if len(hits) == 1 else None


def textbox_count(state_text: str) -> int:
    """How many textboxes the perception listed, compose-shaped or not.

    Diagnostic only, and only meaningful next to a failed composer pick: zero means the page never
    mounted one, several means the picker refused an ambiguous choice. Those are different bugs."""
    return len(P_COMPOSER_ROW_RE.findall(state_text or ""))


def surface_supports_script(current_url: str, state_text: str = "") -> bool:
    """STRUCTURAL, not per-site: fire wherever the live perception actually carries a
    person-composer (a compose-shaped textbox) OR a single messaging opener to reach
    one, on ANY host. This is what generalizes the LinkedIn ~14s send to X/Slack/
    Discord/Instagram/Gmail/etc without per-site URL gates. A page with neither
    declines (net-negative to fire where there's no composer). All the downstream
    safety gates (quoted payload, fill-seen-committed before the one send, two-sided
    receipt) are already site-agnostic, so widening the surface can't loosen safety."""
    if not state_text:
        return False
    return bool(composer_index_in_state(state_text) or opener_index_in_state(state_text))


def dryrun_report(state_text: str, armed: bool, filled: bool, url: str = "") -> str:
    """One grep-stable line for the coverage harness: what the staged perception held
    and how far the script got. Only ever emitted in dry-run measurement mode."""
    boxes = len(P_COMPOSER_ROW_RE.findall(state_text or ""))
    return (f"[dryrun-report] armed={int(bool(armed))} "
            f"composer={int(bool(composer_index_in_state(state_text or '')))} "
            f"opener={int(bool(opener_index_in_state(state_text or '')))} "
            f"textboxes={boxes} filled={int(bool(filled))} url={(url or '')[:120]}")
