#!/usr/bin/env python3
"""A deliberately conservative, stdlib-only local vphone-cli development kit.

This wrapper pins its source evidence and keeps vphone data outside ~/.vphone.
Mutating VM commands require --execute; otherwise the exact argv is printed.
"""
from __future__ import annotations

import argparse
import json
import os
import platform
import re
import shutil
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

SOURCE_COMMIT = "3f7011406722576fb19bffbc8c30d8aa4c4916af"
SOURCE_URL = "https://github.com/Lakr233/vphone-cli.git"
DEFAULT_TIMEOUT = 30
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")


class KitError(RuntimeError):
    pass


def kit_root() -> Path:
    return Path(__file__).resolve().parent


def default_data_root() -> Path:
    return kit_root() / ".local-data"


def require_name(name: str) -> str:
    if not NAME.fullmatch(name) or name in {".", ".."}:
        raise KitError("VM name must be 1-64 characters: letters, digits, dot, underscore, or hyphen")
    return name


def source_sha(source: Path) -> str | None:
    try:
        return subprocess.check_output(
            ["git", "-C", str(source), "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL, timeout=10
        ).strip()
    except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
        return None


def command_text(argv: list[str]) -> str | None:
    try:
        return subprocess.check_output(argv, text=True, stderr=subprocess.DEVNULL, timeout=10).strip()
    except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
        return None


def tracked_dirty(source: Path) -> bool:
    status = command_text(["git", "-C", str(source), "status", "--porcelain", "--untracked-files=no"])
    return bool(status)


def host_conditions(data_root: Path | None = None) -> tuple[dict[str, Any], list[str]]:
    macos = platform.mac_ver()[0]
    facts: dict[str, Any] = {
        "platform": platform.system(), "machine": platform.machine(), "macos_version": macos,
        "memory_bytes": command_text(["/usr/sbin/sysctl", "-n", "hw.memsize"]),
        "nested_vmm_flag": command_text(["/usr/sbin/sysctl", "-n", "kern.hv_vmm_present"]),
        "xcode_developer_dir": command_text(["/usr/bin/xcode-select", "-p"]),
        "iphoneos_sdk": command_text(["/usr/bin/xcrun", "--sdk", "iphoneos", "--show-sdk-path"]),
    }
    disk_anchor = (data_root or default_data_root()).expanduser().resolve()
    while not disk_anchor.exists() and disk_anchor != disk_anchor.parent:
        disk_anchor = disk_anchor.parent
    try:
        facts["disk_check_path"] = str(disk_anchor)
        facts["free_disk_bytes"] = shutil.disk_usage(disk_anchor).free
    except OSError:
        facts["free_disk_bytes"] = None
    blockers: list[str] = []
    if facts["platform"] != "Darwin": blockers.append("requires macOS")
    if facts["machine"] != "arm64": blockers.append("requires Apple Silicon (arm64)")
    try:
        if int((macos or "0").split(".")[0]) < 15: blockers.append("requires macOS 15 or newer")
    except ValueError:
        blockers.append("could not determine macOS version")
    if facts["nested_vmm_flag"] == "1": blockers.append("appears to run inside a macOS virtual machine; PV=3 guests are unsupported")
    return facts, blockers


def resolve_cli(args: argparse.Namespace) -> Path:
    raw = args.cli or os.environ.get("VPHONE_CLI")
    if not raw:
        raise KitError("set --cli /absolute/path/to/vphone-cli (or VPHONE_CLI); the kit never downloads binaries")
    cli = Path(raw).expanduser().resolve()
    if not cli.is_file() or not os.access(cli, os.X_OK):
        raise KitError(f"vphone-cli is not an executable file: {cli}")
    return cli


def env_for(args: argparse.Namespace) -> dict[str, str]:
    root = Path(args.data_root).expanduser().resolve()
    return {**os.environ, "VPHONE_ROOT": str(root), "VPHONE_LIBRARY_ROOT": str(root / "VMs")}


def log_path(args: argparse.Namespace, action: str) -> Path:
    logs = Path(args.data_root).expanduser().resolve() / "logs"
    logs.mkdir(parents=True, exist_ok=True)
    return logs / f"{time.strftime('%Y%m%d-%H%M%S')}-{action}.log"


def run(args: argparse.Namespace, action: str, argv: list[str], *, timeout: int | None = None, cwd: Path | None = None) -> int:
    if args.dry_run or not getattr(args, "execute", False):
        print(json.dumps({"mode": "dry-run", "cwd": str(cwd or kit_root()), "env": {"VPHONE_ROOT": env_for(args)["VPHONE_ROOT"]}, "argv": argv}))
        return 0
    log = log_path(args, action)
    limit = timeout or args.timeout
    try:
        process = subprocess.Popen(
            argv, cwd=cwd or kit_root(), env=env_for(args), text=True, stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT, start_new_session=True,
        )
        try:
            output, _ = process.communicate(timeout=limit)
        except subprocess.TimeoutExpired:
            os.killpg(process.pid, signal.SIGTERM)
            try:
                output, _ = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                os.killpg(process.pid, signal.SIGKILL)
                output, _ = process.communicate()
            log.write_text(output or "", encoding="utf-8")
            raise KitError(f"{action} timed out after {limit}s; terminated its process group and preserved output: {log}")
    except OSError as error:
        raise KitError(f"could not start {action}: {error}") from error
    log.write_text(output or "", encoding="utf-8")
    sys.stdout.write(output or "")
    if process.returncode:
        raise KitError(f"{action} exited {process.returncode}; preserved output: {log}")
    print(f"log: {log}")
    return 0


def cmd_doctor(args: argparse.Namespace) -> int:
    host, blockers = host_conditions(Path(args.data_root))
    build_blockers = [] if host["iphoneos_sdk"] else ["source build requires full Xcode with the iPhoneOS SDK"]
    facts: dict[str, Any] = {"kit_source_commit": SOURCE_COMMIT, **host,
        "isolated_data_root": str(Path(args.data_root).expanduser().resolve()), "host_blockers": blockers,
        "build_blockers": build_blockers, "runtime_note": "a distributed upstream app does not require Xcode or the iPhoneOS SDK at runtime",
        "security_policy_unchanged": True}
    if args.source:
        source = Path(args.source).expanduser().resolve()
        facts["source"] = str(source)
        facts["source_commit"] = source_sha(source)
        facts["source_matches_pinned_commit"] = facts["source_commit"] == SOURCE_COMMIT
        facts["source_tracked_dirty"] = tracked_dirty(source) if facts["source_commit"] else None
        facts["build_script"] = str(source / "Scripts" / "build.sh")
    if args.cli or os.environ.get("VPHONE_CLI"):
        try:
            facts["cli"] = str(resolve_cli(args))
        except KitError as error:
            facts["cli_error"] = str(error)
    print(json.dumps(facts, ensure_ascii=False, indent=2))
    if args.preflight:
        if blockers:
            raise KitError("host blockers: " + "; ".join(blockers))
        cli = resolve_cli(args)
        return run(args, "host-preflight", [str(cli), "host", "preflight"])
    if args.strict and blockers:
        raise KitError("host blockers: " + "; ".join(blockers))
    return 0


def cmd_bootstrap(args: argparse.Namespace) -> int:
    if args.reuse_cli:
        cli = Path(args.reuse_cli).expanduser().resolve()
        if not cli.is_file() or not os.access(cli, os.X_OK):
            raise KitError(f"--reuse-cli must be an executable file: {cli}")
        print(json.dumps({"reuse_cli": str(cli), "note": "no installation was performed"}))
        return 0
    source = Path(args.source).expanduser().resolve()
    if args.fetch_source:
        if args.dry_run or not args.execute:
            if source.exists():
                run(args, "source-fetch", ["git", "-C", str(source), "fetch", "--tags", "origin"], cwd=source)
            else:
                run(args, "source-clone", ["git", "clone", "--recurse-submodules", SOURCE_URL, str(source)], cwd=source.parent)
            run(args, "source-checkout", ["git", "-C", str(source), "checkout", "--detach", SOURCE_COMMIT], cwd=source.parent)
            run(args, "source-submodules", ["git", "-C", str(source), "submodule", "update", "--init", "--recursive"], cwd=source.parent)
            return 0
        if source.exists():
            if not (source / ".git").exists():
                raise KitError(f"--fetch-source target exists but is not a git checkout: {source}")
            if tracked_dirty(source):
                raise KitError("refusing to update a tracked-dirty checkout; commit/stash it or choose another --source")
            run(args, "source-fetch", ["git", "-C", str(source), "fetch", "--tags", "origin"], cwd=source)
        else:
            source.parent.mkdir(parents=True, exist_ok=True)
            run(args, "source-clone", ["git", "clone", "--recurse-submodules", SOURCE_URL, str(source)], cwd=source.parent)
        run(args, "source-checkout", ["git", "-C", str(source), "checkout", "--detach", SOURCE_COMMIT], cwd=source.parent)
        run(args, "source-submodules", ["git", "-C", str(source), "submodule", "update", "--init", "--recursive"], cwd=source.parent)
    if tracked_dirty(source):
        raise KitError("source checkout has tracked changes; refusing a non-reproducible build")
    if source_sha(source) != SOURCE_COMMIT:
        raise KitError(f"source must be pinned at {SOURCE_COMMIT}; got {source_sha(source) or 'unavailable'}")
    build = source / "Scripts" / "build.sh"
    if not build.is_file():
        raise KitError(f"missing source build script: {build}")
    build_host, _ = host_conditions(Path(args.data_root))
    if not build_host["iphoneos_sdk"]:
        raise KitError("source build requires full Xcode with the iPhoneOS SDK; xcrun --sdk iphoneos --show-sdk-path did not return a path")
    # Build remains opt-in: no clone, dependency installer, sudo, or host-policy change.
    old_cwd = os.getcwd()
    try:
        os.chdir(source)
        return run(args, "source-build", ["/bin/zsh", str(build)], timeout=args.build_timeout, cwd=source)
    finally:
        os.chdir(old_cwd)


def cmd_create(args: argparse.Namespace) -> int:
    cli = resolve_cli(args)
    _, blockers = host_conditions(Path(args.data_root))
    if blockers:
        raise KitError("host blockers: " + "; ".join(blockers))
    name = require_name(args.name)
    iphone = Path(args.iphone_source).expanduser()
    cloud = Path(args.cloudos_source).expanduser()
    for label, value in (("iphone", iphone), ("cloudos", cloud)):
        if not value.is_file():
            raise KitError(f"{label} firmware must be an existing local IPSW file; no URL/download is accepted: {value}")
    argv = [str(cli), "vm", "create", name, "--iphone-source", str(iphone.resolve()), "--cloudos-source", str(cloud.resolve()), "--disk-size", str(args.disk_size)]
    if args.root_popup:
        argv.append("--root-popup")
    if args.keep_artifacts:
        argv.append("--keep-artifacts")
    run(args, "host-preflight", [str(cli), "host", "preflight"])
    return run(args, "vm-create", argv, timeout=args.create_timeout)


def cmd_launch(args: argparse.Namespace) -> int:
    cli = resolve_cli(args)
    name = require_name(args.name)
    local_url(args.api_listen, "/v1/health")
    argv = [str(cli), "vm", "launch", name, "--api-listen", args.api_listen]
    if args.dry_run or not args.execute:
        return run(args, "vm-launch", argv)
    print("launch is foreground: live VM output follows; Ctrl-C is delivered to the upstream VM process. No wrapper timeout is used.")
    process = subprocess.Popen(argv, cwd=kit_root(), env=env_for(args))
    try:
        return process.wait()
    except KeyboardInterrupt:
        print("interrupted; child shares this terminal's foreground process group", file=sys.stderr)
        return 130


def cmd_stop(args: argparse.Namespace) -> int:
    cli = resolve_cli(args)
    return run(args, "vm-stop", [str(cli), "vm", "stop", require_name(args.name), "--timeout", str(args.stop_timeout)], timeout=args.stop_timeout + 10)


def local_url(address: str, path: str) -> str:
    host, _, port = address.rpartition(":")
    if not host or not port.isdigit() or not 1 <= int(port) <= 65535 or host not in {"127.0.0.1", "[::1]"}:
        raise KitError("--api-listen must be local-only: 127.0.0.1:PORT or [::1]:PORT")
    return f"http://{address}{path}"


def cmd_health(args: argparse.Namespace) -> int:
    url = local_url(args.api_listen, "/v1/health")
    if args.dry_run:
        print(json.dumps({"mode": "dry-run", "url": url, "timeout_seconds": args.timeout}))
        return 0
    request = urllib.request.Request(url, method="GET")
    try:
        with urllib.request.urlopen(request, timeout=args.timeout) as response:
            if response.status != 200:
                raise KitError(f"health returned HTTP {response.status}")
            payload = json.loads(response.read().decode("utf-8"))
    except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
        raise KitError(f"health check failed for {url}: {error}") from error
    if payload.get("status") != "ok":
        raise KitError(f"health returned unexpected payload: {payload}")
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


def socket_request(path: Path, payload: dict[str, Any], timeout: int) -> dict[str, Any]:
    if not path.exists():
        raise KitError(f"host control socket not found: {path}; launch the GUI VM first")
    encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
        client.settimeout(timeout)
        client.connect(str(path))
        client.sendall(encoded)
        response = bytearray()
        while len(response) < 4 * 1024 * 1024:
            chunk = client.recv(65536)
            if not chunk:
                break
            response.extend(chunk)
            if b"\n" in chunk:
                break
    try:
        return json.loads(response.split(b"\n", 1)[0])
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise KitError("host control socket returned invalid JSON") from exc


def vm_socket(args: argparse.Namespace, name: str) -> Path:
    return Path(args.data_root).expanduser().resolve() / "VMs" / require_name(name) / "vphone.sock"


def cmd_screenshot(args: argparse.Namespace) -> int:
    output = Path(args.output).expanduser().resolve()
    if args.dry_run:
        print(json.dumps({"mode": "dry-run", "socket": str(vm_socket(args, args.name)), "payload": {"t": "screenshot", "path": str(output)}}))
        return 0
    output.parent.mkdir(parents=True, exist_ok=True)
    result = socket_request(vm_socket(args, args.name), {"t": "screenshot", "path": str(output), "screen": False}, args.timeout)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not result.get("ok"):
        raise KitError(f"screenshot failed: {result.get('error', 'unknown socket error')}")
    return 0


def cmd_touch(args: argparse.Namespace) -> int:
    payload = {"t": "tap", "x": args.x, "y": args.y, "screen": False}
    if args.dry_run:
        print(json.dumps({"mode": "dry-run", "socket": str(vm_socket(args, args.name)), "payload": payload}))
        return 0
    result = socket_request(vm_socket(args, args.name), payload, args.timeout)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not result.get("ok"):
        raise KitError(f"touch failed: {result.get('error', 'unknown socket error')}")
    return 0


def cmd_diagnose(args: argparse.Namespace) -> int:
    log = Path(args.log).expanduser().resolve() if args.log else None
    text = log.read_text(encoding="utf-8", errors="replace") if log and log.is_file() else ""
    findings: list[dict[str, str]] = []
    for line in text.splitlines():
        if re.search(r"(?:amfi.*(?:denied|reject|fail)|(?:code signature|entitlement).*(?:denied|reject|fail))", line, re.I):
            findings.append({"kind": "AMFI rejection candidate", "confidence": "candidate, not root cause", "match": line[:500], "action": "Run upstream host-preflight and correlate the failing process; this kit makes no SIP/AMFI changes."})
        if re.search(r"(?:out of memory|jetsam|memory pressure).*(?:kill|terminate)|(?:killed process).*(?:memory|oom|jetsam)", line, re.I):
            findings.append({"kind": "OOM candidate", "confidence": "candidate, not root cause", "match": line[:500], "action": "Correlate process and timestamp with macOS memory-pressure evidence before retrying."})
        if re.search(r"signal 9|sigkill|\bkilled\b", line, re.I):
            findings.append({"kind": "SIGKILL", "confidence": "unknown cause", "match": line[:500], "action": "SIGKILL alone does not prove OOM or AMFI; preserve logs and correlate host evidence."})
    if not findings:
        findings.append({"kind": "no candidate signature", "confidence": "unknown", "action": "Provide the preserved wrapper log; do not infer OOM or AMFI from normal status lines or keywords."})
    print(json.dumps({"log": str(log) if log else None, "findings": findings}, ensure_ascii=False, indent=2))
    return 0


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--data-root", default=str(default_data_root()), help="isolated vphone data root; never defaults to ~/.vphone")
    p.add_argument("--cli", help="absolute vphone-cli executable")
    p.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
    sub = p.add_subparsers(dest="command", required=True)
    def dry_run(command: argparse.ArgumentParser) -> None:
        command.add_argument("--dry-run", action="store_true", help="print argv/socket payload; never starts VM or build")
    doctor = sub.add_parser("doctor"); doctor.add_argument("--source"); doctor.add_argument("--preflight", action="store_true"); doctor.add_argument("--strict", action="store_true"); dry_run(doctor); doctor.set_defaults(func=cmd_doctor, execute=True)
    boot = sub.add_parser("bootstrap"); boot.add_argument("--source", default=str(kit_root() / "upstream")); boot.add_argument("--fetch-source", action="store_true", help="clone/fetch the pinned official source when --execute is supplied"); boot.add_argument("--reuse-cli"); boot.add_argument("--build-timeout", type=int, default=1800); boot.add_argument("--execute", action="store_true"); dry_run(boot); boot.set_defaults(func=cmd_bootstrap)
    create = sub.add_parser("create"); create.add_argument("name"); create.add_argument("--iphone-source", required=True); create.add_argument("--cloudos-source", required=True); create.add_argument("--disk-size", type=int, default=64); create.add_argument("--root-popup", action=argparse.BooleanOptionalAction, default=True, help="use the upstream macOS authentication dialog for CFW install (default)"); create.add_argument("--keep-artifacts", action="store_true"); create.add_argument("--create-timeout", type=int, default=7200); create.add_argument("--execute", action="store_true"); dry_run(create); create.set_defaults(func=cmd_create)
    launch = sub.add_parser("launch"); launch.add_argument("name"); launch.add_argument("--api-listen", default="127.0.0.1:8765"); launch.add_argument("--execute", action="store_true"); dry_run(launch); launch.set_defaults(func=cmd_launch)
    stop = sub.add_parser("stop"); stop.add_argument("name"); stop.add_argument("--stop-timeout", type=int, default=20); stop.add_argument("--execute", action="store_true"); dry_run(stop); stop.set_defaults(func=cmd_stop)
    shot = sub.add_parser("screenshot"); shot.add_argument("name"); shot.add_argument("--output", required=True); dry_run(shot); shot.set_defaults(func=cmd_screenshot)
    touch = sub.add_parser("touch"); touch.add_argument("name"); touch.add_argument("x", type=float); touch.add_argument("y", type=float); dry_run(touch); touch.set_defaults(func=cmd_touch)
    health = sub.add_parser("health"); health.add_argument("--api-listen", default="127.0.0.1:8765"); dry_run(health); health.set_defaults(func=cmd_health)
    diag = sub.add_parser("diagnose"); diag.add_argument("--log"); dry_run(diag); diag.set_defaults(func=cmd_diagnose)
    return p


def main(argv: list[str] | None = None) -> int:
    args = parser().parse_args(argv)
    try:
        return args.func(args)
    except (KitError, OSError) as error:
        print(f"error: {error}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
