#!/usr/bin/env python3
"""A fake ``openshell`` CLI for tests and the instrumented validator run.

It accepts the argument shapes of the OpenShell CLI at upstream ``ba302a6``
(v0.1.6) that tongs-agents uses, prints ``sandbox get -o json`` objects with
the keys of ``sandbox_to_json``, and keeps its state under
``$FAKE_OPENSHELL_HOME``:

- ``calls.jsonl``: one ``{"argv": [...], "no_color": ..., "cwd": ...}`` line per call;
- ``state.json``: sandboxes (name, labels, phase queue, settings);
- ``fs/<sandbox>/...``: each sandbox's filesystem. Sandbox paths starting
  with ``/sandbox`` map there, so ``sandbox exec --no-tty`` runs the command
  for real on the host against that tree (the bridge's pull script).

M7-B adds ``sandbox list -o json`` (an array of the ``get`` objects),
``sandbox get <name> --policy-only`` (the stored policy text: the
``--policy`` file given at create when it exists, else a default), ``sandbox start|stop``
(phase Ready or Stopped; an upstream main checked for M7-B has neither
subcommand nor a Stopped phase, so these shapes are unvalidated), ``sandbox download`` (upstream
``sandbox_sync_down``: a directory's contents land in ``dest``, a file at
``dest`` or ``dest/<basename>`` when ``dest`` is a directory), ``policy get``,
``logs <name> -n N`` and a ``delete`` that prints upstream's messages.

M7-C adds ``rule get <name> [--status S]``, printed like upstream
``sandbox_draft_get`` at ba302a6 (run.rs), ``rule approve <name> --chunk-id
ID`` (bumps the policy version and allows the endpoint) and ``rule reject
<name> --chunk-id ID --reason R``. ``rule approve-all`` and any other ``rule``
verb fail loudly. Pending rules live in ``state.json`` under each sandbox's
``rules``; tests add them there, or let the network knob draft them.
``FAKE_OPENSHELL_RULES_TEXT`` names a file ``rule get`` prints instead (the
text fixtures in tests/fixtures/openshell_rules).

M7-D adds ``provider list -o json`` (``provider_to_json`` keys: id, name,
type, credential key names, never values) from ``state.json["providers"]``,
a ``{name: type}`` map tests fill; ``FAKE_OPENSHELL_FAIL=provider`` fails it.

Uploads follow upstream ``sandbox_sync_up``: a directory lands at
``<dest>/<basename>`` (flat only for ``.``), so a nesting mistake shows up in
``fs/``.

Knobs: ``FAKE_OPENSHELL_VERSION`` (default ``0.1.6``),
``FAKE_OPENSHELL_PHASES`` (comma-separated phases returned by successive
``get`` calls after create, the last one sticks; default ``Ready``),
``FAKE_OPENSHELL_FAIL`` (comma-separated subcommands that fail; ``status``
fails ``status -o json``, ``rule`` fails every ``rule`` verb, ``version``
fails ``--version``), ``FAKE_OPENSHELL_LIFECYCLE_PHASE`` (the phase a
``sandbox start`` or ``stop`` leaves instead of Ready or Stopped),
``FAKE_OPENSHELL_LOG_TEXT`` (printed by ``logs`` instead of numbered lines),
``FAKE_OPENSHELL_RUN_TTY=1`` (run ``exec --tty`` commands like ``--no-tty``
instead of printing them) and ``FAKE_OPENSHELL_NETWORK=1`` (an ``exec`` whose
last argument is an ``http(s)://`` URL is a network fetch, never run: it
passes when an approved rule allows the host and port, else it exits 56 like
curl behind a refused CONNECT and the gateway drafts a pending rule, or counts
a hit on the one already drafted). It never starts a gateway or a container.
"""

import json
import os
import posixpath
import shutil
import subprocess
import sys
import time
import urllib.parse
import uuid
from pathlib import Path

HOME = Path(os.environ["FAKE_OPENSHELL_HOME"])
STATE = HOME / "state.json"
FS = HOME / "fs"
PREFIX = "/sandbox"
DEFAULT_POLICY = """version: 1
filesystem_policy:
  include_workdir: true
network_policies: {}
"""


def load():
    if STATE.exists():
        return json.loads(STATE.read_text())
    return {"sandboxes": {}}


def save(data):
    STATE.write_text(json.dumps(data, indent=1, sort_keys=True))


