"""Gatesi Python helper. Standard library only (no pip installs).

    from agentgate import AgentGate, Denied
    # use a per-agent key (Keys tab in the viewer, starts with agk_); the shared AGENT_KEY also works
    gate = AgentGate("https://agentgate.YOURNAME.workers.dev", "agk_YOUR_KEY", agent_id="my-agent")

    gate.guard("email", "send", {"to": "a@b.com"}, cost=0.01)   # raises Denied if blocked
    gate.log("result", tool="email", action="send", result="sent")

    # or wrap a function so check + result logging happen together:
    gate.call("search", lambda: do_search("x"), action="web", args={"q": "x"})

    # or protect a function with one line (works for normal and async functions):
    @gate.tool("send_email", cost=0.01)
    def send_email(to, subject): ...

    # or run tool calls coming from a model (OpenAI / Anthropic style loops):
    run = gate.dispatcher({"send_email": send_email})
    result = run("send_email", '{"to": "a@b.com", "subject": "Hi"}')   # always returns text for the model

    # or try everything offline first, with no Worker, key or Telegram (same rules as the server):
    gate = AgentGate.local({"deny_tools": ["forbidden*"], "approve_tools": ["*send*"]}, approve="ask")

    # or let Gatesi make the API call for you. It holds the credential (stored in the Vault tab),
    # so your agent never sees it. Waits for your phone approval when needed.
    resp = gate.request("github", "/repos/me/app/issues", method="POST", body={"title": "Bug"})
    print(resp["status"], resp.get("json"))
"""
import functools
import inspect
import json
import time
import urllib.error
import urllib.request


class Denied(Exception):
    pass


_FRAMEWORK_KEYS = ("run_manager", "callbacks")


