#!/usr/bin/env python3
"""check_uas_spec.py -- is this Agent Functional Spec ready for the Agent Analyzer?

Run it on the spec you (or your coding agent) wrote, against the agent's source code, BEFORE uploading:

    python check_uas_spec.py my-agent.uas.json --source /path/to/agent/repo

It needs Python 3.9+ and `pip install jsonschema`, and the file `uas-1.0.schema.json` next to this script (or --schema PATH).
It does not run your agent and does not need the network.

ERRORS are things that make the analyzer refuse the spec, crash on it, or lose part of it (bad schema, duplicate ids, references to
nothing, locators that do not resolve, leftover template placeholders, a start command that does not exist).
WARNINGS are things that let the analyzer run but make its verdicts weaker or wrong (a limit that appears nowhere in the code, a
`recommend` decision whose code writes to an external system, a formula the analyzer cannot verify).

Exit code 0 = READY (no errors), 1 = NOT READY.
"""
from __future__ import annotations

import argparse
import ast
import difflib
import json
import os
import re
import shlex
import sys
from pathlib import Path

try:
    import jsonschema
except ImportError:  # pragma: no cover
    sys.exit("This checker needs the 'jsonschema' package:  pip install jsonschema")

SOURCE_EXT = {".py": "python", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".ts": "typescript", ".tsx": "typescript", ".java": "java", ".go": "go"}
SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", "target", "site-packages", ".tox", "vendor"}
ERRORS: list[str] = []
WARNINGS: list[str] = []


def error(kind: str, msg: str) -> None:
    ERRORS.append(f"[{kind}] {msg}")


def warn(kind: str, msg: str) -> None:
    WARNINGS.append(f"[{kind}] {msg}")


def jpath(parts) -> str:
    out = ""
    for p in parts:
        out += f"[{p}]" if isinstance(p, int) else (f".{p}" if out else str(p))
    return out or "(document)"


# ----------------------------------------------------------------------------------------------- 1. placeholders left from the template
PLACEHOLDER = re.compile(r"^\s*\[[^\]]*\]\s*$|\[e\.g\.|\[at least one|\[one sentence|\[the |\[a |\[an |\[id of|\[path\]|\[unique")


def find_placeholders(node, path="") -> None:
    if isinstance(node, dict):
        for k, v in node.items():
            if k in ("_readme", "//"):
                error("template", f"{jpath([path, k]) if path else k}: template-only key; delete it")
            else:
                find_placeholders(v, f"{path}.{k}" if path else k)
    elif isinstance(node, list):
        for i, v in enumerate(node):
            find_placeholders(v, f"{path}[{i}]")
    elif isinstance(node, str) and PLACEHOLDER.search(node):
        error("template", f"{path}: still contains template placeholder text: {node[:70]!r}")


# ----------------------------------------------------------------------------------------------- 2. JSON Schema
def check_schema(spec: dict, schema: dict) -> None:
    validator = jsonschema.Draft202012Validator(schema)
    seen = set()
    for e in sorted(validator.iter_errors(spec), key=lambda e: [str(p) for p in e.path]):
        where = jpath(e.path)
        msg = e.message if len(e.message) <= 220 else e.message[:217] + "..."
        if e.validator == "enum":
            close = difflib.get_close_matches(str(e.instance), [str(v) for v in e.validator_value], n=1)
            msg = f"{e.instance!r} is not an allowed value; allowed: {', '.join(map(str, e.validator_value))}" + (f" (did you mean '{close[0]}'?)" if close else "")
        elif e.validator in ("anyOf", "oneOf") and e.context:
            enum_err = next((c for c in e.context if c.validator == "enum"), None)
            custom = any(c.validator == "pattern" for c in e.context)  # e.g. a custom `x-name` category is also allowed
            if enum_err is not None and isinstance(e.instance, str):
                close = difflib.get_close_matches(e.instance, [str(v) for v in enum_err.validator_value], n=1)
                msg = (f"{e.instance!r} is not an allowed value; allowed: {', '.join(map(str, enum_err.validator_value))}"
                       + (" (or a custom value written x-your-name)" if custom else "") + (f" (did you mean '{close[0]}'?)" if close else ""))
            else:
                best = jsonschema.exceptions.best_match(e.context)
                msg = f"{msg.split(' is not valid')[0][:80]} does not match any allowed shape (closest problem: {best.message[:120]})"
        key = (where, msg)
        if key not in seen:
            seen.add(key)
            error("schema", f"{where}: {msg}")


# ----------------------------------------------------------------------------------------------- 3. ids and references (same rules as the analyzer)
def all_ids(node) -> set:
    found = set()
    if isinstance(node, dict):
        if isinstance(node.get("id"), str):
            found.add(node["id"])
        for v in node.values():
            found |= all_ids(v)
    elif isinstance(node, list):
        for v in node:
            found |= all_ids(v)
    return found


