#!/usr/bin/env python
"""Unified, guarded MCSJ user/operator facade.

Read-only commands: state, find, proof, ready.
Navigation commands: open (exact unique JAB target), close (exact JavaFX _Close or Escape fallback).
No unguarded field mutation is exposed; future adapters must require expected-old values and read-back.
"""
from __future__ import annotations
import argparse, base64, datetime as dt, json, subprocess, sys, time, urllib.parse, urllib.request
from pathlib import Path

ROOT=Path("C:/MCSJAgent")
JAB_BASE="http://127.0.0.1:8765"
SWING=ROOT/"mcsj-fast-swing.py"
FX=ROOT/"mcsj-fast-fx.py"
OP=ROOT/"mcsj-fast-operator.ps1"
PRINTWINDOW=ROOT/"printwindow-shot.ps1"
PROOF=ROOT/"proof"
CUI=Path("C:/Users/Administrator/AppData/Local/Programs/Cua/cua-driver/bin/cua-driver.exe")


def cua_call(tool,payload,timeout=10):
    p=subprocess.run([str(CUI),"call",tool,"--json",json.dumps(payload,separators=(",",":"))],capture_output=True,text=True,timeout=timeout)
    text=(p.stdout or p.stderr).strip()
    try: return json.loads(text)
    except Exception: return {"status":"ERROR","message":text,"returncode":p.returncode}


def _proto(value):
    if value=="": return "."
    return base64.urlsafe_b64encode(str(value).encode("utf-8")).decode("ascii").rstrip("=")


def agent18(command):
    import socket
    with socket.create_connection(("127.0.0.1",9818),timeout=5) as sock:
        sock.settimeout(20); sock.sendall((command.rstrip()+"\n").encode("utf-8")); sock.shutdown(socket.SHUT_WR)
        chunks=[]
        while True:
            data=sock.recv(65536)
            if not data: break
            chunks.append(data)
    raw=b"".join(chunks).decode("utf-8",errors="replace").strip()
    try: return json.loads(raw)
    except Exception: return {"status":"ERROR","message":"non-JSON AgentV18 response","raw":raw}


def v18_commit_cell(panel,header,row,column,expected,value):
    action="commit64:"+_proto(value)
    command="FXTABLECELL %s 0 %s 0 %d %d %s %s" % (_proto(panel),_proto(header),row,column,action,_proto(expected))
    return agent18(command)


def v18_combo_options(panel,node_class,node_id,occurrence):
    return agent18("FXCOMBOOPTIONS %s %s %s %d" % (_proto(panel),_proto(node_class),_proto(node_id),occurrence))


def v18_select_combo(panel,node_class,node_id,occurrence,expected,value):
    return agent18("FXSELECTCOMBO %s %s %s %d %s %s" % (_proto(panel),_proto(node_class),_proto(node_id),occurrence,_proto(expected),_proto(value)))


def emit(obj, code=0):
    print(json.dumps(obj, indent=2, separators=None)); return code


def run(cmd, timeout=60):
    p=subprocess.run([str(x) for x in cmd],capture_output=True,text=True,timeout=timeout)
    if p.returncode:
        raise RuntimeError((p.stderr or p.stdout).strip())
    return p.stdout.strip()


def json_run(cmd, timeout=60):
    text=run(cmd,timeout)
    try: return json.loads(text)
    except json.JSONDecodeError as exc: raise RuntimeError(f"non-JSON output from {cmd[0]}: {text[:500]}") from exc


def jab(method,path,payload=None):
    data=None; headers={}
    if payload is not None:
        data=json.dumps(payload).encode(); headers["Content-Type"]="application/json"
    req=urllib.request.Request(JAB_BASE+path,data=data,headers=headers,method=method)
    with urllib.request.urlopen(req,timeout=120) as r: return json.loads(r.read().decode())


def jab_ready():
    try: return jab("GET","/state")
    except Exception:
        # Client owns the supported self-restart behavior.
        return json_run([sys.executable,ROOT/"mcsj-jab-client.py","state"],30)


def fx_state(): return json_run([sys.executable,FX,"state"],60)
def swing_state(): return json_run([sys.executable,SWING,"state"],60)


