# Offline sync between real clients of the reading-list-7 server on this machine: the Android app on an emulator,
# taken offline with airplane mode, and the web app in Chrome. Both change the same book; then the phone comes back.
#   python scripts/kmp/sync_demo.py WORKDIR  ->  content/Kotlin Multiplatform/demo/kmp-sync-demo.json and demo/sync/*.png
# Needs: reading-list-7 built (:server:installDist, :androidApp:assembleDebug, :shared:wasmJsBrowserDistribution),
# a running emulator (ANDROID_SERIAL, default emulator-5554), Node.js with puppeteer-core, Chrome (CHROME_BIN).
import functools, http.server, json, os, re, subprocess, sys, threading, time, urllib.request

HERE = os.path.dirname(os.path.abspath(__file__))
APP = os.path.join(HERE, "reading-list-7")
WORK = os.path.abspath(sys.argv[1])
DEMO = os.path.join(HERE, "..", "..", "content", "Kotlin Multiplatform", "demo")
ADB = [os.path.expanduser("~/Library/Android/sdk/platform-tools/adb"), "-s", os.environ.get("ANDROID_SERIAL", "emulator-5554")]
CHROME = os.environ.get("CHROME_BIN", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
PKG, BOOK, NOTE = "dev.fauzisho.readinglist", "Kotlin in Action", "written in the browser"
os.makedirs(os.path.join(DEMO, "sync"), exist_ok=True); os.makedirs(WORK, exist_ok=True)
log = []
def step(what, **facts):
    log.append(dict(step=what, **facts)); print(what, facts, flush=True)

def adb(*a, check=True, timeout=60):
    try: return subprocess.run(ADB + list(a), capture_output=True, text=True, check=check, timeout=timeout).stdout
    except subprocess.TimeoutExpired:   # a uiautomator dump occasionally never returns: report it, let the caller retry
        print("adb timed out:", " ".join(a), flush=True); return ""
def nodes():
    for _ in range(3):
        adb("shell", "uiautomator", "dump", "/sdcard/ui.xml", check=False, timeout=30)
        xml = adb("shell", "cat", "/sdcard/ui.xml", check=False)
        if "<node" in xml: break
    return [dict(text=t, checkable=c == "true", checked=k == "true", bounds=tuple(map(int, b)))
            for t, c, k, *b in re.findall(r'text="([^"]*)"[^>]*?checkable="(\w+)" checked="(\w+)"[^>]*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml)]
def texts(): return [n["text"] for n in nodes() if n["text"]]
def tap_text(t):
    n = next(n for n in nodes() if n["text"] == t); x1, y1, x2, y2 = n["bounds"]
    adb("shell", "input", "tap", str((x1 + x2) // 2), str((y1 + y2) // 2)); time.sleep(2)
def tick_on_phone(title):
    ns = nodes(); row = next(n for n in ns if n["text"] == title)
    box = min((n for n in ns if n["checkable"]), key=lambda n: abs((n["bounds"][1] + n["bounds"][3]) - (row["bounds"][1] + row["bounds"][3])))
    x1, y1, x2, y2 = box["bounds"]; adb("shell", "input", "tap", str((x1 + x2) // 2), str((y1 + y2) // 2)); time.sleep(1.5)
def phone_read(title):
    ns = nodes(); row = next(n for n in ns if n["text"] == title)
    box = min((n for n in ns if n["checkable"]), key=lambda n: abs((n["bounds"][1] + n["bounds"][3]) - (row["bounds"][1] + row["bounds"][3])))
    return box["checked"]
def status(): return next((t for t in texts() if any(w in t for w in ("Up to date", "Not synced", "not reachable", "Syncing"))), None)
def shot(name):
    with open(os.path.join(DEMO, "sync", name), "wb") as f: f.write(subprocess.run(ADB + ["exec-out", "screencap", "-p"], capture_output=True, check=True).stdout)
def wait_for_network():
    """Airplane mode off is not the same as online: wait until the emulator can reach this machine."""
    for _ in range(30):
        if " 0% packet loss" in adb("shell", "ping", "-c", "1", "-W", "1", "10.0.2.2", check=False, timeout=10): return True
        time.sleep(1)
    return False
def server_books(): return json.load(urllib.request.urlopen("http://127.0.0.1:8080/books"))

# the server, on port 8080 of this machine (the emulator reaches it as 10.0.2.2)
server = subprocess.Popen([os.path.join(APP, "server/build/install/server/bin/server")], stdout=open(os.path.join(WORK, "server.log"), "w"), stderr=subprocess.STDOUT,
                          env=dict(os.environ, PORT="8080"))
for _ in range(60):
    try: server_books(); break
    except Exception: time.sleep(1)
step("server started", books=len(server_books()))

# the web app, served from this machine, told where the server is
web = http.server.ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(type("Q", (http.server.SimpleHTTPRequestHandler,), {"log_message": lambda *a: None}),
                                      directory=os.path.join(APP, "shared/build/dist/wasmJs/productionExecutable")))
threading.Thread(target=web.serve_forever, daemon=True).start()
WEB_URL = f"http://127.0.0.1:{web.server_port}/index.html?server=http://127.0.0.1:8080"
BROWSER = r"""
import puppeteer from "puppeteer-core";
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const [url, chrome, action, book, note, png, profile] = process.argv.slice(2);
const b = await puppeteer.launch({ executablePath: chrome, headless: "new" });   // a fresh profile: a new browser each time
const q = await b.newPage(); await q.setViewport({ width: 700, height: 640 });   // narrow: one screen at a time
const texts = async () => { const s = await q.accessibility.snapshot({ interestingOnly: false }); const t = []; (function w(n) { if (!n) return; if (n.name) t.push(n.name); (n.children || []).forEach(w); })(s); return t; };
// Compose draws on a canvas; its widgets are found through the accessibility tree Compose builds for the page
async function tapSelector(sel) { const el = await q.waitForSelector(sel, { timeout: 20000 }); const r = await el.boundingBox();
  await q.mouse.click(r.x + r.width / 2, r.y + r.height / 2); await sleep(2500); }
// a list row's accessible name joins its title and author ("Kotlin in Action Aigner, …"): match it by the title
async function tap(name) { const t = await texts(); const full = t.find((x) => x.startsWith(name + " ")) || name;
  await tapSelector(`::-p-aria(${full.replace(/([,()\\])/g, "\\$1")})`); }   // commas end the selector unless escaped
await q.goto(url, { waitUntil: "networkidle0" }); await sleep(6000);
if (action === "note") { await tap("Sync"); await tap(book); await tapSelector("::-p-aria([role=\"textbox\"])"); await q.keyboard.type(note); await sleep(500); await tap("Save notes"); await tap("← All books"); await tap("Sync"); }   // Sync is on the list screen
if (action === "check") { await tap("Sync"); }
await q.screenshot({ path: png });
console.log(JSON.stringify(await texts())); await b.close();
"""
js = os.path.join(WORK, "browser.mjs"); open(js, "w").write(BROWSER)
if not os.path.exists(os.path.join(WORK, "node_modules")) and os.path.exists(os.path.join(WORK, "..", "node_modules")):
    os.symlink(os.path.abspath(os.path.join(WORK, "..", "node_modules")), os.path.join(WORK, "node_modules"))
def browser(action, png):
    r = subprocess.run(["node", js, WEB_URL, CHROME, action, BOOK, NOTE, os.path.join(DEMO, "sync", png), os.path.join(WORK, "chrome-profile")],
                       capture_output=True, text=True, cwd=WORK, timeout=300)
    if r.returncode: raise SystemExit("browser: " + r.stderr[-1500:])
    return json.loads(r.stdout.strip().splitlines()[-1])

try:
    # 1. the phone: a fresh install, first sync
    adb("install", "-r", os.path.join(APP, "androidApp/build/outputs/apk/debug/androidApp-debug.apk"))
    adb("shell", "cmd", "connectivity", "airplane-mode", "disable"); wait_for_network()
    adb("shell", "pm", "clear", PKG); adb("shell", "am", "start", "-W", "-n", f"{PKG}/.MainActivity"); time.sleep(4)
    tap_text("Sync"); time.sleep(2)
    step("phone: first sync", status=status(), server_books=len(server_books()))

    # 2. the phone goes offline and ticks the book
    adb("shell", "cmd", "connectivity", "airplane-mode", "enable"); time.sleep(4)
    tick_on_phone(BOOK); tap_text("Sync"); time.sleep(3)
    step("phone, offline: ticked the book and tried to sync", status=status(), read_on_phone=phone_read(BOOK))
    shot("phone-offline.png")

    # 3. meanwhile, the browser writes a note on the same book and syncs
    t = browser("note", "browser-note.png")
    srv = next(b for b in server_books() if b["title"] == BOOK)
    step("browser: wrote a note and synced", server_read=srv.get("read", False), server_notes=srv.get("notes", ""))

    # 4. the phone comes back
    adb("shell", "cmd", "connectivity", "airplane-mode", "disable"); wait_for_network()
    tap_text("Sync"); time.sleep(3)
    read_after = phone_read(BOOK); row_texts = texts()
    tap_text(BOOK); time.sleep(2)
    note_on_phone = NOTE in texts()
    shot("phone-merged.png")
    adb("shell", "input", "keyevent", "KEYCODE_BACK"); time.sleep(1)
    srv = next(b for b in server_books() if b["title"] == BOOK)
    step("phone, online again: synced", status=next((x for x in row_texts if "Up to date" in x or "reachable" in x), None),
         read_on_phone=read_after, note_on_phone=note_on_phone, server_read=srv.get("read", False), server_notes=srv.get("notes", ""))

    # 5. and a browser opened afterwards gets the merged book
    t = browser("check", "browser-merged.png")
    step("a new browser: synced", sees=[x for x in t if x.startswith(BOOK)][:1])
finally:
    adb("shell", "cmd", "connectivity", "airplane-mode", "disable", check=False)
    server.terminate(); web.shutdown()

json.dump(dict(device=adb("shell", "getprop", "ro.build.version.release").strip(), steps=log), open(os.path.join(DEMO, "kmp-sync-demo.json"), "w"), indent=1, ensure_ascii=False)
print("wrote", os.path.relpath(os.path.join(DEMO, "kmp-sync-demo.json")))