def node_ids(spec: dict) -> list:
    out = []

    def add(items, kind, path):
        for i, x in enumerate(items or []):
            if isinstance(x, dict) and x.get("id"):
                out.append((x["id"], kind, f"{path}[{i}]"))

    add(spec.get("capabilities"), "capability", "capabilities")
    ifs = spec.get("interfaces", {}) or {}
    add(ifs.get("consumes"), "input interface", "interfaces.consumes")
    add(ifs.get("produces"), "output interface", "interfaces.produces")
    add(ifs.get("systems"), "system", "interfaces.systems")
    add(spec.get("policies"), "policy", "policies")
    for i, d in enumerate(spec.get("decisions", []) or []):
        out.append((d.get("id"), "decision", f"decisions[{i}]"))
        for j, g in enumerate(d.get("guardrails") or []):
            out.append((g.get("id"), "guardrail", f"decisions[{i}].guardrails[{j}]"))
    ops = spec.get("operations", {}) or {}
    add(ops.get("safe_states"), "safe state", "operations.safe_states")
    add(ops.get("failure_modes"), "failure mode", "operations.failure_modes")
    add((spec.get("oversight", {}) or {}).get("roles"), "oversight role", "oversight.roles")
    add((spec.get("governance", {}) or {}).get("risks"), "risk", "governance.risks")
    add((spec.get("lifecycle", {}) or {}).get("model_dependencies"), "model dependency", "lifecycle.model_dependencies")
    for name, c in ((spec.get("oversight", {}) or {}).get("controls", {}) or {}).items():
        if isinstance(c, dict) and c.get("available"):
            out.append((f"oversight-control-{name}", "oversight control", f"oversight.controls.{name}"))
    return [o for o in out if o[0]]


def check_ids_and_refs(spec: dict) -> None:
    seen: dict = {}
    for id_, kind, where in node_ids(spec):
        if id_ in seen:
            error("ids", f"id '{id_}' is used twice: by the {seen[id_][0]} at {seen[id_][1]} and by the {kind} at {where} -- every id must be unique across the whole document")
        else:
            seen[id_] = (kind, where)
    ifs = spec.get("interfaces", {}) or {}
    ops = spec.get("operations", {}) or {}
    ids = {k: {x.get("id") for x in (v or []) if isinstance(x, dict)} for k, v in {
        "capability": spec.get("capabilities"), "consume": ifs.get("consumes"), "produce": ifs.get("produces"), "system": ifs.get("systems"), "policy": spec.get("policies"),
        "decision": spec.get("decisions"), "safe_state": ops.get("safe_states"), "role": (spec.get("oversight", {}) or {}).get("roles"), "risk": (spec.get("governance", {}) or {}).get("risks")}.items()}
    anything = all_ids(spec) | {f"oversight-control-{n}" for n in ((spec.get("oversight", {}) or {}).get("controls", {}) or {})}

    def need(ref, pool, what, where):
        if ref and ref not in pool:
            error("references", f"{where} refers to {what} '{ref}', which does not exist")

    for i, d in enumerate(spec.get("decisions", []) or []):
        w = f"decisions[{i}] ({d.get('id')})"
        need(d.get("capability"), ids["capability"], "capability", f"{w}.capability")
        for r in d.get("inputs") or []:
            need(r, ids["consume"], "an input interface (interfaces.consumes)", f"{w}.inputs")
        for r in d.get("executes_via") or []:
            need(r, ids["produce"], "an output interface (interfaces.produces)", f"{w}.executes_via")
        for r in d.get("policy_refs") or []:
            need(r, ids["policy"], "a policy", f"{w}.policy_refs")
        for r in d.get("risk_refs") or []:
            need(r, ids["risk"], "a risk (governance.risks)", f"{w}.risk_refs")
        esc = (d.get("escalation") or {}).get("to")
        if esc and ids["role"] and esc not in ids["role"]:
            warn("references", f"{w}.escalation.to '{esc}' is not a role id in oversight.roles ({', '.join(sorted(ids['role']))}); use a role id so the escalation can be traced")
    for kind in ("consumes", "produces"):
        for i, x in enumerate(ifs.get(kind) or []):
            need(x.get("system"), ids["system"], "a system (interfaces.systems)", f"interfaces.{kind}[{i}] ({x.get('id')}).system")
    for i, fm in enumerate(ops.get("failure_modes") or []):
        w = f"operations.failure_modes[{i}] ({fm.get('id')})"
        for r in fm.get("affects") or []:
            need(r, anything - ids["safe_state"], "an interface, system or decision", f"{w}.affects")
        need(fm.get("safe_state"), ids["safe_state"], "a safe state", f"{w}.safe_state")
    for i, role in enumerate((spec.get("oversight", {}) or {}).get("roles") or []):
        for r in role.get("decisions") or []:
            need(r, ids["decision"], "a decision", f"oversight.roles[{i}] ({role.get('id')}).decisions")
    for i, risk in enumerate((spec.get("governance", {}) or {}).get("risks") or []):
        for r in risk.get("mitigations") or []:
            need(r, anything, "an element of this spec", f"governance.risks[{i}] ({risk.get('id')}).mitigations")
    for i, sc in enumerate(spec.get("scenarios") or []):
        for r in sc.get("verifies") or []:
            need(r, anything, "an element of this spec", f"scenarios[{i}] ({sc.get('id')}).verifies")
    for i, m in enumerate((spec.get("execution", {}) or {}).get("service_mocks") or []):
        need(m.get("system_id"), ids["system"], "a system (interfaces.systems)", f"execution.service_mocks[{i}].system_id")