def normalized_state():
    j=jab_ready(); s=swing_state(); f=fx_state()
    if not j.get("ok") or s.get("status")!="OK" or f.get("status")!="OK":
        raise RuntimeError("one or more MCSJ control surfaces are not ready")
    panels=[]
    for p in f.get("panels",[]):
        controls=[]
        for c in p.get("controls",[]):
            controls.append({k:c.get(k) for k in ("class","id","text","visible","disabled","focused","editable","bound") if k in c})
        panels.append({"class":p.get("panelClass"),"root":p.get("rootClass"),"controls":controls,"tables":p.get("tables",[])})
    internal=s.get("internalFrames",[])
    return {
        "status":"READY","databaseTitle":j.get("title"),"pid":j.get("pid"),
        "screen":{"internalFrames":internal,"javaFxPanels":[p["class"] for p in panels],"modalOrChildOpen":bool(internal),"menuOpen":bool(s.get("popupCount")),"menuItems":s.get("popupItems",[])},
        "menus":s.get("menus",[]),"panels":panels,
        "controlSurfaces":{"jab":{"ok":j.get("ok"),"cacheCount":j.get("cache_count")},"swing":{"port":s.get("port"),"ownerPid":s.get("ownerPid")},"javaFx":{"port":9810,"panelCount":f.get("panelCount")}},
        "safeActions":["find","open","close","inspect-form","set-field","set-cell","set-cell-option","press-button","proof"],
    }


def find(text, refresh=False):
    if refresh: jab("POST","/refresh",{"max_depth":10,"max_nodes":700})
    out=jab("GET","/find?text="+urllib.parse.quote(text))
    hits=out.get("hits") or []
    exact=[h for h in hits if (h.get("name") or "").strip().casefold()==text.strip().casefold()]
    return {"status":"OK","query":text,"count":len(hits),"exactCount":len(exact),"hits":hits[:30]}


def open_exact(text):
    def resolve_actionable():
        result=find(text,False); hits=result["hits"]
        exact=[h for h in hits if (h.get("name") or "").strip().casefold()==text.strip().casefold() and h.get("visible") and h.get("enabled")]
        actionable=[h for h in exact if h.get("role") in ("menu","menu item","push button")]
        # JAB can publish duplicate cache nodes for the same rendered control after popup refreshes.
        dedup={}
        for h in actionable:
            b=h.get("bounds") or {}
            sig=(h.get("name"),h.get("role"),b.get("x"),b.get("y"),b.get("width"),b.get("height"))
            dedup.setdefault(sig,h)
        return list(dedup.values())

    # Fast path: validate and use the hot cache. Broad refresh is recovery-only because it costs
    # roughly 11 seconds on this host and is unnecessary for compiled/known workflow routes.
    actionable=resolve_actionable(); route="hot-cache"
    if len(actionable)!=1:
        jab("POST","/refresh",{"max_depth":10,"max_nodes":700})
        actionable=resolve_actionable(); route="broad-refresh-recovery"
    if len(actionable)!=1:
        return {"status":"REJECTED","reason":"target must resolve to exactly one visible enabled actionable control","query":text,"exactActionableCount":len(actionable),"candidates":actionable,"route":route}
    before=normalized_state(); target=actionable[0]
    clicked=jab("POST","/click",{"id":target["id"]})
    time.sleep(.5)
    after=normalized_state()
    return {"status":"OK" if clicked.get("ok") else "ERROR","action":"open","route":route,"target":{"id":target.get("id"),"name":target.get("name"),"role":target.get("role")},"beforeScreen":before["screen"],"afterScreen":after["screen"],"verifiedState":after}


def close_current():
    state=normalized_state()
    candidates=[]
    for panel in state["panels"]:
        for c in panel["controls"]:
            if c.get("text") in ("_Close","Close") and c.get("visible") and not c.get("disabled"):
                candidates.append((panel["class"],c["text"]))
    if len(candidates)==1 and candidates[0][0] not in ("javafx.embed.swing.JFXPanel",None):
        panel,text=candidates[0]
        out=json_run([sys.executable,FX,"fire-button","--panel-class",panel,"--text",text],45)
        time.sleep(.4); after=normalized_state()
        return {"status":"OK","action":"close","route":"guarded-javafx-fire","result":out,"verifiedState":after}
    # Escape is a bounded navigation fallback, never treated as a saved write.
    out=json_run(["powershell.exe","-NoProfile","-ExecutionPolicy","Bypass","-File",OP,"press","Escape"],30)
    time.sleep(.3); after=normalized_state()
    return {"status":"OK","action":"close","route":"escape-fallback","result":out,"verifiedState":after}