def fail(message, code=1):
    print(f"Error:   x {message}", file=sys.stderr)
    sys.exit(code)


def failing(name):
    return name in os.environ.get("FAKE_OPENSHELL_FAIL", "").split(",")


def mapped(sandbox, value):
    if value == PREFIX or value.startswith(PREFIX + "/"):
        return str(FS / sandbox) + value
    return value


def basename(local):
    """Rust ``Path::file_name``: None for ``.``, ``..``, ``/`` and paths ending in ``..``."""
    name = Path(local).name
    return None if name in ("", "..") or local.rstrip("/").endswith("/..") else name


def copy_in(sandbox, local, dest):
    """Upstream ``sandbox_sync_up`` at ba302a6 (ssh.rs), relative to this process's cwd.

    A directory always lands at ``<dest>/<basename>/...`` like ``cp -r``,
    whether or not ``<dest>`` exists; a source without a basename (``.``)
    lands flat at ``<dest>``. A file (or symlink) goes to ``dest`` itself unless
    ``dest`` ends with ``/`` or its parent is ``/``, else to ``<dest>/<basename>``.
    """
    source = Path(local)
    if not source.exists() and not source.is_symlink():
        fail(f"local path does not exist: {local}")
    file_like = source.is_symlink() or source.is_file()
    if file_like and not dest.endswith("/") and posixpath.dirname(dest) not in ("", "/"):
        target = Path(mapped(sandbox, dest))
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, target, follow_symlinks=False)
        return
    root = Path(mapped(sandbox, dest.rstrip("/") or "/"))
    name = basename(local)
    target = root / name if name else root
    target.parent.mkdir(parents=True, exist_ok=True)
    if file_like:
        shutil.copy2(source, target, follow_symlinks=False)
    else:
        shutil.copytree(source, target, symlinks=True, dirs_exist_ok=True)


def sandbox_json(sandbox):
    return {
        "id": sandbox["id"],
        "name": sandbox["name"],
        "workspace": "default",
        "host_key_fingerprint": "",
        "labels": sandbox["labels"],
        "annotations": {},
        "resource_version": 1,
        "created_at": "2026-11-03 12:00:00",
        "phase": sandbox["phases"][0],
        "current_policy_version": sandbox.get("policy_version", 1),
        "exit_code": None,
        "conditions": [],
        "endpoint_statuses": [],
        "last_token_grant_failure": None,
        "configuration_admission": None,
        "provisioning": None,
        "created_from_workload_template": None,
    }


def take(args, flag):
    values = []
    while flag in args:
        index = args.index(flag)
        values.append(args[index + 1])
        del args[index:index + 2]
    return values


def split_command(args):
    if "--" in args:
        index = args.index("--")
        return args[:index], args[index + 1:]
    return args, []


def create(args):
    if failing("create"):
        fail("create failed")
    args, command = split_command(args)
    name = take(args, "--name")[0]
    data = load()
    if name in data["sandboxes"]:
        fail(f"sandbox '{name}' already exists")
    labels = dict(item.split("=", 1) for item in take(args, "--label"))
    uploads = take(args, "--upload")
    policies = take(args, "--policy")
    policy = DEFAULT_POLICY
    if policies and Path(policies[0]).is_file():
        policy = Path(policies[0]).read_text()
    phases = os.environ.get("FAKE_OPENSHELL_PHASES", "Ready").split(",")
    data["sandboxes"][name] = {
        "id": uuid.uuid4().hex, "name": name, "labels": labels, "phases": phases,
        "settings": {}, "command": command, "args": args, "policy": policy,
    }
    save(data)
    (FS / name).mkdir(parents=True, exist_ok=True)
    for spec in uploads:
        local, _, dest = spec.partition(":")
        copy_in(name, local, dest or PREFIX)
    print(f"Created sandbox {name}")


def get(args):
    if failing("get"):
        fail("status: Unavailable, message: \"gateway unreachable\"")
    policy_only = "--policy-only" in args
    args = [arg for arg in args if arg != "--policy-only"]
    name = args[0]
    data = load()
    sandbox = data["sandboxes"].get(name)
    if sandbox is None:
        fail('status: NotFound, message: "sandbox not found"')
    if policy_only:
        print(sandbox.get("policy", DEFAULT_POLICY), end="")
        return
    print(json.dumps(sandbox_json(sandbox), indent=2))
    if len(sandbox["phases"]) > 1:
        sandbox["phases"].pop(0)
        save(data)