# ----------------------------------------------------------------------------------------------- 4. source tree helpers
class Repo:
    def __init__(self, root: Path):
        self.root = root
        self._text: dict = {}
        self._py: dict = {}
        self._all_text: str | None = None

    def exact_case(self, rel: str):
        """The real spelling of a relative path if it exists with different letter case (None when it does not exist or already matches)."""
        cur = self.root
        parts = []
        for part in Path(rel).parts:
            try:
                names = os.listdir(cur)
            except OSError:
                return None
            if part in names:
                parts.append(part)
            else:
                hit = next((n for n in names if n.lower() == part.lower()), None)
                if hit is None:
                    return None
                parts.append(hit)
            cur = cur / parts[-1]
        return "/".join(parts)

    def files(self):
        for dp, dn, fn in os.walk(self.root):
            dn[:] = sorted(d for d in dn if d not in SKIP_DIRS and not d.startswith("."))
            for f in sorted(fn):
                if os.path.splitext(f)[1].lower() in SOURCE_EXT:
                    yield Path(dp) / f

    def text(self, rel: str) -> str | None:
        if rel not in self._text:
            p = self.root / rel
            try:
                self._text[rel] = p.read_bytes().decode("utf-8-sig", errors="replace") if p.is_file() else None
            except OSError:
                self._text[rel] = None
        return self._text[rel]

    def everything(self) -> str:
        if self._all_text is None:
            parts, total = [], 0
            for p in self.files():
                try:
                    t = p.read_bytes().decode("utf-8-sig", errors="replace")
                except OSError:
                    continue
                parts.append(t)
                total += len(t)
                if total > 40_000_000:
                    break
            self._all_text = "\n".join(parts)
        return self._all_text

    def python_functions(self, rel: str):
        """{qualname: node} for every function/method in a Python file, plus the set of class names."""
        if rel not in self._py:
            funcs, classes = {}, set()
            try:
                tree = ast.parse(self.text(rel) or "")
            except SyntaxError:
                self._py[rel] = None
                return None

            def visit(body, prefix):
                for st in body:
                    if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        funcs.setdefault(prefix + st.name, st)
                        visit(st.body, prefix + st.name + ".")
                    elif isinstance(st, ast.ClassDef):
                        classes.add(prefix + st.name)
                        visit(st.body, prefix + st.name + ".")
                    elif isinstance(st, (ast.If, ast.Try, ast.With, ast.AsyncWith, ast.For, ast.AsyncFor, ast.While)):
                        for attr in ("body", "orelse", "finalbody"):
                            visit(getattr(st, attr, []), prefix)
                        for h in getattr(st, "handlers", []):
                            visit(h.body, prefix)
            visit(tree.body, "")
            self._py[rel] = (funcs, classes)
        return self._py[rel]


def fn_pattern(n: str) -> list:
    n = re.escape(n)
    return [rf"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*{n}\b", rf"\b(?:const|let|var)\s+{n}\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*(?::\s*[\w<>\[\]|,\s]+)?\s*=>|\w+\s*=>)",
            rf"^\s*(?:public|private|protected|static|final|abstract|async|override|readonly|\s)*(?:[\w<>\[\],.?\s]+\s+)?{n}\s*\([^)]*\)\s*(?::\s*[\w<>\[\]|,.\s]+)?\s*(?:throws [\w,\s]+)?\{{",
            rf"^func\s+(?:\(\w+\s+\*?\w+\)\s+)?{n}\b",
            rf"\b{n}\s*:\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*(?::\s*[^=\n]+)?=>|\w+\s*=>)"]  # `execute: (input) => {...}` inside an object


