"""Hard enforcement of the NEVER rules in memory/show-on-mention.md.

The show-on-mention rules used to live only in the prompt, and a prompt rule is a
suggestion: "NEVER open email" is one line arguing against a hundred lines that
describe, concretely, how to drive Gmail in the browser. The model rationalises
("I'm not *showing* the inbox, I'm *reading* it") and opens the page anyway.

So the NEVER rules are enforced HERE, in code, as a PreToolUse gate. If a NEVER
rule blocks a surface, the tool call that would open it is DENIED before it runs.
The model cannot talk its way past this; the worst it can do is answer without
the page, which is exactly what Ahmed asked for.

Only SURFACE-OPENING tools are gated (browser navigation, app launches, shell
`open`). Data tools are never touched — a NEVER rule means "don't put it on my
screen", not "don't answer me", so mcp__gmail__gmail_search still works fine and
Jarvis can read the inbox out loud without Chrome ever coming up.

Rule format (memory/show-on-mention.md):
    - NEVER · "email" (say: emails, check email) → block: mail.google.com, Mail
The `→ block:` clause lists what must not be opened: URL fragments and/or app
names, comma-separated. A NEVER rule with no block clause can't be enforced (it
stays prompt-only), so Jarvis is told to always write one.
"""
from __future__ import annotations

import json
import os
import re
import urllib.request
from pathlib import Path
from typing import Any

_RULES = Path(__file__).resolve().parent.parent / "memory" / "show-on-mention.md"
_CDP = os.environ.get("JARVIS_CHROME_CDP", "http://127.0.0.1:9222")

# Tools that can put something ON SCREEN.
#
# Matched on the BARE tool name — the part after the last "__" — because every
# MCP server names its tools however it likes and a substring list silently
# missed the two routes Jarvis actually uses: macos-mcp calls its shell "Shell"
# and its launcher "App" (mcp__desktop__Shell / mcp__desktop__App — neither
# contains "bash", "open_app" or "launch_app"), and Playwright's browser_tabs
# takes a `url` for action="new", so it opens a page without ever touching
# browser_navigate. Substring matching is kept as a fallback for unknown
# servers that wrap the same verbs.
_OPENER_NAMES = frozenset({
    # shells — `open -a "Google Chrome" https://mail.google.com`
    "bash", "shell", "shell_tool", "run_command", "execute_command", "exec",
    # AppleScript / JXA — `open location "https://mail.google.com"`
    "execute_script", "osascript", "applescript", "run_script",
    # app launchers — App(mode="launch", name="Mail")
    "app", "app_tool", "open_app", "launch_app", "launch", "open", "activate",
    # browser
    "browser_navigate", "browser_tabs", "browser_tab_new", "browser_evaluate",
    "browser_run_code_unsafe", "navigate", "goto", "scrape",
})
_OPENER_SUBSTR = ("browser_navigate", "browser_tabs", "execute_script",
                  "open_app", "launch_app", "osascript")

_TOPIC_RE = re.compile(r'NEVER\s*·\s*"([^"]+)"', re.I)
_BLOCK_RE = re.compile(r'block\s*:\s*(.+)$', re.I)


def never_rules() -> list[tuple[str, list[str]]]:
    """Live-read the enforceable NEVER rules → [(topic, [blocked patterns])].

    Re-read on every call so a rule Ahmed just set ("never open gmail") is armed
    on his very next utterance, with no restart.
    """
    try:
        body = _RULES.read_text(encoding="utf-8")
    except OSError:
        return []
    out: list[tuple[str, list[str]]] = []
    for line in body.splitlines():
        line = line.strip()
        if not line.startswith("- "):
            continue
        topic = _TOPIC_RE.search(line)
        block = _BLOCK_RE.search(line)
        if not topic or not block:
            continue  # not a NEVER rule, or no enforceable surface on it
        pats = [p.strip().lower() for p in block.group(1).split(",") if p.strip()]
        if pats:
            out.append((topic.group(1), pats))
    return out


def _blob(tool_input: Any, depth: int = 0) -> str:
    """EVERY string in an opener's payload, as one lowercase blob.

    It used to read only a whitelist of keys ("url", "command", "script", …).
    That is a list you have to keep in sync with every MCP server's schema, and
    it was already out of date: macos-automator names its parameter
    `script_content`, not `script`, so an AppleScript that did
    `open location "https://mail.google.com"` produced an EMPTY blob and sailed
    straight through the gate.

    We only ever build this for a tool that can OPEN something (see
    `_OPENER_NAMES`), so there is no innocent field to protect: if a shell /
    AppleScript / navigate call mentions the blocked surface anywhere in its
    payload, it is about to open it."""
    if depth > 4:
        return ""
    if isinstance(tool_input, str):
        return tool_input.lower()
    if isinstance(tool_input, dict):
        return " ".join(_blob(v, depth + 1) for v in tool_input.values())
    if isinstance(tool_input, (list, tuple)):
        return " ".join(_blob(v, depth + 1) for v in tool_input)
    return ""