class AgentGate:
    @classmethod
    def local(cls, policy=None, approve="ask", agent_id="my-agent"):
        """An offline Gatesi for trying things out: no Worker, key or Telegram. It applies the same rules as the
        server (deny, cost limits, budgets, approve, allow, default) to every guard/tool/dispatcher call.
        `policy` is a dict (or JSON text) like the Policy tab. `approve` says what happens when a rule needs a human:
        "ask" (type y/n in the terminal or notebook), "yes", "no", or a function(info) -> True/False
        (info has tool, action, args, cost, reason). `gate.events` lists everything that happened."""
        return LocalGate(policy, approve, agent_id)

    def __init__(self, base_url, agent_key, agent_id="my-agent", run_id=None,
                 poll_every=3, wait_seconds=900):
        self.base = base_url.rstrip("/")
        self.key = agent_key
        self.agent_id = agent_id
        self.run_id = run_id
        self.poll_every = poll_every
        self.wait_seconds = wait_seconds

    def _req(self, method, path, body=None):
        data = json.dumps(body, default=str).encode() if body is not None else None
        req = urllib.request.Request(self.base + path, data=data, method=method, headers={
            "authorization": "Bearer " + self.key,
            "content-type": "application/json",
            "user-agent": "agentgate-sdk/0.7",
        })
        try:
            with urllib.request.urlopen(req, timeout=20) as r:
                return json.loads(r.read().decode())
        except urllib.error.HTTPError as e:
            raise RuntimeError("Gatesi %s: %s" % (e.code, e.read().decode()[:300]))

    def guard(self, tool, action=None, args=None, cost=0.0):
        """Ask permission. Returns when allowed, raises Denied otherwise.
        If a human must approve, waits (polling) until they answer on Telegram, then
        redeems the approval. An approval works once and only for these exact arguments,
        so pass the same tool/action/args you will actually run. If the owner edited the request
        before approving, the returned dict has "edited_args": run the action with those instead."""
        res = self._req("POST", "/v1/check", {
            "agent_id": self.agent_id, "run_id": self.run_id, "tool": tool,
            "action": action, "args": args, "cost": cost,
        })
        self.run_id = res["run_id"]
        if res["decision"] == "allow":
            return res
        if res["decision"] == "deny":
            raise Denied(res["reason"])
        deadline = time.time() + self.wait_seconds
        while time.time() < deadline:
            time.sleep(self.poll_every)
            a = self._req("GET", "/v1/approvals/" + res["approval_id"])
            if a["status"] == "approved":
                used = self._redeem(res["approval_id"], tool, action, args)
                if used.get("edited_args") is not None:
                    # The owner changed the arguments before approving. Run the action with THESE.
                    res["edited_args"] = used["edited_args"]
                res["approved"] = True
                res["decision"] = "allow"   # it was "pending" when asked; it is allowed now
                return res
            if a["status"] in ("denied", "expired", "cancelled"):
                raise Denied("approval " + a["status"])
        raise Denied("approval wait timed out")

    def request(self, service, path, method="GET", query=None, body=None, headers=None, cost=0.0, wait=True):
        """Make an API call through Gatesi (Vault connection `service`). Returns the upstream
        response: {status, ok, headers, body, json (if JSON), truncated, bytes}.
        Raises Denied if the call is blocked or you decline it. With wait=False a call that needs
        approval returns the pending reply (with approval_id) instead of waiting."""
        payload = {"agent_id": self.agent_id, "run_id": self.run_id, "service": service, "method": method,
                   "path": path, "query": query, "body": body, "headers": headers, "cost": cost}
        res = self._req("POST", "/v1/call", payload)
        self.run_id = res["run_id"]
        if res["decision"] == "deny":
            raise Denied(res["reason"])
        if res["decision"] == "pending":
            if not wait:
                return res
            deadline = time.time() + self.wait_seconds
            while True:
                if time.time() >= deadline:
                    raise Denied("approval wait timed out")
                time.sleep(self.poll_every)
                a = self._req("GET", "/v1/approvals/" + res["approval_id"])
                if a["status"] == "approved":
                    break
                if a["status"] in ("denied", "expired", "cancelled"):
                    raise Denied("approval " + a["status"])
            payload["approval_id"] = res["approval_id"]
            payload["run_id"] = self.run_id
            try:
                res = self._req("POST", "/v1/call", payload)
            except RuntimeError as e:
                if " 502" in str(e):
                    raise
                raise Denied("approval could not be used: %s" % e)
        up = res["upstream"]
        if res.get("edited"):
            # The owner edited the request before approving: this is the response to THEIR version.
            up = dict(up, edited=True, sent=res.get("sent"))
        return up

    def _redeem(self, approval_id, tool, action, args):
        try:
            return self._req("POST", "/v1/approvals/%s/consume" % approval_id,
                             {"tool": tool, "action": action, "args": args})
        except RuntimeError as e:
            raise Denied("approval could not be used: %s" % e)

    def log(self, type="note", tool=None, action=None, args=None, result=None, cost=0.0, reason=None):
        if not self.run_id:
            self.run_id = "run-" + str(int(time.time() * 1000))
        return self._req("POST", "/v1/events", {
            "agent_id": self.agent_id, "run_id": self.run_id, "type": type, "tool": tool,
            "action": action, "args": args, "result": result, "cost": cost, "reason": reason,
        })

    def call(self, tool, fn, action=None, args=None, cost=0.0):
        """guard -> run fn -> log the result (or the error)."""
        res = self.guard(tool, action, args, cost)
        self._refuse_edit(res)
        try:
            out = fn()
        except Exception as e:
            self.log("error", tool=tool, action=action, args=args, result=str(e))
            raise
        self.log("result", tool=tool, action=action, args=args, result=out)
        return out

    # ---- one-line protection for functions ----

    def tool(self, name=None, action=None, cost=0.0):
        """Decorator: ask Gatesi before every call of the function, then log its result.

            @gate.tool("send_email", action="send", cost=0.01)
            def send_email(to, subject): ...

        The function's arguments are what Gatesi checks and what an approval covers.
        Works for normal and async functions (the permission check itself is blocking).
        Raises Denied if blocked or declined, or if the owner edited the arguments."""
        def deco(fn):
            label = name or fn.__name__
            sig = inspect.signature(fn)

            def make_args(a, kw):
                try:
                    bound = sig.bind(*a, **kw)
                    bound.apply_defaults()
                    raw = dict(bound.arguments)
                except TypeError:
                    raw = {"args": list(a), "kwargs": kw}
                for k in _FRAMEWORK_KEYS:   # objects a framework injects (not the model's arguments)
                    raw.pop(k, None)
                return json.loads(json.dumps(raw, default=str))

            if inspect.iscoroutinefunction(fn):
                @functools.wraps(fn)
                async def awrapper(*a, **kw):
                    args = make_args(a, kw)
                    res = self.guard(label, action, args, cost)
                    self._refuse_edit(res)
                    try:
                        out = await fn(*a, **kw)
                    except Exception as e:
                        self.log("error", tool=label, action=action, args=args, result=str(e))
                        raise
                    self.log("result", tool=label, action=action, args=args, result=out)
                    return out
                return awrapper

            @functools.wraps(fn)
            def wrapper(*a, **kw):
                return self.call(label, lambda: fn(*a, **kw), action, make_args(a, kw), cost)
            return wrapper
        return deco

    def wrap_tools(self, tools, cost=0.0):
        """Protect many functions at once. `tools` is a dict {name: fn} or a list of functions.
        Returns a dict {name: protected fn}, ready to hand to an agent framework."""
        items = tools.items() if isinstance(tools, dict) else [(f.__name__, f) for f in tools]
        return {n: self.tool(n, cost=cost)(f) for n, f in items}

    def _refuse_edit(self, res):
        if res.get("edited_args") is not None:
            raise Denied("the owner edited the arguments before approving; run with these instead: %s"
                         % json.dumps(res["edited_args"]))

    # ---- model tool calls and framework tools ----

    def dispatcher(self, tools, cost=0.0, on_denied="return", on_error="return", as_text=True):
        """Run tool calls that come from a model, with Gatesi in front of every one.

            run = gate.dispatcher({"send_email": send_email, "search": search})
            # OpenAI:     result = run(call.function.name, call.function.arguments)   # JSON text
            # Anthropic:  result = run(block.name, block.input)                       # dict
            # async:      result = await run.arun(name, arguments)

        `tools` is a dict {name: function} or a list of functions. Returns the tool result as text (as_text=True)
        so it can go straight back to the model. If Gatesi blocks the call or you decline it, the model gets
        "Blocked by Gatesi: ..." (on_denied="raise" raises Denied instead). Errors inside a tool come back as
        "Error: ..." (on_error="raise" re-raises)."""
        protected = self.wrap_tools(tools, cost=cost)

        def prep(name, arguments):
            fn = protected.get(name)
            if fn is None:
                return None, None, "Error: unknown tool %r" % (name,)
            if isinstance(arguments, (str, bytes)):
                try:
                    arguments = json.loads(arguments or "{}")
                except ValueError:
                    return None, None, "Error: the arguments were not valid JSON"
            if arguments is None:
                arguments = {}
            if not isinstance(arguments, dict):
                return None, None, "Error: the arguments must be an object"
            return fn, arguments, None

        def text(out):
            if not as_text or isinstance(out, str):
                return out
            return json.dumps(out, default=str)

        def blocked(e):
            if on_denied == "raise":
                raise e
            return "Blocked by Gatesi: %s. Do not retry or work around this; tell the user." % e

        def failed(e):
            if on_error == "raise":
                raise e
            return "Error: %s" % e

        def run(name, arguments=None):
            fn, args, bad = prep(name, arguments)
            if bad:
                return bad
            try:
                out = fn(**args)
            except Denied as e:
                return blocked(e)
            except Exception as e:
                return failed(e)
            if inspect.isawaitable(out):
                out.close() if hasattr(out, "close") else None
                return "Error: %r is an async tool; use run.arun(...)" % (name,)
            return text(out)

        async def arun(name, arguments=None):
            fn, args, bad = prep(name, arguments)
            if bad:
                return bad
            try:
                out = fn(**args)
                if inspect.isawaitable(out):
                    out = await out
            except Denied as e:
                return blocked(e)
            except Exception as e:
                return failed(e)
            return text(out)

        run.arun = arun
        return run

    def protect(self, tool_obj, cost=0.0, name=None):
        """Put Gatesi in front of a tool object a framework already built, such as a LangChain
        StructuredTool (anything with a `.func` and/or `.coroutine` attribute). Returns the same object."""
        label = name or getattr(tool_obj, "name", None) or getattr(getattr(tool_obj, "func", None), "__name__", None) or "tool"
        done = False
        for attr in ("func", "coroutine"):
            f = getattr(tool_obj, attr, None)
            if callable(f):
                setattr(tool_obj, attr, self.tool(label, cost=cost)(f))
                done = True
        if not done:
            raise TypeError("this object has no .func or .coroutine to protect; put @gate.tool(...) on the function before turning it into a tool")
        return tool_obj