def resolve_locator(repo: Repo, loc: dict) -> tuple[str, str]:
    """('function'|'class'|'missing-file'|'missing-symbol'|'unsupported', detail). Mirrors what the analyzer can follow."""
    rel = loc.get("file", "")
    sym = loc.get("symbol", "")
    ext = os.path.splitext(rel)[1].lower()
    if ext not in SOURCE_EXT:
        return "unsupported", f"'{rel}' is not a language the analyzer reads (Python, JavaScript/TypeScript, Java, Go)"
    text = repo.text(rel)
    if text is None:
        return "missing-file", f"file '{rel}' does not exist under the source root"
    if ext == ".py":
        got = repo.python_functions(rel)
        if got is None:
            return "missing-symbol", f"'{rel}' has a syntax error, so the analyzer cannot read it"
        funcs, classes = got
        last = sym.rsplit(".", 1)[-1]
        if sym in funcs or any(q == sym or q.endswith("." + sym) or sym.endswith("." + q) for q in funcs):
            return "function", ""
        if sym in classes:
            return "class", ""
        if any(q.rsplit(".", 1)[-1] == last for q in funcs) and "." not in sym:
            return "function", ""
        near = difflib.get_close_matches(last, [q.rsplit(".", 1)[-1] for q in list(funcs) + list(classes)], n=3)
        return "missing-symbol", f"no function, method or class named '{sym}' in {rel}" + (f" -- did you mean {', '.join(near)}?" if near else "")
    parts = sym.split(".")
    if len(parts) > 1 and ext in (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"):
        # `issueCredit.execute`: a function that is a property of an object (or a method of a class) with that name
        owner_declared = re.search(rf"\b(?:const|let|var|class|function)\s+{re.escape(parts[-2])}\b", text)
        if owner_declared and any(re.search(p, text, re.M) for p in fn_pattern(parts[-1])):
            return "function", ""
    elif any(re.search(p, text, re.M) for n in {parts[-1]} for p in fn_pattern(n)):
        return "function", ""
    if any(re.search(rf"\b(?:class|interface|enum|struct|type)\s+{re.escape(n)}\b", text) for n in parts) or (
            len(parts) == 1 and re.search(rf"\b(?:const|let|var)\s+{re.escape(parts[0])}\b[^=\n]*=\s*\{{", text)):
        return "class", ""
    return "missing-symbol", f"no function, method or class named '{sym}' in {rel}"


def number_forms(v) -> list:
    forms = set()
    if isinstance(v, bool) or not isinstance(v, (int, float)):
        return []
    if float(v).is_integer():
        i = int(v)
        forms |= {str(i), f"{i}.0", f"{i:,}", f"{i:_}"}
    else:
        r = repr(float(v))
        forms |= {r, r.lstrip("0") if r.startswith("0.") else r, f"{v:g}"}
    return sorted(forms)


def threshold_number(t):
    if isinstance(t, bool):
        return None
    if isinstance(t, (int, float)):
        return t
    if isinstance(t, dict) and isinstance(t.get("value"), (int, float)) and not isinstance(t.get("value"), bool):
        return t["value"]
    return None


# ----------------------------------------------------------------------------------------------- 5. locators, limits, authority, formulas
WRITE_ATTRS = {"post", "put", "patch", "delete", "commit", "sendmail", "send_email", "publish", "send_message", "insert_one", "update_one", "delete_one", "write_text", "executemany", "to_sql"}


def writes_in(fn_node) -> list:
    out = []
    for n in ast.walk(fn_node):
        if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr in WRITE_ATTRS:
            recv = ast.unparse(n.func.value)
            if n.func.attr in ("post", "put", "patch", "delete") and not re.search(r"request|http|client|session|\bapi\b|\br\b|\bs\b|\bc\b", recv, re.I):
                continue
            out.append(f"{recv}.{n.func.attr}(...)")
        elif isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "execute" and n.args and isinstance(n.args[0], ast.Constant) \
                and isinstance(n.args[0].value, str) and re.match(r"\s*(insert|update|delete|replace)\b", n.args[0].value, re.I):
            out.append("execute(<write SQL>)")
    return out


def check_source(spec: dict, repo: Repo) -> None:
    def locator(loc, where, kind, needs_function=False):
        if not loc:
            return
        rel = loc.get("file", "")
        if os.path.isabs(rel) or rel.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", rel) or ".." in Path(rel).parts:
            error("locator", f"{where}: file '{rel}' must be a path relative to the repository root, exactly as after a fresh clone")
            return
        if rel != rel.strip() or loc.get("symbol", "") != loc.get("symbol", "").strip():
            error("locator", f"{where}: the file or symbol has leading or trailing whitespace ('{rel}' / '{loc.get('symbol', '')}'); the analyzer matches them exactly")
            return
        if "\\" in rel or rel.startswith("./"):
            warn("locator", f"{where}: write the file as a clean forward-slash path from the repository root ('{rel.lstrip('./').replace(chr(92), '/')}')")
            loc = dict(loc, file=rel.lstrip("./").replace("\\", "/"))
        actual = repo.exact_case(loc["file"])
        if actual not in (None, loc["file"]):
            error("locator", f"{where}: the path differs from the real one only in letter case ('{loc['file']}' vs '{actual}'); paths are case-sensitive on the machine that runs the analyzer")
            return
        status, detail = resolve_locator(repo, loc)
        if status == "missing-symbol" and "syntax error" in detail:
            warn("locator", f"{where}: {detail} (if it parses under your Python version this is harmless)")
        elif status in ("missing-file", "missing-symbol"):
            error("locator", f"{where}: {detail}")
        elif status == "unsupported":
            warn("locator", f"{where}: {detail}")
        elif status == "class" and needs_function:
            error("locator", f"{where}: '{loc['symbol']}' is a class, but a {kind} must point at the FUNCTION or METHOD that makes the decision (e.g. {loc['symbol']}.run): the analyzer follows calls from a function, not from a class")
        return status

    for i, c in enumerate(spec.get("capabilities", []) or []):
        locator(c.get("locator"), f"capabilities[{i}] ({c.get('id')}).locator", "capability")
    for kind in ("consumes", "produces"):
        for i, x in enumerate((spec.get("interfaces", {}) or {}).get(kind) or []):
            locator(x.get("locator"), f"interfaces.{kind}[{i}] ({x.get('id')}).locator", "interface")
    for i, p in enumerate(spec.get("policies") or []):
        locator(p.get("locator"), f"policies[{i}] ({p.get('id')}).locator", "policy")
    everything = None
    for i, d in enumerate(spec.get("decisions", []) or []):
        w = f"decisions[{i}] ({d.get('id')})"
        dl = d.get("locator")
        if not dl:
            warn("locator", f"{w}: has no locator, so the analyzer cannot find the code and will not check its guardrails or formulas")
        status = locator(dl, f"{w}.locator", "decision", needs_function=True)
        # authority vs what the function itself does
        if dl and status == "function" and d.get("authority") in ("observe", "recommend") and dl.get("file", "").endswith(".py"):
            got = repo.python_functions(dl["file"])
            sym = dl["symbol"]
            node = next((n for q, n in (got[0].items() if got else []) if q == sym or q.endswith("." + sym) or sym.endswith("." + q) or q.rsplit(".", 1)[-1] == sym.rsplit(".", 1)[-1]), None)
            if node is not None:
                found = writes_in(node)
                if found:
                    warn("authority", f"{w}: declared '{d['authority']}' but {dl['symbol']} appears to write to something ({', '.join(sorted(set(found))[:3])}); declare the authority the code really exercises")
        for j, g in enumerate(d.get("guardrails") or []):
            gw = f"{w}.guardrails[{j}] ({g.get('id')})"
            locator(g.get("locator"), f"{gw}.locator", "guardrail")
            num = threshold_number(g.get("threshold"))
            if num is not None:
                if everything is None:
                    everything = repo.everything()
                if not any(re.search(r"(?<![\w.])" + re.escape(f) + r"(?![\w.])", everything) for f in number_forms(num)):
                    warn("limit", f"{gw}: the threshold {num} does not appear as a literal anywhere in the source. If it is computed or comes from configuration, use the value the code really compares against (its default), or drop the number and describe the limit in words")
        for j, f in enumerate(d.get("formulas") or []):
            check_formula(f, f"{w}.formulas[{j}] ({f.get('id')})", dl, repo)

    ex = spec.get("execution", {}) or {}
    if ex.get("candidate_ref"):
        check_command(ex["candidate_ref"], repo)


LAUNCHERS = {"uvicorn", "gunicorn", "hypercorn", "flask", "streamlit", "chainlit", "gradio"}
KNOWN_FUNCS = {"max", "min", "abs", "ceil", "floor", "sqrt", "round", "int", "float"}  # names are case-insensitive: Max(...) == max(...)
ALLOWED_BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Pow)


