"""The verb grammar — argv turned into {op, args}.

A thin client can't know each op's schema, but the framework's grammar is small
and stable, so we carry a compact map of POSITIONAL arg names per op and let
everything else arrive as --flags / --json. The server binds args BY NAME, so the
client only has to get the right names into the bag — positional or flag, either
works. Short names (`w`, `i`, `ls`) and the `-b -` stdin body convention mirror
`./r`. Anything without a friendly verb is still reachable via `runner op <name>`.
"""
import json
import sys

# Arg names whose values are parsed as JSON (or @file). Everything else is a string.
# ('writes' is NOT here: for pipe.set it is the `code:label` shorthand, for a worker
#  it is a bare set code — both strings, handled in their verb branches.)
JSON_ARGS = {"meta", "schema", "decls", "tree", "props", "parts", "input",
             "attrs", "config", "config_schema", "storage", "allow_hosts", "spec"}

# For these ops a bare `--json '{…}'` IS the primary payload arg (mirrors ./r, where
# `set put … --json` is the row meta). Anywhere else, --json merges into args.
PRIMARY_JSON = {
    "set.put": "meta", "set.fetch_url": "meta", "set.declare_schema": "schema",
    "css.set_rule": "decls", "component.set": "tree", "component.set_route": "props",
    "behavior.attach": "config", "head.set": "attrs",
}
INT_ARGS = {"ordinal", "rate", "timeout", "retries", "at", "max_bytes", "ttl_days", "days"}
FLOAT_ARGS = {"backoff"}
BOOL_ARGS = {"replace", "force", "keep", "probe", "enabled", "muted", "autoplay",
             "controls", "auth_flow"}

# Ordered positional arg names per op (UX only; the server binds by name).
POS = {
    "set.create": ["code"], "set.put": ["set_code", "row_code"],
    "set.declare_schema": ["code"], "set.fetch_url": ["set_code", "row_code", "url"],
    "set.remove_row": ["set_code", "row_code"], "set.drop": ["set_code"],
    "set.describe": ["set_code"], "set.rows": ["set_code"],
    "pipe.set": ["code"], "pipe.show": ["code"], "pipe.remove": ["code"],
    "css.set_class": ["code"], "css.set_rule": ["class_code", "variant"],
    "css.clear_rule": ["class_code"], "css.attach": ["class_code", "target"],
    "css.detach": ["code"], "css.describe": ["class_code"], "css.resolve": ["page"],
    "asset.add": ["code", "kind"], "asset.attach": ["asset", "slot"],
    "asset.detach": ["code"], "asset.describe": ["code"], "asset.resolve": ["page"],
    "behavior.add": ["code"], "behavior.set_enabled": ["code", "enabled"],
    "behavior.remove": ["code"], "behavior.attach": ["behavior_code", "target"],
    "behavior.set_attachment_enabled": ["code", "enabled"], "behavior.detach": ["code"],
    "behavior.describe": ["code"],
    "component.set": ["code"], "component.remove": ["code"],
    "component.set_route": ["code", "component"], "component.remove_route": ["code"],
    "component.describe": ["code"],
    "head.set": ["code"], "head.remove": ["code"],
}

# Planes that take `runner <plane> <subverb> …` (subverb maps to op `<plane>.<subverb>`).
PLANES = {"set", "css", "asset", "behavior", "component", "head"}

# Short flag aliases (match ./r): -w writes, -k keys, -u url, -f body_from, etc.
SHORT = {"w": "writes", "k": "keys", "u": "url", "f": "body_from", "H": "handler",
         "X": "method", "s": "spec", "l": "label", "i": "input"}


class VerbError(Exception):
    pass


def coerce(name, value):
    if value is None:
        return None
    try:
        if name in JSON_ARGS:
            if isinstance(value, str) and value.startswith("@"):
                with open(value[1:]) as f:
                    return json.load(f)
            return json.loads(value) if isinstance(value, str) else value
        if name in INT_ARGS:
            return int(value)
        if name in FLOAT_ARGS:
            return float(value)
        if name in BOOL_ARGS:
            return str(value).lower() in ("1", "true", "yes", "on")
    except (ValueError, OSError) as e:
        raise VerbError(f"bad value for --{name}: {e}")
    return value


def _read_body(value):
    return sys.stdin.read() if value == "-" else value