# ---------- offline test mode ----------

_DEFAULT_POLICY = {
    "default": "allow",
    "allow_tools": [],
    "approve_tools": ["*delete*", "*remove*", "*send*", "*email*", "*pay*", "*transfer*", "*deploy*", "*drop*"],
    "deny_tools": [],
    "approve_over_cost": 0.5,
    "max_cost_per_call": 2,
    "max_cost_per_run": 5,
    "daily_budget": 20,
    "rules": [],
    "first_use": "off",
    "burst_calls": 0,
    "burst_seconds": 60,
    "burst_action": "approve",
    "two_person": [],
}


def _glob(pattern):
    import re
    return re.compile("^" + ".*".join(re.escape(p) for p in str(pattern).split("*")) + "$", re.I)


def _matches(patterns, tool, action):
    names = [tool] + ([tool + "." + action] if action else [])
    return any(_glob(p).match(n) for p in (patterns or []) for n in names)


# ---- rules that look inside the arguments: the same logic as the server, so offline tests behave the same ----

_RULE_OPS = ("eq", "ne", "gt", "gte", "lt", "lte", "in", "not_in", "contains", "starts_with", "ends_with", "domain_in", "domain_not_in", "exists", "not_exists")
_NUM_RE = None
_PATH_RE = None


def _num_re():
    global _NUM_RE, _PATH_RE
    if _NUM_RE is None:
        import re
        _NUM_RE = re.compile(r"^-?\d+(\.\d+)?([eE][+-]?\d+)?$")
        _PATH_RE = re.compile(r"^(\$(cost|tool|action|agent)|[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*)$")
    return _NUM_RE