def check_formula(f: dict, where: str, loc, repo: Repo) -> None:
    """Mirrors what the analyzer can verify: arithmetic (+ - * / // **) over named variables, numbers and a few functions. Anything else
    makes the analyzer report 'cannot be checked' against the spec itself."""
    expr = f.get("expression", "")
    try:
        tree = ast.parse(expr, mode="eval").body
    except SyntaxError:
        error("formula", f"{where}: expression {expr!r} is not valid arithmetic")
        return
    func_names, names, nodes = set(), set(), 0
    for n in ast.walk(tree):
        nodes += 1
        if isinstance(n, ast.Call):
            if n.keywords:
                error("formula", f"{where}: keyword arguments in {ast.unparse(n)[:50]!r} cannot be verified; use positional arguments")
                return
            fname = n.func.id if isinstance(n.func, ast.Name) else n.func.attr if isinstance(n.func, ast.Attribute) else None
            if fname is None:
                error("formula", f"{where}: a dynamic call in {expr!r} cannot be verified")
                return
            func_names.add(n.func.id if isinstance(n.func, ast.Name) else "")
            if fname.lower() not in KNOWN_FUNCS:
                warn("formula", f"{where}: function '{fname}' is not one the analyzer understands (max min abs ceil floor sqrt round int float); it will be treated as an opaque function, so this formula cannot be verified")
        elif isinstance(n, ast.BinOp) and not isinstance(n.op, ALLOWED_BINOPS):
            error("formula", f"{where}: operator '{type(n.op).__name__}' in {expr!r} cannot be verified (allowed: + - * / // **)")
            return
        elif isinstance(n, ast.UnaryOp) and not isinstance(n.op, (ast.USub, ast.UAdd)):
            error("formula", f"{where}: '{type(n.op).__name__}' in {expr!r} cannot be verified")
            return
        elif isinstance(n, ast.Constant) and (isinstance(n.value, bool) or not isinstance(n.value, (int, float))):
            parent_sub = any(isinstance(m, ast.Subscript) and m.slice is n for m in ast.walk(tree))
            if not parent_sub:
                error("formula", f"{where}: the literal {n.value!r} in {expr!r} is not a number")
                return
        elif isinstance(n, ast.Name):
            names.add(n.id)
        elif not isinstance(n, (ast.BinOp, ast.UnaryOp, ast.Constant, ast.Call, ast.Attribute, ast.Subscript, ast.Load, ast.operator, ast.unaryop)):
            error("formula", f"{where}: '{type(n).__name__}' in {expr!r} cannot be verified; use only arithmetic over named variables")
            return
    if nodes > 250:
        error("formula", f"{where}: the expression is too large to verify ({nodes} nodes; the limit is 250)")
        return
    names -= {x for x in func_names if x}
    declared = {v.get("name") for v in f.get("variables", []) or []}
    for nm in sorted(names):
        if declared and nm not in declared:
            warn("formula", f"{where}: '{nm}' is used in the expression but not declared in variables[]")
    if loc and loc.get("file"):
        # the arithmetic may live in a helper the decision calls, so look in the whole repository, not only the locator's file
        code = repo.everything()
        for nm in sorted(names):
            bind = next((v.get("binds_to") for v in f.get("variables", []) or [] if v.get("name") == nm and v.get("binds_to")), nm)
            if not re.search(r"\b" + re.escape(bind.split(".")[-1]) + r"\b", code):
                warn("formula", f"{where}: variable '{nm}' does not appear anywhere in the source; name it as the code does, or map it with variables[].binds_to")
        if f.get("output") and not re.search(r"\b" + re.escape(f["output"]) + r"\b", code):
            warn("formula", f"{where}: output '{f['output']}' does not appear anywhere in the source; use the name of the variable the code assigns the result to")