def inspect_form():
    state=normalized_state(); forms=[]
    for p in state["panels"]:
        if p["class"]=="javafx.embed.swing.JFXPanel": continue
        fields=[]; buttons=[]
        for i,c in enumerate(p["controls"]):
            item={**c,"index":i}
            cls=c.get("class","")
            if c.get("editable") or "Field" in cls or "ComboBox" in cls or "CheckBox" in cls:
                fields.append(item)
            if "Button" in cls:
                buttons.append(item)
        forms.append({"panelClass":p["class"],"fields":fields,"buttons":buttons,"tables":p.get("tables",[])})
    return {"status":"OK","databaseTitle":state["databaseTitle"],"screen":state["screen"],"formCount":len(forms),"forms":forms}


def _form_matches(snapshot,panel,node_class,node_id,node_text=None):
    matches=[]
    for form in snapshot.get("forms",[]):
        if form.get("panelClass")!=panel: continue
        for field in form.get("fields",[]):
            if field.get("class")==node_class and field.get("id")==node_id and (node_text is None or field.get("text")==node_text):
                matches.append(field)
    return matches


def _sendkeys_literal(value):
    # Escape SendKeys metacharacters. Ctrl+A replacement avoids clipboard use.
    if value=="": return "^a{BACKSPACE}"
    escaped="".join("{"+ch+"}" if ch in "+^%~()[]{}" else ch for ch in value)
    return "^a"+escaped


def set_field(panel,node_class,node_id,node_text,expected,value):
    before=inspect_form(); matches=_form_matches(before,panel,node_class,node_id,node_text)
    if len(matches)!=1:
        return {"status":"REJECTED","reason":"field must resolve to exactly one current control","matchCount":len(matches),"guard":{"panelClass":panel,"class":node_class,"id":node_id,"text":node_text}}
    target=matches[0]
    if target.get("text")!=expected:
        return {"status":"REJECTED","reason":"expected prior value mismatch","expected":expected,"actual":target.get("text")}
    if target.get("disabled") or not target.get("visible"):
        return {"status":"REJECTED","reason":"target is not visible and enabled","target":target}
    cmd=[sys.executable,FX,"edit","--panel-class",panel,"--class",node_class,"--id",node_id,"--expected",expected,"--new",value]
    if node_text is not None: cmd += ["--text",node_text]
    proc=subprocess.run([str(x) for x in cmd],capture_output=True,text=True,timeout=60)
    route="guarded-agentv10"
    detail=(proc.stdout or proc.stderr).strip()
    try: result=json.loads(detail)
    except json.JSONDecodeError: result={"status":"ERROR","raw":detail}
    if proc.returncode!=0:
        if not target.get("focused"):
            return {"status":"REJECTED","reason":"semantic mutation failed and exact target is not uniquely focused; keyboard fallback forbidden","semanticResult":result,"target":target}
        title=before.get("databaseTitle","")
        result=json_run(["powershell.exe","-NoProfile","-ExecutionPolicy","Bypass","-File",ROOT/"sendkeys-title.ps1","-Title",title,"-Keys",_sendkeys_literal(value)],30)
        route="focused-keyboard-fallback"
        time.sleep(.4)
    after=inspect_form(); after_matches=_form_matches(after,panel,node_class,node_id,None)
    actual=after_matches[0].get("text") if len(after_matches)==1 else None
    normalized=False
    if actual!=value and "MaskField" in node_class and isinstance(actual,str) and actual.rstrip()==value.rstrip():
        normalized=True
    elif actual!=value:
        return {"status":"REJECTED","reason":"post-operation read-back mismatch","route":route,"requested":value,"actual":actual,"result":result}
    return {"status":"OK","action":"set-field","route":route,"guard":{"panelClass":panel,"class":node_class,"id":node_id,"text":node_text,"expected":expected},"requested":value,"actual":actual,"maskNormalized":normalized,"result":result,"beforeField":target,"afterField":after_matches[0]}