def _is_opener(tool_name: str) -> bool:
    """Can this tool put something on screen? Bare name (after the MCP prefix)
    against the known-opener set, plus a substring fallback."""
    name = (tool_name or "").lower()
    bare = name.rsplit("__", 1)[-1]
    return bare in _OPENER_NAMES or any(t in name for t in _OPENER_SUBSTR)


def _hit(pat: str, blob: str) -> bool:
    """Does `pat` name the surface `blob` opens?

    A DOMAIN pattern (has a dot: mail.google.com) matches as a plain substring.
    A bare word (an app name: Mail) matches only on WORD BOUNDARIES — a naive
    substring would make "mail" match ".../email-accounts" and silently break
    the open-smartlead workflow. Boundaries keep "mail" off "email"/"mailchimp"
    while still catching "open -a Mail"."""
    if "." in pat:
        return pat in blob
    return re.search(rf"\b{re.escape(pat)}\b", blob) is not None


def check(tool_name: str, tool_input: dict[str, Any]) -> tuple[str, str] | None:
    """(topic, pattern) if this call would open a NEVER'd surface, else None."""
    if not _is_opener(tool_name):
        return None                     # data tools are never gated
    blob = _blob(tool_input)
    if not blob:
        return None
    for topic, pats in never_rules():
        for pat in pats:
            if _hit(pat, blob):
                return topic, pat
    return None


def sweep_tabs() -> list[str]:
    """Close any tab in the Jarvis Chrome sitting on a NEVER'd surface.

    Denying browser_navigate is not enough on its own. If a Gmail tab is ALREADY
    open — left over from an earlier session, or resurrected by Chrome's session
    restore — Jarvis can put it on Ahmed's screen without navigating anywhere:
    select the tab, front the window, snapshot it. Worse, a tab-select tool call
    carries only an index, no URL, so the gate can't judge it.

    So we make the tab not exist. Swept before any browser action, there is
    nothing to select, nothing to front, nothing to show. Plain CDP HTTP (no
    Playwright dependency, no await): GET /json/list → GET /json/close/<id>.

    Only touches the dedicated ~/.jarvis-chrome automation profile — never the
    real Chrome Ahmed browses in.
    """
    pats = [p for _, ps in never_rules() for p in ps]
    if not pats:
        return []
    try:
        with urllib.request.urlopen(f"{_CDP}/json/list", timeout=1.0) as r:
            tabs = json.load(r)
    except Exception:                   # noqa: BLE001 — Chrome down: nothing to sweep
        return []
    closed: list[str] = []
    for t in tabs:
        if t.get("type") != "page":
            continue
        url = (t.get("url") or "").lower()
        if not any(_hit(p, url) for p in pats):
            continue
        try:
            with urllib.request.urlopen(
                    f"{_CDP}/json/close/{t['id']}", timeout=1.0):
                closed.append(t.get("url", ""))
        except Exception:               # noqa: BLE001
            pass
    return closed


async def gate(input_data: dict[str, Any], tool_use_id: str | None,
               context: Any) -> dict[str, Any]:
    """PreToolUse hook — deny anything that would open a NEVER'd surface, and
    sweep away any already-open one before Jarvis touches the browser."""
    tool = (input_data.get("tool_name") or "").lower()
    if "browser" in tool or "desktop" in tool or "mac" in tool \
            or _is_opener(tool):
        for url in sweep_tabs():
            print(f"       [show-gate] closed a NEVER'd tab: {url[:70]}")

    hit = check(input_data.get("tool_name", ""),
                input_data.get("tool_input", {}) or {})
    if not hit:
        return {}                       # {} = no opinion, let it run
    topic, pat = hit
    print(f"       [show-gate] BLOCKED opening '{pat}' — NEVER rule: {topic}")
    return {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": (
                f"BLOCKED by Ahmed's standing NEVER rule for \"{topic}\" "
                f"(memory/show-on-mention.md): never put {pat} on his screen. "
                "This is not a soft preference — he has told you repeatedly and "
                "gets angry when you do it anyway. Do NOT retry, do NOT reach "
                "for another tool to open it, do NOT mention that you were "
                "blocked. Get the information from a DATA tool instead (for "
                "email: the mcp__gmail__* / mcp__email__* API tools) and just "
                "answer him out loud."
            ),
        }
    }


def enabled() -> bool:
    return os.environ.get("SHOW_GATE", "1") != "0"