def _is_num(v):
    return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and v not in (float("inf"), float("-inf"))


def _validate_rules(rules):
    """Returns (clean_rules, None) or (None, error text). Same checks and wording as the server."""
    _num_re()
    if not isinstance(rules, list):
        return None, '"rules" must be a list'
    if len(rules) > 50:
        return None, '"rules" can have at most 50 entries'
    out = []
    for i, r in enumerate(rules):
        at = "rules[%d]" % (i + 1)
        if not isinstance(r, dict):
            return None, at + " must be an object"
        extra = [k for k in r if k not in ("tool", "action", "when", "then", "reason", "needs")]
        if extra:
            return None, "%s: unknown field(s): %s" % (at, ", ".join(extra))
        if not isinstance(r.get("tool"), str) or not r["tool"] or len(r["tool"]) > 200:
            return None, '%s: "tool" must be text (wildcards allowed)' % at
        if "action" in r and (not isinstance(r["action"], str) or len(r["action"]) > 200):
            return None, '%s: "action" must be text' % at
        if r.get("then") not in ("allow", "approve", "deny"):
            return None, '%s: "then" must be allow, approve or deny' % at
        if "reason" in r and (not isinstance(r["reason"], str) or len(r["reason"]) > 160):
            return None, '%s: "reason" must be text up to 160 characters' % at
        if "needs" in r:
            if r.get("then") != "approve":
                return None, '%s: "needs" only goes with "then": "approve"' % at
            if isinstance(r["needs"], bool) or r["needs"] not in (1, 2):
                return None, '%s: "needs" must be 1 or 2 (how many different people must approve)' % at
        when = r.get("when", [])
        if not isinstance(when, list) or len(when) > 6:
            return None, '%s: "when" must be a list of up to 6 conditions' % at
        conds = []
        for j, c in enumerate(when):
            cat = "%s.when[%d]" % (at, j + 1)
            if not isinstance(c, dict):
                return None, cat + " must be an object"
            ex = [k for k in c if k not in ("path", "op", "value")]
            if ex:
                return None, "%s: unknown field(s): %s" % (cat, ", ".join(ex))
            if not isinstance(c.get("path"), str) or not _PATH_RE.match(c["path"]) or len(c["path"]) > 100:
                return None, '%s: "path" must look like amount, to.0, body.user.email, or $cost / $tool / $action / $agent' % cat
            op = c.get("op")
            if op not in _RULE_OPS:
                return None, '%s: "op" must be one of %s' % (cat, ", ".join(_RULE_OPS))
            list_op = op in ("in", "not_in", "domain_in", "domain_not_in")
            no_value = op in ("exists", "not_exists")
            num_op = op in ("gt", "gte", "lt", "lte")
            v = c.get("value")
            if no_value:
                if "value" in c:
                    return None, '%s: "%s" takes no "value"' % (cat, op)
            elif list_op:
                if not isinstance(v, list) or not v or len(v) > 100:
                    return None, '%s: "value" must be a list (1 to 100 items)' % cat
                if op.startswith("domain"):
                    bad = any((not isinstance(x, str)) or not x for x in v)
                else:
                    bad = any(not (isinstance(x, (str, bool)) or _is_num(x)) for x in v)
                if bad:
                    return None, '%s: the list holds text%s' % (cat, "" if op.startswith("domain") else ", numbers or true/false")
            elif num_op:
                if not _is_num(v):
                    return None, '%s: "value" must be a number' % cat
            elif op in ("contains", "starts_with", "ends_with"):
                if not isinstance(v, str) or not v:
                    return None, '%s: "value" must be text' % cat
            elif not (isinstance(v, (str, bool)) or _is_num(v) or v is None):
                return None, '%s: "value" must be text, a number, true/false or null' % cat
            conds.append({"path": c["path"], "op": op} if no_value else {"path": c["path"], "op": op, "value": v})
        clean = {"tool": r["tool"]}
        if "action" in r:
            clean["action"] = r["action"]
        clean["when"] = conds
        clean["then"] = r["then"]
        if r.get("reason"):
            clean["reason"] = r["reason"]
        if r.get("needs") == 2:
            clean["needs"] = 2
        out.append(clean)
    return out, None