def _find_table(snapshot,panel,header):
    matches=[]
    for form in snapshot.get("forms",[]):
        if form.get("panelClass")!=panel: continue
        for table in form.get("tables",[]):
            if header in (table.get("columns") or []): matches.append(table)
    return matches


def set_cell(panel,header,row,column,expected,value,commit="Tab"):
    before=inspect_form(); tables=_find_table(before,panel,header)
    if len(tables)!=1:
        return {"status":"REJECTED","reason":"table must resolve uniquely by panel and exact header","matchCount":len(tables)}
    table=tables[0]; rows=table.get("rows") or []; columns=table.get("columns") or []
    if row<0 or row>=len(rows) or column<0 or column>=len(columns):
        return {"status":"REJECTED","reason":"row or column out of range","rowCount":len(rows),"columnCount":len(columns)}
    actual_before=rows[row][column]
    if actual_before!=expected:
        return {"status":"REJECTED","reason":"expected prior cell mismatch","expected":expected,"actual":actual_before}
    activated=json_run([sys.executable,FX,"cell","--panel-class",panel,"--header",header,"--row",str(row),"--column",str(column),"--action","edit","--expected",expected],60)
    editing=inspect_form(); focused=[]; candidates=[]
    for form in editing.get("forms",[]):
        if form.get("panelClass")!=panel: continue
        eligible=[f for f in form.get("fields",[]) if f.get("visible") and not f.get("disabled") and f.get("editable") and "FakeFocusTextField" not in f.get("class","") and f.get("text")==expected]
        candidates += eligible
        focused += [f for f in eligible if f.get("focused")]
    resolved=focused if len(focused)==1 else candidates
    if len(resolved)==1:
        editor=resolved[0]
        entered=set_field(panel,editor["class"],editor.get("id","") ,editor.get("text"),editor.get("text",""),value)
        semantic=entered.get("result") or {}
        transient_agent_ok=(entered.get("status")=="REJECTED" and entered.get("reason")=="post-operation read-back mismatch" and semantic.get("status")=="OK" and semantic.get("actual")==value)
        if entered.get("status")!="OK" and not transient_agent_ok:
            return {"status":"REJECTED","reason":"cell editor value entry failed","entry":entered,"activation":activated}
        entry_route="transient-editor-agentv10" if transient_agent_ok else entered.get("route")
        editor_info={"class":editor.get("class"),"id":editor.get("id")}
    elif len(focused)==0 and activated.get("verification")=="editing-cell-readback" and activated.get("selectedRow")==row and activated.get("selectedColumn")==column:
        keys="^a"+_sendkeys_literal(value)
        typed=json_run(["powershell.exe","-NoProfile","-ExecutionPolicy","Bypass","-File",ROOT/"sendkeys-title.ps1","-Title",before.get("databaseTitle","") ,"-Keys",keys],30)
        entered={"title":before.get("databaseTitle","") ,"keys":keys,"typed":typed}
        entry_route="exact-editing-cell-title-sendkeys"
        editor_info={"class":"transient-javafx-table-editor","id":""}
    else:
        return {"status":"REJECTED","reason":"exact cell activated but editor did not resolve safely","focusedEditors":focused,"activation":activated}
    commit_result=v18_commit_cell(panel,header,row,column,expected,value)
    if commit_result.get("status")!="OK" or commit_result.get("verification")!="backing-row-commit-readback":
        return {"status":"REJECTED","reason":"AgentV18 guarded table commit failed","activation":activated,"entry":entered,"commit":commit_result}
    time.sleep(.2)
    after=inspect_form(); after_tables=_find_table(after,panel,header)
    actual_after=None
    if len(after_tables)==1:
        after_rows=after_tables[0].get("rows") or []
        if row<len(after_rows) and column<len(after_rows[row]): actual_after=after_rows[row][column]
    normalized=False
    if actual_after!=value and "MaskField" in editor_info.get("class","") and isinstance(actual_after,str) and actual_after.rstrip()==value.rstrip(): normalized=True
    elif actual_after!=value:
        return {"status":"REJECTED","reason":"committed cell read-back mismatch","requested":value,"actual":actual_after,"activation":activated,"entry":entered,"commit":commit_result}
    return {"status":"OK","action":"set-cell","guard":{"panelClass":panel,"header":header,"row":row,"column":column,"columnName":columns[column],"expected":expected},"requested":value,"actual":actual_after,"maskNormalized":normalized,"editor":editor_info,"activation":activated,"entryRoute":entry_route,"commit":commit_result}