def upload(args):
    if failing("upload"):
        fail("upload failed")
    args = [arg for arg in args if arg != "--no-git-ignore"]
    name, local = args[0], args[1]
    dest = args[2] if len(args) > 2 else PREFIX
    if name not in load()["sandboxes"]:
        fail('status: NotFound, message: "sandbox not found"')
    copy_in(name, local, dest)


def run_inside(name, args, command):
    workdir = take(args, "--workdir")
    env = dict(os.environ)
    for item in take(args, "--env"):
        key, _, value = item.partition("=")
        env[key] = mapped(name, value)
    cwd = mapped(name, workdir[0]) if workdir else str(FS / name)
    Path(cwd).mkdir(parents=True, exist_ok=True)
    argv = [mapped(name, part) for part in command]
    result = subprocess.run(argv, cwd=cwd, env=env)
    sys.exit(result.returncode)


def epoch_text(seconds):
    return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds))


def render_rules(name, chunks, version):
    """Upstream ``sandbox_draft_get`` with colour off (NO_COLOR or a pipe)."""
    if not chunks:
        print(f"No network rules for sandbox '{name}'")
        return
    print(f"Network Rules:  (version {version}, {len(chunks)} chunk"
          f"{'' if len(chunks) == 1 else 's'})")
    print()
    for chunk in chunks:
        print(f"  Chunk: {chunk['id']}")
        print(f"  Status: {chunk['status']}")
        print(f"  Rule: {chunk['rule_name']}")
        if chunk.get("binary"):
            print(f"  Binary: {chunk['binary']}")
        print(f"  Confidence: {chunk.get('confidence', 0.0) * 100:.0f}%")
        print(f"  Rationale: {chunk.get('rationale', '')}")
        for label, key in (("Security", "security_notes"), ("Prover", "validation_result"),
                           ("Application", "application_error")):
            if chunk.get(key):
                print(f"  {label}: {chunk[key]}")
        if chunk.get("candidate"):
            print(f"  Candidate: {chunk['candidate'][:12]}")
        if chunk.get("endpoint"):
            print(f"  Endpoints: {chunk['endpoint']}")
            if chunk.get("binaries"):
                print(f"  Binaries: {', '.join(chunk['binaries'])}")
        if chunk.get("hit_count", 1) > 1:
            print(f"  Hits: {chunk['hit_count']} (first seen "
                  f"{epoch_text(chunk.get('first_seen', 0))}, last seen "
                  f"{epoch_text(chunk.get('last_seen', 0))})")
        print()


def rule(args):
    if failing("rule"):
        fail("status: Unavailable, message: \"gateway unreachable\"")
    verb, rest = args[0], args[1:]
    if verb not in ("get", "approve", "reject"):
        fail(f"the fake refuses rule {verb}: tongs-agents approves one rule at a time", 2)
    chunk_ids = take(rest, "--chunk-id")
    reasons = take(rest, "--reason")
    statuses = take(rest, "--status")
    data = load()
    name = rest[0]
    sandbox = data["sandboxes"].get(name)
    if sandbox is None:
        fail('status: NotFound, message: "sandbox not found"')
    chunks = sandbox.setdefault("rules", [])
    if verb == "get" and os.environ.get("FAKE_OPENSHELL_RULES_TEXT"):
        sys.stdout.write(Path(os.environ["FAKE_OPENSHELL_RULES_TEXT"]).read_text())
        return
    if verb == "get":
        shown = [chunk for chunk in chunks if not statuses or chunk["status"] == statuses[0]]
        render_rules(name, shown, sandbox.get("draft_version", len(chunks)))
        return
    chunk = next((item for item in chunks if item["id"] == chunk_ids[0]), None)
    if chunk is None:
        fail(f"draft chunk '{chunk_ids[0]}' not found")
    if chunk["status"] != "pending":
        fail(f"status: FailedPrecondition, message: \"chunk is {chunk['status']}\"")
    sandbox["draft_version"] = sandbox.get("draft_version", len(chunks)) + 1
    if verb == "approve":
        chunk["status"] = "approved"
        sandbox["policy_version"] = sandbox.get("policy_version", 1) + 1
        sandbox.setdefault("allowed", []).append(chunk["endpoint"].split(" ")[0])
        save(data)
        print(f"OK Chunk approved. Policy version: {sandbox['policy_version']}, "
              f"hash: {uuid.uuid4().hex[:12]}")
        return
    chunk["status"] = "rejected"
    chunk["rejection_reason"] = reasons[0] if reasons else ""
    save(data)
    print("OK Chunk rejected.")


