#!/usr/bin/env python3
"""Client de l'agent de l'iPod émulé (emuagent, via ipod-netd sur 127.0.0.1:7788).

  ipod-agent ping
  ipod-agent apps                          bundle-id, nom et chemin des apps installées
  ipod-agent launch BUNDLE_ID [--log F] [--url URL]
                                           lance une app (stdout/stderr, donc NSLog, dans F sur l'iPod ; URL à ouvrir)
  ipod-agent fetch http://hôte/chemin      requête HTTP depuis l'iPod (test réseau : DNS + TCP)
  ipod-agent front                         app au premier plan
  ipod-agent screen | wake                 état du verrouillage / réveil sans clavier de code (agent v11)
  ipod-agent unlock                        réveille puis glisse et vérifie le déverrouillage (sans code)
  ipod-agent exec PROG [ARGS...]           exécute un programme (pas de shell : chemin absolu)
  ipod-agent ps | net | respring | sync
  ipod-agent kill PID|NOM [SIGNAL]
  ipod-agent ls CHEMIN | rm CHEMIN | mkdir CHEMIN
  ipod-agent get CHEMIN_IPOD [FICHIER_LOCAL]
  ipod-agent put FICHIER_LOCAL CHEMIN_IPOD [MODE]
  ipod-agent push-app MonApp.app           copie l'app, synchronise et redémarre l'iPod (NAND conservée)
  ipod-agent wait [SECONDES]               attend que l'agent réponde (réseau PPP monté)

Code de sortie : 0 si OK, 1 si erreur, code de sortie du programme pour exec.
"""
import os, socket, subprocess, sys, tempfile, time

HOST = os.environ.get("IPOD_AGENT_HOST", "127.0.0.1")
PORT = int(os.environ.get("IPOD_AGENT_PORT", "7788"))


class Agent:
    def __init__(self, timeout=330):
        self.s = socket.create_connection((HOST, PORT), timeout=timeout)
        self.f = self.s.makefile("rb")

    def call(self, *fields, data=None, sink=None):
        line = "\t".join(str(x) for x in fields) + "\n"
        self.s.sendall(line.encode())
        if data is not None:
            if hasattr(data, "read"):
                remaining = int(fields[-1])
                while remaining:
                    chunk = data.read(min(65536, remaining))
                    if not chunk:
                        raise ConnectionError("fichier local tronqué pendant l'upload")
                    self.s.sendall(chunk)
                    remaining -= len(chunk)
            else:
                self.s.sendall(data)
        hdr = self.f.readline(4096).decode().rstrip("\n")
        if not hdr:
            raise ConnectionError("connexion fermée par l'agent")
        parts = hdr.split(" ", 2)
        if len(parts) < 2:
            raise ConnectionError("en-tête invalide")
        n = int(parts[1])
        if n < 0 or parts[0] not in ("OK", "ERR"):
            raise ConnectionError("en-tête invalide")
        body = bytearray()
        received = 0
        while received < n:
            chunk = self.f.read(min(65536, n - received))
            if not chunk:
                raise ConnectionError(f"réponse tronquée : {received} octets reçus sur {n}")
            received += len(chunk)
            if sink is not None and parts[0] == "OK":
                sink.write(chunk)
            else:
                body.extend(chunk)
        body = bytes(body)
        return parts[0] == "OK", body, parts[2] if len(parts) > 2 else None


def out(body):
    sys.stdout.buffer.write(body)
    if body and not body.endswith(b"\n"):
        sys.stdout.buffer.write(b"\n")


def emu_home():
    tools_home = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    default_home = tools_home if os.path.isdir(os.path.join(tools_home, "images")) else os.path.expanduser("~/ipod-emu")
    return os.environ.get("IPOD_EMU_HOME", default_home)


def unlock(a):
    def screen():
        ok, body, _ = a.call("screen")
        if not ok:
            sys.exit("État d'écran indisponible (agent v11 requis) : " + body.decode())
        return dict(field.split("=", 1) for field in body.decode().split())

    state = screen()
    if state.get("locked") == "0":
        print("Écran déverrouillé")
        return
    if state.get("passcode") != "0":
        sys.exit("Un code de verrouillage est activé : déverrouiller manuellement.")
    # Le pilote tactile doit avoir terminé son démarrage avant tout glissement.
    try:
        with open(os.path.join(emu_home(), "run", "serial.log"), "rb") as f:
            serial = f.read()
    except OSError as e:
        sys.exit(f"Console de session inaccessible : {e}")
    helper_ready = any(b"SCHelper" in line and b'"EXIT"' in line for line in serial.splitlines())
    if serial.count(b"AppleMultitouchSPIUserClient::attach") < 2 or not helper_ready:
        sys.exit("Contrôleur tactile pas encore prêt : attendre ipod-emu wait.")
    qmp = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ipod-qmp")
    sock = os.environ.get("IPOD_QMP", os.path.join(emu_home(), "run", "qmp.sock"))
    for attempt in range(3):
        ok, body, _ = a.call("wake")
        if not ok:
            sys.exit(body.decode())
        time.sleep(2)  # laisser le pilote réactiver l'écran et le tactile
        # Terminer le geste au bord droit du curseur de déverrouillage.
        subprocess.run([qmp, "-s", sock, "swipe", "60", "450", "319", "450"], check=True)
        time.sleep(2)
        state = screen()
        if state.get("locked") == "0":
            print("Écran déverrouillé")
            return
        if state.get("passcode") != "0":
            sys.exit("Un code de verrouillage est activé : déverrouiller manuellement.")
    sys.exit("Écran toujours verrouillé après trois glissements.")