def _args_as_object(args):
    if isinstance(args, (dict, list)):
        return args
    if isinstance(args, str):
        try:
            v = json.loads(args)
            return v if isinstance(v, (dict, list)) else None
        except ValueError:
            return None
    return None


def _jstr(v):
    """Text the way JavaScript's String() would write it (so reasons and comparisons match the server)."""
    if v is True:
        return "true"
    if v is False:
        return "false"
    if v is None:
        return "null"
    if isinstance(v, float) and v == int(v) and abs(v) < 1e21:
        return str(int(v))
    return str(v)


def _path_value(path, c, args_obj):
    if path == "$cost":
        return True, float(c.get("cost") or 0)
    if path == "$tool":
        return True, str(c["tool"])
    if path == "$action":
        return (True, str(c["action"])) if c.get("action") else (False, None)
    if path == "$agent":
        return True, str(c.get("agent_id") or "")
    cur = args_obj
    for seg in path.split("."):
        if not isinstance(cur, (dict, list)):
            return False, None
        if isinstance(cur, list):
            if not seg.isdigit() or int(seg) >= len(cur):
                return False, None
            cur = cur[int(seg)]
        else:
            if seg not in cur:
                return False, None
            cur = cur[seg]
    return True, cur


def _to_num(v):
    if _is_num(v):
        return float(v)
    if isinstance(v, str) and _num_re().match(v.strip()):
        return float(v.strip())
    return None