def network_fetch(name, url):
    """A fetch through the sandbox proxy: allowed by an approved rule or drafted as one."""
    parts = urllib.parse.urlsplit(url)
    host = parts.hostname or ""
    port = parts.port or (443 if parts.scheme == "https" else 80)
    endpoint = f"{host}:{port}"
    data = load()
    sandbox = data["sandboxes"][name]
    if endpoint in sandbox.get("allowed", []):
        return 0
    chunks = sandbox.setdefault("rules", [])
    now = int(time.time())
    known = next((chunk for chunk in chunks if chunk["endpoint"].split(" ")[0] == endpoint
                  and chunk["status"] in ("pending", "rejected")), None)
    if known is None:
        chunks.append({
            "id": str(uuid.uuid4()), "status": "pending",
            "rule_name": "allow_" + endpoint.replace(".", "_").replace(":", "_"),
            "binary": "/usr/bin/curl", "confidence": 0.85,
            "rationale": f"Blocked connection to {endpoint} by /usr/bin/curl",
            "endpoint": f"{endpoint} [L4]", "binaries": ["/usr/bin/curl"],
            "hit_count": 1, "first_seen": now, "last_seen": now,
        })
        sandbox["draft_version"] = sandbox.get("draft_version", len(chunks) - 1) + 1
    else:
        known["hit_count"] = known.get("hit_count", 1) + 1
        known["last_seen"] = now
    save(data)
    print("curl: (56) CONNECT tunnel failed, response 403", file=sys.stderr)
    return 56


def exec_(args):
    if failing("exec"):
        fail("exec failed")
    args, command = split_command(args)
    names = take(args, "-n") or take(args, "--name")
    name = names[0]
    data = load()
    sandbox = data["sandboxes"].get(name)
    if sandbox is None:
        fail('status: NotFound, message: "sandbox not found"')
    if sandbox["phases"][0] != "Ready":
        fail(f"sandbox '{name}' is not ready (phase: {sandbox['phases'][0]}); "
             "wait for it to reach Ready state")
    if (os.environ.get("FAKE_OPENSHELL_NETWORK") == "1" and command
            and "://" in command[-1]):
        sys.exit(network_fetch(name, command[-1]))
    tty = "--tty" in args
    if tty and os.environ.get("FAKE_OPENSHELL_RUN_TTY") != "1":
        print(json.dumps({"exec": name, "args": args, "command": command}))
        return
    run_inside(name, args, command)


def settings(args):
    if args[0] != "set" or "--global" in args:
        fail("the fake only supports per-sandbox settings set")
    rest = args[1:]
    key = take(rest, "--key")[0]
    value = take(rest, "--value")[0]
    data = load()
    data["sandboxes"][rest[0]]["settings"][key] = value
    save(data)


def delete(args):
    if failing("delete"):
        fail("delete failed")
    data = load()
    for name in args:
        if data["sandboxes"].pop(name, None) is None:
            print(f"! Sandbox {name} not found")
        else:
            print(f"Deleted sandbox {name}")
        shutil.rmtree(FS / name, ignore_errors=True)
    save(data)


def list_(args):
    if failing("list"):
        fail("status: Unavailable, message: \"gateway unreachable\"")
    if args[-2:] != ["-o", "json"]:
        fail("the fake only supports sandbox list -o json", 2)
    sandboxes = load()["sandboxes"].values()
    print(json.dumps([sandbox_json(sandbox) for sandbox in sandboxes], indent=2))


def lifecycle(verb, args):
    if failing(verb):
        fail(f"{verb} failed")
    name = args[0]
    data = load()
    sandbox = data["sandboxes"].get(name)
    if sandbox is None:
        fail('status: NotFound, message: "sandbox not found"')
    phase = os.environ.get("FAKE_OPENSHELL_LIFECYCLE_PHASE")
    sandbox["phases"] = [phase or ("Ready" if verb == "start" else "Stopped")]
    save(data)
    print(f"{'Started' if verb == 'start' else 'Stopped'} sandbox {name}")