def push_app(a, app):
    home = emu_home()
    run = os.path.join(home, "run")
    try:
        with open(os.path.join(run, "nand", ".ipod-nand-format")) as f:
            layout = f.read().strip()
        with open(os.path.join(run, "fw")) as f:
            firmware = f.read().strip()
        with open(os.path.join(home, "images", firmware, "machine-opts")) as f:
            machine_opts = f.read()
    except OSError:
        sys.exit("push-app nécessite une image locale avec persistance NAND (ipod-mkimage / ipod-build-fw).")
    if layout != "vsvfl-production-v1" or ",nand-write=on" not in machine_opts:
        sys.exit("Écritures NAND désactivées : reconstruire l'image avant push-app.")
    app = app.rstrip("/")
    dest = "/Applications/" + os.path.basename(app)
    a.call("rm", dest)
    n = 0
    for root, dirs, files in os.walk(app):
        for f in files:
            p = os.path.join(root, f)
            rel = os.path.relpath(p, app)
            st = os.stat(p)
            with open(p, "rb") as fh:
                data = fh.read()
            mode = "755" if (st.st_mode & 0o111 or data[:4] in (b"\xce\xfa\xed\xfe", b"\xca\xfe\xba\xbe")) else "644"
            ok, body, _ = a.call("put", f"{dest}/{rel}", mode, len(data), data=data)
            if not ok:
                sys.exit(body.decode())
            n += 1
    # SpringBoard ne relit la liste des apps qu'à son démarrage
    a.call("rm", "/private/var/mobile/Library/Caches/com.apple.mobile.installation.plist")
    ok, body, _ = a.call("sync")
    if not ok:
        sys.exit(body.decode())
    # Le cache d'installation iOS 2.x peut bloquer SpringBoard lors d'une
    # relance seule. Un démarrage complet le reconstruit, sans refaire l'image.
    a.f.close()
    a.s.close()
    launcher = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ipod-emu")
    print(f"{n} fichiers copiés dans {dest}, redémarrage de l'iPod", flush=True)
    subprocess.run([launcher, "stop"], check=True)
    subprocess.run([launcher, "start", "-w", firmware, "-W"], check=True)


def main():
    args = sys.argv[1:]
    if not args or args[0] in ("-h", "--help"):
        sys.exit(__doc__)
    cmd, rest = args[0], args[1:]

    if cmd == "wait":
        limit = float(rest[0]) if rest else 300
        t0 = time.time()
        while True:
            try:
                ok, body, _ = Agent(timeout=5).call("ping")
                print(body.decode(), f"({time.time() - t0:.0f} s)")
                return
            except OSError:
                if time.time() - t0 > limit:
                    sys.exit("agent injoignable (ipod-netd lancé ? réseau PPP monté ?)")
                time.sleep(2)

    try:
        a = Agent()
    except OSError as e:
        sys.exit(f"agent injoignable sur {HOST}:{PORT} ({e}) — ipod-netd lancé ?")

    if cmd == "unlock":
        a.s.settimeout(15)
        unlock(a)
        return
    if cmd == "get":
        tmp = None
        try:
            if len(rest) > 1:
                destination = os.path.abspath(rest[1])
                fd, tmp = tempfile.mkstemp(prefix=".ipod-get-", dir=os.path.dirname(destination))
                with os.fdopen(fd, "wb") as fh:
                    ok, body, _ = a.call("get", rest[0], sink=fh)
                    fh.flush()
                    os.fsync(fh.fileno())
                if ok:
                    os.replace(tmp, destination)
                    tmp = None
            else:
                ok, body, _ = a.call("get", rest[0], sink=sys.stdout.buffer)
            if not ok:
                sys.exit(body.decode())
        finally:
            if tmp is not None:
                os.unlink(tmp)
        return
    if cmd == "put":
        mode = rest[2] if len(rest) > 2 else ("755" if os.access(rest[0], os.X_OK) else "644")
        with open(rest[0], "rb") as fh:
            ok, body, _ = a.call("put", rest[1], mode, os.fstat(fh.fileno()).st_size, data=fh)
    elif cmd == "push-app":
        push_app(a, rest[0])
        return
    elif cmd == "launch":
        fields = ["launch", rest[0], rest[rest.index("--log") + 1] if "--log" in rest else "-"]
        if "--url" in rest:
            fields.append(rest[rest.index("--url") + 1])
        ok, body, _ = a.call(*fields)
    else:
        ok, body, info = a.call(cmd, *rest)
        if cmd == "exec":
            out(body)
            sys.exit(int(info) if ok and info else (0 if ok else 1))
    out(body)
    sys.exit(0 if ok else 1)


if __name__ == "__main__":
    try:
        main()
    except (OSError, ValueError, ConnectionError) as e:
        sys.exit(f"Erreur de transfert ou de protocole : {e}")