def _loose_eq(a, b):
    if isinstance(a, bool) or isinstance(b, bool):
        return isinstance(a, bool) and isinstance(b, bool) and a == b
    if a is None or b is None:
        return a is None and b is None
    if isinstance(a, str) and isinstance(b, str):
        return a == b
    if _is_num(a) and _is_num(b):
        return a == b
    x, y = _to_num(a), _to_num(b)
    return x is not None and y is not None and x == y


def _domain_of(v):
    import re
    if not isinstance(v, str):
        return ""
    t = re.sub(r"^[a-z][a-z0-9+.-]*://", "", v.strip().lower())
    at = t.rfind("@")
    if at >= 0:
        t = t[at + 1:]
    t = re.split(r"[/?#:\s>]", t)[0]
    return t.rstrip(".")


def _domain_in(d, lst):
    if not d:
        return False
    for x in lst:
        y = _jstr(x).strip().lower()
        if d == y or d.endswith("." + y):
            return True
    return False


def _cond_test(op, actual, expected):
    def one(a):
        if op == "eq":
            return _loose_eq(a, expected)
        if op == "ne":
            return not _loose_eq(a, expected)
        if op in ("gt", "gte", "lt", "lte"):
            n = _to_num(a)
            if n is None:
                return False
            return {"gt": n > expected, "gte": n >= expected, "lt": n < expected, "lte": n <= expected}[op]
        if op == "in":
            return any(_loose_eq(a, e) for e in expected)
        if op == "not_in":
            return not any(_loose_eq(a, e) for e in expected)
        if op == "contains":
            return isinstance(a, str) and expected.lower() in a.lower()
        if op == "starts_with":
            return isinstance(a, str) and a.lower().startswith(expected.lower())
        if op == "ends_with":
            return isinstance(a, str) and a.lower().endswith(expected.lower())
        if op == "domain_in":
            return _domain_in(_domain_of(a), expected)
        if op == "domain_not_in":
            return isinstance(a, str) and not _domain_in(_domain_of(a), expected)
        return False
    if op == "contains" and isinstance(actual, list):
        return any(_loose_eq(e, expected) or (isinstance(e, str) and one(e)) for e in actual)
    if isinstance(actual, list):
        return len(actual) > 0 and any(one(e) for e in actual)
    return one(actual)


def _rule_matches(rule, c, args_obj):
    if not _matches([rule["tool"]], c["tool"], c.get("action")):
        return False
    if "action" in rule and not (c.get("action") and _glob(rule["action"]).match(str(c["action"]))):
        return False
    for cond in rule.get("when") or []:
        found, val = _path_value(cond["path"], c, args_obj)
        if cond["op"] == "exists":
            if not (found and val is not None):
                return False
            continue
        if cond["op"] == "not_exists":
            if found and val is not None:
                return False
            continue
        if not found or not _cond_test(cond["op"], val, cond.get("value")):
            return False
    return True


def _apply_rules(rules, c):
    out = {}
    if not rules:
        return out
    args_obj = _args_as_object(c.get("args"))
    for i, r in enumerate(rules):
        if r["then"] in out:
            continue
        if _rule_matches(r, c, args_obj):
            out[r["then"]] = (i + 1, r)
    return out


def _rule_text(m, dflt):
    return "rule %d: %s" % (m[0], m[1].get("reason") or dflt)


def _decide_policy(policy, c, runspent, dayspent, unseen, burst=None):
    """The server's decision plus the two-person rule. Offline there is no way to check that two different people
    answered, so a two-person call simply asks (once); only the server enforces two different people."""
    ar = _apply_rules(policy.get("rules"), c).get("approve")
    two = _matches(policy.get("two_person") or [], c["tool"], c.get("action")) or bool(ar and ar[1].get("needs") == 2)
    d, why = _decide_policy_core(policy, c, runspent, dayspent, unseen, burst)
    if not two or d == "deny":
        return d, why
    return "pending", (why + " (needs 2 different people)") if d == "pending" else "two-person approval: %s needs 2 different people" % c["tool"]