def download(args):
    """Upstream ``sandbox_sync_down`` at ba302a6 (ssh.rs)."""
    if failing("download"):
        fail("download failed")
    name, sandbox_path = args[0], args[1]
    dest = Path(args[2] if len(args) > 2 else ".")
    if name not in load()["sandboxes"]:
        fail('status: NotFound, message: "sandbox not found"')
    source = Path(mapped(name, sandbox_path))
    if source.is_dir():
        if dest.exists() and not dest.is_dir():
            fail(f"cannot extract directory '{sandbox_path}' over non-directory destination")
        dest.mkdir(parents=True, exist_ok=True)
        shutil.copytree(source, dest, symlinks=True, dirs_exist_ok=True)
        return
    if not source.is_file():
        fail(f"sandbox path does not exist: {sandbox_path}")
    target = dest / source.name if dest.is_dir() else dest
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(source, target)


def policy(args):
    if args[0] != "get" or "--global" in args:
        fail("the fake only supports per-sandbox policy get", 2)
    if failing("policy"):
        fail("policy get failed")
    rest = [arg for arg in args[1:] if arg != "--full"]
    sandbox = load()["sandboxes"].get(rest[0])
    if sandbox is None:
        fail('status: NotFound, message: "sandbox not found"')
    print(f"Sandbox:      {rest[0]}\nVersion:      1\nStatus:       Loaded\n---")
    print(sandbox.get("policy", DEFAULT_POLICY), end="")


def logs(args):
    if "--tail" in args:
        fail("the fake refuses --tail: it would follow forever", 2)
    if failing("logs"):
        fail("logs failed")
    lines = int(take(args, "-n")[0]) if "-n" in args else 200
    take(args, "--since")
    take(args, "--source")
    if args[0] not in load()["sandboxes"]:
        fail('status: NotFound, message: "sandbox not found"')
    text = os.environ.get("FAKE_OPENSHELL_LOG_TEXT")
    if text is not None:
        print(text)
        return
    for index in range(lines):
        print(f"2026-11-03T12:00:{index % 60:02d}Z INFO sandbox {args[0]} line {index}")


def provider(args):
    if failing("provider"):
        fail("status: Unavailable, message: \"gateway unreachable\"")
    if args != ["list", "-o", "json"]:
        fail("the fake only supports provider list -o json", 2)
    providers = load().get("providers", {})
    print(json.dumps([{"id": uuid.uuid5(uuid.NAMESPACE_DNS, name).hex, "name": name,
                       "workspace": "default", "type": kind, "credential_keys": ["API_KEY"]}
                      for name, kind in sorted(providers.items())], indent=2))


def main():
    args = sys.argv[1:]
    HOME.mkdir(parents=True, exist_ok=True)
    with open(HOME / "calls.jsonl", "a", encoding="utf-8") as stream:
        stream.write(json.dumps({"argv": args, "no_color": os.environ.get("NO_COLOR"),
                                 "cwd": os.getcwd()}) + "\n")
    if args in (["--version"], ["-V"]):
        if failing("version"):
            fail("version failed")
        print(f"openshell {os.environ.get('FAKE_OPENSHELL_VERSION', '0.1.6')}")
        return
    gateway = take(args, "-g") + take(args, "--gateway")
    del gateway
    if args[:2] == ["status", "-o"]:
        if failing("status"):
            fail("Disconnected: transport error")
        print(json.dumps({"gateway": "fake", "status": "connected"}))
        return
    if args and args[0] == "settings":
        return settings(args[1:])
    if args and args[0] == "policy":
        return policy(args[1:])
    if args and args[0] == "logs":
        return logs(args[1:])
    if args and args[0] == "rule":
        return rule(args[1:])
    if args and args[0] == "provider":
        return provider(args[1:])
    if not args or args[0] != "sandbox":
        fail(f"unsupported command {args!r}", 2)
    verb, rest = args[1], args[2:]
    if verb == "create":
        return create(rest)
    if verb == "get":
        rest = [arg for arg in rest if arg not in ("-o", "json")]
        return get(rest)
    if verb == "upload":
        return upload(rest)
    if verb == "exec":
        return exec_(rest)
    if verb == "connect":
        print(json.dumps({"connect": rest[0]}))
        return
    if verb == "delete":
        return delete(rest)
    if verb == "list":
        return list_(rest)
    if verb in ("start", "stop"):
        return lifecycle(verb, rest)
    if verb == "download":
        return download(rest)
    fail(f"unsupported sandbox command {verb!r}", 2)


if __name__ == "__main__":
    main()