def check_command(cmd: str, repo: Repo) -> None:
    try:
        parts = shlex.split(cmd, posix=False)
    except ValueError:
        parts = cmd.split()
    if not parts:
        return
    head = parts[0].lower().replace(".exe", "")
    root = repo.root

    def exists(*cands):
        return any((root / c).exists() for c in cands)

    def elsewhere(*cands):
        """The target exists, but not at the repository root: the command assumes another working directory."""
        for dp, dn, fn in os.walk(root):
            dn[:] = [d for d in dn if d not in SKIP_DIRS and not d.startswith(".")]
            for f in fn:
                rel = os.path.relpath(os.path.join(dp, f), root).replace("\\", "/")
                if any(rel.endswith("/" + c) for c in cands):
                    return rel
        return None

    def missing(what, *cands):
        found = elsewhere(*cands) if cands else None
        if found:
            warn("execution", f"execution.candidate_ref '{cmd}': {what} is not at the repository root but exists as {found}; write the command from the repository root (for example 'cd {os.path.dirname(found) or '.'} && {cmd}') so it can be confirmed")
        else:
            error("execution", f"execution.candidate_ref '{cmd}': {what} does not exist in the repository")

    if head in ("python", "python3", "py"):
        args = [a for a in parts[1:] if not a.startswith("-") or a == "-m"]
        if "-m" in parts:
            name = parts[parts.index("-m") + 1] if len(parts) > parts.index("-m") + 1 else ""
            if name.lower() in LAUNCHERS:  # `python -m uvicorn app.main:app` is the launcher `uvicorn app.main:app`
                return check_command(" ".join(parts[parts.index("-m") + 1:]), repo)
            mod = name.replace(".", "/")
            if mod and not exists(mod + ".py", mod + "/__init__.py", mod + "/__main__.py", "src/" + mod + ".py", "src/" + mod + "/__main__.py"):
                if elsewhere(mod + ".py", mod + "/__main__.py", mod + "/__init__.py"):
                    missing(f"module {name}", mod + ".py", mod + "/__main__.py", mod + "/__init__.py")
                else:
                    warn("execution", f"execution.candidate_ref '{cmd}': module '{name}' is not in the repository; if it is an installed package this is fine, otherwise the command will not start")
        elif args and not exists(args[0].strip('"')):
            missing(f"script {args[0]}", args[0].strip('"'))
    elif head in LAUNCHERS:
        target = next((a for a in parts[1:] if re.match(r"^[\w.]+:\w+$", a) or a.endswith(".py")), None)
        if head in ("streamlit", "chainlit") and "run" in parts:
            target = next((a for a in parts[parts.index("run") + 1:] if not a.startswith("-")), None)
        if target:
            mod = target.split(":")[0]
            path = mod if mod.endswith(".py") else mod.replace(".", "/")
            if not exists(path if path.endswith(".py") else path + ".py", path + "/__init__.py", "src/" + path + ".py", path + "/main.py"):
                missing(f"module/file {mod}", path if path.endswith(".py") else path + ".py", path + "/__init__.py")
    elif head in ("npm", "yarn", "pnpm"):
        pkg = repo.text("package.json")
        script = parts[2] if len(parts) > 2 and parts[1] == "run" else (parts[1] if len(parts) > 1 and parts[1] not in ("run",) else "start")
        if pkg is None:
            missing("package.json")
        else:
            try:
                scripts = json.loads(pkg).get("scripts", {})
            except json.JSONDecodeError:
                scripts = {}
            if script not in scripts:
                missing(f"npm script '{script}' in package.json")
    elif head == "node":
        if len(parts) > 1 and not exists(parts[1]):
            missing(f"script {parts[1]}", parts[1])
    else:
        warn("execution", f"execution.candidate_ref '{cmd}': could not be checked against the repository (unfamiliar launcher '{parts[0]}')")