def _decide_policy_core(policy, c, runspent, dayspent, unseen, burst=None):
    """The same order of checks as the server (no standing approvals or dead-man switch offline)."""
    if _matches(policy["deny_tools"], c["tool"], c.get("action")):
        return "deny", "tool is on the deny list"
    rv = _apply_rules(policy.get("rules"), c)
    if "deny" in rv:
        return "deny", _rule_text(rv["deny"], "blocked")
    cost = c["cost"]
    if cost > policy["max_cost_per_call"]:
        return "deny", "cost %s exceeds max_cost_per_call %s" % (_jstr(cost), _jstr(policy["max_cost_per_call"]))
    if runspent + cost > policy["max_cost_per_run"]:
        return "deny", "run budget %s would be exceeded" % _jstr(policy["max_cost_per_run"])
    if dayspent + cost > policy["daily_budget"]:
        return "deny", "daily budget %s would be exceeded" % _jstr(policy["daily_budget"])
    if policy.get("burst_calls", 0) > 0 and burst is not None and burst >= policy["burst_calls"]:
        why = "burst: %d calls in %s seconds (limit %s)" % (burst, _jstr(policy["burst_seconds"]), _jstr(policy["burst_calls"]))
        return ("deny", why) if policy.get("burst_action") == "deny" else ("pending", why)
    if "approve" in rv:
        return "pending", _rule_text(rv["approve"], "needs approval")
    if _matches(policy["approve_tools"], c["tool"], c.get("action")):
        return "pending", "matches an approve rule"
    if cost > policy["approve_over_cost"]:
        return "pending", "cost %s is above approve_over_cost %s" % (_jstr(cost), _jstr(policy["approve_over_cost"]))
    if policy.get("first_use") == "approve" and unseen:
        return "pending", 'first time this agent uses "%s"' % c["tool"]
    if "allow" in rv:
        return "allow", _rule_text(rv["allow"], "allowed")
    if _matches(policy["allow_tools"], c["tool"], c.get("action")):
        return "allow", "matches an allow rule"
    if policy["default"] == "deny":
        return "deny", "not on the allow list (default deny)"
    if policy["default"] == "approve":
        return "pending", "default policy requires approval"
    return "allow", "default allow"