def _walk(rest):
    """Split the argv tail into (positionals, flags). Handles -S/--scope, --json,
    -b/--body, bare boolean flags, and --name value pairs."""
    positionals, flags = [], {}
    i = 0
    while i < len(rest):
        tok = rest[i]
        if tok in ("-S", "--scope"):
            flags["scope"] = rest[i + 1]; i += 2; continue
        if tok in ("-b", "--body"):
            flags["__body"] = _read_body(rest[i + 1]); i += 2; continue
        if tok == "--json":
            blob = rest[i + 1]
            obj = (json.load(open(blob[1:])) if blob.startswith("@") else json.loads(blob))
            flags.setdefault("__json", {}).update(obj); i += 2; continue
        if tok.startswith("--") or (tok.startswith("-") and len(tok) == 2 and not tok[1:].isdigit()):
            name = tok.lstrip("-").replace("-", "_")
            name = SHORT.get(name, name)
            nxt = rest[i + 1] if i + 1 < len(rest) else None
            if name in BOOL_ARGS and (nxt is None or nxt.startswith("-")
                                      or nxt.lower() not in ("true", "false", "1", "0", "yes", "no", "on", "off")):
                flags[name] = True; i += 1; continue
            if nxt is None:
                raise VerbError(f"missing value for {tok}")
            flags[name] = nxt; i += 2; continue
        positionals.append(tok); i += 1
    return positionals, flags


def _assemble(op, positionals, flags):
    args = {}
    names = POS.get(op, [])
    for idx, val in enumerate(positionals):
        if idx < len(names):
            args[names[idx]] = coerce(names[idx], val)
        else:
            raise VerbError(f"too many positional args for `{op}` (expected {len(names)})")
    if "scope" in flags:
        args["scope"] = flags.pop("scope")
    if "__json" in flags:
        blob = flags.pop("__json")
        prim = PRIMARY_JSON.get(op)
        if prim and prim not in args:
            args[prim] = blob               # `--json` is this op's primary payload
        else:
            args.update(blob)               # otherwise merge keys into args
    flags.pop("__body", None)   # body only meaningful to special verbs (handled there)
    for k, v in flags.items():
        args[k] = coerce(k, v)
    return args


# ---------------------------------------------------------------------------
# The public entry: map (verb, rest) -> (op_name, args). Returns None for op when
# the verb is a client-only command (signin, dev, …) handled in __main__.
# ---------------------------------------------------------------------------
def to_op(verb, rest):
    verb = verb.replace("-", "_")

    if verb in ("w", "work"):
        return _worker(rest)
    if verb == "op":                                   # universal escape hatch
        if not rest:
            raise VerbError("usage: runner op <name> [--json '{…}'] [--flag value]")
        pos, flags = _walk(rest[1:])
        args = _assemble(rest[0], pos, flags)
        if "__body" in flags:
            args["body"] = flags["__body"]
        return rest[0], args
    if verb in ("inspect", "i"):
        pos, flags = _walk(rest)
        op = "set.rows" if pos else "set.list"
        return op, _assemble(op, pos, flags)
    if verb == "ls":
        _, flags = _walk(rest)
        return "set.list", _assemble("set.list", [], flags)
    if verb in ("pipe",):
        if rest and rest[0] in ("show", "list", "remove"):
            sub = rest[0]
            op = "pipe." + ("set" if sub == "set" else sub)
            op = {"show": "pipe.show", "list": "pipe.list", "remove": "pipe.remove"}[sub]
            pos, flags = _walk(rest[1:])
            return op, _assemble(op, pos, flags)
        pos, flags = _walk(rest)
        args = _assemble("pipe.set", pos, flags)
        if isinstance(args.get("writes"), str):       # --writes code:label -> {code,label}
            c, _, lbl = args["writes"].partition(":")
            args["writes"] = {"code": c, "label": lbl or c}
        return "pipe.set", args
    if verb in ("pipes",):
        _, flags = _walk(rest)
        return "pipe.list", _assemble("pipe.list", [], flags)
    if verb == "show":
        pos, flags = _walk(rest)
        return "pipe.show", _assemble("pipe.show", pos, flags)
    if verb == "read":
        pos, flags = _walk(rest)
        args = {"code": pos[0] if pos else flags.get("code")}
        if "scope" in flags:
            args["scope"] = flags["scope"]
        return "read", args
    if verb == "run":
        pos, flags = _walk(rest)
        args = {"pipeline": pos[0] if pos else flags.get("pipeline")}
        if "input" in flags:
            args["input"] = coerce("input", flags["input"])
        return "run", args
    if verb == "runs":
        pos, _ = _walk(rest)
        return "runs", {"launch": pos[0] if pos else None}
    if verb in ("compile",):
        pos, flags = _walk(rest)
        args = {}
        if pos: args["scope"] = pos[0]
        if "scope" in flags: args["scope"] = flags["scope"]
        return "bundle.compile", args
    if verb in ("doctor",):
        pos, flags = _walk(rest)
        args = {}
        if pos: args["scope"] = pos[0]
        if "scope" in flags: args["scope"] = flags["scope"]
        return "doctor.check", args
    if verb == "app":
        if rest and rest[0] == "create":
            pos, flags = _walk(rest[1:])
            return "app.create", {"name": pos[0] if pos else None, **({"label": flags["label"]} if "label" in flags else {})}
        if rest and rest[0] == "list":
            return "app.list", {}
        raise VerbError("usage: runner app create <name> [--label L] | runner app list")
    if verb == "apps":
        return "app.list", {}
    if verb == "auth":
        # Hosted "auth block": gate an app behind pen.acsetra.com sign-in. No
        # secrets — the server reuses its own Keycloak realm; `--off` lifts the gate.
        #   runner auth <app> [--off]      |      runner auth list [scope]
        if rest and rest[0] in ("list", "ls"):
            scopes = [a for a in rest[1:] if not a.startswith("-")]
            return "auth.list", ({"scope": scopes[0]} if scopes else {})
        off = "--off" in rest or "--disable" in rest
        pos = [a for a in rest if not a.startswith("-")]
        if not pos:
            raise VerbError("usage: runner auth <app> [--off]  |  runner auth list")
        return "app.set_auth", {"scope": pos[0], "enabled": not off}

    # plane subverb form: runner <plane> <subverb> …
    if verb in PLANES:
        if not rest:
            raise VerbError(f"usage: runner {verb} <subverb> … (e.g. `{verb} list`)")
        sub = rest[0].replace("-", "_")
        # convenience aliases
        sub = {"class": "set_class", "rule": "set_rule"}.get(sub, sub) if verb == "css" else sub
        op = f"{verb}.{sub}"
        pos, flags = _walk(rest[1:])
        args = _assemble(op, pos, flags)
        # planes that take a body (behavior.add) accept -b/--body -> body
        if "__body" in flags:
            args["body"] = flags["__body"]
        return op, args

    raise VerbError(f"unknown command '{verb}' — try `runner help`")