# ----------------------------------------------------------------------------------------------- 6. whole-repository sweep: nothing the code depends on may be missing
ENV_PATTERNS = [
    r"os\.(?:getenv|environ\.get)\(\s*[\"']([A-Za-z_][A-Za-z0-9_]*)[\"']",
    r"os\.environ\[\s*[\"']([A-Za-z_][A-Za-z0-9_]*)[\"']\s*\]",
    r"process\.env\.([A-Za-z_][A-Za-z0-9_]*)",
    r"process\.env\[\s*[\"']([A-Za-z_][A-Za-z0-9_]*)[\"']\s*\]",
    r"import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)",
    r"System\.getenv\(\s*\"([A-Za-z_][A-Za-z0-9_]*)\"",
    r"os\.Getenv\(\s*\"([A-Za-z_][A-Za-z0-9_]*)\"",
]
ENV_IGNORE = {"PATH", "HOME", "USER", "PWD", "LANG", "TERM", "TZ", "SHELL", "NODE_ENV", "PYTHONPATH"}
# imported library -> words a declared system's id or name should contain
SYSTEM_IMPORTS = [
    (r"weaviate", "Weaviate", {"weaviate", "vector"}), (r"pinecone", "Pinecone", {"pinecone", "vector"}), (r"chromadb", "Chroma", {"chroma", "vector"}),
    (r"qdrant", "Qdrant", {"qdrant", "vector"}), (r"pymongo|motor|mongoose", "MongoDB", {"mongo", "database", "db"}),
    (r"psycopg2|psycopg|asyncpg", "PostgreSQL", {"postgres", "postgresql", "sql", "database", "db"}), (r"pymysql|mysql", "MySQL", {"mysql", "sql", "database", "db"}),
    (r"sqlite3|aiosqlite", "SQLite", {"sqlite", "sql", "database", "db"}), (r"redis", "Redis", {"redis", "cache"}),
    (r"kafka", "Kafka", {"kafka", "queue", "event", "stream", "broker"}), (r"pika|aio_pika", "RabbitMQ", {"rabbit", "amqp", "queue", "broker"}),
    (r"boto3", "AWS", {"aws", "s3", "bucket", "dynamo", "sqs", "storage", "queue", "bedrock"}),
    (r"openai", "OpenAI", {"openai", "llm", "model", "gpt"}), (r"anthropic", "Anthropic", {"anthropic", "claude", "llm", "model"}),
    (r"groq", "Groq", {"groq", "llm", "model"}), (r"ollama", "Ollama", {"ollama", "llm", "model"}),
    (r"google\.generativeai|google\.genai", "Gemini", {"gemini", "google", "llm", "model"}),
    (r"slack_sdk|slack_bolt", "Slack", {"slack", "notification", "chat"}), (r"smtplib|sendgrid", "e-mail", {"email", "mail", "smtp", "sendgrid", "notification"}),
    (r"twilio", "Twilio", {"twilio", "sms", "notification"}), (r"supabase", "Supabase", {"supabase", "database", "db"}),
]