def set_cell_option(panel,header,row,column,expected,value):
    before=inspect_form(); tables=_find_table(before,panel,header)
    if len(tables)!=1:
        return {"status":"REJECTED","reason":"table must resolve uniquely by panel and exact header","matchCount":len(tables)}
    table=tables[0]; rows=table.get("rows") or []; columns=table.get("columns") or []
    if row<0 or row>=len(rows) or column<0 or column>=len(columns):
        return {"status":"REJECTED","reason":"row or column out of range","rowCount":len(rows),"columnCount":len(columns)}
    actual_before=rows[row][column]
    if actual_before!=expected:
        return {"status":"REJECTED","reason":"expected prior cell mismatch","expected":expected,"actual":actual_before}
    activated=json_run([sys.executable,FX,"cell","--panel-class",panel,"--header",header,"--row",str(row),"--column",str(column),"--action","edit","--expected",expected],60)
    editing=inspect_form(); candidates=[]
    for form in editing.get("forms",[]):
        if form.get("panelClass")!=panel: continue
        combo_fields=[f for f in form.get("fields",[]) if "ComboBox" in f.get("class","") and "FakeFocusTextField" not in f.get("class","")]
        for i,f in enumerate(combo_fields):
            if f.get("visible") and not f.get("disabled") and f.get("text")==expected:
                occurrence=sum(1 for prior in combo_fields[:i] if prior.get("class")==f.get("class") and prior.get("id","")==f.get("id",""))
                candidates.append((f,occurrence))
    if len(candidates)!=1:
        return {"status":"REJECTED","reason":"active combo editor must resolve uniquely by panel/current cell value","matchCount":len(candidates),"activation":activated}
    editor,occurrence=candidates[0]
    options=v18_combo_options(panel,editor["class"],editor.get("id",""),occurrence)
    if options.get("status")!="OK" or options.get("current")!=expected:
        return {"status":"REJECTED","reason":"combo options/current-state guard failed","editor":editor,"occurrence":occurrence,"options":options}
    exact_options=[x for x in options.get("options",[]) if x==value]
    if len(exact_options)!=1:
        return {"status":"REJECTED","reason":"requested option must appear exactly once","requested":value,"matches":len(exact_options),"options":options.get("options",[])}
    selected=v18_select_combo(panel,editor["class"],editor.get("id",""),occurrence,expected,value)
    if selected.get("status")!="OK" or selected.get("actual")!=value:
        return {"status":"REJECTED","reason":"combo selection read-back failed","selection":selected,"options":options}
    time.sleep(.2); mid=inspect_form(); mid_tables=_find_table(mid,panel,header)
    actual_mid=None
    if len(mid_tables)==1:
        mid_rows=mid_tables[0].get("rows") or []
        if row<len(mid_rows) and column<len(mid_rows[row]): actual_mid=mid_rows[row][column]
    commit={"status":"OK","verification":"combo-selection-event-readback","actual":actual_mid}
    if actual_mid!=value:
        commit=v18_commit_cell(panel,header,row,column,expected,value)
        if commit.get("status")!="OK" or commit.get("verification")!="backing-row-commit-readback":
            return {"status":"REJECTED","reason":"combo selected but backing-row commit failed","selection":selected,"commit":commit,"actual":actual_mid}
    time.sleep(.2); after=inspect_form(); after_tables=_find_table(after,panel,header)
    actual_after=None
    if len(after_tables)==1:
        after_rows=after_tables[0].get("rows") or []
        if row<len(after_rows) and column<len(after_rows[row]): actual_after=after_rows[row][column]
    if actual_after!=value:
        return {"status":"REJECTED","reason":"final combo cell read-back mismatch","requested":value,"actual":actual_after,"selection":selected,"commit":commit}
    return {"status":"OK","action":"set-cell-option","guard":{"panelClass":panel,"header":header,"row":row,"column":column,"columnName":columns[column],"expected":expected},"requested":value,"actual":actual_after,"editor":{"class":editor.get("class"),"id":editor.get("id"),"occurrence":occurrence},"options":options,"activation":activated,"selection":selected,"commit":commit}