def _worker(rest):
    """`runner w <code> <tier> …` — assemble a worker spec from tier-specific flags,
    exactly like ./r work. Output op is pipe.add_worker {code, tier, spec, writes?, at?}."""
    if len(rest) < 2:
        raise VerbError("usage: runner w <pipeline-code> <tier> [flags]")
    code, tier = rest[0], rest[1]
    pos, flags = _walk(rest[2:])
    spec = {}
    body = flags.get("__body")
    if tier == "snippet":
        if not body:
            raise VerbError("snippet worker needs -b <code|->")
        spec["body"] = body
    elif tier == "internal":
        if "handler" not in flags:
            raise VerbError("internal worker needs --handler NAME")
        spec["handler"] = flags["handler"]
    elif tier == "httpx":
        if "url" not in flags:
            raise VerbError("httpx worker needs --url")
        spec.update({"url": flags["url"], "method": flags.get("method", "POST"),
                     "keys": [k.strip() for k in flags.get("keys", "").split(",") if k.strip()]})
        if "body_from" in flags:
            spec["body_from"] = flags["body_from"]
    elif tier == "read":
        if "set" not in flags:
            raise VerbError("read worker needs --set CODE")
        spec.update({"set": flags["set"], "mode": flags.get("mode", "latest")})
        for k in ("key", "scope", "as"):
            if k in flags:
                spec[k] = flags[k]
        if "read_scope" in flags:
            spec["scope"] = flags["read_scope"]
    elif tier == "llm":
        spec["provider"] = flags.get("provider", "openai")
        spec["user_from"] = flags.get("user_from", "ask")
        for src, dst in (("model", "model"), ("system_from", "system_from"), ("url", "url"),
                         ("response_path", "response_path"), ("as", "as")):
            if src in flags:
                spec[dst] = flags[src]
        if "keys" in flags:
            spec["keys"] = [k.strip() for k in flags["keys"].split(",") if k.strip()]
    if "spec" in flags:
        spec.update(coerce("spec", flags["spec"]))
    args = {"code": code, "tier": tier, "spec": spec}
    if "writes" in flags:
        args["writes"] = flags["writes"]
    if "w" in flags:
        args["writes"] = flags["w"]
    if "at" in flags:
        args["at"] = coerce("at", flags["at"])
    if "scope" in flags:
        args["scope"] = flags["scope"]
    return "pipe.add_worker", args
