# -*- coding: utf-8 -*-
"""Flow 角色批处理 v4：URL 直驱 /character/new，不依赖视图切换。
用法: python flow_batch_characters.py [limit]
"""
import json, time, os, sys, re, urllib.request, importlib.util

DAEMON = "http://127.0.0.1:10086/command"
SESSION = "flow-jojo-videos"
PROJECT_URL = "https://flow.google.com/project/706fd0ee-3f5e-46ff-8dda-2c2b1cb3eee8"
NEW_URL = PROJECT_URL + "/character/new"
HERE = os.path.dirname(os.path.abspath(__file__))
STATE = os.path.join(HERE, "progress.jsonl")

BASE = ("JoJo's Bizarre Adventure anime style, full-body character design on plain background. "
        "Liangshan hero {zh} ({en}): {traits}. "
        "Bold ink outlines, heavy cross-hatching shading, flamboyant dramatic stance, "
        "faint purple menacing aura lines, floating Japanese katakana sound-effect glyphs.")

def load_heroes():
    heroes = []
    for fn in ("heroes_tiangang.py", "heroes_disha.py"):
        spec = importlib.util.spec_from_file_location(fn[:-3], os.path.join(HERE, fn))
        m = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(m)
        heroes.extend(m.HEROES)
    return heroes

def done_names():
    if not os.path.exists(STATE):
        return set()
    out = set()
    with open(STATE, encoding="utf-8") as f:
        for line in f:
            try:
                d = json.loads(line)
                if d.get("status") == "done":
                    out.add(d.get("name"))
            except Exception:
                pass
    return out

def call(action, args=None, timeout=120):
    body = json.dumps({"action": action, "args": args or {}, "session": SESSION}).encode("utf-8")
    req = urllib.request.Request(DAEMON, data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))

def ev(code, timeout=120):
    r = call("evaluate", {"code": code}, timeout=timeout)
    d = r.get("data", {})
    if isinstance(d, dict) and "value" in d:
        try:
            return json.loads(d["value"])
        except Exception:
            return {"raw": d["value"]}
    return d

def log(name, status, extra=""):
    with open(STATE, "a", encoding="utf-8") as f:
        f.write(json.dumps({"name": name, "status": status, "extra": str(extra)[:200],
                            "ts": time.strftime("%m-%d %H:%M:%S")}, ensure_ascii=False) + "\n")

JS_ARIA_CLICK = """(() => {
  const b = [...document.querySelectorAll('button,[role="button"]')].find(x =>
    ((x.getAttribute('aria-label')||'') + ' ' + (x.innerText||'')).includes(%s));
  if (!b) return JSON.stringify({ok:false, where:'notfound'});
  const r = b.getBoundingClientRect();
  const opts = {bubbles:true, cancelable:true, view:window,
                clientX: Math.round(r.x + r.width/2), clientY: Math.round(r.y + r.height/2)};
  b.dispatchEvent(new PointerEvent('pointerdown', opts));
  b.dispatchEvent(new MouseEvent('mousedown', opts));
  b.dispatchEvent(new PointerEvent('pointerup', opts));
  b.dispatchEvent(new MouseEvent('mouseup', opts));
  b.dispatchEvent(new MouseEvent('click', opts));
  return JSON.stringify({ok:true, tag: b.tagName});
})()"""

JS_URL = "(() => JSON.stringify({url: location.href}))()"
JS_STATUS = """(() => {
  const t = document.body.innerText;
  const pct = /\\d{1,3}%/.test(t);
  const fail = t.includes('\\u751f\\u6210\\u5931\\u8d25') || t.includes('\\u51fa\\u9519\\u4e86');
  const bigImg = [...document.querySelectorAll('img')].some(i => i.naturalWidth > 400);
  return JSON.stringify({pct, fail, bigImg});
})()"""

def poll(fn, timeout, interval=2.5):
    start = time.time()
    while time.time() - start < timeout:
        try:
            if fn():
                return True
        except Exception:
            pass
        time.sleep(interval)
    return False

def open_new_page():
    call("navigate", {"url": NEW_URL}, timeout=90)
    return poll(lambda: ev("(() => JSON.stringify({b: !!document.querySelector('div[contenteditable=\"true\"]')}))()").get("b"), 25, 2)

def insert_text(prompt):
    code = ("(() => { const box = document.querySelector('div[contenteditable=\"true\"]'); "
            "if (!box) return JSON.stringify({ok:false, where:'no-box'}); box.focus(); "
            "document.execCommand('selectAll', false, null); "
            "const ok = document.execCommand('insertText', false, %s); "
            "return JSON.stringify({ok: ok, len: box.innerText.length}); })()"
            % json.dumps(prompt, ensure_ascii=False))
    r = ev(code)
    return bool(r.get("ok")) and int(r.get("len", 0)) > 20

def wait_submitted(timeout=40):
    """提交后 URL 从 /character/new 变为 /character/<id>"""
    def chk():
        u = ev(JS_URL).get("url", "")
        return bool(re.search(r"/character/[0-9a-f]{8}-", u))
    return poll(chk, timeout, 2)

def wait_generated(timeout=180):
    start = time.time()
    seen_pct = False
    calm = 0
    while time.time() - start < timeout:
        time.sleep(4)
        try:
            s = ev(JS_STATUS)
        except Exception:
            continue
        if s.get("fail"):
            return False, "fail-marked"
        if s.get("pct"):
            seen_pct = True
            calm = 0
        else:
            calm += 1
            if seen_pct and calm >= 2:
                return True, "ok"
            if not seen_pct and time.time() - start > 70 and s.get("bigImg"):
                return True, "ok-no-pct"
    s = ev(JS_STATUS)
    return (not s.get("pct")), "timeout"