def check_sweep(spec: dict, repo: Repo) -> None:
    code = repo.everything()
    declared_env = {e.get("name") for e in (spec.get("execution", {}) or {}).get("env") or []}
    found = set()
    for pat in ENV_PATTERNS:
        found |= {m.group(1) for m in re.finditer(pat, code)}
    env_example = repo.text(".env.example")
    if env_example:
        found |= {m.group(1) for m in re.finditer(r"^\s*([A-Z][A-Z0-9_]+)\s*=", env_example, re.M)}
    missing = sorted(n for n in found - declared_env - ENV_IGNORE if n)
    if missing:
        warn("sweep", f"the code reads {len(missing)} environment variable(s) that execution.env does not list: {', '.join(missing[:12])}{'...' if len(missing) > 12 else ''}. Add each as {{\"name\": \"...\", \"secret\": true|false}} (names only, never values); the sandbox needs them")
    system_words = set()
    for s in (spec.get("interfaces", {}) or {}).get("systems") or []:
        system_words |= set(re.split(r"[^a-z0-9]+", (str(s.get("id", "")) + " " + str(s.get("name", ""))).lower()))
    for rx, label, words in SYSTEM_IMPORTS:
        if re.search(rf"^\s*(?:import|from)\s+(?:{rx})\b|require\(\s*['\"](?:{rx})|from\s+['\"](?:@[\w-]+/)?(?:{rx})", code, re.M) and not (words & system_words):
            warn("sweep", f"the code uses {label} but no system in interfaces.systems mentions it (expected a word such as: {', '.join(sorted(words)[:4])}). Add the system, named as the code names it; calls to a system that is not listed are reported as undeclared external calls")


AGENTISH = re.compile(r"\b(crewai|langgraph|langchain|autogen|llama_index|openai|anthropic|groq|ollama|google\.generativeai|fastapi|flask|streamlit|typer|click|argparse|gradio|chainlit)\b", re.I)


def check_entrypoints(spec: dict, repo: Repo) -> None:
    """Other runnable programs in the repository (scripts with a main block that use an agent or web framework). If the spec never mentions
    them, the reader cannot tell whether they were forgotten or deliberately left out."""
    mentioned = json.dumps(spec).lower()
    others = []
    for p in repo.files():
        if p.suffix != ".py":
            continue
        rel = p.relative_to(repo.root).as_posix()
        text = repo.text(rel) or ""
        if re.search(r"if\s+__name__\s*==\s*[\"']__main__[\"']", text) and AGENTISH.search(text) and p.stem.lower() not in mentioned:
            others.append(rel)
    if others:
        warn("sweep", f"{len(others)} other runnable program(s) in the repository are not mentioned anywhere in the spec: {', '.join(others[:6])}. Describe them, or say in `limitations` that this spec does not cover them")


# ----------------------------------------------------------------------------------------------- main
def find_schema(arg: str | None) -> Path:
    here = Path(__file__).resolve().parent
    for c in ([Path(arg)] if arg else []) + [here / "uas-1.0.schema.json", here.parent / "schema" / "uas-1.0.schema.json", Path.cwd() / "uas-1.0.schema.json"]:
        if c.is_file():
            return c
    sys.exit("Cannot find uas-1.0.schema.json (put it next to this script or pass --schema PATH)")


def main() -> int:
    ap = argparse.ArgumentParser(description="Check an Agent Functional Spec before uploading it to the Agent Analyzer.")
    ap.add_argument("spec", help="the spec JSON file")
    ap.add_argument("--source", help="root of the agent's source repository (enables the locator, limit, authority, formula and start-command checks)")
    ap.add_argument("--schema", help="path to uas-1.0.schema.json")
    a = ap.parse_args()
    try:
        spec = json.loads(Path(a.spec).read_text(encoding="utf-8-sig"))
    except FileNotFoundError:
        sys.exit(f"No such file: {a.spec}")
    except json.JSONDecodeError as e:
        print(f"NOT READY: {a.spec} is not valid JSON ({e.msg} at line {e.lineno}, column {e.colno})")
        return 1
    if not isinstance(spec, dict):
        print("NOT READY: the top level of the spec must be a JSON object")
        return 1
    find_placeholders(spec)
    check_schema(spec, json.loads(find_schema(a.schema).read_text(encoding="utf-8")))
    check_ids_and_refs(spec)
    if a.source:
        root = Path(a.source)
        if not root.is_dir():
            sys.exit(f"--source {a.source} is not a directory")
        repo = Repo(root)
        check_source(spec, repo)
        check_sweep(spec, repo)
        check_entrypoints(spec, repo)
    else:
        warn("source", "no --source given: locators, limits, authority, formulas and the start command were NOT checked against the code. Re-run with --source <repo root>")
    print(f"Agent Functional Spec check: {a.spec}")
    if ERRORS:
        print(f"\nERRORS ({len(ERRORS)}) -- fix these; the analyzer would refuse the spec, crash, or lose part of it:")
        for i, e in enumerate(ERRORS, 1):
            print(f"  E{i} {e}")
    if WARNINGS:
        print(f"\nWARNINGS ({len(WARNINGS)}) -- the analyzer will run, but its verdicts will be weaker or wrong here:")
        for i, w in enumerate(WARNINGS, 1):
            print(f"  W{i} {w}")
    print("\nRESULT: " + (f"NOT READY ({len(ERRORS)} errors, {len(WARNINGS)} warnings)" if ERRORS else f"READY FOR THE ANALYZER (0 errors, {len(WARNINGS)} warnings)"))
    return 1 if ERRORS else 0


if __name__ == "__main__":
    sys.exit(main())