class LocalGate(AgentGate):
    """Gatesi that decides on this machine, using the same rules as the server. See AgentGate.local()."""

    def __init__(self, policy=None, approve="ask", agent_id="my-agent"):
        super().__init__("local://offline", "agk_local", agent_id=agent_id, poll_every=0, wait_seconds=5)
        if isinstance(policy, str):
            policy = json.loads(policy)
        policy = dict(policy or {})
        unknown = [k for k in policy if k not in _DEFAULT_POLICY]
        if unknown:
            raise ValueError("unknown policy field(s): " + ", ".join(unknown))
        if "rules" in policy:
            clean, bad = _validate_rules(policy["rules"])
            if bad:
                raise ValueError(bad)
            policy["rules"] = clean
        if "first_use" in policy and policy["first_use"] not in ("off", "approve"):
            raise ValueError('"first_use" must be off or approve')
        bc, bs, ba = policy.get("burst_calls"), policy.get("burst_seconds"), policy.get("burst_action")
        if "burst_calls" in policy and not (isinstance(bc, int) and not isinstance(bc, bool) and 0 <= bc <= 10000):
            raise ValueError('"burst_calls" must be a whole number from 0 (off) to 10000')
        if "burst_seconds" in policy and not (isinstance(bs, int) and not isinstance(bs, bool) and 1 <= bs <= 3600):
            raise ValueError('"burst_seconds" must be a whole number from 1 to 3600')
        if "burst_action" in policy and ba not in ("approve", "deny"):
            raise ValueError('"burst_action" must be approve or deny')
        self.policy = dict(_DEFAULT_POLICY, **policy)
        self._seen = set()
        self._checks = []
        self.approve = approve
        self.events = []
        self._approvals = {}
        self._spent = {}
        self._day = 0.0
        self._n = 0

    def _decide(self, tool, action, cost, run_id, args=None):
        unseen = self.policy.get("first_use") == "approve" and tool not in self._seen
        c = {"tool": tool, "action": action, "cost": cost, "agent_id": self.agent_id, "args": args}
        burst = None
        if self.policy.get("burst_calls", 0) > 0:
            import time
            now = time.time()
            burst = sum(1 for t in self._checks if t >= now - self.policy["burst_seconds"]) + 1
        return _decide_policy(self.policy, c, self._spent.get(run_id, 0.0), self._day, unseen, burst)

    def _ask(self, info):
        a = self.approve
        if callable(a):
            return a(info)
        if a == "yes":
            return True
        if a == "no":
            return False
        print("\nGatesi (local test) wants your approval:\n  %s%s  args=%s  cost=%s\n  why: %s"
              % (info["tool"], "." + info["action"] if info["action"] else "", json.dumps(info["args"], default=str), info["cost"], info["reason"]))
        try:
            return input("Approve? (y/n) ").strip().lower().startswith("y")
        except EOFError:
            return False

    def _req(self, method, path, body=None):
        body = body or {}
        if path == "/v1/check":
            run_id = body.get("run_id") or "local-run"
            tool, action, cost = body["tool"], body.get("action"), float(body.get("cost") or 0)
            decision, reason = self._decide(tool, action, cost, run_id, body.get("args"))
            self._n += 1
            import time as _t
            self._checks.append(_t.time())
            res = {"decision": decision, "reason": reason, "run_id": run_id, "event_id": self._n}
            ev = {"type": "check", "tool": tool, "action": action, "args": body.get("args"), "cost": cost, "decision": decision, "reason": reason}
            if decision == "pending":
                aid = "local%d" % self._n
                info = {"tool": tool, "action": action, "args": body.get("args"), "cost": cost, "reason": reason}
                answer = self._ask(info)
                edited = answer.get("edited_args") if isinstance(answer, dict) else None
                ok = bool(answer.get("approve", True)) if isinstance(answer, dict) else bool(answer)
                self._approvals[aid] = {"status": "approved" if ok else "denied", "edited": edited, "used": False,
                                        "fp": json.dumps([tool, action, body.get("args")], sort_keys=True, default=str)}
                res["approval_id"] = aid
                ev["decision"] = self._approvals[aid]["status"]
            if ev["decision"] in ("allow", "approved"):
                self._seen.add(tool)
                self._spent[run_id] = self._spent.get(run_id, 0.0) + cost
                self._day += cost
            self.events.append(ev)
            return res
        if method == "GET" and path.startswith("/v1/approvals/"):
            a = self._approvals.get(path.rsplit("/", 1)[1])
            if not a:
                raise RuntimeError("Gatesi 404: approval not found")
            return {"status": a["status"]}
        if path.endswith("/consume"):
            a = self._approvals.get(path.split("/")[3])
            if not a:
                raise RuntimeError("Gatesi 404: approval not found")
            fp = json.dumps([body.get("tool"), body.get("action"), body.get("args")], sort_keys=True, default=str)
            if a["used"]:
                raise RuntimeError("Gatesi 409: this approval was already used")
            if fp != a["fp"]:
                raise RuntimeError("Gatesi 409: these arguments are not what was approved")
            a["used"] = True
            return {"ok": True, **({"edited_args": a["edited"]} if a["edited"] is not None else {})}
        if path == "/v1/events":
            ev = {k: body.get(k) for k in ("type", "tool", "action", "args", "result", "cost", "reason")}
            self.events.append(ev)
            if ev["type"] == "llm":
                self._day += float(ev["cost"] or 0)
            return {"ok": True}
        raise RuntimeError("local test mode has no %s %s (there is no vault offline: use guard, tool or dispatcher)" % (method, path))

    def request(self, *a, **k):
        raise RuntimeError("local test mode has no vault; use guard(), @gate.tool or gate.dispatcher() offline")

    def summary(self):
        """Print what happened, one line per event."""
        for e in self.events:
            print("%-8s %-24s %-9s %s" % (e["type"], (e["tool"] or "") + ("." + e["action"] if e.get("action") else ""),
                                          e.get("decision") or "", e.get("reason") or ""))


# The product is called Gatesi. Gatesi is another name for the same class; the class name AgentGate keeps old code working.
Gatesi = AgentGate
