#!/usr/bin/env python3
"""
MichelBridge: keyboard-first local MCSJ bridge.
Run INSIDE the interactive Windows desktop session (directly, or via run-operator-interactive.ps1).
Returns compact JSON; screenshots are proof/debug, not the main reasoning loop.
"""
import argparse, ctypes, json, os, subprocess, sys, time, warnings
from pathlib import Path

# Python 3.12 on this host may not automatically add pywin32_system32 to the
# DLL search path when MichelBridge is launched by Start-Process. Add it here
# before any win32gui/win32process imports in helper functions.
_pywin32_dll_dir = Path(sys.prefix) / 'Lib' / 'site-packages' / 'pywin32_system32'
if _pywin32_dll_dir.exists() and hasattr(os, 'add_dll_directory'):
    os.add_dll_directory(str(_pywin32_dll_dir))

warnings.filterwarnings('ignore', category=DeprecationWarning)

ROOT = Path(r"C:\MCSJAgent\MichelBridge")
PROOF = Path(r"C:\MCSJAgent\proof\MichelBridge")
TABMAP_DIR = ROOT / "tabmaps"
CROP_DIR = ROOT / "crops"
TESSERACT = r"C:\Program Files\Tesseract-OCR\tesseract.exe"

user32 = ctypes.WinDLL('user32', use_last_error=True)
VK = {
    'TAB':0x09,'ENTER':0x0D,'RETURN':0x0D,'ESC':0x1B,'ESCAPE':0x1B,'SPACE':0x20,
    'LEFT':0x25,'UP':0x26,'RIGHT':0x27,'DOWN':0x28,'DELETE':0x2E,'BACKSPACE':0x08,
    'F1':0x70,'F2':0x71,'F3':0x72,'F4':0x73,'F5':0x74,'F6':0x75,'F7':0x76,'F8':0x77,
    'F9':0x78,'F10':0x79,'F11':0x7A,'F12':0x7B,
    'CTRL':0x11,'ALT':0x12,'SHIFT':0x10,'A':0x41,'C':0x43,'V':0x56,'X':0x58,
}
KEYEVENTF_KEYUP = 0x0002


def jdump(obj):
    print(json.dumps(obj, ensure_ascii=False, separators=(',', ':')))


def key_down(vk): user32.keybd_event(vk, 0, 0, 0)
def key_up(vk): user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0)

def tap(key, delay=0.045):
    k=key.upper()
    if k not in VK: raise SystemExit(f"unknown key: {key}")
    key_down(VK[k]); time.sleep(delay); key_up(VK[k])

def combo(keys):
    ks=[VK[k.upper()] for k in keys]
    for k in ks: key_down(k); time.sleep(0.02)
    for k in reversed(ks): key_up(k); time.sleep(0.02)

def set_clipboard(text):
    # pyperclip is installed; powershell fallback avoids console focus problems.
    try:
        import pyperclip
        pyperclip.copy(text)
    except Exception:
        subprocess.run(['powershell','-NoProfile','-Command',"Set-Clipboard -Value @'\n"+text+"\n'@"], check=True)

def activate_mcsj():
    import win32gui, win32process
    target=None
    def enum(hwnd, _):
        nonlocal target
        if not win32gui.IsWindowVisible(hwnd): return
        title=win32gui.GetWindowText(hwnd) or ''
        if title.startswith('MCSJ - 2026.1'):
            target=hwnd
            return
    win32gui.EnumWindows(enum, None)
    if not target: return {'activated':False,'error':'MCSJ window not found'}
    try:
        win32gui.ShowWindow(target, 9) # SW_RESTORE
        user32.SetForegroundWindow(target)
        time.sleep(0.2)
    except Exception as e:
        return {'activated':False,'hwnd':target,'error':str(e)}
    return {'activated':True,'hwnd':target,'title':win32gui.GetWindowText(target)}

def window_state():
    import psutil, win32gui, win32process
    fg=user32.GetForegroundWindow()
    fg_title=win32gui.GetWindowText(fg) if fg else ''
    hwnds=[]
    def enum(hwnd, _):
        if win32gui.IsWindowVisible(hwnd):
            title=win32gui.GetWindowText(hwnd) or ''
            if 'MCSJ' in title:
                _, pid=win32process.GetWindowThreadProcessId(hwnd)
                rect=win32gui.GetWindowRect(hwnd)
                hwnds.append({'hwnd':hwnd,'pid':pid,'title':title,'rect':rect})
    win32gui.EnumWindows(enum, None)
    procs=[]
    for p in psutil.process_iter(['pid','name','username']):
        try:
            if (p.info.get('name') or '').lower() in ('mcsj.exe','java.exe','javaw.exe'):
                procs.append(p.info)
        except Exception: pass
    return {'time':time.strftime('%Y-%m-%dT%H:%M:%S%z'),'foreground':{'hwnd':fg,'title':fg_title},'mcsj_windows':hwnds,'mcsj_processes':procs}