def press_button(panel,text,expected_disabled=False):
    cmd=[sys.executable,FX,"fire-button","--panel-class",panel,"--text",text]
    if expected_disabled: cmd.append("--expected-disabled")
    result=json_run(cmd,60); time.sleep(.3)
    return {"status":"OK","action":"press-button","guard":{"panelClass":panel,"text":text,"expectedDisabled":expected_disabled},"result":result,"verifiedState":normalized_state()}


def proof(path=None):
    if not path:
        stamp=dt.datetime.now().strftime("%Y%m%d-%H%M%S")
        path=str(PROOF/f"mcsj-user-{stamp}.png")
    out=json_run(["powershell.exe","-NoProfile","-ExecutionPolicy","Bypass","-File",PRINTWINDOW,"-Path",path],30)
    if not out.get("ok") or not out.get("bytes"):
        raise RuntimeError("occlusion-safe PrintWindow proof failed: "+json.dumps(out))
    return {"status":"OK","proof":{**out,"source":"win32-printwindow-occlusion-safe"},"state":normalized_state()}


def ready():
    return {"status":"OK","state":normalized_state(),"proof":proof(str(PROOF/"mcsj-user-ready.png"))["proof"]}


def main():
    ap=argparse.ArgumentParser(description="Unified guarded MCSJ user facade")
    sub=ap.add_subparsers(dest="cmd",required=True)
    sub.add_parser("state"); sub.add_parser("ready"); sub.add_parser("close"); sub.add_parser("inspect-form")
    p=sub.add_parser("find"); p.add_argument("text"); p.add_argument("--refresh",action="store_true")
    p=sub.add_parser("open"); p.add_argument("text")
    p=sub.add_parser("proof"); p.add_argument("path",nargs="?")
    p=sub.add_parser("set-field"); p.add_argument("--panel",required=True); p.add_argument("--class",dest="node_class",required=True); p.add_argument("--id",required=True); p.add_argument("--text"); p.add_argument("--expected",required=True); p.add_argument("--value",required=True)
    p=sub.add_parser("set-cell"); p.add_argument("--panel",required=True); p.add_argument("--header",required=True); p.add_argument("--row",type=int,required=True); p.add_argument("--column",type=int,required=True); p.add_argument("--expected",required=True); p.add_argument("--value",required=True); p.add_argument("--commit",choices=["Tab","Enter"],default="Tab")
    p=sub.add_parser("set-cell-option"); p.add_argument("--panel",required=True); p.add_argument("--header",required=True); p.add_argument("--row",type=int,required=True); p.add_argument("--column",type=int,required=True); p.add_argument("--expected",required=True); p.add_argument("--value",required=True)
    p=sub.add_parser("press-button"); p.add_argument("--panel",required=True); p.add_argument("--text",required=True); p.add_argument("--expected-disabled",action="store_true")
    args=ap.parse_args()
    try:
        if args.cmd=="state": out=normalized_state()
        elif args.cmd=="ready": out=ready()
        elif args.cmd=="inspect-form": out=inspect_form()
        elif args.cmd=="find": out=find(args.text,args.refresh)
        elif args.cmd=="open": out=open_exact(args.text)
        elif args.cmd=="close": out=close_current()
        elif args.cmd=="set-field": out=set_field(args.panel,args.node_class,args.id,args.text,args.expected,args.value)
        elif args.cmd=="set-cell": out=set_cell(args.panel,args.header,args.row,args.column,args.expected,args.value,args.commit)
        elif args.cmd=="set-cell-option": out=set_cell_option(args.panel,args.header,args.row,args.column,args.expected,args.value)
        elif args.cmd=="press-button": out=press_button(args.panel,args.text,args.expected_disabled)
        else: out=proof(args.path)
        return emit(out,0 if out.get("status") in ("OK","READY") else 2)
    except Exception as exc: return emit({"status":"ERROR","message":str(exc)},2)

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