def wait_visible(max_wait=900):
    """等 Chrome 窗口可见（渲染冻结时图片生成会失败）。返回 True=可见"""
    start = time.time()
    while time.time() - start < max_wait:
        try:
            r = ev("(() => JSON.stringify({v: document.visibilityState}))()", timeout=30)
            if r.get("v") == "visible":
                return True
        except Exception:
            pass
        time.sleep(5)
    return False

def wait_generated(timeout=200):
    start = time.time()
    seen_pct = False
    calm = 0
    while time.time() - start < timeout:
        time.sleep(4)
        try:
            s = ev(JS_STATUS)
        except Exception:
            continue
        if s.get("fail"):
            return False, "fail-marked"
        if s.get("pct"):
            seen_pct = True
            calm = 0
        else:
            calm += 1
            if seen_pct and calm >= 2 and s.get("bigImg"):
                return True, "ok"
            if seen_pct and calm >= 4:
                return True, "ok-no-img-yet"
            if not seen_pct and time.time() - start > 90 and s.get("bigImg"):
                return True, "ok-no-pct"
    return False, "no-signal"

def rename(zh):
    r = ev(JS_ARIA_CLICK % json.dumps("修改名称", ensure_ascii=False))
    if not r.get("ok"):
        return False, "no-pencil"
    time.sleep(0.8)
    ev("""(() => {
      const input = (document.activeElement && document.activeElement.tagName === 'INPUT')
        ? document.activeElement : document.querySelector('input:focus');
      if (!input) return JSON.stringify({ok:false, where:'no-input'});
      input.focus();
      document.execCommand('selectAll', false, null);
      document.execCommand('insertText', false, %s);
      const ke = {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true, cancelable:true};
      input.dispatchEvent(new KeyboardEvent('keydown', ke));
      input.dispatchEvent(new KeyboardEvent('keypress', ke));
      input.dispatchEvent(new KeyboardEvent('keyup', ke));
      input.blur();
      return JSON.stringify({ok:true});
    })()""" % json.dumps(zh, ensure_ascii=False))
    if poll(lambda: ev("(() => { const t = document.body.innerText; return JSON.stringify({ok: t.includes(%s)}); })()"
                       % json.dumps(zh, ensure_ascii=False)).get("ok"), 6, 1.5):
        return True, "synthetic"
    try:
        ev(JS_ARIA_CLICK % json.dumps("修改名称", ensure_ascii=False))
        time.sleep(0.8)
        call("key_type", {"text": zh})
        time.sleep(0.5)
        call("send_keys", {"keys": "Enter"})
        if poll(lambda: ev("(() => { const t = document.body.innerText; return JSON.stringify({ok: t.includes(%s)}); })()"
                           % json.dumps(zh, ensure_ascii=False)).get("ok"), 6, 1.5):
            return True, "trusted"
    except Exception as e:
        return False, repr(e)[:80]
    return False, "no-commit"

def create_one(zh, en, traits):
    if not wait_visible(max_wait=1200):
        raise RuntimeError("window-hidden")
    if not open_new_page():
        raise RuntimeError("new page never opened")
    prompt = BASE.format(zh=zh.split("·")[0], en=en, traits=traits)
    if not poll(lambda: insert_text(prompt), 12, 2):
        raise RuntimeError("insert failed")
    time.sleep(1)
    r = ev(JS_ARIA_CLICK % json.dumps("开始生成", ensure_ascii=False))
    if not r.get("ok"):
        raise RuntimeError("no gen button")
    if not wait_submitted():
        log(zh, "warn", "submit-url not confirmed; continue polling gen")
    ok, why = wait_generated()
    if not ok:
        raise RuntimeError("gen not ready: %s" % why)
    time.sleep(2)
    renamed, how = rename(zh)
    if not renamed:
        log(zh, "rename-failed", how)
    ev(JS_ARIA_CLICK % json.dumps("完成", ensure_ascii=False))
    time.sleep(2)

def main():
    limit = int(sys.argv[1]) if len(sys.argv) > 1 else 0
    heroes = load_heroes()
    done = done_names()
    todo = [h for h in heroes if h[0] not in done]
    if limit:
        todo = todo[:limit]
    log("BATCH", "start", "todo=%d of %d" % (len(todo), len(heroes)))
    for i, (zh, en, traits) in enumerate(todo, 1):
        attempts = 0
        while attempts < 5:
            attempts += 1
            try:
                create_one(zh, en, traits)
                log(zh, "done", "attempt %d" % attempts)
                break
            except Exception as e:
                msg = repr(e)[:150]
                if "window-hidden" in msg:
                    log(zh, "waiting-visible", "attempt %d" % attempts)
                    time.sleep(30)
                    if attempts >= 5:
                        log(zh, "failed", "window never visible")
                    continue
                log(zh, "retry" if attempts < 2 else "failed", msg)
                if attempts >= 2:
                    break
                time.sleep(8)
                call("navigate", {"url": NEW_URL}, timeout=90)
                time.sleep(5)
        if i % 10 == 0:
            try:
                r = call("screenshot", {"format": "jpeg", "quality": 50})
                log("SNAPSHOT", "shot", (r.get("data") or {}).get("path", ""))
            except Exception:
                pass
        time.sleep(2)
    log("BATCH", "end", "")

if __name__ == "__main__":
    main()