def screenshot(path=None):
    """Capture proof, preferring the desktop but falling back to MCSJ PrintWindow.

    BitBlt/mss fails when the interactive desktop surface is disconnected. PrintWindow
    renders the live MCSJ HWND without depending on that surface and is the approved
    occlusion-safe proof route on this host.
    """
    import mss
    from PIL import Image
    PROOF.mkdir(parents=True, exist_ok=True)
    if path is None:
        path=str(PROOF / (time.strftime('screen-%Y%m%d-%H%M%S')+'.png'))
    try:
        with mss.mss() as sct:
            mon=sct.monitors[1]
            img=sct.grab(mon)
            im=Image.frombytes('RGB', img.size, img.rgb)
            im.save(path)
            return {'path':path,'width':im.width,'height':im.height,'source':'mss-desktop'}
    except Exception as desktop_error:
        proc = subprocess.run([
            'powershell.exe','-NoProfile','-ExecutionPolicy','Bypass','-File',
            'C:/MCSJAgent/printwindow-shot.ps1','-Path',path
        ], capture_output=True, text=True, timeout=20)
        if proc.returncode != 0:
            raise RuntimeError(f"desktop capture failed: {desktop_error}; PrintWindow failed: {proc.stderr.strip()}")
        line = next((x.strip() for x in reversed(proc.stdout.splitlines()) if x.strip().startswith('{')), '')
        result = json.loads(line)
        if not result.get('ok') or int(result.get('bytes', 0)) <= 0:
            raise RuntimeError(f"PrintWindow returned invalid proof: {result}")
        return {'path':result['path'],'width':result['width'],'height':result['height'],
                'bytes':result['bytes'],'title':result['title'],'hwnd':result['hwnd'],
                'source':'win32-printwindow-occlusion-safe','desktop_error':str(desktop_error)}

def crop_ocr(x,y,w,h,path=None, psm=7):
    from PIL import Image, ImageOps, ImageFilter
    import pytesseract
    if os.path.exists(TESSERACT): pytesseract.pytesseract.tesseract_cmd=TESSERACT
    shot=screenshot()
    im=Image.open(shot['path'])
    crop=im.crop((x,y,x+w,y+h))
    # light preprocessing for form text
    gray=ImageOps.grayscale(crop).filter(ImageFilter.SHARPEN)
    if path is None:
        path=str(PROOF / f'crop-{x}-{y}-{w}-{h}-{int(time.time())}.png')
    gray.save(path)
    text=pytesseract.image_to_string(gray, config=f'--psm {psm}').strip()
    return {'crop':path,'rect':[x,y,w,h],'text':text}

def fingerprint():
    # Fast coarse screen recognizer using small OCR bands.
    top=crop_ocr(0,0,1600,90,psm=6)
    left=crop_ocr(0,90,520,620,psm=6)
    text=(top['text']+'\n'+left['text']).lower()
    screen='unknown'; confidence=0.25; signals=[]
    if 'mcsj' in text: signals.append('mcsj-text')
    if 'purchase order' in text: screen='Purchase Order / PO visible'; confidence=0.80; signals.append('purchase-order')
    if 'statement of revenue' in text or 'revenue and expenditures' in text: screen='Statement of Revenue and Expenditures visible'; confidence=max(confidence,0.82); signals.append('statement-grid')
    if 'billing' in text and 'collections' in text: signals.append('top-menu')
    return {'screen':screen,'confidence':confidence,'signals':signals,'top_text':top['text'][:500],'left_text':left['text'][:500]}

def load_tabmap(name):
    p=TABMAP_DIR / f'{name}.json'
    return json.loads(p.read_text(encoding='utf-8'))


def main():
    ap=argparse.ArgumentParser()
    sub=ap.add_subparsers(dest='cmd', required=True)
    sub.add_parser('state')
    sub.add_parser('activate')
    p=sub.add_parser('press'); p.add_argument('key'); p.add_argument('--count',type=int,default=1)
    p=sub.add_parser('combo'); p.add_argument('keys', nargs='+')
    p=sub.add_parser('tab'); p.add_argument('count', type=int, nargs='?', default=1); p.add_argument('--shift', action='store_true')
    p=sub.add_parser('paste'); p.add_argument('text', nargs='+')
    p=sub.add_parser('click'); p.add_argument('x', type=int); p.add_argument('y', type=int); p.add_argument('--wait', type=float, default=0.2)
    p=sub.add_parser('screenshot'); p.add_argument('path', nargs='?')
    p=sub.add_parser('ocr-crop'); p.add_argument('x',type=int); p.add_argument('y',type=int); p.add_argument('w',type=int); p.add_argument('h',type=int); p.add_argument('--psm',type=int,default=7)
    sub.add_parser('fingerprint')
    p=sub.add_parser('tabmap'); p.add_argument('name')
    args=ap.parse_args()
    ROOT.mkdir(parents=True, exist_ok=True); PROOF.mkdir(parents=True, exist_ok=True)
    if args.cmd=='state': jdump(window_state())
    elif args.cmd=='activate': jdump(activate_mcsj())
    elif args.cmd=='press':
        a=activate_mcsj();
        for _ in range(args.count): tap(args.key)
        jdump({'activated':a,'pressed':args.key,'count':args.count})
    elif args.cmd=='combo':
        a=activate_mcsj(); combo(args.keys); jdump({'activated':a,'combo':args.keys})
    elif args.cmd=='tab':
        a=activate_mcsj();
        for _ in range(args.count):
            if args.shift: combo(['SHIFT','TAB'])
            else: tap('TAB')
            time.sleep(0.035)
        jdump({'activated':a,'key':'SHIFT+TAB' if args.shift else 'TAB','count':args.count})
    elif args.cmd=='paste':
        text = ' '.join(args.text)
        a=activate_mcsj(); set_clipboard(text); combo(['CTRL','V']); jdump({'activated':a,'pasted_chars':len(text)})
    elif args.cmd=='click':
        a=activate_mcsj()
        import pyautogui
        pyautogui.FAILSAFE = False
        pyautogui.click(args.x, args.y)
        time.sleep(args.wait)
        jdump({'activated':a,'clicked':[args.x,args.y]})
    elif args.cmd=='screenshot': jdump(screenshot(args.path))
    elif args.cmd=='ocr-crop': jdump(crop_ocr(args.x,args.y,args.w,args.h,psm=args.psm))
    elif args.cmd=='fingerprint': jdump(fingerprint())
    elif args.cmd=='tabmap': jdump(load_tabmap(args.name))

if __name__=='__main__': main()
